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>
152 lines
4.1 KiB
Go
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
|
|
}
|
|
}
|