Close Task 10 renderer delivery gaps
Why: - Independent review found trusted-boundary, semantic parity, replay-budget, comparison, persistence, cancellation, provenance, and routing gaps. - Storage migration must include the new durable delivery table. Evidence: - go test -count=1 ./... - go test -race ./internal/docgen ./internal/tools ./internal/runmgr - go test ./... (deps/agent-document-renderer) - go vet ./... - go build ./... - CGO_ENABLED=0 go build -trimpath ./cmd/agent-system - Python house-style unittest suite and git diff --check Impact: - Word delivery now uses host-owned freezes, Markdown-first resumable persistence, closed registration boundaries, semantic shared rendering, and real comparison. - Controlled/project documents still fail closed to RDM and non-Word flows remain unchanged. Refs: - Unified Document Renderer implementation plan Task 10 - /tmp/task10-backend-review-last.md - Renderer d5cd8c126045a27d5a5cc5da23f64d3c737e116a
This commit is contained in:
@@ -286,6 +286,7 @@ func cmdBackup(args []string) error {
|
||||
var copyOrder = []string{
|
||||
"projects", "users", "account_directory_revision", "profiles", "settings", "plugins",
|
||||
"conversations", "runs", "messages", "project_work_outbox", "events", "artifacts",
|
||||
"document_render_deliveries",
|
||||
"hitl_cards", "human_decisions", "plans", "todos", "knowledge", "knowledge_vectors",
|
||||
"activity_cards", "project_event_shares", "command_audit", "project_audit_links", "prompt_modules",
|
||||
"sessions", "api_keys",
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
package docgen
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"agent-document-renderer/model"
|
||||
)
|
||||
|
||||
const (
|
||||
comparisonExceptionTitle = "RDR-T10-CMP-001"
|
||||
comparisonExceptionCorporate = "RDR-T10-CMP-002"
|
||||
comparisonExceptionAnchors = "RDR-T10-CMP-003"
|
||||
comparisonExceptionSafeLinks = "RDR-T10-CMP-004"
|
||||
)
|
||||
|
||||
type comparisonBlock struct {
|
||||
Kind string `json:"kind"`
|
||||
Level int `json:"level,omitempty"`
|
||||
Text []string `json:"text,omitempty"`
|
||||
Rows [][]string `json:"rows,omitempty"`
|
||||
}
|
||||
|
||||
type docxComparison struct {
|
||||
Paragraphs []string `json:"paragraphs"`
|
||||
PageSize map[string]string `json:"pageSize"`
|
||||
Margins map[string]string `json:"margins"`
|
||||
Headers int `json:"headers"`
|
||||
Footers int `json:"footers"`
|
||||
Bookmarks int `json:"bookmarks"`
|
||||
ExternalLinks int `json:"externalLinks"`
|
||||
ForbiddenObject bool `json:"forbiddenObject"`
|
||||
Semantics map[string]bool `json:"semantics"`
|
||||
}
|
||||
|
||||
func compareLegacyAndShared(title string, source []byte, canonical model.CanonicalDocument, legacy, shared []byte) ComparisonReceipt {
|
||||
receipt := ComparisonReceipt{Enabled: true, LegacySHA256: digestBytes(legacy), SharedSHA256: digestBytes(shared)}
|
||||
legacyStructure := legacyComparisonStructure(string(source))
|
||||
sharedStructure := canonicalComparisonStructure(canonical)
|
||||
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("canonical-structure", legacyStructure, sharedStructure, ""))
|
||||
|
||||
legacyDOCX, legacyErr := inspectComparisonDOCX(legacy)
|
||||
sharedDOCX, sharedErr := inspectComparisonDOCX(shared)
|
||||
if legacyErr != nil || sharedErr != nil {
|
||||
detail := fmt.Sprintf("legacy=%v shared=%v", legacyErr, sharedErr)
|
||||
for _, name := range []string{"visible-text", "page-layout", "header-footer", "node-bookmarks", "safe-links", "security-objects", "word-semantics"} {
|
||||
receipt.Dimensions = append(receipt.Dimensions, ComparisonDimension{Name: name, Status: "unassessed", Detail: detail})
|
||||
}
|
||||
return finishComparison(receipt)
|
||||
}
|
||||
|
||||
legacyParagraphs := append([]string(nil), legacyDOCX.Paragraphs...)
|
||||
if len(legacyParagraphs) >= 2 && legacyParagraphs[0] == normalizeComparisonText(title) && legacyParagraphs[1] == normalizeComparisonText(title) {
|
||||
withoutDuplicate := append([]string(nil), legacyParagraphs[1:]...)
|
||||
if equalJSON(withoutDuplicate, sharedDOCX.Paragraphs) {
|
||||
receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("visible-text", legacyParagraphs, sharedDOCX.Paragraphs,
|
||||
comparisonExceptionTitle, "legacy synthetic Title duplicated the first canonical heading"))
|
||||
} else {
|
||||
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("visible-text", legacyParagraphs, sharedDOCX.Paragraphs, ""))
|
||||
}
|
||||
} else {
|
||||
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("visible-text", legacyParagraphs, sharedDOCX.Paragraphs, ""))
|
||||
}
|
||||
|
||||
if equalJSON(legacyDOCX.PageSize, sharedDOCX.PageSize) {
|
||||
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("page-size", legacyDOCX.PageSize, sharedDOCX.PageSize, ""))
|
||||
} else {
|
||||
receipt.Dimensions = append(receipt.Dimensions, ComparisonDimension{Name: "page-size", Status: "accidental", LegacyHash: digestJSON(legacyDOCX.PageSize), SharedHash: digestJSON(sharedDOCX.PageSize), Detail: "page size changed"})
|
||||
}
|
||||
if equalJSON(legacyDOCX.Margins, sharedDOCX.Margins) {
|
||||
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("page-margins", legacyDOCX.Margins, sharedDOCX.Margins, ""))
|
||||
} else {
|
||||
receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("page-margins", legacyDOCX.Margins, sharedDOCX.Margins,
|
||||
comparisonExceptionCorporate, "organization-default candidate owns page margins"))
|
||||
}
|
||||
if legacyDOCX.Headers == sharedDOCX.Headers && legacyDOCX.Footers == sharedDOCX.Footers {
|
||||
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("header-footer", []int{legacyDOCX.Headers, legacyDOCX.Footers}, []int{sharedDOCX.Headers, sharedDOCX.Footers}, ""))
|
||||
} else {
|
||||
receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("header-footer", []int{legacyDOCX.Headers, legacyDOCX.Footers}, []int{sharedDOCX.Headers, sharedDOCX.Footers},
|
||||
comparisonExceptionCorporate, "organization-default candidate adds reviewed header/footer rules"))
|
||||
}
|
||||
if legacyDOCX.Bookmarks == sharedDOCX.Bookmarks {
|
||||
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("node-bookmarks", legacyDOCX.Bookmarks, sharedDOCX.Bookmarks, ""))
|
||||
} else if legacyDOCX.Bookmarks == 0 && sharedDOCX.Bookmarks > 0 {
|
||||
receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("node-bookmarks", legacyDOCX.Bookmarks, sharedDOCX.Bookmarks,
|
||||
comparisonExceptionAnchors, "shared output anchors canonical node IDs"))
|
||||
} else {
|
||||
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("node-bookmarks", legacyDOCX.Bookmarks, sharedDOCX.Bookmarks, ""))
|
||||
}
|
||||
if legacyDOCX.ExternalLinks == sharedDOCX.ExternalLinks {
|
||||
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("safe-links", legacyDOCX.ExternalLinks, sharedDOCX.ExternalLinks, ""))
|
||||
} else if legacyDOCX.ExternalLinks > 0 && sharedDOCX.ExternalLinks == 0 {
|
||||
receipt.Dimensions = append(receipt.Dimensions, intentionalDimension("safe-links", legacyDOCX.ExternalLinks, sharedDOCX.ExternalLinks,
|
||||
comparisonExceptionSafeLinks, "shared links are inert labels with no external relationship or target leakage"))
|
||||
} else {
|
||||
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("safe-links", legacyDOCX.ExternalLinks, sharedDOCX.ExternalLinks, ""))
|
||||
}
|
||||
receipt.Dimensions = append(receipt.Dimensions,
|
||||
compareExactDimension("security-objects", legacyDOCX.ForbiddenObject, sharedDOCX.ForbiddenObject, ""),
|
||||
compareExactDimension("word-semantics", legacyDOCX.Semantics, sharedDOCX.Semantics, ""),
|
||||
)
|
||||
return finishComparison(receipt)
|
||||
}
|
||||
|
||||
func finishComparison(receipt ComparisonReceipt) ComparisonReceipt {
|
||||
for _, dimension := range receipt.Dimensions {
|
||||
switch dimension.Status {
|
||||
case "intentional":
|
||||
receipt.IntentionalDifferences = append(receipt.IntentionalDifferences, dimension.Name+": "+dimension.Detail)
|
||||
case "accidental":
|
||||
receipt.AccidentalDifferences = append(receipt.AccidentalDifferences, dimension.Name+": "+dimension.Detail)
|
||||
case "unassessed":
|
||||
receipt.Unassessed = append(receipt.Unassessed, dimension.Name+": "+dimension.Detail)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(receipt.Unassessed) > 0:
|
||||
receipt.Assessment = "unassessed"
|
||||
case len(receipt.AccidentalDifferences) > 0:
|
||||
receipt.Assessment = "differences"
|
||||
default:
|
||||
receipt.Assessment = "assessed"
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
|
||||
func compareExactDimension(name string, legacy, shared any, detail string) ComparisonDimension {
|
||||
status := "match"
|
||||
if !equalJSON(legacy, shared) {
|
||||
status = "accidental"
|
||||
if detail == "" {
|
||||
detail = "normalized values differ"
|
||||
}
|
||||
}
|
||||
return ComparisonDimension{Name: name, Status: status, LegacyHash: digestJSON(legacy), SharedHash: digestJSON(shared), Detail: detail}
|
||||
}
|
||||
|
||||
func intentionalDimension(name string, legacy, shared any, exceptionID, detail string) ComparisonDimension {
|
||||
return ComparisonDimension{Name: name, Status: "intentional", LegacyHash: digestJSON(legacy), SharedHash: digestJSON(shared), ExceptionID: exceptionID, Detail: detail}
|
||||
}
|
||||
|
||||
func legacyComparisonStructure(source string) []comparisonBlock {
|
||||
document := parseMarkdown(source)
|
||||
blocks := make([]comparisonBlock, 0, len(document.blocks))
|
||||
for _, block := range document.blocks {
|
||||
entry := comparisonBlock{Level: block.level}
|
||||
switch block.kind {
|
||||
case bHeading:
|
||||
entry.Kind, entry.Text = "heading", []string{visibleLegacyRuns(document.parseInline(strings.Join(block.lines, "\n")))}
|
||||
case bPara:
|
||||
entry.Kind, entry.Text = "paragraph", []string{visibleLegacyRuns(document.parseInline(strings.Join(block.lines, "\n")))}
|
||||
case bQuote:
|
||||
entry.Kind, entry.Text = "quote", []string{normalizeComparisonText(strings.Join(block.lines, "\n"))}
|
||||
case bCode:
|
||||
entry.Kind, entry.Text = "code-block", []string{strings.Join(block.lines, "\n") + "\n"}
|
||||
case bList:
|
||||
entry.Kind = "list"
|
||||
for _, item := range block.items {
|
||||
entry.Text = append(entry.Text, visibleLegacyRuns(document.parseInline(item.text)))
|
||||
}
|
||||
case bTable:
|
||||
entry.Kind = "table"
|
||||
for _, row := range block.rows {
|
||||
visible := make([]string, len(row))
|
||||
for index := range row {
|
||||
visible[index] = visibleLegacyRuns(document.parseInline(row[index]))
|
||||
}
|
||||
entry.Rows = append(entry.Rows, visible)
|
||||
}
|
||||
case bHR:
|
||||
entry.Kind = "thematic-break"
|
||||
}
|
||||
blocks = append(blocks, entry)
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
func canonicalComparisonStructure(document model.CanonicalDocument) []comparisonBlock {
|
||||
blocks := make([]comparisonBlock, 0, len(document.Root.Children))
|
||||
for _, node := range document.Root.Children {
|
||||
entry := comparisonBlock{Kind: string(node.Kind)}
|
||||
if node.Kind == model.NodeHeading {
|
||||
fmt.Sscanf(node.Semantic, "heading%d", &entry.Level)
|
||||
}
|
||||
switch node.Kind {
|
||||
case model.NodeHeading, model.NodeParagraph, model.NodeQuote, model.NodeCodeBlock, model.NodeCallout:
|
||||
entry.Text = []string{normalizeComparisonNodeText(node)}
|
||||
case model.NodeList:
|
||||
for _, item := range node.Children {
|
||||
entry.Text = append(entry.Text, normalizeComparisonNodeText(item))
|
||||
}
|
||||
case model.NodeTable:
|
||||
for _, row := range node.Children {
|
||||
values := make([]string, len(row.Children))
|
||||
for index := range row.Children {
|
||||
values[index] = normalizeComparisonNodeText(row.Children[index])
|
||||
}
|
||||
entry.Rows = append(entry.Rows, values)
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
blocks = append(blocks, entry)
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
func normalizeComparisonNodeText(node model.Node) string {
|
||||
if node.Kind == model.NodeCodeBlock {
|
||||
return node.Text
|
||||
}
|
||||
return normalizeComparisonText(node.Text)
|
||||
}
|
||||
|
||||
func visibleLegacyRuns(runs []run) string {
|
||||
var value strings.Builder
|
||||
for _, run := range runs {
|
||||
value.WriteString(run.text)
|
||||
}
|
||||
return normalizeComparisonText(value.String())
|
||||
}
|
||||
|
||||
func normalizeComparisonText(value string) string { return strings.Join(strings.Fields(value), " ") }
|
||||
|
||||
func inspectComparisonDOCX(raw []byte) (docxComparison, error) {
|
||||
reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
||||
if err != nil {
|
||||
return docxComparison{}, err
|
||||
}
|
||||
parts := make(map[string][]byte, len(reader.File))
|
||||
for _, file := range reader.File {
|
||||
stream, err := file.Open()
|
||||
if err != nil {
|
||||
return docxComparison{}, err
|
||||
}
|
||||
content, err := io.ReadAll(stream)
|
||||
stream.Close()
|
||||
if err != nil {
|
||||
return docxComparison{}, err
|
||||
}
|
||||
parts[file.Name] = content
|
||||
}
|
||||
document, found := parts["word/document.xml"]
|
||||
if !found {
|
||||
return docxComparison{}, errors.New("word/document.xml missing")
|
||||
}
|
||||
result := docxComparison{PageSize: map[string]string{}, Margins: map[string]string{}, Semantics: map[string]bool{}}
|
||||
decoder := xml.NewDecoder(bytes.NewReader(document))
|
||||
var paragraph strings.Builder
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return docxComparison{}, err
|
||||
}
|
||||
switch value := token.(type) {
|
||||
case xml.StartElement:
|
||||
switch value.Name.Local {
|
||||
case "pgSz":
|
||||
copyAttributes(result.PageSize, value.Attr, "w", "h", "orient")
|
||||
case "pgMar":
|
||||
copyAttributes(result.Margins, value.Attr, "top", "right", "bottom", "left", "header", "footer", "gutter")
|
||||
case "bookmarkStart":
|
||||
result.Bookmarks++
|
||||
}
|
||||
case xml.CharData:
|
||||
paragraph.Write(value)
|
||||
case xml.EndElement:
|
||||
if value.Name.Local == "p" {
|
||||
if text := normalizeComparisonText(paragraph.String()); text != "" {
|
||||
result.Paragraphs = append(result.Paragraphs, text)
|
||||
}
|
||||
paragraph.Reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
for name, content := range parts {
|
||||
lower := strings.ToLower(name)
|
||||
if strings.HasPrefix(lower, "word/header") && strings.HasSuffix(lower, ".xml") {
|
||||
result.Headers++
|
||||
}
|
||||
if strings.HasPrefix(lower, "word/footer") && strings.HasSuffix(lower, ".xml") {
|
||||
result.Footers++
|
||||
}
|
||||
if strings.HasSuffix(lower, ".rels") {
|
||||
result.ExternalLinks += strings.Count(strings.ToLower(string(content)), `targetmode="external"`)
|
||||
}
|
||||
if strings.Contains(lower, "vba") || strings.Contains(lower, "activex") || strings.Contains(lower, "embeddings") {
|
||||
result.ForbiddenObject = true
|
||||
}
|
||||
}
|
||||
xmlText := string(document)
|
||||
result.Semantics = map[string]bool{
|
||||
"bold": strings.Contains(xmlText, "<w:b"), "italic": strings.Contains(xmlText, "<w:i"),
|
||||
"strike": strings.Contains(xmlText, "<w:strike"), "inline-code": strings.Contains(xmlText, "inlineCode") || strings.Contains(xmlText, "Consolas"),
|
||||
"link-label": strings.Contains(xmlText, `w:val="link"`) || strings.Contains(xmlText, `w:val="Hyperlink"`),
|
||||
"quote": strings.Contains(strings.ToLower(xmlText), `w:val="quote"`), "code-block": strings.Contains(strings.ToLower(xmlText), `w:val="code`),
|
||||
"list": strings.Contains(xmlText, "<w:numPr>"), "table": strings.Contains(xmlText, "<w:tbl>"),
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func copyAttributes(target map[string]string, attributes []xml.Attr, names ...string) {
|
||||
wanted := make(map[string]bool, len(names))
|
||||
for _, name := range names {
|
||||
wanted[name] = true
|
||||
}
|
||||
for _, attribute := range attributes {
|
||||
if wanted[attribute.Name.Local] {
|
||||
target[attribute.Name.Local] = attribute.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func equalJSON(left, right any) bool { return bytes.Equal(canonicalJSON(left), canonicalJSON(right)) }
|
||||
|
||||
func digestJSON(value any) string { return digestBytes(canonicalJSON(value)) }
|
||||
|
||||
func canonicalJSON(value any) []byte {
|
||||
raw, _ := json.Marshal(value)
|
||||
return raw
|
||||
}
|
||||
|
||||
func digestBytes(value []byte) string {
|
||||
sum := sha256.Sum256(value)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
check := flag.Bool("check", false, "fail when the generated identity differs from the checked-out renderer")
|
||||
flag.Parse()
|
||||
repository := gitOutput("rev-parse", "--show-toplevel")
|
||||
revision := gitOutput("-C", filepath.Join(repository, "deps", "agent-document-renderer"), "rev-parse", "HEAD")
|
||||
content := []byte(fmt.Sprintf("// Code generated from deps/agent-document-renderer; DO NOT EDIT.\n\npackage docgen\n\nconst rendererRevision = %q\n\nconst RendererIdentity = \"agent-document-renderer@\" + rendererRevision\n", revision))
|
||||
target := filepath.Join(repository, "internal", "docgen", "renderer_identity_generated.go")
|
||||
if *check {
|
||||
current, err := os.ReadFile(target)
|
||||
if err != nil || !bytes.Equal(current, content) {
|
||||
fmt.Fprintf(os.Stderr, "renderer identity is stale for %s\n", revision)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(target, content, 0o644); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func gitOutput(arguments ...string) string {
|
||||
output, err := exec.Command("git", arguments...).Output()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return strings.TrimSpace(string(output))
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package docgen
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"agent-document-renderer/render"
|
||||
)
|
||||
|
||||
type OperationRequest struct {
|
||||
OperationID string `json:"operationId"`
|
||||
DocumentID string `json:"documentId"`
|
||||
Title string `json:"name"`
|
||||
DocumentType string `json:"documentType,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
Markdown []byte `json:"-"`
|
||||
Mode render.Mode `json:"mode"`
|
||||
Controlled bool `json:"controlled"`
|
||||
ProjectDocument bool `json:"projectDocument"`
|
||||
}
|
||||
|
||||
type ReceiptArtifact struct {
|
||||
Role string `json:"role"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
MIME string `json:"mime"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
type OperationReceipt struct {
|
||||
OperationID string `json:"operationId"`
|
||||
Outcome string `json:"outcome"`
|
||||
State string `json:"state"`
|
||||
FrozenAt time.Time `json:"frozenAt"`
|
||||
DeliveryKey string `json:"deliveryKey,omitempty"`
|
||||
Renderer string `json:"rendererIdentity"`
|
||||
CacheHit bool `json:"cacheHit,omitempty"`
|
||||
Artifacts []ReceiptArtifact `json:"artifacts,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
+211
-87
@@ -24,10 +24,14 @@ import (
|
||||
const (
|
||||
WorkspaceProjectID = "workspace"
|
||||
ManifestSchemaVersion = "backend-document-delivery/v1"
|
||||
RendererIdentity = "agent-document-renderer@d449b9f779f2f968f8ac556b89940a6c9aaf3fdc"
|
||||
AdapterIdentity = RendererIdentity + "/word-adapter-v1"
|
||||
defaultProfileID = "company.default"
|
||||
defaultProfileVersion = "1.0.0"
|
||||
defaultRenderCapacity = 4
|
||||
)
|
||||
|
||||
var renderCapacity = make(chan struct{}, defaultRenderCapacity)
|
||||
|
||||
var (
|
||||
ErrRDMRequired = errors.New("docgen: controlled or project document requires RDM")
|
||||
ErrProfileMismatch = errors.New("docgen: organization-default profile mismatch")
|
||||
@@ -42,20 +46,20 @@ type Artifact struct {
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
DocumentID string
|
||||
Title string
|
||||
DocumentType string
|
||||
Language string
|
||||
Markdown []byte
|
||||
Mode render.Mode
|
||||
FrozenAt time.Time
|
||||
RequestedBy string
|
||||
ProfileID string
|
||||
ProfileVersion string
|
||||
Controlled bool
|
||||
ProjectDocument bool
|
||||
LegacyComparison bool
|
||||
Limits Limits
|
||||
DocumentID string
|
||||
Title string
|
||||
DocumentType string
|
||||
Language string
|
||||
Markdown []byte
|
||||
Mode render.Mode
|
||||
FrozenAt time.Time
|
||||
RequestedBy string
|
||||
ProfileID string
|
||||
ProfileVersion string
|
||||
Controlled bool
|
||||
ProjectDocument bool
|
||||
LegacySource []byte
|
||||
Limits Limits
|
||||
}
|
||||
|
||||
type Limits struct {
|
||||
@@ -85,12 +89,28 @@ func (failure *DiagnosticError) Unwrap() error {
|
||||
return failure.Err
|
||||
}
|
||||
|
||||
type ComparisonDimension struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
LegacyHash string `json:"legacyHash,omitempty"`
|
||||
SharedHash string `json:"sharedHash,omitempty"`
|
||||
ExceptionID string `json:"exceptionId,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
type ComparisonReceipt struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
LegacySHA256 string `json:"legacySha256,omitempty"`
|
||||
SharedSHA256 string `json:"sharedSha256,omitempty"`
|
||||
IntentionalDifferences []string `json:"intentionalDifferences,omitempty"`
|
||||
AccidentalDifferences []string `json:"accidentalDifferences,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Assessment string `json:"assessment,omitempty"`
|
||||
LegacySHA256 string `json:"legacySha256,omitempty"`
|
||||
SharedSHA256 string `json:"sharedSha256,omitempty"`
|
||||
Dimensions []ComparisonDimension `json:"dimensions,omitempty"`
|
||||
IntentionalDifferences []string `json:"intentionalDifferences,omitempty"`
|
||||
AccidentalDifferences []string `json:"accidentalDifferences,omitempty"`
|
||||
Unassessed []string `json:"unassessed,omitempty"`
|
||||
}
|
||||
|
||||
type RenderOptions struct {
|
||||
MigrationComparison bool
|
||||
}
|
||||
|
||||
type ManifestArtifact struct {
|
||||
@@ -130,6 +150,7 @@ type Manifest struct {
|
||||
type Delivery struct {
|
||||
Document model.CanonicalDocument
|
||||
Profile ProfileReceipt
|
||||
Renderer render.Manifest
|
||||
Markdown Artifact
|
||||
DOCX Artifact
|
||||
Manifest Artifact
|
||||
@@ -137,15 +158,87 @@ type Delivery struct {
|
||||
Comparison ComparisonReceipt
|
||||
}
|
||||
|
||||
type PreparedDocument struct {
|
||||
Document model.CanonicalDocument
|
||||
Markdown Artifact
|
||||
CanonicalHash string
|
||||
}
|
||||
|
||||
// Prepare canonicalizes and validates authoritative Markdown without loading a
|
||||
// profile or invoking the Word adapter. Durable callers persist its Markdown
|
||||
// artifact before any rendering work.
|
||||
func Prepare(ctx context.Context, request Request) (PreparedDocument, error) {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return PreparedDocument{}, err
|
||||
}
|
||||
if request.Controlled || request.ProjectDocument || request.Mode == render.ModeReview || request.Mode == render.ModeApproved {
|
||||
return PreparedDocument{}, ErrRDMRequired
|
||||
}
|
||||
if request.Mode != render.ModeWorking && request.Mode != render.ModePreview {
|
||||
return PreparedDocument{}, fmt.Errorf("docgen: unsupported ordinary workspace mode %q", request.Mode)
|
||||
}
|
||||
limits, err := normalizeLimits(request.Limits)
|
||||
if err != nil {
|
||||
return PreparedDocument{}, err
|
||||
}
|
||||
if len(request.Markdown) > limits.MaxMarkdownBytes {
|
||||
return PreparedDocument{}, fmt.Errorf("%w: Markdown is %d bytes, limit is %d", ErrResourceLimit, len(request.Markdown), limits.MaxMarkdownBytes)
|
||||
}
|
||||
materialized := bytes.HasPrefix(bytes.TrimSpace(request.Markdown), []byte("---"))
|
||||
var parsed markdownrenderer.Result
|
||||
if materialized {
|
||||
parsed, err = parseMarkdownContext(ctx, request.Markdown, false, limits)
|
||||
if err != nil {
|
||||
return PreparedDocument{}, classifyParseError(err)
|
||||
}
|
||||
if err := validateWorkspaceRoute(request, parsed.Metadata); err != nil {
|
||||
return PreparedDocument{}, err
|
||||
}
|
||||
if parsed.Metadata.ProfileID != defaultProfileID || parsed.Metadata.ProfileVersion != defaultProfileVersion {
|
||||
return PreparedDocument{}, fmt.Errorf("%w: Markdown binds %s@%s, expected %s@%s", ErrProfileMismatch,
|
||||
parsed.Metadata.ProfileID, parsed.Metadata.ProfileVersion, defaultProfileID, defaultProfileVersion)
|
||||
}
|
||||
} else {
|
||||
if request.ProfileID != "" || request.ProfileVersion != "" {
|
||||
if request.ProfileID != defaultProfileID || request.ProfileVersion != defaultProfileVersion {
|
||||
return PreparedDocument{}, fmt.Errorf("%w: requested %s@%s, expected %s@%s", ErrProfileMismatch,
|
||||
request.ProfileID, request.ProfileVersion, defaultProfileID, defaultProfileVersion)
|
||||
}
|
||||
}
|
||||
source, err := workspaceMarkdown(request, defaultProfileID, defaultProfileVersion)
|
||||
if err != nil {
|
||||
return PreparedDocument{}, err
|
||||
}
|
||||
if len(source) > limits.MaxMarkdownBytes {
|
||||
return PreparedDocument{}, fmt.Errorf("%w: normalized Markdown source is %d bytes, limit is %d", ErrResourceLimit, len(source), limits.MaxMarkdownBytes)
|
||||
}
|
||||
parsed, err = parseMarkdownContext(ctx, source, true, limits)
|
||||
if err != nil {
|
||||
return PreparedDocument{}, classifyParseError(err)
|
||||
}
|
||||
}
|
||||
if len(parsed.Markdown) > limits.MaxMarkdownBytes {
|
||||
return PreparedDocument{}, fmt.Errorf("%w: materialized Markdown is %d bytes, limit is %d", ErrResourceLimit, len(parsed.Markdown), limits.MaxMarkdownBytes)
|
||||
}
|
||||
canonicalHash, err := parsed.Document.HashContext(ctx)
|
||||
if err != nil {
|
||||
return PreparedDocument{}, err
|
||||
}
|
||||
return PreparedDocument{
|
||||
Document: parsed.Document, CanonicalHash: canonicalHash,
|
||||
Markdown: artifact(safeBaseName(request.Title)+".md", "text/markdown; charset=utf-8", parsed.Markdown),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type generatorDependencies struct {
|
||||
loadDefault func() (profiles.Bundle, error)
|
||||
loadDefault func(context.Context) (profiles.Bundle, error)
|
||||
newRenderer func(*profile.AssetReader) render.Renderer
|
||||
legacyBuild func(string, string) ([]byte, error)
|
||||
}
|
||||
|
||||
func defaultGeneratorDependencies() generatorDependencies {
|
||||
return generatorDependencies{
|
||||
loadDefault: profiles.LoadCompanyDefault,
|
||||
loadDefault: profiles.LoadCompanyDefaultContext,
|
||||
newRenderer: func(assets *profile.AssetReader) render.Renderer { return word.NewAdapter(assets) },
|
||||
legacyBuild: legacyBuild,
|
||||
}
|
||||
@@ -156,6 +249,14 @@ func Generate(ctx context.Context, request Request) (Delivery, error) {
|
||||
}
|
||||
|
||||
func generate(ctx context.Context, request Request, dependencies generatorDependencies) (Delivery, error) {
|
||||
return generateWithOptions(ctx, request, RenderOptions{}, dependencies)
|
||||
}
|
||||
|
||||
func GenerateWithOptions(ctx context.Context, request Request, options RenderOptions) (Delivery, error) {
|
||||
return generateWithOptions(ctx, request, options, defaultGeneratorDependencies())
|
||||
}
|
||||
|
||||
func generateWithOptions(ctx context.Context, request Request, options RenderOptions, dependencies generatorDependencies) (Delivery, error) {
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return Delivery{}, err
|
||||
}
|
||||
@@ -172,7 +273,18 @@ func generate(ctx context.Context, request Request, dependencies generatorDepend
|
||||
if len(request.Markdown) > limits.MaxMarkdownBytes {
|
||||
return Delivery{}, fmt.Errorf("%w: Markdown is %d bytes, limit is %d", ErrResourceLimit, len(request.Markdown), limits.MaxMarkdownBytes)
|
||||
}
|
||||
bundle, err := dependencies.loadDefault()
|
||||
var parsed markdownrenderer.Result
|
||||
materialized := bytes.HasPrefix(bytes.TrimSpace(request.Markdown), []byte("---"))
|
||||
if materialized {
|
||||
parsed, err = parseMarkdownContext(ctx, request.Markdown, false, limits)
|
||||
if err != nil {
|
||||
return Delivery{}, classifyParseError(err)
|
||||
}
|
||||
if err := validateWorkspaceRoute(request, parsed.Metadata); err != nil {
|
||||
return Delivery{}, err
|
||||
}
|
||||
}
|
||||
bundle, err := dependencies.loadDefault(ctx)
|
||||
if err != nil {
|
||||
return Delivery{}, fmt.Errorf("docgen: load company default: %w", err)
|
||||
}
|
||||
@@ -183,30 +295,21 @@ func generate(ctx context.Context, request Request, dependencies generatorDepend
|
||||
request.ProfileID, request.ProfileVersion, profileID, profileVersion)
|
||||
}
|
||||
}
|
||||
source, firstImport, err := workspaceMarkdown(request, profileID, profileVersion)
|
||||
if err != nil {
|
||||
return Delivery{}, err
|
||||
if !materialized {
|
||||
source, sourceErr := workspaceMarkdown(request, profileID, profileVersion)
|
||||
if sourceErr != nil {
|
||||
return Delivery{}, sourceErr
|
||||
}
|
||||
if len(source) > limits.MaxMarkdownBytes {
|
||||
return Delivery{}, fmt.Errorf("%w: normalized Markdown source is %d bytes, limit is %d", ErrResourceLimit, len(source), limits.MaxMarkdownBytes)
|
||||
}
|
||||
parsed, err = parseMarkdownContext(ctx, source, true, limits)
|
||||
if err != nil {
|
||||
return Delivery{}, classifyParseError(err)
|
||||
}
|
||||
}
|
||||
parsed, err := markdownrenderer.Parse(source, markdownrenderer.Options{
|
||||
Controlled: true, FirstImport: firstImport, MaxBytes: limits.MaxMarkdownBytes + 64<<10,
|
||||
MaxNodes: limits.MaxNodes, MaxDepth: limits.MaxDepth,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, markdownrenderer.ErrDocumentLimit) {
|
||||
return Delivery{}, fmt.Errorf("%w: %v", ErrResourceLimit, err)
|
||||
}
|
||||
code := "markdown_invalid"
|
||||
switch {
|
||||
case errors.Is(err, markdownrenderer.ErrUnknownMetadata):
|
||||
code = "unknown_metadata"
|
||||
case errors.Is(err, markdownrenderer.ErrHiddenHTML):
|
||||
code = "unsafe_markdown"
|
||||
case errors.Is(err, markdownrenderer.ErrUnsafeDirective) && strings.Contains(err.Error(), "unknown directive"):
|
||||
code = "unknown_directive"
|
||||
case errors.Is(err, markdownrenderer.ErrUnsafeDirective):
|
||||
code = "unsafe_markdown"
|
||||
}
|
||||
return Delivery{}, diagnosticFailure("parse", code, err, nil)
|
||||
if len(parsed.Markdown) > limits.MaxMarkdownBytes {
|
||||
return Delivery{}, fmt.Errorf("%w: materialized Markdown is %d bytes, limit is %d", ErrResourceLimit, len(parsed.Markdown), limits.MaxMarkdownBytes)
|
||||
}
|
||||
if err := validateWorkspaceMetadata(request, parsed.Metadata, profileID, profileVersion); err != nil {
|
||||
return Delivery{}, err
|
||||
@@ -214,13 +317,17 @@ func generate(ctx context.Context, request Request, dependencies generatorDepend
|
||||
if err := contextErr(ctx); err != nil {
|
||||
return Delivery{}, err
|
||||
}
|
||||
result, err := renderWithContext(ctx, dependencies.newRenderer(bundle.Assets), render.Request{
|
||||
if err := acquireRenderCapacity(ctx); err != nil {
|
||||
return Delivery{}, err
|
||||
}
|
||||
result, err := dependencies.newRenderer(bundle.Assets).Render(ctx, render.Request{
|
||||
Document: parsed.Document,
|
||||
Profile: bundle.Snapshot,
|
||||
Mode: request.Mode,
|
||||
FrozenAt: request.FrozenAt,
|
||||
RequestedBy: request.RequestedBy,
|
||||
})
|
||||
releaseRenderCapacity()
|
||||
if err != nil {
|
||||
code := "renderer_failed"
|
||||
if strings.Contains(err.Error(), "asset") || strings.Contains(err.Error(), "hash") {
|
||||
@@ -230,7 +337,7 @@ func generate(ctx context.Context, request Request, dependencies generatorDepend
|
||||
}
|
||||
base := safeBaseName(request.Title)
|
||||
delivery := Delivery{
|
||||
Document: parsed.Document,
|
||||
Document: parsed.Document, Renderer: result.Manifest,
|
||||
Profile: ProfileReceipt{
|
||||
ProfileID: profileID, Version: profileVersion, SnapshotHash: bundle.Snapshot.SnapshotHash(),
|
||||
Candidate: true, ReviewStatus: bundle.Review.Status, PublicationAllowed: bundle.Review.PublicationAllowed,
|
||||
@@ -240,26 +347,22 @@ func generate(ctx context.Context, request Request, dependencies generatorDepend
|
||||
Markdown: artifact(base+".md", "text/markdown; charset=utf-8", parsed.Markdown),
|
||||
DOCX: artifact(base+".docx", result.MIME, result.Artifact),
|
||||
}
|
||||
if request.LegacyComparison {
|
||||
legacy, err := dependencies.legacyBuild(request.Title, string(request.Markdown))
|
||||
if options.MigrationComparison {
|
||||
comparisonSource := request.LegacySource
|
||||
if len(comparisonSource) == 0 {
|
||||
comparisonSource = request.Markdown
|
||||
}
|
||||
legacy, err := dependencies.legacyBuild(request.Title, string(comparisonSource))
|
||||
if err != nil {
|
||||
return Delivery{}, fmt.Errorf("docgen: legacy migration comparison: %w", err)
|
||||
}
|
||||
legacySum := sha256.Sum256(legacy)
|
||||
delivery.Comparison = ComparisonReceipt{
|
||||
Enabled: true, LegacySHA256: hex.EncodeToString(legacySum[:]), SharedSHA256: delivery.DOCX.SHA256,
|
||||
IntentionalDifferences: []string{
|
||||
"shared output removes the legacy duplicate title paragraph",
|
||||
"shared output adds stable node bookmarks and deterministic package metadata",
|
||||
"shared output uses the pending organization-default candidate page and footer rules",
|
||||
},
|
||||
}
|
||||
delivery.Comparison = compareLegacyAndShared(request.Title, comparisonSource, parsed.Document, legacy, delivery.DOCX.Bytes)
|
||||
}
|
||||
manifest := Manifest{
|
||||
SchemaVersion: ManifestSchemaVersion, RendererIdentity: RendererIdentity, AdapterIdentity: AdapterIdentity,
|
||||
SelectionSource: "organization-default",
|
||||
Profile: delivery.Profile,
|
||||
DocumentRootID: parsed.Document.Root.ID,
|
||||
Profile: delivery.Profile,
|
||||
DocumentRootID: parsed.Document.Root.ID,
|
||||
Artifacts: []ManifestArtifact{
|
||||
manifestArtifact(delivery.Markdown), manifestArtifact(delivery.DOCX),
|
||||
},
|
||||
@@ -280,25 +383,42 @@ func generate(ctx context.Context, request Request, dependencies generatorDepend
|
||||
return delivery, nil
|
||||
}
|
||||
|
||||
type renderOutcome struct {
|
||||
result render.Result
|
||||
err error
|
||||
}
|
||||
|
||||
func renderWithContext(ctx context.Context, renderer render.Renderer, request render.Request) (render.Result, error) {
|
||||
outcomes := make(chan renderOutcome, 1)
|
||||
go func() {
|
||||
result, err := renderer.Render(ctx, request)
|
||||
outcomes <- renderOutcome{result: result, err: err}
|
||||
}()
|
||||
func acquireRenderCapacity(ctx context.Context) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return render.Result{}, ctx.Err()
|
||||
case outcome := <-outcomes:
|
||||
return outcome.result, outcome.err
|
||||
return ctx.Err()
|
||||
case renderCapacity <- struct{}{}:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func releaseRenderCapacity() { <-renderCapacity }
|
||||
|
||||
func parseMarkdownContext(ctx context.Context, source []byte, firstImport bool, limits Limits) (markdownrenderer.Result, error) {
|
||||
return markdownrenderer.ParseContext(ctx, source, markdownrenderer.Options{
|
||||
Controlled: true, FirstImport: firstImport, MaxBytes: limits.MaxMarkdownBytes,
|
||||
MaxNodes: limits.MaxNodes, MaxDepth: limits.MaxDepth,
|
||||
})
|
||||
}
|
||||
|
||||
func classifyParseError(err error) error {
|
||||
if errors.Is(err, markdownrenderer.ErrDocumentLimit) {
|
||||
return fmt.Errorf("%w: %v", ErrResourceLimit, err)
|
||||
}
|
||||
code := "markdown_invalid"
|
||||
switch {
|
||||
case errors.Is(err, markdownrenderer.ErrUnknownMetadata):
|
||||
code = "unknown_metadata"
|
||||
case errors.Is(err, markdownrenderer.ErrHiddenHTML):
|
||||
code = "unsafe_markdown"
|
||||
case errors.Is(err, markdownrenderer.ErrUnsafeDirective) && strings.Contains(err.Error(), "unknown directive"):
|
||||
code = "unknown_directive"
|
||||
case errors.Is(err, markdownrenderer.ErrUnsafeDirective):
|
||||
code = "unsafe_markdown"
|
||||
}
|
||||
return diagnosticFailure("parse", code, err, nil)
|
||||
}
|
||||
|
||||
func diagnosticFailure(stage, code string, err error, diagnostics []render.Diagnostic) error {
|
||||
cloned := append([]render.Diagnostic(nil), diagnostics...)
|
||||
if len(cloned) == 0 {
|
||||
@@ -323,16 +443,13 @@ func normalizeLimits(input Limits) (Limits, error) {
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func workspaceMarkdown(request Request, profileID, profileVersion string) ([]byte, bool, error) {
|
||||
func workspaceMarkdown(request Request, profileID, profileVersion string) ([]byte, error) {
|
||||
if strings.TrimSpace(request.DocumentID) == "" || strings.TrimSpace(request.Title) == "" ||
|
||||
strings.TrimSpace(request.DocumentType) == "" || strings.TrimSpace(request.Language) == "" {
|
||||
return nil, false, errors.New("docgen: document ID, title, document type, and language are required")
|
||||
return nil, errors.New("docgen: document ID, title, document type, and language are required")
|
||||
}
|
||||
if request.Mode == "" || request.FrozenAt.IsZero() || strings.TrimSpace(request.RequestedBy) == "" {
|
||||
return nil, false, errors.New("docgen: mode, frozen time, and requester are required")
|
||||
}
|
||||
if bytes.HasPrefix(bytes.TrimSpace(request.Markdown), []byte("---")) {
|
||||
return bytes.Clone(request.Markdown), false, nil
|
||||
return nil, errors.New("docgen: mode, frozen time, and requester are required")
|
||||
}
|
||||
quote := func(value string) string {
|
||||
raw, _ := json.Marshal(value)
|
||||
@@ -350,17 +467,13 @@ func workspaceMarkdown(request Request, profileID, profileVersion string) ([]byt
|
||||
header.WriteString("controlled: false\n---\n\n")
|
||||
header.Write(bytes.TrimSpace(request.Markdown))
|
||||
header.WriteByte('\n')
|
||||
return []byte(header.String()), true, nil
|
||||
return []byte(header.String()), nil
|
||||
}
|
||||
|
||||
func validateWorkspaceMetadata(request Request, metadata markdownrenderer.Metadata, profileID, profileVersion string) error {
|
||||
func validateWorkspaceRoute(request Request, metadata markdownrenderer.Metadata) error {
|
||||
if metadata.Controlled || metadata.ProjectID != WorkspaceProjectID {
|
||||
return ErrRDMRequired
|
||||
}
|
||||
if metadata.ProfileID != profileID || metadata.ProfileVersion != profileVersion {
|
||||
return fmt.Errorf("%w: Markdown binds %s@%s, available %s@%s", ErrProfileMismatch,
|
||||
metadata.ProfileID, metadata.ProfileVersion, profileID, profileVersion)
|
||||
}
|
||||
if metadata.DocumentID != request.DocumentID || metadata.DocumentType != request.DocumentType ||
|
||||
metadata.Title != request.Title || metadata.Language != request.Language {
|
||||
return errors.New("docgen: persisted Markdown metadata does not match the frozen request")
|
||||
@@ -368,6 +481,17 @@ func validateWorkspaceMetadata(request Request, metadata markdownrenderer.Metada
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWorkspaceMetadata(request Request, metadata markdownrenderer.Metadata, profileID, profileVersion string) error {
|
||||
if err := validateWorkspaceRoute(request, metadata); err != nil {
|
||||
return err
|
||||
}
|
||||
if metadata.ProfileID != profileID || metadata.ProfileVersion != profileVersion {
|
||||
return fmt.Errorf("%w: Markdown binds %s@%s, available %s@%s", ErrProfileMismatch,
|
||||
metadata.ProfileID, metadata.ProfileVersion, profileID, profileVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func artifact(name, mime string, raw []byte) Artifact {
|
||||
sum := sha256.Sum256(raw)
|
||||
// Callers transfer ownership of freshly produced buffers to the delivery.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package docgen
|
||||
|
||||
//go:generate go run ./internal/genrendereridentity
|
||||
@@ -0,0 +1,7 @@
|
||||
// Code generated from deps/agent-document-renderer; DO NOT EDIT.
|
||||
|
||||
package docgen
|
||||
|
||||
const rendererRevision = "d5cd8c126045a27d5a5cc5da23f64d3c737e116a"
|
||||
|
||||
const RendererIdentity = "agent-document-renderer@" + rendererRevision
|
||||
@@ -8,14 +8,18 @@ import (
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
markdownrenderer "agent-document-renderer/markdown"
|
||||
"agent-document-renderer/model"
|
||||
"agent-document-renderer/profile"
|
||||
"agent-document-renderer/profiles"
|
||||
"agent-document-renderer/render"
|
||||
)
|
||||
|
||||
@@ -56,6 +60,23 @@ func workspaceRequest(markdown []byte) Request {
|
||||
}
|
||||
}
|
||||
|
||||
const semanticMarkdown = `# **Semantic** report
|
||||
|
||||
Paragraph with **bold**, *italic*, ~~strike~~, ` + "`inline code`" + `, and [safe **label**](https://example.com/private-target).
|
||||
|
||||
> Quoted **evidence** with *emphasis*.
|
||||
|
||||
` + "```go" + `
|
||||
fmt.Println("safe")
|
||||
` + "```" + `
|
||||
|
||||
- **bold item**
|
||||
|
||||
| Item | Meaning |
|
||||
|---|---|
|
||||
| ` + "`code cell`" + ` | [table label](#semantic) |
|
||||
`
|
||||
|
||||
func TestGenerateProducesDeterministicHashedDeliveryPackage(t *testing.T) {
|
||||
request := workspaceRequest([]byte(representativeMarkdown))
|
||||
first, err := Generate(context.Background(), request)
|
||||
@@ -186,6 +207,125 @@ func TestGeneratePersistsCanonicalMarkdownWithStableNodeParity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePreservesAllSharedSemanticMarkdownWithoutLinkTargetLeak(t *testing.T) {
|
||||
request := workspaceRequest([]byte(semanticMarkdown))
|
||||
request.Title = "Semantic report"
|
||||
delivery, err := Generate(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var quote, code *model.Node
|
||||
var hasStrong, hasEmphasis, hasStrike, hasInlineCode, hasLink bool
|
||||
var walkInline func([]model.Inline)
|
||||
walkInline = func(inlines []model.Inline) {
|
||||
for index := range inlines {
|
||||
switch inlines[index].Kind {
|
||||
case model.InlineStrong:
|
||||
hasStrong = true
|
||||
case model.InlineEmphasis:
|
||||
hasEmphasis = true
|
||||
case model.InlineStrike:
|
||||
hasStrike = true
|
||||
case model.InlineCode:
|
||||
hasInlineCode = true
|
||||
case model.InlineLink:
|
||||
hasLink = true
|
||||
}
|
||||
walkInline(inlines[index].Children)
|
||||
}
|
||||
}
|
||||
var walk func(*model.Node)
|
||||
walk = func(node *model.Node) {
|
||||
if node.Kind == model.NodeQuote {
|
||||
quote = node
|
||||
}
|
||||
if node.Kind == model.NodeCodeBlock {
|
||||
code = node
|
||||
}
|
||||
walkInline(node.Inlines)
|
||||
for index := range node.Children {
|
||||
walk(&node.Children[index])
|
||||
}
|
||||
}
|
||||
walk(&delivery.Document.Root)
|
||||
if quote == nil || code == nil || !hasStrong || !hasEmphasis || !hasStrike || !hasInlineCode || !hasLink {
|
||||
t.Fatalf("semantic canonical document incomplete: quote=%v code=%v strong=%v emphasis=%v strike=%v inlineCode=%v link=%v", quote != nil, code != nil, hasStrong, hasEmphasis, hasStrike, hasInlineCode, hasLink)
|
||||
}
|
||||
documentXML := docxPart(t, delivery.DOCX.Bytes, "word/document.xml")
|
||||
for _, fragment := range []string{`<w:b/>`, `<w:i/>`, `<w:strike/>`, `w:val="inlineCode"`, `w:val="link"`, `w:val="quote"`, `w:val="code"`, `<w:numPr>`, `<w:tbl>`} {
|
||||
if !strings.Contains(documentXML, fragment) {
|
||||
t.Errorf("Word output missing %s", fragment)
|
||||
}
|
||||
}
|
||||
if bytes.Contains(delivery.DOCX.Bytes, []byte("https://example.com/private-target")) || strings.Contains(documentXML, "TargetMode=\"External\"") {
|
||||
t.Fatal("safe link target leaked into DOCX package")
|
||||
}
|
||||
replayed := request
|
||||
replayed.Markdown = delivery.Markdown.Bytes
|
||||
second, err := Generate(context.Background(), replayed)
|
||||
if err != nil || !reflect.DeepEqual(delivery.Document, second.Document) {
|
||||
t.Fatalf("semantic Markdown did not replay canonically: err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaterializedMarkdownMustFitAndReplayAtOneBudget(t *testing.T) {
|
||||
request := workspaceRequest([]byte("# T\n\nsmall body\n"))
|
||||
request.Title = "T"
|
||||
request.Limits.MaxMarkdownBytes = 256
|
||||
if delivery, err := Generate(context.Background(), request); !errors.Is(err, ErrResourceLimit) {
|
||||
t.Fatalf("materialized overflow returned delivery=%d bytes err=%v, want ErrResourceLimit", len(delivery.Markdown.Bytes), err)
|
||||
}
|
||||
|
||||
request.Limits.MaxMarkdownBytes = 0
|
||||
first, err := Generate(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request.Markdown = first.Markdown.Bytes
|
||||
request.Limits.MaxMarkdownBytes = len(first.Markdown.Bytes)
|
||||
second, err := Generate(context.Background(), request)
|
||||
if err != nil || !bytes.Equal(first.Markdown.Bytes, second.Markdown.Bytes) {
|
||||
t.Fatalf("exact-budget replay failed: err=%v first=%d second=%d", err, len(first.Markdown.Bytes), len(second.Markdown.Bytes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistedControlledMetadataRoutesBeforeDefaultProfileLoad(t *testing.T) {
|
||||
source := []byte("---\ndocumentId: controlled\nprojectId: workspace\ndocumentType: general\ntitle: Controlled\nlanguage: zh-CN\nprofileId: company.default\nprofileVersion: 1.0.0\ncontrolled: true\n---\n\n<!-- node-id:heading -->\n# Controlled\n")
|
||||
request := workspaceRequest(source)
|
||||
request.DocumentID = "controlled"
|
||||
request.Title = "Controlled"
|
||||
loads := 0
|
||||
deps := defaultGeneratorDependencies()
|
||||
baseLoad := deps.loadDefault
|
||||
deps.loadDefault = func(ctx context.Context) (profiles.Bundle, error) {
|
||||
loads++
|
||||
return baseLoad(ctx)
|
||||
}
|
||||
_, err := generate(context.Background(), request, deps)
|
||||
if !errors.Is(err, ErrRDMRequired) || loads != 0 {
|
||||
t.Fatalf("persisted controlled route err=%v profileLoads=%d, want ErrRDMRequired before load", err, loads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRendererIdentityMatchesPublishedPin(t *testing.T) {
|
||||
const want = "agent-document-renderer@d5cd8c126045a27d5a5cc5da23f64d3c737e116a"
|
||||
if RendererIdentity != want {
|
||||
t.Fatalf("RendererIdentity = %q, want %q", RendererIdentity, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRendererIdentityIsGeneratedFromCheckedOutComponent(t *testing.T) {
|
||||
repository, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command := exec.Command("go", "run", "./internal/docgen/internal/genrendereridentity", "-check")
|
||||
command.Dir = repository
|
||||
if output, err := command.CombinedOutput(); err != nil {
|
||||
t.Fatalf("renderer identity generation check: %v\n%s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateReusesPersistedMarkdownWithoutChangingNodeIdentity(t *testing.T) {
|
||||
request := workspaceRequest([]byte(representativeMarkdown))
|
||||
first, err := Generate(context.Background(), request)
|
||||
@@ -313,28 +453,35 @@ func (renderer failingRenderer) Render(context.Context, render.Request) (render.
|
||||
return render.Result{Diagnostics: []render.Diagnostic{{Code: "forced_failure", Severity: render.SeverityError, Message: renderer.err.Error()}}}, renderer.err
|
||||
}
|
||||
|
||||
type blockingRenderer struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
type cleanupRenderer struct {
|
||||
active *atomic.Int32
|
||||
started chan struct{}
|
||||
canceled chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (renderer blockingRenderer) Validate(context.Context, render.Request) ([]render.Diagnostic, error) {
|
||||
func (renderer cleanupRenderer) Validate(context.Context, render.Request) ([]render.Diagnostic, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (renderer blockingRenderer) Render(context.Context, render.Request) (render.Result, error) {
|
||||
func (renderer cleanupRenderer) Render(ctx context.Context, _ render.Request) (render.Result, error) {
|
||||
renderer.active.Add(1)
|
||||
defer renderer.active.Add(-1)
|
||||
close(renderer.started)
|
||||
<-ctx.Done()
|
||||
close(renderer.canceled)
|
||||
<-renderer.release
|
||||
return render.Result{}, nil
|
||||
return render.Result{}, ctx.Err()
|
||||
}
|
||||
|
||||
func TestCancellationDuringRendererReturnsPromptly(t *testing.T) {
|
||||
func TestCancellationWaitsForRendererUnwindAndRecoversCapacity(t *testing.T) {
|
||||
var active atomic.Int32
|
||||
started := make(chan struct{})
|
||||
canceled := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
deps := defaultGeneratorDependencies()
|
||||
deps.newRenderer = func(*profile.AssetReader) render.Renderer {
|
||||
return blockingRenderer{started: started, release: release}
|
||||
return cleanupRenderer{active: &active, started: started, canceled: canceled, release: release}
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
result := make(chan error, 1)
|
||||
@@ -344,13 +491,20 @@ func TestCancellationDuringRendererReturnsPromptly(t *testing.T) {
|
||||
}()
|
||||
<-started
|
||||
cancel()
|
||||
<-canceled
|
||||
select {
|
||||
case err := <-result:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancellation error = %v, want context.Canceled", err)
|
||||
t.Fatalf("Generate returned before renderer cleanup: %v", err)
|
||||
default:
|
||||
}
|
||||
close(release)
|
||||
select {
|
||||
case err := <-result:
|
||||
if !errors.Is(err, context.Canceled) || active.Load() != 0 {
|
||||
t.Fatalf("cancellation err=%v active=%d, want canceled and zero capacity", err, active.Load())
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Generate did not return after cancellation")
|
||||
t.Fatal("Generate did not return after renderer cleanup")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,8 +518,7 @@ func TestRendererFailureNeverFallsBackToLegacy(t *testing.T) {
|
||||
return []byte("legacy"), nil
|
||||
}
|
||||
request := workspaceRequest([]byte(representativeMarkdown))
|
||||
request.LegacyComparison = true
|
||||
if _, err := generate(context.Background(), request, deps); !errors.Is(err, forced) {
|
||||
if _, err := generateWithOptions(context.Background(), request, RenderOptions{MigrationComparison: true}, deps); !errors.Is(err, forced) {
|
||||
t.Fatalf("generate error = %v, want forced renderer failure", err)
|
||||
}
|
||||
if legacyCalls != 0 {
|
||||
@@ -389,12 +542,11 @@ func TestLegacyComparisonIsExplicitAndDoesNotReplaceSharedOutput(t *testing.T) {
|
||||
t.Fatalf("disabled comparison called legacy renderer: calls=%d receipt=%#v", legacyCalls, withoutFlag.Comparison)
|
||||
}
|
||||
|
||||
request.LegacyComparison = true
|
||||
withFlag, err := generate(context.Background(), request, deps)
|
||||
withFlag, err := generateWithOptions(context.Background(), request, RenderOptions{MigrationComparison: true}, deps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if legacyCalls != 1 || !withFlag.Comparison.Enabled || withFlag.Comparison.LegacySHA256 == "" {
|
||||
if legacyCalls != 1 || !withFlag.Comparison.Enabled || withFlag.Comparison.LegacySHA256 == "" || withFlag.Comparison.Assessment != "unassessed" || len(withFlag.Comparison.Unassessed) == 0 {
|
||||
t.Fatalf("enabled comparison receipt=%#v calls=%d", withFlag.Comparison, legacyCalls)
|
||||
}
|
||||
if !bytes.Equal(withoutFlag.DOCX.Bytes, withFlag.DOCX.Bytes) {
|
||||
@@ -403,35 +555,47 @@ func TestLegacyComparisonIsExplicitAndDoesNotReplaceSharedOutput(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRepresentativeLegacyComparisonRecordsOnlyIntentionalDifferences(t *testing.T) {
|
||||
const common = "# 研发周会纪要\n\n正文结论。\n\n## 要点\n\n- 统一解析入口\n- 保留 Markdown\n\n| 项目 | 状态 |\n|---|---|\n| 解析器 | 统一 |\n"
|
||||
const common = "# 研发周会纪要\n\n正文有 **粗体**、*斜体*、~~删除~~、`代码` 和 [链接](https://example.com/private)。\n\n> 引用内容。\n\n```go\nfmt.Println(1)\n```\n\n## 要点\n\n- 统一解析入口\n- 保留 Markdown\n\n| 项目 | 状态 |\n|---|---|\n| 解析器 | 统一 |\n"
|
||||
request := workspaceRequest([]byte(common))
|
||||
request.LegacyComparison = true
|
||||
delivery, err := Generate(context.Background(), request)
|
||||
delivery, err := generateWithOptions(context.Background(), request, RenderOptions{MigrationComparison: true}, defaultGeneratorDependencies())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy, err := legacyBuild(request.Title, common)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyText := docxVisibleText(t, legacy)
|
||||
sharedText := docxVisibleText(t, delivery.DOCX.Bytes)
|
||||
for _, value := range []string{"研发周会纪要", "正文结论。", "要点", "统一解析入口", "保留 Markdown", "项目", "状态", "解析器", "统一"} {
|
||||
if !strings.Contains(legacyText, value) || !strings.Contains(sharedText, value) {
|
||||
t.Errorf("representative content %q missing: legacy=%q shared=%q", value, legacyText, sharedText)
|
||||
}
|
||||
}
|
||||
if len(delivery.Comparison.AccidentalDifferences) != 0 {
|
||||
if delivery.Comparison.Assessment != "assessed" || len(delivery.Comparison.AccidentalDifferences) != 0 || len(delivery.Comparison.Unassessed) != 0 {
|
||||
t.Fatalf("accidental differences remain: %#v", delivery.Comparison.AccidentalDifferences)
|
||||
}
|
||||
foundTitleDifference := false
|
||||
for _, difference := range delivery.Comparison.IntentionalDifferences {
|
||||
if strings.Contains(difference, "duplicate title") {
|
||||
foundTitleDifference = true
|
||||
exceptions := map[string]bool{}
|
||||
for _, dimension := range delivery.Comparison.Dimensions {
|
||||
if dimension.Status == "intentional" {
|
||||
exceptions[dimension.ExceptionID] = true
|
||||
}
|
||||
}
|
||||
if !foundTitleDifference {
|
||||
t.Fatalf("comparison did not record the legacy duplicate-title correction: %#v", delivery.Comparison.IntentionalDifferences)
|
||||
for _, id := range []string{"RDR-T10-CMP-001", "RDR-T10-CMP-002", "RDR-T10-CMP-003", "RDR-T10-CMP-004"} {
|
||||
if !exceptions[id] {
|
||||
t.Errorf("comparison omitted reviewed exception %s: %#v", id, delivery.Comparison.Dimensions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyComparisonReportsAccidentalAndUnassessedDimensions(t *testing.T) {
|
||||
request := workspaceRequest([]byte("# Expected\n\nExpected body.\n"))
|
||||
deps := defaultGeneratorDependencies()
|
||||
deps.legacyBuild = func(title, _ string) ([]byte, error) { return legacyBuild(title, "# Wrong\n\nLost body.\n") }
|
||||
accidental, err := generateWithOptions(context.Background(), request, RenderOptions{MigrationComparison: true}, deps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if accidental.Comparison.Assessment != "differences" || len(accidental.Comparison.AccidentalDifferences) == 0 {
|
||||
t.Fatalf("content loss was not accidental: %#v", accidental.Comparison)
|
||||
}
|
||||
|
||||
deps.legacyBuild = func(string, string) ([]byte, error) { return []byte("not a DOCX"), nil }
|
||||
unassessed, err := generateWithOptions(context.Background(), request, RenderOptions{MigrationComparison: true}, deps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if unassessed.Comparison.Assessment != "unassessed" || len(unassessed.Comparison.Unassessed) == 0 {
|
||||
t.Fatalf("invalid legacy sample was reported as assessed: %#v", unassessed.Comparison)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,3 +635,28 @@ func docxVisibleText(t *testing.T, raw []byte) string {
|
||||
t.Fatal("word/document.xml missing")
|
||||
return ""
|
||||
}
|
||||
|
||||
func docxPart(t *testing.T, raw []byte, name string) string {
|
||||
t.Helper()
|
||||
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, file := range zr.File {
|
||||
if file.Name != name {
|
||||
continue
|
||||
}
|
||||
stream, err := file.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := io.ReadAll(stream)
|
||||
stream.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(content)
|
||||
}
|
||||
t.Fatalf("DOCX part %s missing", name)
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ description: 生成文档、表格、演示稿的工作流、样式决策与技
|
||||
|
||||
1. **先判路由**:普通、非受控的工作区 Word 可用组织默认候选;项目文档、受控文档、专用模板或审签输出必须交给 RDM,禁止调用 Backend 默认规则回退。组织默认候选的 manifest 状态固定显示 `pending-human-visual-review`,不能描述成已发布或已视觉批准。Excel / PPT / 图片再按用户指令、模板、参考文档、会话样式和默认主题的顺序定样式。
|
||||
2. **规划版面**:先定读者、结构和每一节的内容,再写脚本。不要边生成边补空节。
|
||||
3. **生成 Word**:先用 `time_now` 冻结 `frozenAt`,再调用 `render_document`,传稳定 `documentId`、标题 `name`、Markdown、语言和 `working|preview` 模式,并显式传 `controlled=false`、`projectDocument=false`;任何一项不是 false 就停止并交给 RDM。Markdown 用 `#`-`######` 标题、段落、`-`/`1.` 列表、GFM 表格、`asset:` 图片、`:::callout warning`/`:::` 提示块和 `:::page-break`。工具先保存带稳定 node ID 的 Markdown,再生成确定性 DOCX、manifest 和完整 zip;返回值逐项列出 SHA-256。失败时按诊断修 Markdown,绝不调用 legacy Word 路径回退。
|
||||
3. **生成 Word**:调用 `render_document`,传本次交付稳定且重试复用的 `operationId`、稳定 `documentId`、标题 `name`、Markdown、语言和 `working|preview` 模式,并显式传 `controlled=false`、`projectDocument=false`;任何一项不是 false 就停止并交给 RDM。时间与 requester 由宿主冻结,禁止传入或声称自定 `frozenAt`。Markdown 用 `#`-`######` 标题、段落、引用、围栏/行内代码、粗体、斜体、删除线、安全链接标签、`-`/`1.` 列表、GFM 表格、`asset:` 图片、`:::callout warning`/`:::` 提示块和 `:::page-break`。工具先保存带稳定 node ID 的 Markdown,再生成确定性 DOCX、manifest 和完整 zip;返回值逐项列出稳定 ID 与 SHA-256。失败时按回执状态重试同一 operationId,绝不调用 legacy Word 路径回退。
|
||||
4. **生成其他格式并自检**:Excel/PPT/图片用 `run_python` + house-style 对应 kit;用 lint/outline 回读。Word 的 OPC、安全、锚点与回读校验已经由统一 renderer 执行,复核 manifest 中的诊断、组织默认候选状态和 Markdown/DOCX 哈希。
|
||||
5. **登记**:`render_document` 已登记 Markdown、DOCX、manifest 和 zip,不要重复登记。其他格式继续用 `register_artifact`。
|
||||
6. **收尾**:回复只写结论摘要,并说明这次用的样式(主题、是否模板、是否仿制)以及用户还能怎么调。不要在回复里复述全文,也不要只口头说文件名。
|
||||
|
||||
@@ -5,6 +5,6 @@ version: 1
|
||||
title: render_document 工具描述
|
||||
description: 普通工作区 Markdown 到统一 Word 交付包的安全渲染入口
|
||||
---
|
||||
把普通、非受控的工作区 Markdown 交给共享 canonical parser、组织默认候选 profile 和 Word adapter。先用 time_now 取得 frozenAt;必须显式传 `controlled=false` 和 `projectDocument=false`,缺任一字段即拒绝;requester 由系统可信身份填写,不要作为参数伪造。成功会按顺序保存 Markdown、DOCX、manifest 和 zip,并返回每项 SHA-256。
|
||||
把普通、非受控的工作区 Markdown 交给共享 canonical parser、组织默认候选 profile 和 Word adapter。必须传稳定且重试复用的 operationId,并显式传 `controlled=false` 和 `projectDocument=false`;缺任一字段即拒绝。frozenAt 与 requester 由宿主首次操作时创建并持久化,调用方不能提交。成功回执包含 Markdown、DOCX、manifest、zip 的稳定 ID 与 SHA-256;失败回执明确 partial 或 unknown,重试必须沿用同一 operationId。
|
||||
|
||||
只允许 working 或 preview。项目文档、受控文档、专用模板、审阅稿或批准稿必须交给 RDM;失败不得改走 legacy Word builder。当前公司默认规则状态是 pending-human-visual-review,只可用于普通工作/预览交付,不能称为已发布或已视觉批准。
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"agent-system-backend/internal/docgen"
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func (t *toolCtx) ExecuteDocumentDelivery(ctx context.Context, request docgen.OperationRequest) (docgen.OperationReceipt, error) {
|
||||
receipt, err := t.rc.executeDocumentDelivery(ctx, request)
|
||||
if receipt.Outcome == "complete" || receipt.Outcome == "unknown" {
|
||||
for _, artifact := range receipt.Artifacts {
|
||||
if artifact.Role != string(store.DocumentArtifactArchive) {
|
||||
continue
|
||||
}
|
||||
t.arts = append(t.arts, core.Artifact{
|
||||
ID: core.ID(artifact.ID), ProjectID: t.rc.run.ProjectID, Owner: t.rc.run.Owner,
|
||||
Name: artifact.Name, Kind: "document", MIME: artifact.MIME, Size: artifact.Size,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
return receipt, err
|
||||
}
|
||||
|
||||
func (c *runCtx) executeDocumentDelivery(ctx context.Context, operation docgen.OperationRequest) (docgen.OperationReceipt, error) {
|
||||
if ctx == nil {
|
||||
return docgen.OperationReceipt{}, errors.New("document delivery: nil context")
|
||||
}
|
||||
if err := c.mgr.authorizeRunWork(ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return docgen.OperationReceipt{}, err
|
||||
}
|
||||
requestHash, err := documentOperationHash(operation)
|
||||
if err != nil {
|
||||
return docgen.OperationReceipt{}, err
|
||||
}
|
||||
record, err := c.mgr.st.BeginDocumentDeliveryAccountChecked(c.run.Owner, store.DocumentDeliveryBeginInput{
|
||||
OperationID: operation.OperationID, RequestSHA256: requestHash,
|
||||
ProjectID: string(c.run.ProjectID), ConversationID: string(c.run.ConversationID), SourceRun: string(c.run.ID),
|
||||
DocumentID: operation.DocumentID, Mode: string(operation.Mode), RendererIdentity: docgen.RendererIdentity,
|
||||
}, c.mgr.accountAllowImplicitLocal)
|
||||
if err != nil {
|
||||
return receiptFromDelivery(record, "partial", false, err), err
|
||||
}
|
||||
if record.State == store.DocumentDeliveryComplete {
|
||||
return receiptFromDelivery(record, "complete", true, nil), nil
|
||||
}
|
||||
|
||||
request := docgen.Request{
|
||||
DocumentID: operation.DocumentID, Title: operation.Title, DocumentType: operation.DocumentType, Language: operation.Language,
|
||||
Markdown: operation.Markdown, Mode: operation.Mode, FrozenAt: record.FrozenAt, RequestedBy: c.run.Owner,
|
||||
Controlled: operation.Controlled, ProjectDocument: operation.ProjectDocument,
|
||||
}
|
||||
if record.MarkdownArtifactID != "" {
|
||||
persisted, readErr := c.mgr.st.DocumentDeliveryMarkdown(c.run.Owner, operation.OperationID)
|
||||
if readErr != nil {
|
||||
return receiptFromDelivery(record, "unknown", false, readErr), readErr
|
||||
}
|
||||
request.Markdown = persisted
|
||||
}
|
||||
prepared, err := docgen.Prepare(ctx, request)
|
||||
if err != nil {
|
||||
return receiptFromDelivery(record, "partial", false, err), err
|
||||
}
|
||||
if record.MarkdownArtifactID == "" {
|
||||
record, err = c.mgr.st.PersistDocumentDeliveryMarkdownAccountChecked(c.run.Owner, operation.OperationID,
|
||||
deliveryArtifactInput(store.DocumentArtifactMarkdown, prepared.Markdown), prepared.CanonicalHash,
|
||||
c.mgr.accountAllowImplicitLocal)
|
||||
if err != nil {
|
||||
outcome := "partial"
|
||||
if errors.Is(err, store.ErrDocumentDeliveryOutcomeUnknown) {
|
||||
outcome = "unknown"
|
||||
}
|
||||
return receiptFromDelivery(record, outcome, false, err), err
|
||||
}
|
||||
}
|
||||
request.LegacySource = append([]byte(nil), operation.Markdown...)
|
||||
request.Markdown = prepared.Markdown.Bytes
|
||||
if err := c.mgr.authorizeRunWork(ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return receiptFromDelivery(record, "partial", false, err), err
|
||||
}
|
||||
delivery, err := docgen.GenerateWithOptions(ctx, request, docgen.RenderOptions{MigrationComparison: c.mgr.documentMigrationComparison})
|
||||
if err != nil {
|
||||
record, markErr := c.mgr.st.MarkDocumentDeliveryFailedAccountChecked(c.run.Owner, operation.OperationID, err.Error(), c.mgr.accountAllowImplicitLocal)
|
||||
if markErr != nil {
|
||||
return receiptFromDelivery(record, "unknown", false, markErr), errors.Join(err, markErr)
|
||||
}
|
||||
return receiptFromDelivery(record, "partial", false, err), err
|
||||
}
|
||||
if err := c.mgr.authorizeRunWork(ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||||
return receiptFromDelivery(record, "partial", false, err), err
|
||||
}
|
||||
deliveryKey := documentDeliveryKey(delivery)
|
||||
record, err = c.mgr.st.CommitDocumentDeliveryDerivedAccountChecked(c.run.Owner, operation.OperationID,
|
||||
deliveryKey, delivery.Profile.SnapshotHash, delivery.Renderer.ReviewHash,
|
||||
[]store.DocumentDeliveryArtifactInput{
|
||||
deliveryArtifactInput(store.DocumentArtifactDOCX, delivery.DOCX),
|
||||
deliveryArtifactInput(store.DocumentArtifactManifest, delivery.Manifest),
|
||||
deliveryArtifactInput(store.DocumentArtifactArchive, delivery.Archive),
|
||||
}, c.mgr.accountAllowImplicitLocal)
|
||||
if err != nil {
|
||||
outcome := "partial"
|
||||
if errors.Is(err, store.ErrDocumentDeliveryOutcomeUnknown) {
|
||||
outcome = "unknown"
|
||||
}
|
||||
return receiptFromDelivery(record, outcome, false, err), err
|
||||
}
|
||||
return receiptFromDelivery(record, "complete", false, nil), nil
|
||||
}
|
||||
|
||||
func documentOperationHash(operation docgen.OperationRequest) (string, error) {
|
||||
payload := struct {
|
||||
DocumentID string `json:"documentId"`
|
||||
Title string `json:"title"`
|
||||
DocumentType string `json:"documentType"`
|
||||
Language string `json:"language"`
|
||||
Markdown string `json:"markdown"`
|
||||
Mode string `json:"mode"`
|
||||
Controlled bool `json:"controlled"`
|
||||
ProjectDocument bool `json:"projectDocument"`
|
||||
}{
|
||||
DocumentID: operation.DocumentID, Title: operation.Title, DocumentType: operation.DocumentType,
|
||||
Language: operation.Language, Markdown: string(operation.Markdown), Mode: string(operation.Mode),
|
||||
Controlled: operation.Controlled, ProjectDocument: operation.ProjectDocument,
|
||||
}
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func documentDeliveryKey(delivery docgen.Delivery) string {
|
||||
manifest := delivery.Renderer
|
||||
value := strings.Join([]string{manifest.DocumentHash, manifest.ProfileHash, manifest.ReviewHash, string(manifest.Mode), docgen.RendererIdentity}, "\x00")
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func deliveryArtifactInput(role store.DocumentDeliveryArtifactRole, artifact docgen.Artifact) store.DocumentDeliveryArtifactInput {
|
||||
kind := "document"
|
||||
if artifact.MIME == "application/json" {
|
||||
kind = "data"
|
||||
}
|
||||
return store.DocumentDeliveryArtifactInput{
|
||||
Role: role, Name: artifact.Name, Kind: kind, MIME: artifact.MIME,
|
||||
SHA256: artifact.SHA256, Content: artifact.Bytes,
|
||||
}
|
||||
}
|
||||
|
||||
func receiptFromDelivery(record store.DocumentDeliveryRecord, outcome string, cacheHit bool, cause error) docgen.OperationReceipt {
|
||||
receipt := docgen.OperationReceipt{
|
||||
OperationID: record.OperationID, Outcome: outcome, State: string(record.State), FrozenAt: record.FrozenAt,
|
||||
DeliveryKey: record.DeliveryKey, Renderer: record.RendererIdentity, CacheHit: cacheHit,
|
||||
}
|
||||
if cause != nil {
|
||||
receipt.Error = cause.Error()
|
||||
}
|
||||
for _, item := range record.Artifacts {
|
||||
receipt.Artifacts = append(receipt.Artifacts, docgen.ReceiptArtifact{
|
||||
Role: string(item.Role), ID: string(item.Artifact.ID), Name: item.Artifact.Name,
|
||||
MIME: item.Artifact.MIME, Size: item.Artifact.Size, SHA256: item.SHA256,
|
||||
})
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/bus"
|
||||
"agent-system-backend/internal/docgen"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/tools"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func TestDocumentDeliveryReportsPartialAndUnknownReceiptsWithStableRetry(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stage string
|
||||
wantOutcome string
|
||||
wantCount int
|
||||
}{
|
||||
{"derived staging", store.DocumentDeliveryFaultAfterManifestBlob, "partial", 1},
|
||||
{"post commit", store.DocumentDeliveryFaultAfterDerivedCommit, "unknown", 4},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
run, st := newDocumentDeliveryRun(t)
|
||||
st.SetDocumentDeliveryFault(test.stage, errors.New("injected "+test.stage))
|
||||
arguments := json.RawMessage(`{"operationId":"receipt-operation","name":"Receipt","documentId":"receipt","documentType":"general","language":"en","markdown":"# Receipt\n\nbody\n","mode":"preview","controlled":false,"projectDocument":false}`)
|
||||
failed := run.ExecuteTool(core.ToolCallRequest{CallID: "delivery-failed", Name: "render_document", Arguments: arguments})
|
||||
if failed.Status != "error" {
|
||||
t.Fatalf("fault result = %#v", failed)
|
||||
}
|
||||
var receipt docgen.OperationReceipt
|
||||
if err := json.Unmarshal([]byte(strings.TrimPrefix(failed.Output, "render_document: ")), &receipt); err != nil {
|
||||
t.Fatalf("decode failure receipt: %v\n%s", err, failed.Output)
|
||||
}
|
||||
if receipt.Outcome != test.wantOutcome || len(receipt.Artifacts) != test.wantCount || receipt.FrozenAt.IsZero() {
|
||||
t.Fatalf("failure receipt = %#v", receipt)
|
||||
}
|
||||
firstIDs := receiptArtifactIdentity(receipt.Artifacts)
|
||||
|
||||
retry := run.ExecuteTool(core.ToolCallRequest{CallID: "delivery-retry", Name: "render_document", Arguments: arguments})
|
||||
if retry.Status != "ok" {
|
||||
t.Fatalf("retry failed: %s", retry.Output)
|
||||
}
|
||||
var completed docgen.OperationReceipt
|
||||
if err := json.Unmarshal([]byte(retry.Output), &completed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if completed.Outcome != "complete" || len(completed.Artifacts) != 4 || !completed.FrozenAt.Equal(receipt.FrozenAt) {
|
||||
t.Fatalf("completed receipt = %#v", completed)
|
||||
}
|
||||
for role, identity := range firstIDs {
|
||||
if got := receiptArtifactIdentity(completed.Artifacts)[role]; got != identity {
|
||||
t.Errorf("artifact %s changed on retry: %s -> %s", role, identity, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentMigrationComparisonRequiresTrustedManagerCapability(t *testing.T) {
|
||||
run, st := newDocumentDeliveryRun(t)
|
||||
arguments := json.RawMessage(`{"operationId":"comparison-operation","name":"Comparison","documentId":"comparison","documentType":"general","language":"en","markdown":"# Comparison\n\nBody with **bold** and [safe](https://example.com/private).\n","mode":"preview","controlled":false,"projectDocument":false,"migrationComparison":true}`)
|
||||
rejected := run.ExecuteTool(core.ToolCallRequest{CallID: "comparison-model", Name: "render_document", Arguments: arguments})
|
||||
if rejected.Status != "error" || !strings.Contains(rejected.Output, "unknown field") {
|
||||
t.Fatalf("model enabled comparison: %#v", rejected)
|
||||
}
|
||||
|
||||
run.mgr.documentMigrationComparison = true
|
||||
arguments = json.RawMessage(`{"operationId":"comparison-operation","name":"Comparison","documentId":"comparison","documentType":"general","language":"en","markdown":"# Comparison\n\nBody with **bold** and [safe](https://example.com/private).\n","mode":"preview","controlled":false,"projectDocument":false}`)
|
||||
result := run.ExecuteTool(core.ToolCallRequest{CallID: "comparison-host", Name: "render_document", Arguments: arguments})
|
||||
if result.Status != "ok" {
|
||||
t.Fatalf("trusted comparison: %s", result.Output)
|
||||
}
|
||||
var receipt docgen.OperationReceipt
|
||||
if err := json.Unmarshal([]byte(result.Output), &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var manifestID string
|
||||
for _, artifact := range receipt.Artifacts {
|
||||
if artifact.Role == string(store.DocumentArtifactManifest) {
|
||||
manifestID = artifact.ID
|
||||
}
|
||||
}
|
||||
raw, err := st.ArtifactContent("alice", manifestID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var manifest docgen.Manifest
|
||||
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !manifest.Comparison.Enabled || manifest.Comparison.Assessment == "" || len(manifest.Comparison.Dimensions) == 0 {
|
||||
t.Fatalf("trusted comparison was not assessed: %#v", manifest.Comparison)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentRendererFailureKeepsAuthoritativeMarkdownForRetry(t *testing.T) {
|
||||
run, st := newDocumentDeliveryRun(t)
|
||||
arguments := json.RawMessage(`{"operationId":"renderer-failure","name":"Failure","documentId":"failure","documentType":"general","language":"en","markdown":"# Failure\n\n\n","mode":"preview","controlled":false,"projectDocument":false}`)
|
||||
first := run.ExecuteTool(core.ToolCallRequest{CallID: "renderer-failure-1", Name: "render_document", Arguments: arguments})
|
||||
if first.Status != "error" {
|
||||
t.Fatalf("renderer failure result = %#v", first)
|
||||
}
|
||||
var firstReceipt docgen.OperationReceipt
|
||||
if err := json.Unmarshal([]byte(strings.TrimPrefix(first.Output, "render_document: ")), &firstReceipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if firstReceipt.Outcome != "partial" || len(firstReceipt.Artifacts) != 1 || firstReceipt.Artifacts[0].Role != string(store.DocumentArtifactMarkdown) {
|
||||
t.Fatalf("renderer failure lost Markdown receipt: %#v", firstReceipt)
|
||||
}
|
||||
markdown, err := st.ArtifactContent("alice", firstReceipt.Artifacts[0].ID)
|
||||
if err != nil || !strings.Contains(string(markdown), "<!-- node-id:") {
|
||||
t.Fatalf("authoritative Markdown missing markers: %v %q", err, markdown)
|
||||
}
|
||||
|
||||
second := run.ExecuteTool(core.ToolCallRequest{CallID: "renderer-failure-2", Name: "render_document", Arguments: arguments})
|
||||
var secondReceipt docgen.OperationReceipt
|
||||
if second.Status != "error" || json.Unmarshal([]byte(strings.TrimPrefix(second.Output, "render_document: ")), &secondReceipt) != nil ||
|
||||
len(secondReceipt.Artifacts) != 1 || secondReceipt.Artifacts[0].ID != firstReceipt.Artifacts[0].ID || secondReceipt.Artifacts[0].SHA256 != firstReceipt.Artifacts[0].SHA256 {
|
||||
t.Fatalf("renderer failure retry changed Markdown identity: first=%#v second=%#v result=%#v", firstReceipt, secondReceipt, second)
|
||||
}
|
||||
}
|
||||
|
||||
func newDocumentDeliveryRun(t *testing.T) (*runCtx, *store.Store) {
|
||||
t.Helper()
|
||||
st := storeForRunManagerTest(t)
|
||||
if err := st.EnsureUser("alice", "Alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project, err := st.CreateProject("alice", "delivery")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversation, err := st.CreateConversation("alice", project.ID, "delivery", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
if err := registry.RegisterBuiltin(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventBus := bus.New()
|
||||
events := newEventBuffer(st, eventBus)
|
||||
t.Cleanup(events.stop)
|
||||
manager := &Manager{st: st, bus: eventBus, registry: registry, settings: settings.NewService(st), events: events}
|
||||
return &runCtx{
|
||||
mgr: manager, run: core.Run{ID: "run-delivery", ProjectID: project.ID, ConversationID: conversation.ID, Owner: "alice"},
|
||||
prof: core.AgentProfile{}, eff: manager.settings.Effective("alice", ""), ctx: context.Background(),
|
||||
}, st
|
||||
}
|
||||
|
||||
func storeForRunManagerTest(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
_, st := newExecEnv(t)
|
||||
return st
|
||||
}
|
||||
|
||||
func receiptArtifactIdentity(artifacts []docgen.ReceiptArtifact) map[string]string {
|
||||
result := make(map[string]string, len(artifacts))
|
||||
for _, artifact := range artifacts {
|
||||
result[artifact.Role] = artifact.ID + ":" + artifact.SHA256
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -70,9 +71,10 @@ func TestExecuteToolBackfillsArtifact(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// run_python 生成文件后 register_artifact 必须把工件回填到 ToolCallResult.Artifact。
|
||||
func TestRunPythonRegisterArtifactBackfill(t *testing.T) {
|
||||
c, _ := newExecEnv(t)
|
||||
// Arbitrary Python may create local DOCX bytes, but cannot publish them as a
|
||||
// trusted Word delivery without the host renderer operation.
|
||||
func TestRunPythonDOCXCannotRegisterAsTrustedDelivery(t *testing.T) {
|
||||
c, st := newExecEnv(t)
|
||||
root := t.TempDir()
|
||||
c.mgr.SetWorkspaceRoot(root)
|
||||
if err := tools.RegisterExecTools(c.mgr.registry); err != nil {
|
||||
@@ -94,14 +96,8 @@ func TestRunPythonRegisterArtifactBackfill(t *testing.T) {
|
||||
CallID: "reg1", Name: "register_artifact",
|
||||
Arguments: json.RawMessage(`{"path":"note.docx"}`),
|
||||
})
|
||||
if res.Status != "ok" {
|
||||
t.Fatalf("register_artifact: %s", res.Output)
|
||||
}
|
||||
if res.Artifact == nil || res.Artifact.Name != "note.docx" {
|
||||
t.Fatalf("ToolCallResult.Artifact 未回填: %+v", res.Artifact)
|
||||
}
|
||||
if res.Artifact.MIME != "application/vnd.openxmlformats-officedocument.wordprocessingml.document" {
|
||||
t.Errorf("docx mime: %s", res.Artifact.MIME)
|
||||
if res.Status != "error" || res.Artifact != nil || !strings.Contains(res.Output, "render_document") {
|
||||
t.Fatalf("direct DOCX registration was not rejected: %+v", res)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(root, "alice", c.run.ConversationID, "note.docx"))
|
||||
if err != nil {
|
||||
@@ -110,6 +106,10 @@ func TestRunPythonRegisterArtifactBackfill(t *testing.T) {
|
||||
if string(b[:4]) != "PK\x03\x04" {
|
||||
t.Fatalf("stored bytes: %q", b)
|
||||
}
|
||||
artifacts, err := st.ListArtifacts("alice", c.run.ProjectID)
|
||||
if err != nil || len(artifacts) != 0 {
|
||||
t.Fatalf("rejected DOCX became visible: %+v err=%v", artifacts, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 空手工具(如 time_now)不得凭空挂 Artifact。
|
||||
@@ -161,6 +161,9 @@ func TestRenderedDeliverySurvivesRestartWithStableIdentity(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.EnsureUser("alice", "Alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
if err := registry.RegisterBuiltin(); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -183,14 +186,18 @@ func TestRenderedDeliverySurvivesRestartWithStableIdentity(t *testing.T) {
|
||||
})
|
||||
manager := &Manager{st: st, bus: eventBus, registry: registry, settings: settings.NewService(st), events: events}
|
||||
run := &runCtx{
|
||||
mgr: manager, run: core.Run{ProjectID: project.ID, ConversationID: conversation.ID, Owner: "alice"},
|
||||
mgr: manager, run: core.Run{ID: "run-before-restart", ProjectID: project.ID, ConversationID: conversation.ID, Owner: "alice"},
|
||||
prof: core.AgentProfile{}, eff: manager.settings.Effective("alice", ""), ctx: context.Background(),
|
||||
}
|
||||
args := json.RawMessage(`{"name":"重启纪要","documentId":"restart-minutes","documentType":"general","language":"zh-CN","markdown":"# 重启纪要\n\n缓存身份保持。\n","mode":"preview","frozenAt":"2026-10-15T12:00:00Z","controlled":false,"projectDocument":false}`)
|
||||
args := json.RawMessage(`{"operationId":"restart-operation","name":"重启纪要","documentId":"restart-minutes","documentType":"general","language":"zh-CN","markdown":"# 重启纪要\n\n缓存身份保持。\n","mode":"preview","controlled":false,"projectDocument":false}`)
|
||||
result := run.ExecuteTool(core.ToolCallRequest{CallID: "render-restart", Name: "render_document", Arguments: args})
|
||||
if result.Status != "ok" {
|
||||
t.Fatalf("render_document: %s", result.Output)
|
||||
}
|
||||
var firstReceipt docgen.OperationReceipt
|
||||
if err := json.Unmarshal([]byte(result.Output), &firstReceipt); err != nil || firstReceipt.FrozenAt.IsZero() {
|
||||
t.Fatalf("first receipt=%#v err=%v", firstReceipt, err)
|
||||
}
|
||||
artifacts, err := st.ListArtifacts("alice", project.ID)
|
||||
if err != nil || len(artifacts) != 4 {
|
||||
t.Fatalf("stored artifacts=%d err=%v", len(artifacts), err)
|
||||
@@ -215,6 +222,23 @@ func TestRenderedDeliverySurvivesRestartWithStableIdentity(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
reopenedBus := bus.New()
|
||||
reopenedEvents := newEventBuffer(reopened, reopenedBus)
|
||||
defer reopenedEvents.stop()
|
||||
reopenedManager := &Manager{st: reopened, bus: reopenedBus, registry: registry, settings: settings.NewService(reopened), events: reopenedEvents}
|
||||
reopenedRun := &runCtx{
|
||||
mgr: reopenedManager, run: core.Run{ID: "run-after-restart", ProjectID: project.ID, ConversationID: conversation.ID, Owner: "alice"},
|
||||
prof: core.AgentProfile{}, eff: reopenedManager.settings.Effective("alice", ""), ctx: context.Background(),
|
||||
}
|
||||
retryResult := reopenedRun.ExecuteTool(core.ToolCallRequest{CallID: "render-restart-retry", Name: "render_document", Arguments: args})
|
||||
if retryResult.Status != "ok" {
|
||||
t.Fatalf("restart retry: %s", retryResult.Output)
|
||||
}
|
||||
var retryReceipt docgen.OperationReceipt
|
||||
if err := json.Unmarshal([]byte(retryResult.Output), &retryReceipt); err != nil || !retryReceipt.CacheHit ||
|
||||
!retryReceipt.FrozenAt.Equal(firstReceipt.FrozenAt) || !reflect.DeepEqual(retryReceipt.Artifacts, firstReceipt.Artifacts) {
|
||||
t.Fatalf("restart receipt changed: first=%#v retry=%#v err=%v", firstReceipt, retryReceipt, err)
|
||||
}
|
||||
for artifactID, wantHash := range want {
|
||||
raw, err := reopened.ArtifactContentByID(artifactID)
|
||||
if err != nil {
|
||||
|
||||
@@ -74,6 +74,9 @@ type Manager struct {
|
||||
projectWorkObservationEnabled bool
|
||||
projectWorkPreflight projectWorkPreflight
|
||||
projectRiskRespond projectRiskRespond
|
||||
// documentMigrationComparison is an internal host capability. It is never
|
||||
// exposed through tool arguments, settings, prompts, or child environments.
|
||||
documentMigrationComparison bool
|
||||
// commands EXT-14:命令即工具的投影与分发源(nil = 未接线,命令不进工具清单)。
|
||||
commands CommandSource
|
||||
indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块)
|
||||
|
||||
+10
-29
@@ -1,11 +1,11 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/docgen"
|
||||
"agent-system-backend/internal/squadadapter"
|
||||
"agent-system-backend/internal/wfcompile"
|
||||
"agent-system-backend/internal/workflow"
|
||||
@@ -178,37 +178,18 @@ func (m *MockRunner) Execute(rc RunCtx) error {
|
||||
// 工件,再保存 DOCX、manifest 和带哈希清单的完整归档。
|
||||
md := "# 演示会议纪要\n\n这是 mock runner 生成的示例 Word 工件。\n\n## 基本信息\n\n| 项目 | 内容 |\n|---|---|\n| 指令 | #doc |\n| 生成器 | docgen |\n\n## 要点\n\n1. 由 #doc 指令触发\n2. 带 Word 排版(标题/表格/编号列表)\n3. 对话内以交付物卡片呈现,点击右栏预览\n"
|
||||
run := rc.Run()
|
||||
frozenAt := run.CreatedAt.UTC()
|
||||
if frozenAt.IsZero() {
|
||||
frozenAt = time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)
|
||||
}
|
||||
requester := string(run.Owner)
|
||||
if requester == "" {
|
||||
requester = "mock-runner"
|
||||
}
|
||||
delivery, derr := docgen.Generate(rc.Context(), docgen.Request{
|
||||
DocumentID: "mock-demo-minutes", Title: "demo-会议纪要", DocumentType: "general", Language: "zh-CN",
|
||||
Markdown: []byte(md), Mode: "preview", FrozenAt: frozenAt, RequestedBy: requester,
|
||||
arguments, _ := json.Marshal(map[string]any{
|
||||
"operationId": "mock-doc-" + string(run.ID), "name": "demo-会议纪要", "documentId": "mock-demo-minutes",
|
||||
"documentType": "general", "language": "zh-CN", "markdown": md, "mode": "preview",
|
||||
"controlled": false, "projectDocument": false,
|
||||
})
|
||||
if derr != nil {
|
||||
addText("演示文档生成失败:" + derr.Error())
|
||||
result := rc.ExecuteTool(core.ToolCallRequest{CallID: "mock-render-document", Name: "render_document", Arguments: arguments})
|
||||
if result.Status != "ok" || result.Artifact == nil {
|
||||
addText("演示文档生成失败:" + result.Output)
|
||||
break
|
||||
}
|
||||
var written []core.Artifact
|
||||
for _, item := range []docgen.Artifact{delivery.Markdown, delivery.DOCX, delivery.Manifest, delivery.Archive} {
|
||||
kind := "document"
|
||||
if item.MIME == "application/json" {
|
||||
kind = "data"
|
||||
}
|
||||
art, err := rc.WriteArtifact(item.Name, kind, item.MIME, item.Bytes)
|
||||
if err != nil {
|
||||
addText("演示文档工件保存失败:" + err.Error())
|
||||
break
|
||||
}
|
||||
written = append(written, art)
|
||||
}
|
||||
if len(written) == 4 {
|
||||
art := written[len(written)-1]
|
||||
if result.Artifact != nil {
|
||||
art := *result.Artifact
|
||||
addText("Markdown、Word、渲染清单和完整归档已生成;清单列出可复核 SHA-256。点击下方交付卡片下载归档,或在工件面板查看单项。")
|
||||
// 交付物钉尾(内容部件,与 llm.go 契约一致):卡片在最终正文之后。
|
||||
parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind}})
|
||||
|
||||
@@ -5,27 +5,9 @@ description: 用统一 renderer 生成 Markdown、DOCX、manifest 和归档
|
||||
|
||||
# docx 生成配方
|
||||
|
||||
先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md`。普通、非受控工作区 Word 调用 `render_document`;先用 `time_now` 冻结时间,传稳定 documentId、标题和 Markdown。项目或受控 Word、专用模板、审阅与批准输出必须交给 RDM,禁止默认回退。
|
||||
先读 `$AGENT_SKILLS_DIR/house-style/SKILL.md`。普通、非受控工作区 Word 调用 `render_document`;传本次交付稳定且重试复用的 operationId、稳定 documentId、标题和 Markdown,时间由宿主持久冻结。项目或受控 Word、专用模板、审阅与批准输出必须交给 RDM,禁止默认回退。
|
||||
|
||||
成功结果包括 Markdown、DOCX、manifest 和 zip,逐项核对 SHA-256;当前组织默认候选仍是 `pending-human-visual-review`。下面的 Python 构件只供显式迁移对照,不得用于生产交付或 renderer 失败回退。
|
||||
|
||||
```python
|
||||
from housestyle import docx_kit as dx
|
||||
|
||||
doc = dx.new_document(theme="modern", title="文档标题")
|
||||
dx.add_title(doc, "文档标题", "日期或副标题")
|
||||
dx.add_kv_table(doc, [("项目", "值"), ("日期", "2026年10月1日")])
|
||||
dx.add_heading(doc, "一、结论", 1)
|
||||
dx.add_paragraph(doc, "先写结论。")
|
||||
dx.add_heading(doc, "依据", 2)
|
||||
dx.add_bullets(doc, ["要点一", "要点二"])
|
||||
dx.add_numbers(doc, ["步骤一", "步骤二"])
|
||||
dx.add_table(doc, ["事项", "负责人", "期限"], [["完成需求说明", "未指明", "未指明"]], ["left", "left", "left"])
|
||||
dx.add_callout(doc, "只看这一段也能知道要做什么。", "摘要")
|
||||
dx.save(doc, "交付物.docx")
|
||||
```
|
||||
|
||||
迁移对照必须显式设置 `AGENT_SYSTEM_LEGACY_WORD_COMPARISON=1`。生产 Word 的 OPC、安全、锚点与回读结果以共享 renderer manifest 为准;修订以返回的 Markdown 为权威源。
|
||||
成功结果包括 Markdown、DOCX、manifest 和 zip,逐项核对 SHA-256;当前组织默认候选仍是 `pending-human-visual-review`。Python、shell、`docx_kit` 和 `register_artifact` 都不是 Word 交付入口。迁移对照只能由 Backend 内部受信能力启动。生产 Word 的 OPC、安全、锚点与回读结果以共享 renderer manifest 为准;修订以返回的 Markdown 为权威源。
|
||||
|
||||
版面范式:
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ description: 交付物设计系统。普通 Word 使用统一 renderer;housest
|
||||
|
||||
普通、非受控工作区 Word 的生产入口是 `render_document`:它保存 Markdown、确定性 DOCX、manifest 和 zip,并在 manifest 标注组织默认候选 `pending-human-visual-review`。项目或受控 Word、专用模板、审阅和批准输出必须交给 RDM,禁止 Backend 默认回退。
|
||||
|
||||
housestyle 的 Excel、PowerPoint、图片构件继续用于生产。Python Word 构件只保留给显式迁移对照;`housestyle.build` 默认禁用,仅在 `AGENT_SYSTEM_LEGACY_WORD_COMPARISON=1` 时运行,而且永不作为共享 renderer 失败后的回退。
|
||||
housestyle 的 Excel、PowerPoint、图片构件继续用于生产。Python Word 构件不是生产或迁移入口;`housestyle.build` 永久禁用,DOC/DOCX 也不能通过 `register_artifact` 登记。
|
||||
|
||||
## 主题
|
||||
|
||||
@@ -26,8 +26,8 @@ housestyle 的 Excel、PowerPoint、图片构件继续用于生产。Python Word
|
||||
按这个顺序,只取第一个命中项:
|
||||
|
||||
1. 用户本轮说清楚的要求(“改成公文”“主色用品牌绿”)。
|
||||
2. 本轮上传的模板(`.docx` `.dotx` `.pptx` `.potx` `.xlsx` `.xltx`),路径在 `inputs/`。Word 用 `new_document(template=路径)`,它会清空正文,保留纸张、页边距和页眉页脚。
|
||||
3. 用户要求仿照的参考文档:`from housestyle.extract import extract`,再 `extract(路径)` 得到字典,传给 `new_document(抽取结果)`。PDF 在装了 pdfplumber 时抽取字体和页面尺寸,否则说明只能近似。图片不能精确抽取,按描述近似,并请用户确认主色、字体和页边距。
|
||||
2. 本轮上传的 PowerPoint/Excel 模板(`.pptx` `.potx` `.xlsx` `.xltx`),路径在 `inputs/`。
|
||||
3. 用户要求仿照的 PowerPoint/Excel/PDF/图片参考;Word 模板属于 RDM 受控路径。
|
||||
4. 本会话前面已经定过的样式。
|
||||
5. 环境段里的默认主题、强调色和 `inputs/_default-template*`。
|
||||
|
||||
@@ -35,28 +35,9 @@ housestyle 的 Excel、PowerPoint、图片构件继续用于生产。Python Word
|
||||
|
||||
```python
|
||||
import housestyle
|
||||
from housestyle import docx_kit as dx
|
||||
from housestyle import pptx_kit as px
|
||||
from housestyle import xlsx_kit as xs
|
||||
|
||||
doc = dx.new_document(theme="modern", title="文档标题") # accent="1B3A4B" 或 template="inputs/模板.docx"
|
||||
dx.add_title(doc, "文档标题", "副标题或日期")
|
||||
dx.add_kv_table(doc, [("字段", "值")])
|
||||
dx.add_heading(doc, "一级标题", 1) # 只到 3 级
|
||||
dx.add_paragraph(doc, "正文。")
|
||||
dx.add_bullets(doc, ["要点"]) # 文本里不要再写 •
|
||||
dx.add_numbers(doc, ["步骤"]) # 文本里不要再写 1.
|
||||
dx.add_table(doc, ["列"], [["值"]], ["left"])
|
||||
dx.add_callout(doc, "给决策者的一段话。", "摘要")
|
||||
dx.add_quote(doc, "关键原话。", "出处")
|
||||
from housestyle import visuals
|
||||
visuals.status_row(doc, [{"title": "已修复", "badge": "历史", "body": "一句说明。", "tone": "ok"}])
|
||||
visuals.card_grid(doc, [{"title": "判断", "points": ["要点"], "footnote": "一句话总结", "tone": "info"}])
|
||||
visuals.column_row(doc, [{"title": "要求", "points": ["事项"], "footnote": "收束", "tone": "warn"}])
|
||||
png = visuals.render_flow("/tmp/flow.png", [{"title": "先入库", "body": "再输出", "tone": "info"}])
|
||||
visuals.add_picture(doc, png)
|
||||
dx.save(doc, "交付物.docx")
|
||||
|
||||
prs = px.new_presentation(theme="modern", title="汇报")
|
||||
px.add_title_slide(prs, "汇报标题", "副标题")
|
||||
px.add_section_slide(prs, "第一部分")
|
||||
@@ -76,16 +57,16 @@ xs.save(wb, "交付物.xlsx", ["单位:元。口径见源表。"])
|
||||
## 统一 Word 构建(生产)
|
||||
|
||||
```text
|
||||
1. time_now 冻结 frozenAt。
|
||||
2. 调用 render_document,传稳定 documentId、name、Markdown、language、working|preview,并显式传 controlled=false、projectDocument=false。
|
||||
1. 为本次交付选择稳定且重试复用的 operationId;时间由宿主冻结,不传 frozenAt。
|
||||
2. 调用 render_document,传 operationId、稳定 documentId、name、Markdown、language、working|preview,并显式传 controlled=false、projectDocument=false。
|
||||
3. 核对返回的 Markdown、DOCX、manifest、zip 四项 SHA-256。
|
||||
4. 修改时以返回的 Markdown 为权威源,再次调用同一工具;失败不走 legacy 回退。
|
||||
```
|
||||
|
||||
Markdown 支持 `#`-`######` 标题、段落、`-`/`1.` 列表、GFM 表格、已声明 `asset:` 图片、
|
||||
Markdown 支持 `#`-`######` 标题、段落、引用、围栏/行内代码、粗体、斜体、删除线、安全链接标签、`-`/`1.` 列表、GFM 表格、已声明 `asset:` 图片、
|
||||
`:::callout warning`/`:::` 提示块和 `:::page-break`。脚本、HTML、外链图片、未知指令和未声明资产会 fail-closed。
|
||||
|
||||
迁移对照如需运行旧构建器,显式设置 `AGENT_SYSTEM_LEGACY_WORD_COMPARISON=1` 后调用模块;它的输出只供对照,不得交付或替代统一 renderer 输出。Excel/PPT 仍用对应 kit 的 run_python 脚本。
|
||||
迁移对照只能由 Backend 内部受信能力运行,不能由模型、Python 或环境变量开启。Excel/PPT 仍用对应 kit 的 run_python 脚本。
|
||||
|
||||
## 自检
|
||||
|
||||
|
||||
@@ -1,177 +1,20 @@
|
||||
"""Legacy Word migration comparator.
|
||||
"""Disabled legacy Word builder.
|
||||
|
||||
普通 Word 生产交付必须调用 Backend 的 render_document 工具,共享 canonical
|
||||
parser/profile/compiler/Word adapter。此模块仅在
|
||||
AGENT_SYSTEM_LEGACY_WORD_COMPARISON=1 时用于迁移对照,永不作为 renderer
|
||||
失败后的回退。xlsx/pptx 等其他类型仍走 run_python + 对应 kit。
|
||||
|
||||
Markdown 约定:
|
||||
# 标题 文档标题(Title,只取第一个)
|
||||
> 副标题或日期 标题下副题(只取标题后的第一段 >)
|
||||
## 一级 / ### 二级 / #### 三级 Heading 1/2/3(标题层级连续)
|
||||
- 或 * 条目 项目符号
|
||||
1. 条目 编号列表
|
||||
| a | b | 表格(首个分隔行 |---|---| 定表头)
|
||||
> 引文 引用块(副题之后)
|
||||
!!! 标签 文本 强调块(label=标签,缺省「摘要」)
|
||||
<!-- housestyle: theme=modern accent=1B3A4B --> 主题与强调色(可省)
|
||||
Word delivery is a trusted host operation implemented by render_document. A
|
||||
model-controlled Python process cannot authorize migration comparison or emit
|
||||
a trusted DOCX receipt, so this module never writes Word output.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from housestyle import docx_kit as dx
|
||||
from housestyle import lint
|
||||
|
||||
|
||||
def build(source: str, output: str | None = None) -> int:
|
||||
if os.environ.get("AGENT_SYSTEM_LEGACY_WORD_COMPARISON") != "1":
|
||||
print(
|
||||
"build: legacy Word path is disabled; use the render_document tool "
|
||||
"(set AGENT_SYSTEM_LEGACY_WORD_COMPARISON=1 only for migration comparison)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
src = Path(source)
|
||||
if not src.is_file():
|
||||
print(f"build: 源文件不存在: {source}", file=sys.stderr)
|
||||
return 2
|
||||
text = src.read_text(encoding="utf-8")
|
||||
out = Path(output or str(src.with_suffix(".docx")))
|
||||
if out.suffix.lower() not in (".docx",):
|
||||
print(f"build: 输出只支持 .docx(其他类型用 run_python + 对应 kit)", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
theme, accent = "modern", None
|
||||
lines = text.splitlines()
|
||||
if lines and lines[0].strip().startswith("<!-- housestyle:"):
|
||||
for kv in lines[0].replace("<!-- housestyle:", "").replace("-->", "").split():
|
||||
if "=" in kv:
|
||||
k, v = kv.split("=", 1)
|
||||
if k == "theme":
|
||||
theme = v
|
||||
elif k == "accent":
|
||||
accent = v
|
||||
lines = lines[1:]
|
||||
|
||||
doc = dx.new_document(theme=theme, accent=accent, title=_first_title(lines) or out.stem)
|
||||
_render(doc, lines)
|
||||
dx.save(doc, str(out))
|
||||
|
||||
issues = lint.lint_path(str(out))
|
||||
errors = [i for i in issues if i.get("severity") == "error"]
|
||||
from housestyle.outline import summarize
|
||||
|
||||
print(f"built {out.name}")
|
||||
print(summarize(str(out)))
|
||||
if issues:
|
||||
print(json.dumps(issues, ensure_ascii=False, indent=2))
|
||||
if errors:
|
||||
print(f"lint: {len(errors)} 个 error——修 .md 源里对应段落后重新 build", file=sys.stderr)
|
||||
return 1
|
||||
print("lint: 通过(0 error)")
|
||||
return 0
|
||||
|
||||
|
||||
def _first_title(lines: list[str]) -> str | None:
|
||||
for line in lines:
|
||||
if line.startswith("# "):
|
||||
return line[2:].strip()
|
||||
return None
|
||||
|
||||
|
||||
def _split_row(line: str) -> list[str]:
|
||||
body = line.strip().strip("|")
|
||||
return [c.strip() for c in body.split("|")]
|
||||
|
||||
|
||||
def _is_table_sep(line: str) -> bool:
|
||||
s = line.strip()
|
||||
if not s.startswith("|"):
|
||||
return False
|
||||
cells = [c.strip() for c in s.strip("|").split("|")]
|
||||
return bool(cells) and all(set(c) <= set("-: ") and c for c in cells)
|
||||
|
||||
|
||||
def _render(doc, lines: list[str]) -> None:
|
||||
title_seen = False
|
||||
subtitle_used = False
|
||||
i = 0
|
||||
pending_numbers: list[str] = []
|
||||
pending_bullets: list[str] = []
|
||||
|
||||
def flush_lists() -> None:
|
||||
nonlocal pending_numbers, pending_bullets
|
||||
if pending_bullets:
|
||||
dx.add_bullets(doc, pending_bullets)
|
||||
pending_bullets = []
|
||||
if pending_numbers:
|
||||
dx.add_numbers(doc, pending_numbers)
|
||||
pending_numbers = []
|
||||
|
||||
while i < len(lines):
|
||||
raw = lines[i]
|
||||
line = raw.rstrip()
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped == "---":
|
||||
flush_lists()
|
||||
i += 1
|
||||
continue
|
||||
if stripped.startswith("|") and i + 1 < len(lines) and _is_table_sep(lines[i + 1]):
|
||||
flush_lists()
|
||||
headers = _split_row(stripped)
|
||||
rows = []
|
||||
j = i + 2
|
||||
while j < len(lines) and lines[j].strip().startswith("|"):
|
||||
rows.append(_split_row(lines[j]))
|
||||
j += 1
|
||||
dx.add_table(doc, headers, rows)
|
||||
i = j
|
||||
continue
|
||||
flush_lists()
|
||||
if stripped.startswith("# ") and not title_seen:
|
||||
dx.add_title(doc, stripped[2:].strip())
|
||||
title_seen = True
|
||||
elif stripped.startswith("#### "):
|
||||
dx.add_heading(doc, stripped[5:].strip(), 3)
|
||||
elif stripped.startswith("### "):
|
||||
dx.add_heading(doc, stripped[4:].strip(), 2)
|
||||
elif stripped.startswith("## "):
|
||||
dx.add_heading(doc, stripped[3:].strip(), 1)
|
||||
elif stripped.startswith("!!!"):
|
||||
rest = stripped[3:].strip()
|
||||
label, _, body = rest.partition(" ")
|
||||
dx.add_callout(doc, body.strip() or rest, label.strip() or "摘要")
|
||||
elif stripped.startswith("> ") or stripped.startswith(">"):
|
||||
quote = stripped.lstrip(">").strip()
|
||||
dx.add_quote(doc, quote, "")
|
||||
elif stripped.startswith(("- ", "* ")):
|
||||
pending_bullets.append(stripped[2:].strip())
|
||||
elif len(stripped) > 2 and stripped[0].isdigit() and stripped[1:3] in (". ", ") ") or (
|
||||
len(stripped) > 3 and stripped[:2].isdigit() and stripped[2:4] == ". "
|
||||
):
|
||||
pending_numbers.append(stripped.split(".", 1)[1].strip() if "." in stripped else stripped)
|
||||
else:
|
||||
dx.add_paragraph(doc, stripped)
|
||||
i += 1
|
||||
flush_lists()
|
||||
del source, output
|
||||
print("build: disabled; use the trusted render_document host operation", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: python3 -m housestyle.build <源.md> [-o 输出.docx]", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
args = sys.argv[1:]
|
||||
out = None
|
||||
if "-o" in args:
|
||||
k = args.index("-o")
|
||||
if k + 1 >= len(args):
|
||||
print("build: -o 缺少输出路径", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
out = args[k + 1]
|
||||
args = args[:k] + args[k + 2 :]
|
||||
raise SystemExit(build(args[0], out))
|
||||
raise SystemExit(build("", None))
|
||||
|
||||
@@ -184,53 +184,27 @@ class BuildTests(unittest.TestCase):
|
||||
self.assertEqual(B.build(src, out), 2)
|
||||
self.assertFalse(os.path.exists(out))
|
||||
|
||||
def test_build_renders_markdown_and_passes_lint(self):
|
||||
import tempfile, os, subprocess, sys, json
|
||||
def test_model_set_environment_cannot_enable_legacy_word_build(self):
|
||||
import os
|
||||
from housestyle import build as B
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
src = os.path.join(d, "plan.md")
|
||||
out = os.path.join(d, "plan.docx")
|
||||
Path(src).write_text(
|
||||
"# 测试方案:先写需求再开发\n\n"
|
||||
"## 一、结论\n结论:先写需求文档。\n\n"
|
||||
"- 第一步:停代码\n- 第二步:写需求\n\n"
|
||||
"## 二、行动项\n\n| 事项 | 负责人 |\n|---|---|\n| 需求文档 | 陈威 |\n\n"
|
||||
"1. 确认设计输入\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
Path(src).write_text("# 方案\n\n正文。\n", encoding="utf-8")
|
||||
with mock.patch.dict(os.environ, {"AGENT_SYSTEM_LEGACY_WORD_COMPARISON": "1"}):
|
||||
self.assertEqual(B.build(src, out), 0)
|
||||
self.assertTrue(os.path.exists(out))
|
||||
from housestyle.outline import summarize
|
||||
o = summarize(out)
|
||||
self.assertIn("测试方案", o)
|
||||
self.assertIn("一、结论", o)
|
||||
self.assertIn("tables 1", o)
|
||||
|
||||
def test_build_reports_lint_errors_with_nonzero_exit(self):
|
||||
import tempfile, os
|
||||
from housestyle import build as B
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
# 标题跳级(## 后直接 ####)触发 heading-skip error —— 诊断导向改源。
|
||||
src = os.path.join(d, "bad.md")
|
||||
out = os.path.join(d, "bad.docx")
|
||||
Path(src).write_text("# 标题\n\n## 一、章节\n\n#### 跳级\n内容\n", encoding="utf-8")
|
||||
with mock.patch.dict(os.environ, {"AGENT_SYSTEM_LEGACY_WORD_COMPARISON": "1"}):
|
||||
self.assertEqual(B.build(src, out), 1)
|
||||
self.assertEqual(B.build(src, out), 2)
|
||||
self.assertFalse(os.path.exists(out))
|
||||
|
||||
def test_build_cli_entry(self):
|
||||
import subprocess, sys, tempfile, os
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
src = os.path.join(d, "s.md")
|
||||
Path(src).write_text("# T\n\n## 一、A\n内容\n", encoding="utf-8")
|
||||
env = os.environ.copy()
|
||||
env["AGENT_SYSTEM_LEGACY_WORD_COMPARISON"] = "1"
|
||||
r = subprocess.run([sys.executable, "-m", "housestyle.build", src, "-o", os.path.join(d, "s.docx")],
|
||||
capture_output=True, text=True, env=env,
|
||||
capture_output=True, text=True,
|
||||
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
# PYTHONPATH 由调用方保证;这里直接在 house-style 根目录下跑模块。
|
||||
if r.returncode not in (0, 1):
|
||||
self.fail(r.stderr[:200])
|
||||
self.assertEqual(r.returncode, 2, r.stderr[:200])
|
||||
self.assertFalse(os.path.exists(os.path.join(d, "s.docx")))
|
||||
|
||||
|
||||
class BrokenPptxTests(unittest.TestCase):
|
||||
|
||||
@@ -183,13 +183,15 @@ func TestHouseStyleRoutesProductionWordThroughUnifiedRenderer(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(raw)
|
||||
for _, required := range []string{"render_document", "pending-human-visual-review", "RDM", "AGENT_SYSTEM_LEGACY_WORD_COMPARISON"} {
|
||||
for _, required := range []string{"render_document", "pending-human-visual-review", "RDM"} {
|
||||
if !strings.Contains(body, required) {
|
||||
t.Errorf("house-style skill missing %q", required)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "python3 -m housestyle.build 方案.md") {
|
||||
t.Fatal("house-style still advertises the legacy Word builder as a production command")
|
||||
for _, forbidden := range []string{"AGENT_SYSTEM_LEGACY_WORD_COMPARISON", "docx_kit", "new_document(template=", "dx.save("} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("house-style still advertises Word bypass %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
type DocumentDeliveryState string
|
||||
|
||||
const (
|
||||
DocumentDeliveryFrozen DocumentDeliveryState = "frozen"
|
||||
DocumentDeliverySourceReady DocumentDeliveryState = "source-ready"
|
||||
DocumentDeliveryFailed DocumentDeliveryState = "failed"
|
||||
DocumentDeliveryComplete DocumentDeliveryState = "complete"
|
||||
)
|
||||
|
||||
type DocumentDeliveryArtifactRole string
|
||||
|
||||
const (
|
||||
DocumentArtifactMarkdown DocumentDeliveryArtifactRole = "markdown"
|
||||
DocumentArtifactDOCX DocumentDeliveryArtifactRole = "docx"
|
||||
DocumentArtifactManifest DocumentDeliveryArtifactRole = "manifest"
|
||||
DocumentArtifactArchive DocumentDeliveryArtifactRole = "archive"
|
||||
)
|
||||
|
||||
const (
|
||||
DocumentDeliveryFaultAfterMarkdownBlob = "after-markdown-blob"
|
||||
DocumentDeliveryFaultBeforeMarkdownCommit = "before-markdown-commit"
|
||||
DocumentDeliveryFaultAfterDOCXBlob = "after-docx-blob"
|
||||
DocumentDeliveryFaultAfterManifestBlob = "after-manifest-blob"
|
||||
DocumentDeliveryFaultAfterArchiveBlob = "after-archive-blob"
|
||||
DocumentDeliveryFaultBeforeDerivedCommit = "before-derived-commit"
|
||||
DocumentDeliveryFaultAfterDerivedCommit = "after-derived-commit"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDocumentDeliveryConflict = errors.New("document delivery operation conflicts with its frozen request")
|
||||
ErrDocumentDeliveryOutcomeUnknown = errors.New("document delivery commit outcome is unknown")
|
||||
deliveryOperationPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
|
||||
)
|
||||
|
||||
type DocumentDeliveryBeginInput struct {
|
||||
OperationID string
|
||||
RequestSHA256 string
|
||||
ProjectID string
|
||||
ConversationID string
|
||||
SourceRun string
|
||||
DocumentID string
|
||||
Mode string
|
||||
RendererIdentity string
|
||||
}
|
||||
|
||||
type DocumentDeliveryArtifactInput struct {
|
||||
Role DocumentDeliveryArtifactRole
|
||||
Name string
|
||||
Kind string
|
||||
MIME string
|
||||
SHA256 string
|
||||
Content []byte
|
||||
}
|
||||
|
||||
type DocumentDeliveryArtifact struct {
|
||||
Role DocumentDeliveryArtifactRole `json:"role"`
|
||||
Artifact core.Artifact `json:"artifact"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
type DocumentDeliveryRecord struct {
|
||||
OperationID string `json:"operationId"`
|
||||
Owner string `json:"owner"`
|
||||
ProjectID string `json:"projectId"`
|
||||
ConversationID string `json:"conversationId"`
|
||||
SourceRun string `json:"sourceRun,omitempty"`
|
||||
RequestSHA256 string `json:"requestSha256"`
|
||||
State DocumentDeliveryState `json:"state"`
|
||||
FrozenAt time.Time `json:"frozenAt"`
|
||||
RendererIdentity string `json:"rendererIdentity"`
|
||||
Mode string `json:"mode"`
|
||||
DocumentID string `json:"documentId"`
|
||||
CanonicalHash string `json:"canonicalHash,omitempty"`
|
||||
ProfileHash string `json:"profileHash,omitempty"`
|
||||
ReviewHash string `json:"reviewHash,omitempty"`
|
||||
DeliveryKey string `json:"deliveryKey,omitempty"`
|
||||
MarkdownArtifactID string `json:"markdownArtifactId,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Artifacts []DocumentDeliveryArtifact `json:"artifacts,omitempty"`
|
||||
}
|
||||
|
||||
type storedDocumentDelivery struct {
|
||||
DocumentDeliveryRecord
|
||||
markdownSHA, docxID, docxSHA, manifestID, manifestSHA, archiveID, archiveSHA string
|
||||
}
|
||||
|
||||
const documentDeliveryColumns = `operation_id,owner,project_id,conversation_id,source_run,request_sha256,state,frozen_at,renderer_identity,mode,document_id,canonical_hash,profile_hash,review_hash,delivery_key,markdown_artifact_id,markdown_sha256,docx_artifact_id,docx_sha256,manifest_artifact_id,manifest_sha256,archive_artifact_id,archive_sha256,error_text`
|
||||
|
||||
func (s *Store) BeginDocumentDeliveryAccountChecked(actor string, input DocumentDeliveryBeginInput, allowImplicitDefault bool) (DocumentDeliveryRecord, error) {
|
||||
if err := validateDocumentDeliveryBegin(actor, input); err != nil {
|
||||
return DocumentDeliveryRecord{}, err
|
||||
}
|
||||
var record DocumentDeliveryRecord
|
||||
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
if err := s.cardResourcesForWrite(tx, actor, input.ProjectID, input.ConversationID); err != nil {
|
||||
return err
|
||||
}
|
||||
stored, found, err := s.documentDeliveryForWrite(tx, actor, input.OperationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
if !sameDocumentDeliveryInput(stored.DocumentDeliveryRecord, input) {
|
||||
return ErrDocumentDeliveryConflict
|
||||
}
|
||||
record = stored.DocumentDeliveryRecord
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
_, err = tx.Exec(s.q(`INSERT INTO document_render_deliveries(operation_id,owner,project_id,conversation_id,source_run,request_sha256,state,frozen_at,renderer_identity,mode,document_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`),
|
||||
input.OperationID, actor, input.ProjectID, input.ConversationID, input.SourceRun, input.RequestSHA256,
|
||||
DocumentDeliveryFrozen, now.Format(time.RFC3339Nano), input.RendererIdentity, input.Mode, input.DocumentID,
|
||||
now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
|
||||
if err == nil {
|
||||
record = DocumentDeliveryRecord{OperationID: input.OperationID, Owner: actor, ProjectID: input.ProjectID,
|
||||
ConversationID: input.ConversationID, SourceRun: input.SourceRun, RequestSHA256: input.RequestSHA256,
|
||||
State: DocumentDeliveryFrozen, FrozenAt: now, RendererIdentity: input.RendererIdentity, Mode: input.Mode, DocumentID: input.DocumentID}
|
||||
}
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return record, err
|
||||
}
|
||||
return s.GetDocumentDelivery(actor, input.OperationID)
|
||||
}
|
||||
|
||||
func (s *Store) PersistDocumentDeliveryMarkdownAccountChecked(actor, operationID string, artifact DocumentDeliveryArtifactInput, canonicalHash string, allowImplicitDefault bool) (DocumentDeliveryRecord, error) {
|
||||
if artifact.Role != DocumentArtifactMarkdown || canonicalHash == "" {
|
||||
return DocumentDeliveryRecord{}, errors.New("document delivery Markdown role and canonical hash are required")
|
||||
}
|
||||
if err := validateDeliveryArtifactInput(artifact); err != nil {
|
||||
return DocumentDeliveryRecord{}, err
|
||||
}
|
||||
current, err := s.GetDocumentDelivery(actor, operationID)
|
||||
if err != nil {
|
||||
return DocumentDeliveryRecord{}, err
|
||||
}
|
||||
if current.MarkdownArtifactID != "" {
|
||||
return current, nil
|
||||
}
|
||||
artifactID := stableDeliveryArtifactID(actor, operationID, artifact.Role)
|
||||
ref, err := s.blobs.Put(newID("blob"), artifact.Content)
|
||||
if err != nil {
|
||||
return current, err
|
||||
}
|
||||
if fault := s.takeDocumentDeliveryFault(DocumentDeliveryFaultAfterMarkdownBlob); fault != nil {
|
||||
_ = s.blobs.Delete(ref)
|
||||
return current, fault
|
||||
}
|
||||
used := false
|
||||
err = s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
stored, found, err := s.documentDeliveryForWrite(tx, actor, operationID)
|
||||
if err != nil || !found {
|
||||
if !found && err == nil {
|
||||
err = ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if stored.MarkdownArtifactID != "" {
|
||||
return nil
|
||||
}
|
||||
if fault := s.takeDocumentDeliveryFault(DocumentDeliveryFaultBeforeMarkdownCommit); fault != nil {
|
||||
return fault
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if err := insertDeliveryArtifactTx(s, tx, actor, stored.ProjectID, stored.SourceRun, artifactID, ref, artifact, now); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(s.q(`UPDATE document_render_deliveries SET state=?,canonical_hash=?,markdown_artifact_id=?,markdown_sha256=?,error_text='',updated_at=? WHERE owner=? AND operation_id=?`),
|
||||
DocumentDeliverySourceReady, canonicalHash, artifactID, artifact.SHA256, now.Format(time.RFC3339Nano), actor, operationID)
|
||||
if err == nil {
|
||||
used = true
|
||||
}
|
||||
return err
|
||||
})
|
||||
if !used {
|
||||
_ = s.blobs.Delete(ref)
|
||||
}
|
||||
record, readErr := s.GetDocumentDelivery(actor, operationID)
|
||||
if err != nil {
|
||||
if used && readErr == nil {
|
||||
return record, ErrDocumentDeliveryOutcomeUnknown
|
||||
}
|
||||
return current, err
|
||||
}
|
||||
return record, readErr
|
||||
}
|
||||
|
||||
func (s *Store) CommitDocumentDeliveryDerivedAccountChecked(actor, operationID, deliveryKey, profileHash, reviewHash string, artifacts []DocumentDeliveryArtifactInput, allowImplicitDefault bool) (DocumentDeliveryRecord, error) {
|
||||
if deliveryKey == "" || profileHash == "" || reviewHash == "" {
|
||||
return DocumentDeliveryRecord{}, errors.New("document delivery key, profile hash, and review hash are required")
|
||||
}
|
||||
ordered, err := validateDerivedArtifacts(artifacts)
|
||||
if err != nil {
|
||||
return DocumentDeliveryRecord{}, err
|
||||
}
|
||||
current, err := s.GetDocumentDelivery(actor, operationID)
|
||||
if err != nil {
|
||||
return DocumentDeliveryRecord{}, err
|
||||
}
|
||||
if current.State == DocumentDeliveryComplete {
|
||||
if current.DeliveryKey != deliveryKey || current.ProfileHash != profileHash || current.ReviewHash != reviewHash {
|
||||
return current, ErrDocumentDeliveryConflict
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
if current.MarkdownArtifactID == "" {
|
||||
return current, errors.New("document delivery has no authoritative Markdown")
|
||||
}
|
||||
type stagedArtifact struct {
|
||||
input DocumentDeliveryArtifactInput
|
||||
id, ref string
|
||||
}
|
||||
staged := make([]stagedArtifact, 0, len(ordered))
|
||||
cleanup := func() {
|
||||
for _, item := range staged {
|
||||
_ = s.blobs.Delete(item.ref)
|
||||
}
|
||||
}
|
||||
for _, input := range ordered {
|
||||
ref, err := s.blobs.Put(newID("blob"), input.Content)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return current, err
|
||||
}
|
||||
staged = append(staged, stagedArtifact{input: input, id: stableDeliveryArtifactID(actor, operationID, input.Role), ref: ref})
|
||||
stage := map[DocumentDeliveryArtifactRole]string{DocumentArtifactDOCX: DocumentDeliveryFaultAfterDOCXBlob, DocumentArtifactManifest: DocumentDeliveryFaultAfterManifestBlob, DocumentArtifactArchive: DocumentDeliveryFaultAfterArchiveBlob}[input.Role]
|
||||
if fault := s.takeDocumentDeliveryFault(stage); fault != nil {
|
||||
cleanup()
|
||||
return current, fault
|
||||
}
|
||||
}
|
||||
used := false
|
||||
err = s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
stored, found, err := s.documentDeliveryForWrite(tx, actor, operationID)
|
||||
if err != nil || !found {
|
||||
if !found && err == nil {
|
||||
err = ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if stored.State == DocumentDeliveryComplete {
|
||||
if stored.DeliveryKey != deliveryKey || stored.ProfileHash != profileHash || stored.ReviewHash != reviewHash {
|
||||
return ErrDocumentDeliveryConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if stored.MarkdownArtifactID == "" {
|
||||
return errors.New("document delivery has no authoritative Markdown")
|
||||
}
|
||||
if fault := s.takeDocumentDeliveryFault(DocumentDeliveryFaultBeforeDerivedCommit); fault != nil {
|
||||
return fault
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, item := range staged {
|
||||
if err := insertDeliveryArtifactTx(s, tx, actor, stored.ProjectID, stored.SourceRun, item.id, item.ref, item.input, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = tx.Exec(s.q(`UPDATE document_render_deliveries SET state=?,profile_hash=?,review_hash=?,delivery_key=?,docx_artifact_id=?,docx_sha256=?,manifest_artifact_id=?,manifest_sha256=?,archive_artifact_id=?,archive_sha256=?,error_text='',updated_at=? WHERE owner=? AND operation_id=?`),
|
||||
DocumentDeliveryComplete, profileHash, reviewHash, deliveryKey,
|
||||
staged[0].id, staged[0].input.SHA256, staged[1].id, staged[1].input.SHA256, staged[2].id, staged[2].input.SHA256,
|
||||
now.Format(time.RFC3339Nano), actor, operationID)
|
||||
if err == nil {
|
||||
used = true
|
||||
}
|
||||
return err
|
||||
})
|
||||
if !used {
|
||||
cleanup()
|
||||
}
|
||||
record, readErr := s.GetDocumentDelivery(actor, operationID)
|
||||
if err != nil {
|
||||
if used && readErr == nil {
|
||||
return record, ErrDocumentDeliveryOutcomeUnknown
|
||||
}
|
||||
return current, err
|
||||
}
|
||||
if fault := s.takeDocumentDeliveryFault(DocumentDeliveryFaultAfterDerivedCommit); fault != nil {
|
||||
return record, fmt.Errorf("%w: %v", ErrDocumentDeliveryOutcomeUnknown, fault)
|
||||
}
|
||||
return record, readErr
|
||||
}
|
||||
|
||||
func (s *Store) MarkDocumentDeliveryFailedAccountChecked(actor, operationID, message string, allowImplicitDefault bool) (DocumentDeliveryRecord, error) {
|
||||
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
||||
stored, found, err := s.documentDeliveryForWrite(tx, actor, operationID)
|
||||
if err != nil || !found {
|
||||
if !found && err == nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if stored.State == DocumentDeliveryComplete {
|
||||
return nil
|
||||
}
|
||||
_, err = tx.Exec(s.q(`UPDATE document_render_deliveries SET state=?,error_text=?,updated_at=? WHERE owner=? AND operation_id=?`),
|
||||
DocumentDeliveryFailed, message, nowISO(), actor, operationID)
|
||||
return err
|
||||
})
|
||||
record, readErr := s.GetDocumentDelivery(actor, operationID)
|
||||
if err != nil {
|
||||
return record, err
|
||||
}
|
||||
return record, readErr
|
||||
}
|
||||
|
||||
func (s *Store) GetDocumentDelivery(owner, operationID string) (DocumentDeliveryRecord, error) {
|
||||
stored, err := scanDocumentDelivery(s.db.QueryRow(s.q(`SELECT `+documentDeliveryColumns+` FROM document_render_deliveries WHERE owner=? AND operation_id=?`), owner, operationID).Scan)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return DocumentDeliveryRecord{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return DocumentDeliveryRecord{}, err
|
||||
}
|
||||
return s.hydrateDocumentDelivery(stored)
|
||||
}
|
||||
|
||||
func (s *Store) DocumentDeliveryMarkdown(owner, operationID string) ([]byte, error) {
|
||||
record, err := s.GetDocumentDelivery(owner, operationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record.MarkdownArtifactID == "" {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return s.ArtifactContent(owner, record.MarkdownArtifactID)
|
||||
}
|
||||
|
||||
func (s *Store) SetDocumentDeliveryFault(stage string, err error) {
|
||||
s.documentDeliveryMu.Lock()
|
||||
s.documentDeliveryFaultStage, s.documentDeliveryFault = stage, err
|
||||
s.documentDeliveryMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Store) takeDocumentDeliveryFault(stage string) error {
|
||||
if stage == "" {
|
||||
return nil
|
||||
}
|
||||
s.documentDeliveryMu.Lock()
|
||||
defer s.documentDeliveryMu.Unlock()
|
||||
if s.documentDeliveryFaultStage != stage {
|
||||
return nil
|
||||
}
|
||||
err := s.documentDeliveryFault
|
||||
s.documentDeliveryFaultStage, s.documentDeliveryFault = "", nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) documentDeliveryForWrite(tx *sql.Tx, owner, operationID string) (storedDocumentDelivery, bool, error) {
|
||||
query := s.q(`SELECT ` + documentDeliveryColumns + ` FROM document_render_deliveries WHERE owner=? AND operation_id=?`)
|
||||
if s.Dialect() == "postgres" {
|
||||
query += ` FOR UPDATE`
|
||||
}
|
||||
stored, err := scanDocumentDelivery(tx.QueryRow(query, owner, operationID).Scan)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return storedDocumentDelivery{}, false, nil
|
||||
}
|
||||
return stored, err == nil, err
|
||||
}
|
||||
|
||||
func scanDocumentDelivery(scan func(...any) error) (storedDocumentDelivery, error) {
|
||||
var stored storedDocumentDelivery
|
||||
var frozen string
|
||||
err := scan(&stored.OperationID, &stored.Owner, &stored.ProjectID, &stored.ConversationID, &stored.SourceRun,
|
||||
&stored.RequestSHA256, &stored.State, &frozen, &stored.RendererIdentity, &stored.Mode, &stored.DocumentID,
|
||||
&stored.CanonicalHash, &stored.ProfileHash, &stored.ReviewHash, &stored.DeliveryKey,
|
||||
&stored.MarkdownArtifactID, &stored.markdownSHA, &stored.docxID, &stored.docxSHA,
|
||||
&stored.manifestID, &stored.manifestSHA, &stored.archiveID, &stored.archiveSHA, &stored.Error)
|
||||
if err == nil {
|
||||
stored.FrozenAt, err = time.Parse(time.RFC3339Nano, frozen)
|
||||
}
|
||||
return stored, err
|
||||
}
|
||||
|
||||
func (s *Store) hydrateDocumentDelivery(stored storedDocumentDelivery) (DocumentDeliveryRecord, error) {
|
||||
record := stored.DocumentDeliveryRecord
|
||||
for _, item := range []struct {
|
||||
role DocumentDeliveryArtifactRole
|
||||
id, hash string
|
||||
}{
|
||||
{DocumentArtifactMarkdown, stored.MarkdownArtifactID, stored.markdownSHA},
|
||||
{DocumentArtifactDOCX, stored.docxID, stored.docxSHA},
|
||||
{DocumentArtifactManifest, stored.manifestID, stored.manifestSHA},
|
||||
{DocumentArtifactArchive, stored.archiveID, stored.archiveSHA},
|
||||
} {
|
||||
if item.id == "" {
|
||||
continue
|
||||
}
|
||||
artifact, err := s.GetArtifact(record.Owner, item.id)
|
||||
if err != nil {
|
||||
return DocumentDeliveryRecord{}, err
|
||||
}
|
||||
record.Artifacts = append(record.Artifacts, DocumentDeliveryArtifact{Role: item.role, Artifact: artifact, SHA256: item.hash})
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func validateDocumentDeliveryBegin(actor string, input DocumentDeliveryBeginInput) error {
|
||||
if actor == "" || !deliveryOperationPattern.MatchString(input.OperationID) || !validDeliveryHash(input.RequestSHA256) ||
|
||||
input.ProjectID == "" || input.ConversationID == "" || input.DocumentID == "" || input.RendererIdentity == "" ||
|
||||
(input.Mode != "working" && input.Mode != "preview") {
|
||||
return errors.New("invalid document delivery begin input")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameDocumentDeliveryInput(record DocumentDeliveryRecord, input DocumentDeliveryBeginInput) bool {
|
||||
return record.ProjectID == input.ProjectID && record.ConversationID == input.ConversationID &&
|
||||
record.RequestSHA256 == input.RequestSHA256 && record.RendererIdentity == input.RendererIdentity && record.Mode == input.Mode && record.DocumentID == input.DocumentID
|
||||
}
|
||||
|
||||
func validateDeliveryArtifactInput(input DocumentDeliveryArtifactInput) error {
|
||||
if input.Name == "" || input.Kind == "" || input.MIME == "" || !validDeliveryHash(input.SHA256) {
|
||||
return errors.New("invalid document delivery artifact")
|
||||
}
|
||||
sum := sha256.Sum256(input.Content)
|
||||
if hex.EncodeToString(sum[:]) != input.SHA256 {
|
||||
return errors.New("document delivery artifact hash mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDerivedArtifacts(inputs []DocumentDeliveryArtifactInput) ([]DocumentDeliveryArtifactInput, error) {
|
||||
want := map[DocumentDeliveryArtifactRole]bool{DocumentArtifactDOCX: true, DocumentArtifactManifest: true, DocumentArtifactArchive: true}
|
||||
byRole := make(map[DocumentDeliveryArtifactRole]DocumentDeliveryArtifactInput, len(inputs))
|
||||
for _, input := range inputs {
|
||||
if !want[input.Role] || byRole[input.Role].Role != "" {
|
||||
return nil, errors.New("derived delivery requires unique DOCX, manifest, and archive artifacts")
|
||||
}
|
||||
if err := validateDeliveryArtifactInput(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byRole[input.Role] = input
|
||||
}
|
||||
if len(byRole) != len(want) {
|
||||
return nil, errors.New("derived delivery requires DOCX, manifest, and archive artifacts")
|
||||
}
|
||||
return []DocumentDeliveryArtifactInput{byRole[DocumentArtifactDOCX], byRole[DocumentArtifactManifest], byRole[DocumentArtifactArchive]}, nil
|
||||
}
|
||||
|
||||
func insertDeliveryArtifactTx(s *Store, tx *sql.Tx, owner, projectID, sourceRun, id, ref string, input DocumentDeliveryArtifactInput, now time.Time) error {
|
||||
_, err := tx.Exec(s.q(`INSERT INTO artifacts(id,project_id,owner,name,kind,mime,size,source_run,content_path,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`),
|
||||
id, projectID, owner, input.Name, input.Kind, input.MIME, len(input.Content), sourceRun, ref,
|
||||
now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
|
||||
return err
|
||||
}
|
||||
|
||||
func stableDeliveryArtifactID(owner, operationID string, role DocumentDeliveryArtifactRole) string {
|
||||
sum := sha256.Sum256([]byte(owner + "\x00" + operationID + "\x00" + string(role)))
|
||||
return "art_doc_" + hex.EncodeToString(sum[:12])
|
||||
}
|
||||
|
||||
func validDeliveryHash(value string) bool {
|
||||
if len(value) != 64 || strings.ToLower(value) != value {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(value)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func TestDocumentDeliveryFreezesOnceAndRejectsOperationConflict(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
conversation := seedConversation(t, st)
|
||||
input := deliveryBeginFixture(conversation, "operation-freeze")
|
||||
|
||||
first, err := st.BeginDocumentDeliveryAccountChecked(OwnerDefault, input, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := st.BeginDocumentDeliveryAccountChecked(OwnerDefault, input, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.FrozenAt.IsZero() || !first.FrozenAt.Equal(second.FrozenAt) || first.State != DocumentDeliveryFrozen {
|
||||
t.Fatalf("freeze records differ: first=%#v second=%#v", first, second)
|
||||
}
|
||||
|
||||
conflict := input
|
||||
conflict.RequestSHA256 = hashText("different request")
|
||||
if _, err := st.BeginDocumentDeliveryAccountChecked(OwnerDefault, conflict, true); !errors.Is(err, ErrDocumentDeliveryConflict) {
|
||||
t.Fatalf("conflicting operation error = %v, want ErrDocumentDeliveryConflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentDeliveryPersistsMarkdownFirstAndCommitsDerivedSetAtomically(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
conversation := seedConversation(t, st)
|
||||
input := deliveryBeginFixture(conversation, "operation-complete")
|
||||
record, err := st.BeginDocumentDeliveryAccountChecked(OwnerDefault, input, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
markdown := deliveryArtifactFixture(DocumentArtifactMarkdown, "report.md", "text/markdown; charset=utf-8", []byte("# report\n"))
|
||||
record, err = st.PersistDocumentDeliveryMarkdownAccountChecked(OwnerDefault, input.OperationID, markdown, hashText("canonical"), true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.State != DocumentDeliverySourceReady || len(record.Artifacts) != 1 || record.Artifacts[0].Role != DocumentArtifactMarkdown {
|
||||
t.Fatalf("source receipt = %#v", record)
|
||||
}
|
||||
if raw, err := st.ArtifactContent(OwnerDefault, record.Artifacts[0].Artifact.ID); err != nil || string(raw) != "# report\n" {
|
||||
t.Fatalf("persisted Markdown = %q err=%v", raw, err)
|
||||
}
|
||||
|
||||
derived := []DocumentDeliveryArtifactInput{
|
||||
deliveryArtifactFixture(DocumentArtifactDOCX, "report.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", []byte("docx")),
|
||||
deliveryArtifactFixture(DocumentArtifactManifest, "report.manifest.json", "application/json", []byte("manifest")),
|
||||
deliveryArtifactFixture(DocumentArtifactArchive, "report.zip", "application/zip", []byte("archive")),
|
||||
}
|
||||
record, err = st.CommitDocumentDeliveryDerivedAccountChecked(OwnerDefault, input.OperationID, "delivery-key", "profile-hash", "review-hash", derived, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.State != DocumentDeliveryComplete || len(record.Artifacts) != 4 || record.DeliveryKey != "delivery-key" {
|
||||
t.Fatalf("complete receipt = %#v", record)
|
||||
}
|
||||
firstIDs := deliveryArtifactIDs(record.Artifacts)
|
||||
retry, err := st.CommitDocumentDeliveryDerivedAccountChecked(OwnerDefault, input.OperationID, "delivery-key", "profile-hash", "review-hash", derived, true)
|
||||
if err != nil || !reflect.DeepEqual(firstIDs, deliveryArtifactIDs(retry.Artifacts)) {
|
||||
t.Fatalf("idempotent derived retry = %#v err=%v", retry, err)
|
||||
}
|
||||
artifacts, err := st.ListArtifacts(OwnerDefault, string(conversation.ProjectID))
|
||||
if err != nil || len(artifacts) != 4 {
|
||||
t.Fatalf("visible artifact set = %d err=%v", len(artifacts), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentDeliveryFailureAfterEveryWriteIsDurableAndRetryable(t *testing.T) {
|
||||
stages := []string{
|
||||
DocumentDeliveryFaultAfterMarkdownBlob,
|
||||
DocumentDeliveryFaultBeforeMarkdownCommit,
|
||||
DocumentDeliveryFaultAfterDOCXBlob,
|
||||
DocumentDeliveryFaultAfterManifestBlob,
|
||||
DocumentDeliveryFaultAfterArchiveBlob,
|
||||
DocumentDeliveryFaultBeforeDerivedCommit,
|
||||
DocumentDeliveryFaultAfterDerivedCommit,
|
||||
}
|
||||
for _, stage := range stages {
|
||||
t.Run(stage, func(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
conversation := seedConversation(t, st)
|
||||
operationID := "fault-" + stage
|
||||
input := deliveryBeginFixture(conversation, operationID)
|
||||
if _, err := st.BeginDocumentDeliveryAccountChecked(OwnerDefault, input, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
markdown := deliveryArtifactFixture(DocumentArtifactMarkdown, "fault.md", "text/markdown", []byte("source"))
|
||||
derived := []DocumentDeliveryArtifactInput{
|
||||
deliveryArtifactFixture(DocumentArtifactDOCX, "fault.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", []byte("docx")),
|
||||
deliveryArtifactFixture(DocumentArtifactManifest, "fault.manifest.json", "application/json", []byte("manifest")),
|
||||
deliveryArtifactFixture(DocumentArtifactArchive, "fault.zip", "application/zip", []byte("archive")),
|
||||
}
|
||||
st.SetDocumentDeliveryFault(stage, errors.New("injected "+stage))
|
||||
var receipt DocumentDeliveryRecord
|
||||
var err error
|
||||
if stage == DocumentDeliveryFaultAfterMarkdownBlob || stage == DocumentDeliveryFaultBeforeMarkdownCommit {
|
||||
receipt, err = st.PersistDocumentDeliveryMarkdownAccountChecked(OwnerDefault, operationID, markdown, hashText("canonical"), true)
|
||||
} else {
|
||||
if _, err = st.PersistDocumentDeliveryMarkdownAccountChecked(OwnerDefault, operationID, markdown, hashText("canonical"), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
receipt, err = st.CommitDocumentDeliveryDerivedAccountChecked(OwnerDefault, operationID, "delivery-key", "profile-hash", "review-hash", derived, true)
|
||||
}
|
||||
if err == nil || receipt.OperationID != operationID {
|
||||
t.Fatalf("fault receipt=%#v err=%v", receipt, err)
|
||||
}
|
||||
if stage == DocumentDeliveryFaultAfterDerivedCommit {
|
||||
if !errors.Is(err, ErrDocumentDeliveryOutcomeUnknown) || receipt.State != DocumentDeliveryComplete {
|
||||
t.Fatalf("post-commit outcome=%#v err=%v", receipt, err)
|
||||
}
|
||||
} else if stage != DocumentDeliveryFaultAfterMarkdownBlob && stage != DocumentDeliveryFaultBeforeMarkdownCommit && receipt.MarkdownArtifactID == "" {
|
||||
t.Fatalf("derived failure lost authoritative Markdown: %#v", receipt)
|
||||
}
|
||||
visible, listErr := st.ListArtifacts(OwnerDefault, string(conversation.ProjectID))
|
||||
wantVisible := 1
|
||||
if stage == DocumentDeliveryFaultAfterMarkdownBlob || stage == DocumentDeliveryFaultBeforeMarkdownCommit {
|
||||
wantVisible = 0
|
||||
}
|
||||
if stage == DocumentDeliveryFaultAfterDerivedCommit {
|
||||
wantVisible = 4
|
||||
}
|
||||
if listErr != nil || len(visible) != wantVisible {
|
||||
t.Fatalf("failure exposed %d artifacts, want %d: %v", len(visible), wantVisible, listErr)
|
||||
}
|
||||
|
||||
st.SetDocumentDeliveryFault("", nil)
|
||||
if receipt.MarkdownArtifactID == "" {
|
||||
if _, err := st.PersistDocumentDeliveryMarkdownAccountChecked(OwnerDefault, operationID, markdown, hashText("canonical"), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
complete, err := st.CommitDocumentDeliveryDerivedAccountChecked(OwnerDefault, operationID, "delivery-key", "profile-hash", "review-hash", derived, true)
|
||||
if err != nil || complete.State != DocumentDeliveryComplete || len(complete.Artifacts) != 4 {
|
||||
t.Fatalf("retry did not complete: %#v err=%v", complete, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentDeliveryRetryAfterRestartReturnsSameIDsAndHashes(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
database := filepath.Join(directory, "delivery.db")
|
||||
st, err := OpenSQLite(database)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project, err := st.CreateProject(OwnerDefault, "delivery")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversation, err := st.CreateConversation(OwnerDefault, project.ID, "delivery", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := deliveryBeginFixture(conversation, "restart-operation")
|
||||
if _, err := st.BeginDocumentDeliveryAccountChecked(OwnerDefault, input, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
markdown := deliveryArtifactFixture(DocumentArtifactMarkdown, "restart.md", "text/markdown", []byte("source"))
|
||||
if _, err := st.PersistDocumentDeliveryMarkdownAccountChecked(OwnerDefault, input.OperationID, markdown, hashText("canonical"), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
derived := []DocumentDeliveryArtifactInput{
|
||||
deliveryArtifactFixture(DocumentArtifactDOCX, "restart.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", []byte("docx")),
|
||||
deliveryArtifactFixture(DocumentArtifactManifest, "restart.manifest.json", "application/json", []byte("manifest")),
|
||||
deliveryArtifactFixture(DocumentArtifactArchive, "restart.zip", "application/zip", []byte("archive")),
|
||||
}
|
||||
first, err := st.CommitDocumentDeliveryDerivedAccountChecked(OwnerDefault, input.OperationID, "restart-key", "profile", "review", derived, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reopened, err := OpenSQLite(database)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
begin, err := reopened.BeginDocumentDeliveryAccountChecked(OwnerDefault, input, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
retry, err := reopened.CommitDocumentDeliveryDerivedAccountChecked(OwnerDefault, input.OperationID, "restart-key", "profile", "review", derived, true)
|
||||
if err != nil || !begin.FrozenAt.Equal(first.FrozenAt) || !reflect.DeepEqual(deliveryArtifactIDs(first.Artifacts), deliveryArtifactIDs(retry.Artifacts)) {
|
||||
t.Fatalf("restart retry changed identity: first=%#v begin=%#v retry=%#v err=%v", first, begin, retry, err)
|
||||
}
|
||||
}
|
||||
|
||||
func deliveryBeginFixture(conversation core.Conversation, operationID string) DocumentDeliveryBeginInput {
|
||||
return DocumentDeliveryBeginInput{
|
||||
OperationID: operationID, RequestSHA256: hashText("request-" + operationID),
|
||||
ProjectID: string(conversation.ProjectID), ConversationID: string(conversation.ID),
|
||||
SourceRun: "run-1", DocumentID: "document-1", Mode: "preview", RendererIdentity: "renderer@revision",
|
||||
}
|
||||
}
|
||||
|
||||
func deliveryArtifactFixture(role DocumentDeliveryArtifactRole, name, mime string, content []byte) DocumentDeliveryArtifactInput {
|
||||
return DocumentDeliveryArtifactInput{Role: role, Name: name, Kind: "document", MIME: mime, SHA256: hashBytesForTest(content), Content: content}
|
||||
}
|
||||
|
||||
func deliveryArtifactIDs(artifacts []DocumentDeliveryArtifact) map[DocumentDeliveryArtifactRole]string {
|
||||
ids := make(map[DocumentDeliveryArtifactRole]string, len(artifacts))
|
||||
for _, artifact := range artifacts {
|
||||
ids[artifact.Role] = string(artifact.Artifact.ID) + ":" + artifact.SHA256
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func hashText(value string) string { return hashBytesForTest([]byte(value)) }
|
||||
|
||||
func hashBytesForTest(value []byte) string {
|
||||
sum := sha256.Sum256(value)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -1145,6 +1145,29 @@ func init() {
|
||||
"sqlite": {`DROP INDEX IF EXISTS idx_human_decisions_pending`, `DROP TABLE IF EXISTS human_decisions`},
|
||||
"postgres": {`DROP INDEX IF EXISTS idx_human_decisions_pending`, `DROP TABLE IF EXISTS human_decisions`},
|
||||
}},
|
||||
migration{version: 47, name: "document_render_deliveries",
|
||||
up: map[string][]string{
|
||||
"sqlite": {`CREATE TABLE document_render_deliveries (
|
||||
operation_id TEXT NOT NULL,owner TEXT NOT NULL,project_id TEXT NOT NULL,conversation_id TEXT NOT NULL,source_run TEXT NOT NULL DEFAULT '',
|
||||
request_sha256 TEXT NOT NULL,state TEXT NOT NULL,frozen_at TEXT NOT NULL,renderer_identity TEXT NOT NULL,mode TEXT NOT NULL,document_id TEXT NOT NULL,
|
||||
canonical_hash TEXT NOT NULL DEFAULT '',profile_hash TEXT NOT NULL DEFAULT '',review_hash TEXT NOT NULL DEFAULT '',delivery_key TEXT NOT NULL DEFAULT '',
|
||||
markdown_artifact_id TEXT NOT NULL DEFAULT '',markdown_sha256 TEXT NOT NULL DEFAULT '',docx_artifact_id TEXT NOT NULL DEFAULT '',docx_sha256 TEXT NOT NULL DEFAULT '',
|
||||
manifest_artifact_id TEXT NOT NULL DEFAULT '',manifest_sha256 TEXT NOT NULL DEFAULT '',archive_artifact_id TEXT NOT NULL DEFAULT '',archive_sha256 TEXT NOT NULL DEFAULT '',
|
||||
error_text TEXT NOT NULL DEFAULT '',created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(owner,operation_id))`,
|
||||
`CREATE INDEX idx_document_render_delivery_key ON document_render_deliveries(owner,project_id,delivery_key)`},
|
||||
"postgres": {`CREATE TABLE document_render_deliveries (
|
||||
operation_id TEXT NOT NULL,owner TEXT NOT NULL,project_id TEXT NOT NULL,conversation_id TEXT NOT NULL,source_run TEXT NOT NULL DEFAULT '',
|
||||
request_sha256 TEXT NOT NULL,state TEXT NOT NULL,frozen_at TEXT NOT NULL,renderer_identity TEXT NOT NULL,mode TEXT NOT NULL,document_id TEXT NOT NULL,
|
||||
canonical_hash TEXT NOT NULL DEFAULT '',profile_hash TEXT NOT NULL DEFAULT '',review_hash TEXT NOT NULL DEFAULT '',delivery_key TEXT NOT NULL DEFAULT '',
|
||||
markdown_artifact_id TEXT NOT NULL DEFAULT '',markdown_sha256 TEXT NOT NULL DEFAULT '',docx_artifact_id TEXT NOT NULL DEFAULT '',docx_sha256 TEXT NOT NULL DEFAULT '',
|
||||
manifest_artifact_id TEXT NOT NULL DEFAULT '',manifest_sha256 TEXT NOT NULL DEFAULT '',archive_artifact_id TEXT NOT NULL DEFAULT '',archive_sha256 TEXT NOT NULL DEFAULT '',
|
||||
error_text TEXT NOT NULL DEFAULT '',created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(owner,operation_id))`,
|
||||
`CREATE INDEX idx_document_render_delivery_key ON document_render_deliveries(owner,project_id,delivery_key)`},
|
||||
},
|
||||
down: map[string][]string{
|
||||
"sqlite": {`DROP INDEX IF EXISTS idx_document_render_delivery_key`, `DROP TABLE IF EXISTS document_render_deliveries`},
|
||||
"postgres": {`DROP INDEX IF EXISTS idx_document_render_delivery_key`, `DROP TABLE IF EXISTS document_render_deliveries`},
|
||||
}},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ var migrationBreaksOldReaders = map[int]bool{
|
||||
44: false, // project_audit_all_writers_guard:PG 在线审计写入门禁,旧读者兼容
|
||||
45: false, // project_work_observation_outbox:独立私域投递表,旧读者不读取
|
||||
46: false, // human_decision_proofs:独立真人确认表,旧读者不读取
|
||||
47: false, // document_render_deliveries:新增普通工作区文档交付回执表
|
||||
}
|
||||
|
||||
// SchemaTarget 是本二进制会迁移到的 schema 版本。
|
||||
|
||||
@@ -39,6 +39,7 @@ func TestMigrationTailKeepsAdoptedExecEnvBeforeFeatureHistory(t *testing.T) {
|
||||
{44, "project_audit_all_writers_guard"},
|
||||
{45, "project_work_observation_outbox"},
|
||||
{46, "human_decision_proofs"},
|
||||
{47, "document_render_deliveries"},
|
||||
}
|
||||
for _, item := range want {
|
||||
if got, ok := MigrationName(item.version); !ok || got != item.name {
|
||||
|
||||
@@ -79,6 +79,10 @@ type Store struct {
|
||||
migrateFault error // 非空时 migratePass 在「DDL 已执行、版本未登记」处失败(原子性测试)
|
||||
accountFault error // 非空时账户事务在提交前失败(半态测试)
|
||||
|
||||
documentDeliveryMu sync.Mutex
|
||||
documentDeliveryFaultStage string
|
||||
documentDeliveryFault error
|
||||
|
||||
vecPGOnce sync.Once // pgvector 列探测(U6-3:一次性缓存)
|
||||
vecPG bool
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-document-renderer/render"
|
||||
"agent-system-backend/internal/docgen"
|
||||
@@ -18,27 +18,31 @@ func registerDocumentRenderer(r *Registry) error {
|
||||
return r.Register(core.ToolDesc{
|
||||
Name: "render_document", Source: core.SourceBuiltin, Edits: true, Risk: "medium",
|
||||
Description: "把普通工作区 Markdown 通过统一文档渲染器生成 Markdown、DOCX、manifest 和可下载归档。只允许非受控工作区交付;项目受控文档必须交给 RDM。公司默认规则仍是待视觉评审候选,状态会写入 manifest。",
|
||||
Parameters: json.RawMessage(`{"type":"object","properties":{` +
|
||||
`"name":{"type":"string"},"documentId":{"type":"string"},"documentType":{"type":"string"},` +
|
||||
Parameters: json.RawMessage(`{"type":"object","additionalProperties":false,"properties":{` +
|
||||
`"operationId":{"type":"string"},"name":{"type":"string"},"documentId":{"type":"string"},"documentType":{"type":"string"},` +
|
||||
`"language":{"type":"string"},"markdown":{"type":"string"},` +
|
||||
`"mode":{"type":"string","enum":["working","preview"]},"frozenAt":{"type":"string"},` +
|
||||
`"mode":{"type":"string","enum":["working","preview"]},` +
|
||||
`"controlled":{"type":"boolean"},"projectDocument":{"type":"boolean"}` +
|
||||
`},"required":["name","documentId","markdown","frozenAt","controlled","projectDocument"]}`),
|
||||
`},"required":["operationId","name","documentId","markdown","controlled","projectDocument"]}`),
|
||||
}, executeDocumentRenderer)
|
||||
}
|
||||
|
||||
type documentRendererArgs struct {
|
||||
OperationID string `json:"operationId"`
|
||||
Name string `json:"name"`
|
||||
DocumentID string `json:"documentId"`
|
||||
DocumentType string `json:"documentType"`
|
||||
Language string `json:"language"`
|
||||
Markdown string `json:"markdown"`
|
||||
Mode string `json:"mode"`
|
||||
FrozenAt string `json:"frozenAt"`
|
||||
Controlled *bool `json:"controlled"`
|
||||
ProjectDocument *bool `json:"projectDocument"`
|
||||
}
|
||||
|
||||
type documentDeliveryHost interface {
|
||||
ExecuteDocumentDelivery(context.Context, docgen.OperationRequest) (docgen.OperationReceipt, error)
|
||||
}
|
||||
|
||||
func executeDocumentRenderer(rc ToolExecCtx, raw json.RawMessage) (string, error) {
|
||||
var args documentRendererArgs
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
@@ -50,19 +54,18 @@ func executeDocumentRenderer(rc ToolExecCtx, raw json.RawMessage) (string, error
|
||||
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
return "", errors.New("render_document: arguments must contain one JSON object")
|
||||
}
|
||||
if strings.TrimSpace(args.Name) == "" || strings.TrimSpace(args.DocumentID) == "" || strings.TrimSpace(args.Markdown) == "" {
|
||||
return "", errors.New("render_document: name, documentId, and markdown are required")
|
||||
if strings.TrimSpace(args.OperationID) == "" || strings.TrimSpace(args.Name) == "" || strings.TrimSpace(args.DocumentID) == "" || strings.TrimSpace(args.Markdown) == "" {
|
||||
return "", errors.New("render_document: operationId, name, documentId, and markdown are required")
|
||||
}
|
||||
if args.Controlled == nil || args.ProjectDocument == nil {
|
||||
return "", errors.New("render_document: controlled and projectDocument must be explicit")
|
||||
}
|
||||
if *args.Controlled || *args.ProjectDocument {
|
||||
return "", docgen.ErrRDMRequired
|
||||
}
|
||||
if strings.TrimSpace(rc.OwnerID()) == "" {
|
||||
return "", errors.New("render_document: trusted requester identity is required")
|
||||
}
|
||||
frozenAt, err := time.Parse(time.RFC3339Nano, args.FrozenAt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("render_document: frozenAt must be RFC3339: %w", err)
|
||||
}
|
||||
mode := render.Mode(args.Mode)
|
||||
if mode == "" {
|
||||
mode = render.ModePreview
|
||||
@@ -78,40 +81,21 @@ func executeDocumentRenderer(rc ToolExecCtx, raw json.RawMessage) (string, error
|
||||
if language == "" {
|
||||
language = "zh-CN"
|
||||
}
|
||||
delivery, err := docgen.Generate(execParentCtx(rc), docgen.Request{
|
||||
DocumentID: args.DocumentID, Title: args.Name, DocumentType: documentType, Language: language,
|
||||
Markdown: []byte(args.Markdown), Mode: mode, FrozenAt: frozenAt.UTC(), RequestedBy: rc.OwnerID(),
|
||||
host, ok := rc.(documentDeliveryHost)
|
||||
if !ok {
|
||||
return "", errors.New("render_document: trusted delivery host is unavailable")
|
||||
}
|
||||
receipt, err := host.ExecuteDocumentDelivery(execParentCtx(rc), docgen.OperationRequest{
|
||||
OperationID: args.OperationID, DocumentID: args.DocumentID, Title: args.Name,
|
||||
DocumentType: documentType, Language: language, Markdown: []byte(args.Markdown), Mode: mode,
|
||||
Controlled: *args.Controlled, ProjectDocument: *args.ProjectDocument,
|
||||
})
|
||||
encoded, marshalErr := json.Marshal(receipt)
|
||||
if marshalErr != nil {
|
||||
return "", marshalErr
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("render_document: %w", err)
|
||||
}
|
||||
|
||||
artifacts := []docgen.Artifact{delivery.Markdown, delivery.DOCX, delivery.Manifest, delivery.Archive}
|
||||
for _, item := range artifacts {
|
||||
if err := checkToolExternalEffect(rc); err != nil {
|
||||
return "", fmt.Errorf("render_document: permission recheck before %s: %w", item.Name, err)
|
||||
}
|
||||
if _, err := rc.WriteArtifact(item.Name, artifactKindOf(item.Name, item.MIME), item.MIME, item.Bytes); err != nil {
|
||||
return "", fmt.Errorf("render_document: persist %s: %w", item.Name, err)
|
||||
}
|
||||
}
|
||||
type outputArtifact struct {
|
||||
Name string `json:"name"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
output := struct {
|
||||
ProfileStatus string `json:"profileStatus"`
|
||||
PublicationAllowed bool `json:"publicationAllowed"`
|
||||
Artifacts []outputArtifact `json:"artifacts"`
|
||||
}{ProfileStatus: delivery.Profile.ReviewStatus, PublicationAllowed: delivery.Profile.PublicationAllowed}
|
||||
for _, item := range artifacts {
|
||||
output.Artifacts = append(output.Artifacts, outputArtifact{Name: item.Name, SHA256: item.SHA256, Size: len(item.Bytes)})
|
||||
}
|
||||
encoded, err := json.Marshal(output)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("render_document: %s", encoded)
|
||||
}
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@ package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/docgen"
|
||||
core "agent-system-core"
|
||||
@@ -20,6 +19,8 @@ type capturedArtifact struct {
|
||||
type documentRendererCtx struct {
|
||||
ctx context.Context
|
||||
artifacts []capturedArtifact
|
||||
receipt docgen.OperationReceipt
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *documentRendererCtx) ProjectID() string { return "conversation-project" }
|
||||
@@ -47,6 +48,9 @@ func (c *documentRendererCtx) WriteArtifact(name, kind, mime string, content []b
|
||||
c.artifacts = append(c.artifacts, capturedArtifact{metadata: artifact, content: append([]byte(nil), content...)})
|
||||
return artifact, nil
|
||||
}
|
||||
func (c *documentRendererCtx) ExecuteDocumentDelivery(context.Context, docgen.OperationRequest) (docgen.OperationReceipt, error) {
|
||||
return c.receipt, c.err
|
||||
}
|
||||
|
||||
func TestRenderDocumentToolWritesMarkdownFirstAndReportsPackageHashes(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
@@ -54,34 +58,91 @@ func TestRenderDocumentToolWritesMarkdownFirstAndReportsPackageHashes(t *testing
|
||||
t.Fatal(err)
|
||||
}
|
||||
arguments, _ := json.Marshal(map[string]any{
|
||||
"name": "研发纪要", "documentId": "workspace-minutes", "documentType": "general", "language": "zh-CN",
|
||||
"markdown": "# 研发纪要\n\n正文。\n", "mode": "preview", "frozenAt": "2026-10-15T12:00:00Z",
|
||||
"operationId": "operation-1", "name": "研发纪要", "documentId": "workspace-minutes", "documentType": "general", "language": "zh-CN",
|
||||
"markdown": "# 研发纪要\n\n正文。\n", "mode": "preview",
|
||||
"controlled": false, "projectDocument": false,
|
||||
})
|
||||
ctx := &documentRendererCtx{}
|
||||
ctx := &documentRendererCtx{receipt: docgen.OperationReceipt{
|
||||
OperationID: "operation-1", Outcome: "complete", State: "complete", Renderer: docgen.RendererIdentity,
|
||||
FrozenAt: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC),
|
||||
Artifacts: []docgen.ReceiptArtifact{
|
||||
{Role: "markdown", ID: "md", Name: "研发纪要.md", SHA256: strings.Repeat("a", 64)},
|
||||
{Role: "docx", ID: "docx", Name: "研发纪要.docx", SHA256: strings.Repeat("b", 64)},
|
||||
{Role: "manifest", ID: "manifest", Name: "研发纪要.manifest.json", SHA256: strings.Repeat("c", 64)},
|
||||
{Role: "archive", ID: "archive", Name: "研发纪要.zip", SHA256: strings.Repeat("d", 64)},
|
||||
},
|
||||
}}
|
||||
result := registry.Execute(ctx, core.ToolCallRequest{CallID: "render-1", Name: "render_document", Arguments: arguments})
|
||||
if result.Status != "ok" {
|
||||
t.Fatalf("render_document: %s", result.Output)
|
||||
}
|
||||
if len(ctx.artifacts) != 4 {
|
||||
t.Fatalf("artifacts = %d, want Markdown, DOCX, manifest, archive", len(ctx.artifacts))
|
||||
}
|
||||
wantSuffixes := []string{".md", ".docx", ".manifest.json", ".zip"}
|
||||
for index, suffix := range wantSuffixes {
|
||||
if !strings.HasSuffix(ctx.artifacts[index].metadata.Name, suffix) {
|
||||
t.Errorf("artifact %d name = %q, want suffix %s", index, ctx.artifacts[index].metadata.Name, suffix)
|
||||
}
|
||||
sum := sha256.Sum256(ctx.artifacts[index].content)
|
||||
if hash := hex.EncodeToString(sum[:]); !strings.Contains(result.Output, hash) {
|
||||
t.Errorf("tool output omitted %s hash %s", suffix, hash)
|
||||
}
|
||||
}
|
||||
var manifest docgen.Manifest
|
||||
if err := json.Unmarshal(ctx.artifacts[2].content, &manifest); err != nil {
|
||||
var receipt docgen.OperationReceipt
|
||||
if err := json.Unmarshal([]byte(result.Output), &receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if manifest.Renderer.RequestedBy != "alice" || manifest.Renderer.FrozenAt.Format("2006-01-02T15:04:05Z07:00") != "2026-10-15T12:00:00Z" {
|
||||
t.Fatalf("untrusted or unfrozen render identity: %#v", manifest.Renderer)
|
||||
if receipt.OperationID != "operation-1" || receipt.Outcome != "complete" || len(receipt.Artifacts) != 4 || receipt.FrozenAt.IsZero() {
|
||||
t.Fatalf("durable receipt = %#v", receipt)
|
||||
}
|
||||
for _, artifact := range receipt.Artifacts {
|
||||
if !strings.Contains(result.Output, artifact.SHA256) {
|
||||
t.Errorf("tool output omitted %s hash %s", artifact.Role, artifact.SHA256)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDocumentToolSchemaIsClosedAndUsesOnlyOperationIdentity(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
if err := registry.RegisterBuiltin(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
description, _, found := registry.Get("render_document")
|
||||
if !found {
|
||||
t.Fatal("render_document is not registered")
|
||||
}
|
||||
var schema struct {
|
||||
AdditionalProperties *bool `json:"additionalProperties"`
|
||||
Properties map[string]json.RawMessage `json:"properties"`
|
||||
Required []string `json:"required"`
|
||||
}
|
||||
if err := json.Unmarshal(description.Parameters, &schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if schema.AdditionalProperties == nil || *schema.AdditionalProperties {
|
||||
t.Fatal("render_document schema is not closed")
|
||||
}
|
||||
if _, found := schema.Properties["operationId"]; !found {
|
||||
t.Fatal("render_document schema omitted operationId")
|
||||
}
|
||||
if _, found := schema.Properties["frozenAt"]; found {
|
||||
t.Fatal("render_document still accepts model-supplied frozenAt")
|
||||
}
|
||||
wantRequired := map[string]bool{"operationId": true, "name": true, "documentId": true, "markdown": true, "controlled": true, "projectDocument": true}
|
||||
for _, name := range schema.Required {
|
||||
delete(wantRequired, name)
|
||||
}
|
||||
if len(wantRequired) != 0 {
|
||||
t.Fatalf("render_document required fields missing: %#v", wantRequired)
|
||||
}
|
||||
arguments := json.RawMessage(`{"operationId":"closed-schema","name":"Report","documentId":"report","markdown":"# Report\n","controlled":false,"projectDocument":false,"frozenAt":"2000-01-01T00:00:00Z"}`)
|
||||
result := registry.Execute(&documentRendererCtx{}, core.ToolCallRequest{CallID: "closed-schema", Name: "render_document", Arguments: arguments})
|
||||
if result.Status != "error" || !strings.Contains(result.Output, `unknown field "frozenAt"`) {
|
||||
t.Fatalf("model-supplied frozenAt was accepted: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPythonToolDescriptionDoesNotAdvertiseWordBypass(t *testing.T) {
|
||||
registry := NewRegistry()
|
||||
if err := RegisterInterpreterTools(registry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
description, _, found := registry.Get("run_python")
|
||||
if !found {
|
||||
t.Fatal("run_python is not registered")
|
||||
}
|
||||
for _, forbidden := range []string{"python-docx", "生成文档", "docx_kit"} {
|
||||
if strings.Contains(description.Description, forbidden) {
|
||||
t.Fatalf("run_python description advertises Word bypass %q: %s", forbidden, description.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,8 +152,8 @@ func TestRenderDocumentToolRequiresExplicitRouteDeclaration(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
arguments, _ := json.Marshal(map[string]any{
|
||||
"name": "未声明路由", "documentId": "ambiguous", "markdown": "# 文档\n",
|
||||
"mode": "preview", "frozenAt": "2026-10-15T12:00:00Z",
|
||||
"operationId": "operation-ambiguous", "name": "未声明路由", "documentId": "ambiguous", "markdown": "# 文档\n",
|
||||
"mode": "preview",
|
||||
})
|
||||
ctx := &documentRendererCtx{}
|
||||
result := registry.Execute(ctx, core.ToolCallRequest{CallID: "render-3", Name: "render_document", Arguments: arguments})
|
||||
@@ -110,8 +171,8 @@ func TestRenderDocumentToolRejectsControlledRouteWithoutArtifacts(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
arguments, _ := json.Marshal(map[string]any{
|
||||
"name": "受控文档", "documentId": "controlled", "documentType": "dhf", "language": "zh-CN",
|
||||
"markdown": "# 受控文档\n", "mode": "preview", "frozenAt": "2026-10-15T12:00:00Z",
|
||||
"operationId": "operation-controlled", "name": "受控文档", "documentId": "controlled", "documentType": "dhf", "language": "zh-CN",
|
||||
"markdown": "# 受控文档\n", "mode": "preview",
|
||||
"controlled": true, "projectDocument": false,
|
||||
})
|
||||
ctx := &documentRendererCtx{}
|
||||
|
||||
@@ -4,10 +4,11 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLegacyComparisonHousestyleBuildPassesOOXMLGate(t *testing.T) {
|
||||
func TestLegacyHousestyleBuildCannotBeEnabledByEnvironment(t *testing.T) {
|
||||
py, err := exec.LookPath("python3")
|
||||
if err != nil {
|
||||
t.Skip("python3 not available")
|
||||
@@ -20,10 +21,6 @@ func TestLegacyComparisonHousestyleBuildPassesOOXMLGate(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(skill, "housestyle", "build.py")); err != nil {
|
||||
t.Skip("house-style skill is not in this checkout")
|
||||
}
|
||||
probe := exec.Command(py, "-c", "import docx")
|
||||
if out, err := probe.CombinedOutput(); err != nil {
|
||||
t.Skipf("python-docx is not installed: %s", out)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "summary.md")
|
||||
body := "# 摘要\n\n## 结论\n\n- 沙箱里可以直接生成 docx\n"
|
||||
@@ -33,15 +30,11 @@ func TestLegacyComparisonHousestyleBuildPassesOOXMLGate(t *testing.T) {
|
||||
cmd := exec.Command(py, "-m", "housestyle.build", src)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "PYTHONPATH="+skill, "AGENT_SYSTEM_LEGACY_WORD_COMPARISON=1")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("housestyle.build: %v\n%s", err, out)
|
||||
if out, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(out), "disabled") {
|
||||
t.Fatalf("housestyle.build environment bypass: %v\n%s", err, out)
|
||||
}
|
||||
docx := filepath.Join(dir, "summary.docx")
|
||||
content, err := os.ReadFile(docx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ooxmlStructuralCheck("summary.docx", content); err != nil {
|
||||
t.Fatal(err)
|
||||
if _, err := os.Stat(docx); !os.IsNotExist(err) {
|
||||
t.Fatalf("disabled builder wrote %s: %v", docx, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ package tools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -120,6 +121,9 @@ func execRegisterArtifact(rc ToolExecCtx, args json.RawMessage) (string, error)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("register_artifact: %w", err)
|
||||
}
|
||||
if isWordBinaryName(abs) {
|
||||
return "", errors.New("register_artifact: direct Word registration is forbidden; use render_document so Markdown, renderer provenance, manifest, and delivery receipt are preserved")
|
||||
}
|
||||
st, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("register_artifact: %s: %v", a.Path, err)
|
||||
|
||||
@@ -44,7 +44,7 @@ func RegisterInterpreterTools(r *Registry) error {
|
||||
}
|
||||
return r.Register(core.ToolDesc{
|
||||
Name: "run_python", Source: core.SourceBuiltin, Risk: "medium",
|
||||
Description: "执行 Python 脚本(python3,工作目录为会话工作区):生成文档/表格/演示稿/数据处理的首选路径。脚本原样落盘执行;stdout/stderr 合并返回,超限头尾截断。基础层已提供 python-docx、openpyxl、python-pptx 等,可直接 import。缺的包用 python_env 申请,不要 pip install。交付物用 import housestyle(技能目录在环境变量 AGENT_SKILLS_DIR,house-style 已在 PYTHONPATH)。",
|
||||
Description: "执行 Python 脚本(python3,工作目录为会话工作区):用于数据处理以及 Excel、PowerPoint、图片生成。普通 Word 必须使用 render_document;register_artifact 会拒绝 Python 或其他任意来源生成的 DOC/DOCX。脚本原样落盘执行;stdout/stderr 合并返回,超限头尾截断。基础层提供 openpyxl、python-pptx 等;缺的包用 python_env 申请,不要 pip install。",
|
||||
Parameters: json.RawMessage(`{"type":"object","properties":{` +
|
||||
`"script":{"type":"string","description":"完整 Python 脚本(utf-8)"},` +
|
||||
`"timeout_sec":{"type":"integer","description":"超时秒数(默认 600,可自行设更长)"}}` +
|
||||
|
||||
+10
-1
@@ -270,7 +270,7 @@ func mimeOfExt(name string) string {
|
||||
// 普通 Word 由 render_document 生成;其他带排版二进制由受控生成器产出后登记。
|
||||
func rejectBinaryOfficeWrite(name string) error {
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".doc", ".docx":
|
||||
case ".doc", ".docx", ".docm", ".dotx", ".dotm":
|
||||
return fmt.Errorf("%s 是 Word 二进制扩展名,不能写入字符串;普通 Word 请用 render_document,项目或受控 Word 交给 RDM", filepath.Ext(name))
|
||||
case ".xls", ".xlsx", ".xlsm", ".ppt", ".pptx", ".pdf":
|
||||
return fmt.Errorf("%s 是二进制文档扩展名,不能把字符串直接写成该文件;请用受控生成器产出后再登记", filepath.Ext(name))
|
||||
@@ -278,3 +278,12 @@ func rejectBinaryOfficeWrite(name string) error {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func isWordBinaryName(name string) bool {
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".doc", ".docx", ".docm", ".dotx", ".dotm":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user