Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

440 lines
12 KiB
Go

package agentruntime
import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
replay "github.com/tutti-os/tutti/packages/agent/session-replay"
)
const (
ProcessCassetteSchemaVersion = replay.ProcessCassetteSchemaVersion
ProcessCassetteProjectionVersion = replay.ProcessCassetteProjectionVersion
)
var (
processCassetteManifestName = "manifest.json"
processCassetteChunksName = "frames.jsonl"
// Provider traffic is expected to be protocol messages, not a bulk file
// archive. These limits fail the recording before one Session can silently
// consume unbounded disk space.
processCassetteMaxPayloadBytes = uint64(replay.MaxProviderPayloadBytes)
processCassetteMaxStoredBytes = uint64(replay.MaxProviderTapeBytes)
)
var ErrProcessCassetteSizeLimit = replay.ErrProcessCassetteSizeLimit
type ProcessCassetteStatus = replay.ProcessCassetteStatus
const (
ProcessCassetteStatusIncomplete = replay.ProcessCassetteStatusIncomplete
ProcessCassetteStatusComplete = replay.ProcessCassetteStatusComplete
)
type ProcessCassetteManifest = replay.ProcessCassetteManifest
type ProcessCassetteLimits = replay.ProcessCassetteLimits
type ProcessCassetteKindStats = replay.ProcessCassetteKindStats
type ProcessCassetteCaptureOrigin = replay.ProcessCassetteCaptureOrigin
const (
ProcessCassetteCaptureOriginProcessStart = replay.ProcessCassetteCaptureOriginProcessStart
ProcessCassetteCaptureOriginAttachedLiveConnection = replay.ProcessCassetteCaptureOriginAttachedLiveConnection
)
type ProcessCassetteConnectionRecord = replay.ProcessCassetteConnectionRecord
type processCassetteChunk = replay.ProcessCassetteChunk
type processCassetteWriter struct {
mu sync.Mutex
directory string
chunks *os.File
manifest ProcessCassetteManifest
nextConnection uint64
nextGlobalSeq uint64
sessionLaunches map[string]uint64
connectionCWD map[string]processCassetteCWD
projections map[string]*processCassetteProjection
pending []*pendingProcessCassetteChunk
maxPayloadBytes uint64
maxStoredBytes uint64
active int
finalized bool
}
type processCassetteCWD struct {
recorded string
token string
}
type pendingProcessCassetteChunk struct {
chunk processCassetteChunk
ready bool
}
func newProcessCassetteWriter(directory string) (*processCassetteWriter, error) {
directory = strings.TrimSpace(directory)
if directory == "" {
return nil, errors.New("process cassette directory is required")
}
if err := os.MkdirAll(directory, 0o700); err != nil {
return nil, fmt.Errorf("create process cassette directory: %w", err)
}
chunks, err := os.OpenFile(
filepath.Join(directory, processCassetteChunksName),
os.O_CREATE|os.O_TRUNC|os.O_WRONLY,
0o600,
)
if err != nil {
return nil, fmt.Errorf("create process cassette chunks: %w", err)
}
writer := &processCassetteWriter{
directory: directory,
chunks: chunks,
manifest: ProcessCassetteManifest{
SchemaVersion: ProcessCassetteSchemaVersion,
ProjectionVersion: ProcessCassetteProjectionVersion,
Status: ProcessCassetteStatusIncomplete,
Limits: ProcessCassetteLimits{
MaxFrameBytes: processCassetteMaxPayloadBytes,
MaxStoredBytes: processCassetteMaxStoredBytes,
},
FramesByKind: map[string]ProcessCassetteKindStats{},
},
sessionLaunches: map[string]uint64{},
connectionCWD: map[string]processCassetteCWD{},
projections: map[string]*processCassetteProjection{},
maxPayloadBytes: processCassetteMaxPayloadBytes,
maxStoredBytes: processCassetteMaxStoredBytes,
}
if err := writer.writeManifestLocked(); err != nil {
_ = chunks.Close()
return nil, err
}
return writer, nil
}
func (w *processCassetteWriter) start(
spec ProcessSpec,
captureOrigin ProcessCassetteCaptureOrigin,
) (string, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.finalized {
return "", errors.New("process cassette is already finalized")
}
sessionKey := normalizeProcessCassetteIdentity(spec.AgentSessionID)
nextLaunch := w.sessionLaunches[sessionKey] + 1
cwd := processCassetteCWD{
recorded: processCassetteProtocolCWD(spec),
token: fmt.Sprintf("${SESSION_CWD:%s:%d}", sessionKey, nextLaunch),
}
projection, err := newProcessCassetteProjection(spec, cwd)
if err != nil {
return "", err
}
w.nextConnection++
connectionID := fmt.Sprintf("connection-%d", w.nextConnection)
w.sessionLaunches[sessionKey] = nextLaunch
w.manifest.Connections = append(w.manifest.Connections, ProcessCassetteConnectionRecord{
ConnectionID: connectionID,
Provider: spec.Provider,
AgentSessionID: spec.AgentSessionID,
RootAgentSessionID: rootProcessSessionID(spec),
LaunchOrdinal: nextLaunch,
CWDToken: cwd.token,
CaptureOrigin: captureOrigin,
})
w.connectionCWD[connectionID] = cwd
w.active++
if err := w.writeManifestLocked(); err != nil {
w.active--
w.manifest.Connections = w.manifest.Connections[:len(w.manifest.Connections)-1]
delete(w.connectionCWD, connectionID)
delete(w.projections, connectionID)
return "", err
}
w.projections[connectionID] = projection
return connectionID, nil
}
func processCassetteProtocolCWD(spec ProcessSpec) string {
if cwd := strings.TrimSpace(spec.ProtocolCWD); cwd != "" {
return cwd
}
return strings.TrimSpace(spec.CWD)
}
func (w *processCassetteWriter) append(chunk processCassetteChunk) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.finalized {
return errors.New("process cassette is already finalized")
}
payloadBytes, err := processCassetteChunkPayloadBytes(chunk)
if err != nil {
return err
}
if payloadBytes > w.maxPayloadBytes {
return fmt.Errorf(
"%w: %s frame payload is %d bytes, limit is %d bytes",
ErrProcessCassetteSizeLimit,
chunk.Kind,
payloadBytes,
w.maxPayloadBytes,
)
}
w.nextGlobalSeq++
chunk.GlobalSeq = w.nextGlobalSeq
pending := &pendingProcessCassetteChunk{chunk: chunk}
w.pending = append(w.pending, pending)
if projection := w.projections[chunk.ConnectionID]; projection != nil {
if err := projection.project(pending); err != nil {
w.pending = w.pending[:len(w.pending)-1]
w.nextGlobalSeq--
return err
}
} else {
pending.ready = true
}
return w.flushPendingLocked()
}
func (w *processCassetteWriter) flushPendingLocked() error {
for len(w.pending) > 0 && w.pending[0].ready {
pending := w.pending[0]
if err := w.writeChunkLocked(pending.chunk); err != nil {
return err
}
w.pending = w.pending[1:]
}
return nil
}
func (w *processCassetteWriter) writeChunkLocked(chunk processCassetteChunk) error {
raw, err := json.Marshal(chunk)
if err != nil {
return fmt.Errorf("encode process cassette chunk: %w", err)
}
raw = append(raw, '\n')
payloadBytes, err := processCassetteChunkPayloadBytes(chunk)
if err != nil {
return err
}
if payloadBytes > w.maxPayloadBytes {
return fmt.Errorf(
"%w: %s projected frame payload is %d bytes, limit is %d bytes",
ErrProcessCassetteSizeLimit,
chunk.Kind,
payloadBytes,
w.maxPayloadBytes,
)
}
projectedStoredBytes := w.manifest.StoredBytes + uint64(len(raw))
if projectedStoredBytes > w.maxStoredBytes {
return fmt.Errorf(
"%w: provider frames would use %d bytes, limit is %d bytes",
ErrProcessCassetteSizeLimit,
projectedStoredBytes,
w.maxStoredBytes,
)
}
if _, err := w.chunks.Write(raw); err != nil {
return fmt.Errorf("write process cassette chunk: %w", err)
}
storedBytes := uint64(len(raw))
w.manifest.PayloadBytes += payloadBytes
w.manifest.StoredBytes = projectedStoredBytes
if payloadBytes > w.manifest.MaxFrameBytes {
w.manifest.MaxFrameBytes = payloadBytes
}
stats := w.manifest.FramesByKind[chunk.Kind]
stats.FrameCount++
stats.PayloadBytes += payloadBytes
stats.StoredBytes += storedBytes
w.manifest.FramesByKind[chunk.Kind] = stats
return nil
}
func processCassetteChunkPayloadBytes(chunk processCassetteChunk) (uint64, error) {
payloadBytes := uint64(len(chunk.Message))
if chunk.Data == "" {
return payloadBytes, nil
}
data, err := base64.StdEncoding.DecodeString(chunk.Data)
if err != nil {
return 0, fmt.Errorf("decode process cassette %s payload for size accounting: %w", chunk.Kind, err)
}
return payloadBytes + uint64(len(data)), nil
}
func (w *processCassetteWriter) finishConnection() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.active > 0 {
w.active--
}
return nil
}
func (w *processCassetteWriter) finalize() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.finalized {
return nil
}
if w.active != 0 {
return fmt.Errorf("cannot finalize process cassette with %d active connections", w.active)
}
for _, projection := range w.projections {
if err := projection.finish(); err != nil {
return err
}
}
if err := w.flushPendingLocked(); err != nil {
return err
}
if len(w.pending) != 0 {
return errors.New("process cassette projection left pending frames")
}
if err := w.chunks.Sync(); err != nil {
return fmt.Errorf("sync process cassette chunks: %w", err)
}
if err := w.chunks.Close(); err != nil {
return fmt.Errorf("close process cassette chunks: %w", err)
}
digest, err := fileSHA256(filepath.Join(w.directory, processCassetteChunksName))
if err != nil {
return fmt.Errorf("hash process cassette frames: %w", err)
}
w.manifest.FrameCount = w.nextGlobalSeq
w.manifest.FramesSHA256 = digest
w.manifest.Status = ProcessCassetteStatusComplete
if err := w.writeManifestLocked(); err != nil {
return err
}
w.finalized = true
return nil
}
func fileSHA256(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
func (w *processCassetteWriter) abort() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.finalized {
return nil
}
var result error
if err := w.chunks.Sync(); err != nil {
result = errors.Join(result, fmt.Errorf("sync process cassette chunks: %w", err))
}
if err := w.chunks.Close(); err != nil {
result = errors.Join(result, fmt.Errorf("close process cassette chunks: %w", err))
}
w.finalized = true
return result
}
func (w *processCassetteWriter) writeManifestLocked() error {
raw, err := json.MarshalIndent(w.manifest, "", " ")
if err != nil {
return fmt.Errorf("encode process cassette manifest: %w", err)
}
raw = append(raw, '\n')
path := filepath.Join(w.directory, processCassetteManifestName)
tempPath := path + ".tmp"
if err := os.WriteFile(tempPath, raw, 0o600); err != nil {
return fmt.Errorf("write process cassette manifest: %w", err)
}
if err := os.Rename(tempPath, path); err != nil {
return fmt.Errorf("replace process cassette manifest: %w", err)
}
return nil
}
func processCassetteFrameChunk(
connectionID string,
seq uint64,
elapsed time.Duration,
frame ProcessFrame,
) (processCassetteChunk, error) {
chunk := processCassetteChunk{
ConnectionID: connectionID,
ChunkSeq: seq,
ElapsedMS: elapsed.Milliseconds(),
Message: frame.Message,
}
kinds := 0
if len(frame.Stdout) > 0 {
kinds++
chunk.Kind = "stdout"
chunk.Data = base64.StdEncoding.EncodeToString(frame.Stdout)
}
if len(frame.Stderr) > 0 {
kinds++
chunk.Kind = "stderr"
chunk.Data = base64.StdEncoding.EncodeToString(frame.Stderr)
}
if frame.ExitCode != nil {
kinds++
chunk.Kind = "exit"
exitCode := *frame.ExitCode
chunk.ExitCode = &exitCode
}
if kinds != 1 {
return processCassetteChunk{}, fmt.Errorf(
"process frame must contain exactly one stdout, stderr, or exit payload; got %d",
kinds,
)
}
return chunk, nil
}
func decodeProcessCassetteFrame(chunk processCassetteChunk) (ProcessFrame, error) {
frame := ProcessFrame{Message: chunk.Message}
switch chunk.Kind {
case "stdout":
data, err := base64.StdEncoding.DecodeString(chunk.Data)
if err != nil {
return ProcessFrame{}, fmt.Errorf("decode stdout chunk %d: %w", chunk.ChunkSeq, err)
}
frame.Stdout = data
case "stderr":
data, err := base64.StdEncoding.DecodeString(chunk.Data)
if err != nil {
return ProcessFrame{}, fmt.Errorf("decode stderr chunk %d: %w", chunk.ChunkSeq, err)
}
frame.Stderr = data
case "exit":
if chunk.ExitCode == nil {
return ProcessFrame{}, fmt.Errorf("exit chunk %d has no exit code", chunk.ChunkSeq)
}
exitCode := *chunk.ExitCode
frame.ExitCode = &exitCode
default:
return ProcessFrame{}, fmt.Errorf("unsupported process cassette chunk kind %q", chunk.Kind)
}
return frame, nil
}