iop/packages/go/agentstate/store.go
toki 3c48879c44 feat(agent-runtime): 독립 Agent CLI 런타임 기반을 구현한다
독립 호스트에서 안전한 작업 실행과 복구를 제공하기 위해 런타임 설정, 정책, 상태 저장소, 워크스페이스 격리 및 AgentTask 오케스트레이션을 확장한다.
2026-07-29 13:12:21 +09:00

451 lines
11 KiB
Go

// Package agentstate provides the crash-safe device-local persistence boundary
// for the shared AgentTask manager state.
package agentstate
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"iop/packages/go/agenttask"
)
const storeSchemaVersion uint32 = 1
var (
ErrCorruptState = errors.New("agentstate corrupt state")
ErrUnsupportedSchema = errors.New("agentstate unsupported store schema")
)
type StateError struct {
Path string
Kind error
Err error
}
func (e *StateError) Error() string {
return fmt.Sprintf("agentstate: %v at %q: %v", e.Kind, e.Path, e.Err)
}
func (e *StateError) Unwrap() []error {
return []error{e.Kind, e.Err}
}
type Store struct {
path string
lockPath string
}
func NewStore(path string) (*Store, error) {
if path == "" {
return nil, fmt.Errorf("agentstate: state path is required")
}
clean := filepath.Clean(path)
if clean == "." {
return nil, fmt.Errorf("agentstate: state path must name a file")
}
return &Store{path: clean, lockPath: clean + ".lock"}, nil
}
func (s *Store) Path() string {
return s.path
}
// LoadIntegrationRecord returns one opaque, checksum-covered host integration
// journal together with its key-local content revision.
func (s *Store) LoadIntegrationRecord(
ctx context.Context,
key string,
) ([]byte, string, bool, error) {
if err := validateIntegrationKey(key); err != nil {
return nil, "", false, err
}
if err := ctx.Err(); err != nil {
return nil, "", false, err
}
var payload []byte
var found bool
err := s.withLock(ctx, syscall.LOCK_SH, func() error {
_, _, records, err := s.readUnlocked()
if err != nil {
return err
}
record, ok := records[key]
if !ok {
return nil
}
payload = append([]byte(nil), record...)
found = true
return nil
})
if err != nil {
return nil, "", false, err
}
if !found {
return nil, "", false, nil
}
return payload, integrationRecordRevision(payload), true, nil
}
// CompareAndSwapIntegrationRecord atomically updates one opaque integration
// journal while preserving the manager snapshot and every sibling journal.
func (s *Store) CompareAndSwapIntegrationRecord(
ctx context.Context,
key string,
expected string,
payload []byte,
) (string, error) {
if err := validateIntegrationKey(key); err != nil {
return "", err
}
if len(payload) == 0 || len(payload) > 16<<20 || !json.Valid(payload) {
return "", fmt.Errorf("agentstate: integration record must be one bounded JSON value")
}
var decoded any
if err := decodeOne(payload, &decoded); err != nil {
return "", fmt.Errorf("agentstate: decode integration record: %w", err)
}
if err := ctx.Err(); err != nil {
return "", err
}
nextRevision := integrationRecordRevision(payload)
err := s.withLock(ctx, syscall.LOCK_EX, func() error {
state, current, records, err := s.readUnlocked()
if err != nil {
return err
}
currentRecord, exists := records[key]
currentRevision := ""
if exists {
currentRevision = integrationRecordRevision(currentRecord)
}
if currentRevision != expected {
return agenttask.ErrRevisionConflict
}
if exists && bytes.Equal(currentRecord, payload) {
return nil
}
if current == ^uint64(0) {
return fmt.Errorf("agentstate: revision overflow")
}
if records == nil {
records = make(map[string]json.RawMessage)
}
records[key] = append(json.RawMessage(nil), payload...)
return s.writeUnlocked(current+1, state, records)
})
if err != nil {
return "", err
}
return nextRevision, nil
}
func (s *Store) Load(
ctx context.Context,
) (agenttask.ManagerState, agenttask.StateRevision, error) {
if err := ctx.Err(); err != nil {
return agenttask.ManagerState{}, "", err
}
var state agenttask.ManagerState
var revision uint64
err := s.withLock(ctx, syscall.LOCK_SH, func() error {
var err error
state, revision, _, err = s.readUnlocked()
return err
})
if err != nil {
return agenttask.ManagerState{}, "", err
}
return state, agenttask.StateRevision(strconv.FormatUint(revision, 10)), nil
}
func (s *Store) CompareAndSwap(
ctx context.Context,
expected agenttask.StateRevision,
next agenttask.ManagerState,
) (agenttask.StateRevision, error) {
if err := ctx.Err(); err != nil {
return "", err
}
var committed uint64
err := s.withLock(ctx, syscall.LOCK_EX, func() error {
_, current, integrationRecords, err := s.readUnlocked()
if err != nil {
return err
}
currentRevision := agenttask.StateRevision(strconv.FormatUint(current, 10))
if expected != currentRevision {
return agenttask.ErrRevisionConflict
}
if current == ^uint64(0) {
return fmt.Errorf("agentstate: revision overflow")
}
if next.SchemaVersion != agenttask.StateSchemaVersion {
return s.stateError(
ErrUnsupportedSchema,
fmt.Errorf("manager schema version %d", next.SchemaVersion),
)
}
committed = current + 1
return s.writeUnlocked(committed, next, integrationRecords)
})
if err != nil {
return "", err
}
return agenttask.StateRevision(strconv.FormatUint(committed, 10)), nil
}
type diskEnvelope struct {
SchemaVersion uint32 `json:"schema_version"`
Revision uint64 `json:"revision"`
Checksum string `json:"checksum"`
State json.RawMessage `json:"state"`
IntegrationRecords map[string]json.RawMessage `json:"integration_records,omitempty"`
}
type checksumPayload struct {
SchemaVersion uint32 `json:"schema_version"`
Revision uint64 `json:"revision"`
State json.RawMessage `json:"state"`
IntegrationRecords map[string]json.RawMessage `json:"integration_records,omitempty"`
}
func (s *Store) readUnlocked() (
agenttask.ManagerState,
uint64,
map[string]json.RawMessage,
error,
) {
payload, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
return agenttask.ManagerState{SchemaVersion: agenttask.StateSchemaVersion},
0,
nil,
nil
}
if err != nil {
return agenttask.ManagerState{}, 0, nil, err
}
var envelope diskEnvelope
if err := decodeOne(payload, &envelope); err != nil {
return agenttask.ManagerState{}, 0, nil, s.stateError(
ErrCorruptState,
fmt.Errorf("decode envelope: %w", err),
)
}
if envelope.SchemaVersion != storeSchemaVersion {
return agenttask.ManagerState{}, 0, nil, s.stateError(
ErrUnsupportedSchema,
fmt.Errorf("schema version %d", envelope.SchemaVersion),
)
}
expected, err := stateChecksum(
envelope.SchemaVersion,
envelope.Revision,
envelope.State,
envelope.IntegrationRecords,
)
if err != nil {
return agenttask.ManagerState{}, 0, nil, s.stateError(ErrCorruptState, err)
}
if envelope.Checksum == "" || !bytes.Equal(
[]byte(envelope.Checksum),
[]byte(expected),
) {
return agenttask.ManagerState{}, 0, nil, s.stateError(
ErrCorruptState,
fmt.Errorf("checksum mismatch"),
)
}
var state agenttask.ManagerState
if err := decodeOne(envelope.State, &state); err != nil {
return agenttask.ManagerState{}, 0, nil, s.stateError(
ErrCorruptState,
fmt.Errorf("decode manager state: %w", err),
)
}
for key, record := range envelope.IntegrationRecords {
if strings.TrimSpace(key) == "" || len(record) == 0 || !json.Valid(record) {
return agenttask.ManagerState{}, 0, nil, s.stateError(
ErrCorruptState,
fmt.Errorf("invalid integration record %q", key),
)
}
}
return state, envelope.Revision, cloneRawRecords(envelope.IntegrationRecords), nil
}
func (s *Store) writeUnlocked(
revision uint64,
state agenttask.ManagerState,
integrationRecords map[string]json.RawMessage,
) error {
statePayload, err := json.Marshal(state)
if err != nil {
return fmt.Errorf("agentstate: encode manager state: %w", err)
}
checksum, err := stateChecksum(
storeSchemaVersion,
revision,
statePayload,
integrationRecords,
)
if err != nil {
return err
}
envelopePayload, err := json.Marshal(diskEnvelope{
SchemaVersion: storeSchemaVersion,
Revision: revision,
Checksum: checksum,
State: statePayload,
IntegrationRecords: integrationRecords,
})
if err != nil {
return fmt.Errorf("agentstate: encode envelope: %w", err)
}
envelopePayload = append(envelopePayload, '\n')
dir := filepath.Dir(s.path)
temp, err := os.CreateTemp(dir, "."+filepath.Base(s.path)+".tmp-*")
if err != nil {
return err
}
tempPath := temp.Name()
removeTemp := true
defer func() {
if removeTemp {
_ = os.Remove(tempPath)
}
}()
if err := temp.Chmod(0o600); err != nil {
_ = temp.Close()
return err
}
if _, err := temp.Write(envelopePayload); err != nil {
_ = temp.Close()
return err
}
if err := temp.Sync(); err != nil {
_ = temp.Close()
return err
}
if err := temp.Close(); err != nil {
return err
}
if err := os.Rename(tempPath, s.path); err != nil {
return err
}
removeTemp = false
dirHandle, err := os.Open(dir)
if err != nil {
return err
}
defer dirHandle.Close()
return dirHandle.Sync()
}
func (s *Store) withLock(
ctx context.Context,
mode int,
action func() error,
) error {
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return err
}
lock, err := os.OpenFile(s.lockPath, os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return err
}
defer lock.Close()
if err := ctx.Err(); err != nil {
return err
}
if err := syscall.Flock(int(lock.Fd()), mode); err != nil {
return err
}
defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) //nolint:errcheck
if err := ctx.Err(); err != nil {
return err
}
return action()
}
func stateChecksum(
schemaVersion uint32,
revision uint64,
state json.RawMessage,
integrationRecords ...map[string]json.RawMessage,
) (string, error) {
var records map[string]json.RawMessage
if len(integrationRecords) != 0 {
records = integrationRecords[0]
}
payload, err := json.Marshal(checksumPayload{
SchemaVersion: schemaVersion,
Revision: revision,
State: state,
IntegrationRecords: records,
})
if err != nil {
return "", fmt.Errorf("agentstate: encode checksum payload: %w", err)
}
sum := sha256.Sum256(payload)
return hex.EncodeToString(sum[:]), nil
}
func decodeOne(payload []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(payload))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
if err == nil {
return fmt.Errorf("multiple JSON values")
}
return err
}
return nil
}
func validateIntegrationKey(key string) error {
if strings.TrimSpace(key) == "" ||
strings.TrimSpace(key) != key ||
strings.ContainsAny(key, "\x00\r\n") {
return fmt.Errorf("agentstate: invalid integration record key")
}
return nil
}
func integrationRecordRevision(payload []byte) string {
sum := sha256.Sum256(payload)
return "sha256:" + hex.EncodeToString(sum[:])
}
func cloneRawRecords(
records map[string]json.RawMessage,
) map[string]json.RawMessage {
if records == nil {
return nil
}
copy := make(map[string]json.RawMessage, len(records))
for key, record := range records {
copy[key] = append(json.RawMessage(nil), record...)
}
return copy
}
func (s *Store) stateError(kind error, err error) error {
return &StateError{Path: s.path, Kind: kind, Err: err}
}