Files
weichenandCursor 7be873073c feat(taskrun): finalize RunManifest in one unit of work
CAS the unique terminal status with the assistant turn, frozen manifest, envelope and FAV staging, and join ctxmgr writes through the same UoW instead of a process mutex.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 02:05:58 +00:00

152 lines
4.1 KiB
Go

package taskrun
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
)
const ManifestSchemaVersion = 1
const (
MemberKindDocument = "document"
MemberKindAssistantTurn = "assistant_turn"
MemberKindRunManifest = "run_manifest"
MemberKindAttachment = "attachment"
MemberKindEphemeral = "ephemeral"
)
// ManifestMember is a retained output of a task run. Evidence/Gaps are not members.
type ManifestMember struct {
LogicalID string `json:"logical_id"`
Digest string `json:"digest"`
Kind string `json:"kind"`
}
// RunManifest is the typed terminal protocol. Workspace copies are rebuildable, not the source of truth.
type RunManifest struct {
SchemaVersion int `json:"schema_version"`
RunID string `json:"run_id"`
WorkspaceID string `json:"workspace_id"`
StorageIdentity string `json:"storage_identity"`
Members []ManifestMember `json:"members"`
Extensions map[string]json.RawMessage `json:"extensions,omitempty"`
CanonicalBytes []byte `json:"-"`
SHA256 string `json:"-"`
}
type canonicalManifest struct {
SchemaVersion int `json:"schema_version"`
RunID string `json:"run_id"`
WorkspaceID string `json:"workspace_id"`
StorageIdentity string `json:"storage_identity"`
Members []ManifestMember `json:"members"`
Extensions json.RawMessage `json:"extensions,omitempty"`
}
// Freeze canonicalizes members, encodes stable JSON, and fills SHA-256.
func (m *RunManifest) Freeze() error {
if m == nil {
return fmt.Errorf("taskrun: manifest required")
}
if strings.TrimSpace(m.RunID) == "" {
return fmt.Errorf("taskrun: manifest run_id required")
}
if m.SchemaVersion == 0 {
m.SchemaVersion = ManifestSchemaVersion
}
if m.SchemaVersion != ManifestSchemaVersion {
return fmt.Errorf("taskrun: unsupported manifest schema %d", m.SchemaVersion)
}
members := append([]ManifestMember(nil), m.Members...)
sort.Slice(members, func(i, j int) bool {
if members[i].LogicalID == members[j].LogicalID {
return members[i].Kind < members[j].Kind
}
return members[i].LogicalID < members[j].LogicalID
})
for i := range members {
members[i].LogicalID = strings.TrimSpace(members[i].LogicalID)
members[i].Digest = strings.ToLower(strings.TrimSpace(members[i].Digest))
members[i].Kind = strings.TrimSpace(members[i].Kind)
if members[i].LogicalID == "" || members[i].Digest == "" || members[i].Kind == "" {
return fmt.Errorf("taskrun: manifest member incomplete")
}
}
m.Members = members
canon := canonicalManifest{
SchemaVersion: m.SchemaVersion,
RunID: m.RunID,
WorkspaceID: m.WorkspaceID,
StorageIdentity: m.StorageIdentity,
Members: members,
Extensions: marshalSortedExtensions(m.Extensions),
}
raw, err := marshalCanonical(canon)
if err != nil {
return err
}
sum := sha256.Sum256(raw)
m.CanonicalBytes = raw
m.SHA256 = hex.EncodeToString(sum[:])
return nil
}
func marshalCanonical(v any) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return nil, err
}
return bytes.TrimSpace(buf.Bytes()), nil
}
func marshalSortedExtensions(ext map[string]json.RawMessage) json.RawMessage {
if len(ext) == 0 {
return nil
}
keys := make([]string, 0, len(ext))
for k := range ext {
if strings.TrimSpace(k) == "" || !strings.Contains(k, "/") {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
if len(keys) == 0 {
return nil
}
var buf bytes.Buffer
buf.WriteByte('{')
for i, k := range keys {
if i > 0 {
buf.WriteByte(',')
}
kb, _ := json.Marshal(k)
buf.Write(kb)
buf.WriteByte(':')
if len(ext[k]) == 0 {
buf.WriteString("null")
} else {
buf.Write(ext[k])
}
}
buf.WriteByte('}')
return buf.Bytes()
}
func memberNeedsRetention(kind string) bool {
switch kind {
case MemberKindDocument, MemberKindAssistantTurn, MemberKindRunManifest, MemberKindAttachment:
return true
default:
return false
}
}