From fec2d16cc6d91c095c515279326667c1fe3aeddb Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 21:44:03 +0000 Subject: [PATCH] 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 --- cmd/storage-migrate/main.go | 1 + internal/docgen/comparison.go | 337 +++++++++++++ .../internal/genrendereridentity/main.go | 39 ++ internal/docgen/operation.go | 40 ++ internal/docgen/renderer.go | 298 +++++++---- internal/docgen/renderer_identity.go | 3 + .../docgen/renderer_identity_generated.go | 7 + internal/docgen/renderer_integration_test.go | 265 ++++++++-- internal/prompts/library/base-deliverables.md | 2 +- .../prompts/library/tool-render-document.md | 2 +- internal/runmgr/document_delivery.go | 174 +++++++ internal/runmgr/document_delivery_test.go | 170 +++++++ internal/runmgr/exec_tool_artifact_test.go | 50 +- internal/runmgr/runmgr.go | 3 + internal/runtime/mock.go | 39 +- internal/skills/recipes/docx-author/SKILL.md | 22 +- internal/skills/recipes/house-style/SKILL.md | 33 +- .../recipes/house-style/housestyle/build.py | 173 +------ .../house-style/tests/test_housestyle.py | 42 +- internal/skills/skills_test.go | 8 +- internal/store/document_delivery.go | 475 ++++++++++++++++++ internal/store/document_delivery_test.go | 229 +++++++++ internal/store/migrate.go | 23 + internal/store/migrate_compat.go | 1 + internal/store/migrate_compat_test.go | 1 + internal/store/store.go | 4 + internal/tools/document_renderer.go | 70 +-- internal/tools/document_renderer_test.go | 113 ++++- internal/tools/docx_e2e_test.go | 19 +- internal/tools/exec.go | 4 + internal/tools/exec_interpreter.go | 2 +- internal/tools/fs.go | 11 +- 32 files changed, 2159 insertions(+), 501 deletions(-) create mode 100644 internal/docgen/comparison.go create mode 100644 internal/docgen/internal/genrendereridentity/main.go create mode 100644 internal/docgen/operation.go create mode 100644 internal/docgen/renderer_identity.go create mode 100644 internal/docgen/renderer_identity_generated.go create mode 100644 internal/runmgr/document_delivery.go create mode 100644 internal/runmgr/document_delivery_test.go create mode 100644 internal/store/document_delivery.go create mode 100644 internal/store/document_delivery_test.go diff --git a/cmd/storage-migrate/main.go b/cmd/storage-migrate/main.go index df15d94..5c696b1 100644 --- a/cmd/storage-migrate/main.go +++ b/cmd/storage-migrate/main.go @@ -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", diff --git a/internal/docgen/comparison.go b/internal/docgen/comparison.go new file mode 100644 index 0000000..8aca2d2 --- /dev/null +++ b/internal/docgen/comparison.go @@ -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, ""), "table": strings.Contains(xmlText, ""), + } + 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[:]) +} diff --git a/internal/docgen/internal/genrendereridentity/main.go b/internal/docgen/internal/genrendereridentity/main.go new file mode 100644 index 0000000..adb60ea --- /dev/null +++ b/internal/docgen/internal/genrendereridentity/main.go @@ -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)) +} diff --git a/internal/docgen/operation.go b/internal/docgen/operation.go new file mode 100644 index 0000000..d14ec71 --- /dev/null +++ b/internal/docgen/operation.go @@ -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"` +} diff --git a/internal/docgen/renderer.go b/internal/docgen/renderer.go index 2304040..457d768 100644 --- a/internal/docgen/renderer.go +++ b/internal/docgen/renderer.go @@ -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. diff --git a/internal/docgen/renderer_identity.go b/internal/docgen/renderer_identity.go new file mode 100644 index 0000000..ef9c483 --- /dev/null +++ b/internal/docgen/renderer_identity.go @@ -0,0 +1,3 @@ +package docgen + +//go:generate go run ./internal/genrendereridentity diff --git a/internal/docgen/renderer_identity_generated.go b/internal/docgen/renderer_identity_generated.go new file mode 100644 index 0000000..f997072 --- /dev/null +++ b/internal/docgen/renderer_identity_generated.go @@ -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 diff --git a/internal/docgen/renderer_integration_test.go b/internal/docgen/renderer_integration_test.go index c3cacdc..ca670cb 100644 --- a/internal/docgen/renderer_integration_test.go +++ b/internal/docgen/renderer_integration_test.go @@ -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:val="inlineCode"`, `w:val="link"`, `w:val="quote"`, `w:val="code"`, ``, ``} { + 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\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 "" +} diff --git a/internal/prompts/library/base-deliverables.md b/internal/prompts/library/base-deliverables.md index 2e8356d..25803e3 100644 --- a/internal/prompts/library/base-deliverables.md +++ b/internal/prompts/library/base-deliverables.md @@ -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. **收尾**:回复只写结论摘要,并说明这次用的样式(主题、是否模板、是否仿制)以及用户还能怎么调。不要在回复里复述全文,也不要只口头说文件名。 diff --git a/internal/prompts/library/tool-render-document.md b/internal/prompts/library/tool-render-document.md index b9e5068..a121dd0 100644 --- a/internal/prompts/library/tool-render-document.md +++ b/internal/prompts/library/tool-render-document.md @@ -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,只可用于普通工作/预览交付,不能称为已发布或已视觉批准。 diff --git a/internal/runmgr/document_delivery.go b/internal/runmgr/document_delivery.go new file mode 100644 index 0000000..1478dec --- /dev/null +++ b/internal/runmgr/document_delivery.go @@ -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 +} diff --git a/internal/runmgr/document_delivery_test.go b/internal/runmgr/document_delivery_test.go new file mode 100644 index 0000000..873b2c6 --- /dev/null +++ b/internal/runmgr/document_delivery_test.go @@ -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![missing](asset:missing@1.0.0)\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), " 主题与强调色(可省) +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("", "").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)) diff --git a/internal/skills/recipes/house-style/tests/test_housestyle.py b/internal/skills/recipes/house-style/tests/test_housestyle.py index 5f8b292..ce7ffa2 100644 --- a/internal/skills/recipes/house-style/tests/test_housestyle.py +++ b/internal/skills/recipes/house-style/tests/test_housestyle.py @@ -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): diff --git a/internal/skills/skills_test.go b/internal/skills/skills_test.go index 7daacc2..857bc8d 100644 --- a/internal/skills/skills_test.go +++ b/internal/skills/skills_test.go @@ -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) + } } } diff --git a/internal/store/document_delivery.go b/internal/store/document_delivery.go new file mode 100644 index 0000000..28bcc40 --- /dev/null +++ b/internal/store/document_delivery.go @@ -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 +} diff --git a/internal/store/document_delivery_test.go b/internal/store/document_delivery_test.go new file mode 100644 index 0000000..e3ddcba --- /dev/null +++ b/internal/store/document_delivery_test.go @@ -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[:]) +} diff --git a/internal/store/migrate.go b/internal/store/migrate.go index c5e0c92..9e6ab70 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -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`}, + }}, ) } diff --git a/internal/store/migrate_compat.go b/internal/store/migrate_compat.go index da387c2..e56782c 100644 --- a/internal/store/migrate_compat.go +++ b/internal/store/migrate_compat.go @@ -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 版本。 diff --git a/internal/store/migrate_compat_test.go b/internal/store/migrate_compat_test.go index ebfcb17..b41aa8a 100644 --- a/internal/store/migrate_compat_test.go +++ b/internal/store/migrate_compat_test.go @@ -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 { diff --git a/internal/store/store.go b/internal/store/store.go index 5d30b74..c5f46bf 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 } diff --git a/internal/tools/document_renderer.go b/internal/tools/document_renderer.go index a80e305..c3c7a83 100644 --- a/internal/tools/document_renderer.go +++ b/internal/tools/document_renderer.go @@ -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 } diff --git a/internal/tools/document_renderer_test.go b/internal/tools/document_renderer_test.go index e9fe212..653bc3b 100644 --- a/internal/tools/document_renderer_test.go +++ b/internal/tools/document_renderer_test.go @@ -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{} diff --git a/internal/tools/docx_e2e_test.go b/internal/tools/docx_e2e_test.go index f8942a4..354a7fe 100644 --- a/internal/tools/docx_e2e_test.go +++ b/internal/tools/docx_e2e_test.go @@ -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) } } diff --git a/internal/tools/exec.go b/internal/tools/exec.go index 87363f7..a582435 100644 --- a/internal/tools/exec.go +++ b/internal/tools/exec.go @@ -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) diff --git a/internal/tools/exec_interpreter.go b/internal/tools/exec_interpreter.go index 35130e5..6064bfc 100644 --- a/internal/tools/exec_interpreter.go +++ b/internal/tools/exec_interpreter.go @@ -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,可自行设更长)"}}` + diff --git a/internal/tools/fs.go b/internal/tools/fs.go index 8171d76..8f84d7d 100644 --- a/internal/tools/fs.go +++ b/internal/tools/fs.go @@ -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 + } +}