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
440 lines
12 KiB
Go
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
|
|
}
|