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

553 lines
18 KiB
Go

package agentworkspace
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"iop/packages/go/agentguard"
"iop/packages/go/agenttask"
)
const changeSetSchemaVersion uint32 = 1
// ChangeOperationKind identifies the transition from the pinned base entry to
// the reviewed task entry.
type ChangeOperationKind string
const (
ChangeOperationAdd ChangeOperationKind = "add"
ChangeOperationModify ChangeOperationKind = "modify"
ChangeOperationDelete ChangeOperationKind = "delete"
)
// ChangeEntry is the immutable filesystem identity for one side of an
// operation. Git state is deliberately excluded: integration operates on
// content, type, mode, and symlink identity while the workspace fingerprint
// separately covers the complete Git state.
type ChangeEntry struct {
Kind SnapshotEntryKind `json:"kind"`
Mode uint32 `json:"mode"`
ContentDigest string `json:"content_digest,omitempty"`
SymlinkTarget string `json:"symlink_target,omitempty"`
}
// ChangeOperation records one normalized workspace-relative transition.
// ContentFile is present only for a regular result and is relative to the
// immutable change-set root.
type ChangeOperation struct {
Kind ChangeOperationKind `json:"kind"`
Path string `json:"path"`
Base *ChangeEntry `json:"base,omitempty"`
Result *ChangeEntry `json:"result,omitempty"`
ContentFile string `json:"content_file,omitempty"`
}
// ValidationEvidence binds review acceptance evidence to the frozen content.
// Digest is content-addressed evidence supplied by the project workflow
// adapter; raw command output does not enter the host record.
type ValidationEvidence struct {
Name string `json:"name"`
Result string `json:"result"`
Digest string `json:"digest"`
}
// ChangeSetLocator contains only device-local immutable record paths.
type ChangeSetLocator struct {
OverlayRecord string `json:"overlay_record"`
Root string `json:"root"`
Record string `json:"record"`
ContentRoot string `json:"content_root"`
}
// ChangeSet is the content-addressed immutable output of a review-PASS task
// overlay. Revision covers every field except the derived locator.
type ChangeSet struct {
SchemaVersion uint32 `json:"schema_version"`
ID agenttask.ChangeSetID `json:"id"`
Revision string `json:"revision"`
ArtifactID agenttask.ArtifactID `json:"artifact_id"`
ProjectID agenttask.ProjectID `json:"project_id"`
WorkspaceID agenttask.WorkspaceID `json:"workspace_id"`
WorkUnitID agenttask.WorkUnitID `json:"work_unit_id"`
AttemptID agenttask.AttemptID `json:"attempt_id"`
OverlayRevision string `json:"overlay_revision"`
CanonicalRoot string `json:"canonical_root"`
BaseFingerprint string `json:"base_fingerprint"`
ConfigRevision string `json:"config_revision"`
GrantRevision string `json:"grant_revision"`
Operations []ChangeOperation `json:"operations"`
WriteSet []string `json:"write_set"`
ValidationEvidence []ValidationEvidence `json:"validation_evidence"`
Locator ChangeSetLocator `json:"locator"`
}
// Identity returns the shared-runtime identity used by ReviewResult and the
// Integrator port.
func (changeSet ChangeSet) Identity() agenttask.ChangeSetIdentity {
return agenttask.ChangeSetIdentity{
ID: changeSet.ID,
Revision: changeSet.Revision,
ArtifactID: changeSet.ArtifactID,
}
}
// FreezeRequest identifies one reviewed overlay and its evidence.
type FreezeRequest struct {
Descriptor agentguard.IsolationDescriptor
ArtifactID agenttask.ArtifactID
ValidationEvidence []ValidationEvidence
}
// Freeze compares the reviewed task view with its exact pinned snapshot,
// copies result content into an immutable record, and installs it
// idempotently under the retained overlay.
func (backend *Backend) Freeze(
ctx context.Context,
request FreezeRequest,
) (ChangeSet, error) {
if err := ctx.Err(); err != nil {
return ChangeSet{}, err
}
if request.ArtifactID == "" {
return ChangeSet{}, errors.New("agentworkspace: artifact identity is required")
}
evidence, err := normalizeValidationEvidence(request.ValidationEvidence)
if err != nil {
return ChangeSet{}, err
}
record, err := backend.LoadRecord(request.Descriptor)
if err != nil {
return ChangeSet{}, err
}
snapshot, err := readWorkspaceSnapshot(record.Locator.SnapshotRecord)
if err != nil {
return ChangeSet{}, fmt.Errorf("agentworkspace: load frozen base: %w", err)
}
baseEntries := changeEntriesFromSnapshot(snapshot)
resultEntries, err := scanChangeEntries(ctx, record.Locator.ViewRoot)
if err != nil {
return ChangeSet{}, fmt.Errorf("agentworkspace: scan reviewed task view: %w", err)
}
operations := buildChangeOperations(baseEntries, resultEntries)
if len(operations) == 0 {
return ChangeSet{}, errors.New("agentworkspace: reviewed overlay has no file operations")
}
taskRoot := filepath.Dir(record.Locator.OverlayRecord)
changeSetsRoot := filepath.Join(taskRoot, "change-sets")
if err := os.MkdirAll(changeSetsRoot, 0o700); err != nil {
return ChangeSet{}, fmt.Errorf("agentworkspace: create change-set root: %w", err)
}
staging, err := os.MkdirTemp(changeSetsRoot, ".freezing-")
if err != nil {
return ChangeSet{}, fmt.Errorf("agentworkspace: create change-set staging root: %w", err)
}
cleanup := true
defer func() {
if cleanup {
_ = os.RemoveAll(staging)
}
}()
for index := range operations {
operation := &operations[index]
if operation.Result == nil || operation.Result.Kind != SnapshotEntryRegular {
continue
}
operation.ContentFile = filepath.ToSlash(filepath.Join("content", operation.Path))
source := filepath.Join(record.Locator.ViewRoot, filepath.FromSlash(operation.Path))
destination := filepath.Join(staging, filepath.FromSlash(operation.ContentFile))
digest, copyErr := digestAndCopyRegular(source, destination)
if copyErr != nil {
return ChangeSet{}, fmt.Errorf(
"agentworkspace: freeze content %q: %w",
operation.Path,
copyErr,
)
}
if digest != operation.Result.ContentDigest {
return ChangeSet{}, fmt.Errorf(
"agentworkspace: task content %q changed while freezing",
operation.Path,
)
}
if err := os.Chmod(destination, 0o400); err != nil {
return ChangeSet{}, err
}
}
writeSet := make([]string, len(operations))
for index, operation := range operations {
writeSet[index] = operation.Path
}
changeSet := ChangeSet{
SchemaVersion: changeSetSchemaVersion,
ArtifactID: request.ArtifactID,
ProjectID: agenttask.ProjectID(record.ProjectID),
WorkspaceID: agenttask.WorkspaceID(record.WorkspaceID),
WorkUnitID: agenttask.WorkUnitID(record.WorkUnitID),
AttemptID: agenttask.AttemptID(record.AttemptID),
OverlayRevision: record.Revision,
CanonicalRoot: record.CanonicalRoot,
BaseFingerprint: snapshot.Revision,
ConfigRevision: record.ConfigRevision,
GrantRevision: record.GrantRevision,
Operations: operations,
WriteSet: writeSet,
ValidationEvidence: evidence,
}
changeSet.Revision = changeSetRevision(changeSet)
changeSet.ID = agenttask.ChangeSetID(
"change-" + strings.TrimPrefix(changeSet.Revision, "sha256:"),
)
finalRoot := filepath.Join(changeSetsRoot, string(changeSet.ID))
changeSet.Locator = ChangeSetLocator{
OverlayRecord: record.Locator.OverlayRecord,
Root: finalRoot,
Record: filepath.Join(finalRoot, "change-set.json"),
ContentRoot: filepath.Join(finalRoot, "content"),
}
if err := writeJSONFile(
filepath.Join(staging, "change-set.json"),
changeSet,
0o400,
); err != nil {
return ChangeSet{}, err
}
if err := os.Rename(staging, finalRoot); err != nil {
if !os.IsExist(err) {
if _, statErr := os.Stat(finalRoot); statErr != nil {
return ChangeSet{}, fmt.Errorf(
"agentworkspace: install immutable change set: %w",
err,
)
}
}
existing, loadErr := readChangeSet(filepath.Join(finalRoot, "change-set.json"))
if loadErr != nil {
return ChangeSet{}, loadErr
}
if !reflect.DeepEqual(existing, changeSet) {
return ChangeSet{}, errors.New(
"agentworkspace: retained change-set identity collision",
)
}
return existing, nil
}
cleanup = false
return changeSet, nil
}
// LoadChangeSet resolves an immutable record through the exact retained
// isolation and shared-runtime change-set identity.
func (backend *Backend) LoadChangeSet(
isolation agenttask.IsolationIdentity,
identity agenttask.ChangeSetIdentity,
) (ChangeSet, error) {
taskName := strings.TrimPrefix(isolation.ID, "overlay:")
if taskName == "" || strings.ContainsAny(taskName, `/\`) {
return ChangeSet{}, errors.New("agentworkspace: invalid overlay identity")
}
if identity.ID == "" || identity.Revision == "" || identity.ArtifactID == "" {
return ChangeSet{}, errors.New("agentworkspace: incomplete change-set identity")
}
if !validChangeSetIdentity(identity) {
return ChangeSet{}, errors.New("agentworkspace: invalid change-set identity")
}
recordPath := filepath.Join(
backend.tasks,
taskName,
"change-sets",
string(identity.ID),
"change-set.json",
)
changeSet, err := readChangeSet(recordPath)
if err != nil {
return ChangeSet{}, err
}
overlayPath := filepath.Join(backend.tasks, taskName, "overlay.json")
overlay, err := readOverlayRecord(overlayPath)
if err != nil {
return ChangeSet{}, err
}
if changeSet.Identity() != identity ||
changeSet.OverlayRevision != isolation.Revision ||
changeSet.BaseFingerprint != isolation.PinnedBaseRevision ||
changeSet.CanonicalRoot != overlay.CanonicalRoot ||
changeSet.Locator.OverlayRecord != overlayPath ||
overlay.Revision != isolation.Revision ||
overlay.SnapshotRevision != isolation.PinnedBaseRevision {
return ChangeSet{}, errors.New("agentworkspace: change-set identity mismatch")
}
return changeSet, nil
}
func normalizeValidationEvidence(
input []ValidationEvidence,
) ([]ValidationEvidence, error) {
if len(input) == 0 {
return nil, errors.New("agentworkspace: validation evidence is required")
}
result := append([]ValidationEvidence(nil), input...)
sort.Slice(result, func(left, right int) bool {
return result[left].Name < result[right].Name
})
for index, evidence := range result {
if strings.TrimSpace(evidence.Name) == "" ||
strings.TrimSpace(evidence.Result) == "" ||
strings.TrimSpace(evidence.Digest) == "" {
return nil, errors.New("agentworkspace: validation evidence is incomplete")
}
if index > 0 && result[index-1].Name == evidence.Name {
return nil, fmt.Errorf(
"agentworkspace: duplicate validation evidence %q",
evidence.Name,
)
}
}
return result, nil
}
func changeEntriesFromSnapshot(
snapshot WorkspaceSnapshot,
) map[string]ChangeEntry {
entries := make(map[string]ChangeEntry, len(snapshot.Entries))
for _, entry := range snapshot.Entries {
if entry.Kind == SnapshotEntryMissing {
continue
}
entries[entry.Path] = ChangeEntry{
Kind: entry.Kind,
Mode: entry.Mode,
ContentDigest: entry.ContentDigest,
SymlinkTarget: entry.SymlinkTarget,
}
}
return entries
}
func scanChangeEntries(
ctx context.Context,
root string,
) (map[string]ChangeEntry, error) {
entries := make(map[string]ChangeEntry)
err := filepath.WalkDir(root, func(
path string,
entry fs.DirEntry,
walkErr error,
) error {
if walkErr != nil {
return walkErr
}
if err := ctx.Err(); err != nil {
return err
}
if path == root {
return nil
}
relative, err := filepath.Rel(root, path)
if err != nil {
return err
}
if relative == ".git" {
if entry.IsDir() {
return filepath.SkipDir
}
return nil
}
if strings.HasPrefix(relative, ".git"+string(filepath.Separator)) {
return nil
}
slashPath := filepath.ToSlash(relative)
info, err := os.Lstat(path)
if err != nil {
return err
}
changeEntry := ChangeEntry{Mode: uint32(info.Mode())}
switch {
case info.IsDir():
changeEntry.Kind = SnapshotEntryDirectory
case info.Mode().IsRegular():
changeEntry.Kind = SnapshotEntryRegular
digest, err := digestAndCopyRegular(path, "")
if err != nil {
return err
}
changeEntry.ContentDigest = digest
case info.Mode()&os.ModeSymlink != 0:
changeEntry.Kind = SnapshotEntrySymlink
target, err := os.Readlink(path)
if err != nil {
return err
}
if err := validateSnapshotSymlink(root, path, target); err != nil {
return err
}
changeEntry.SymlinkTarget = target
changeEntry.ContentDigest = digestText(target)
default:
return fmt.Errorf(
"agentworkspace: unsupported task object %q with mode %s",
slashPath,
info.Mode(),
)
}
entries[slashPath] = changeEntry
return nil
})
return entries, err
}
func buildChangeOperations(
base map[string]ChangeEntry,
result map[string]ChangeEntry,
) []ChangeOperation {
paths := make(map[string]struct{}, len(base)+len(result))
for path := range base {
paths[path] = struct{}{}
}
for path := range result {
paths[path] = struct{}{}
}
ordered := make([]string, 0, len(paths))
for path := range paths {
ordered = append(ordered, path)
}
sort.Strings(ordered)
operations := make([]ChangeOperation, 0)
for _, path := range ordered {
baseEntry, baseExists := base[path]
resultEntry, resultExists := result[path]
if baseExists && resultExists && baseEntry == resultEntry {
continue
}
operation := ChangeOperation{Path: path}
switch {
case !baseExists:
operation.Kind = ChangeOperationAdd
case !resultExists:
operation.Kind = ChangeOperationDelete
default:
operation.Kind = ChangeOperationModify
}
if baseExists {
copy := baseEntry
operation.Base = &copy
}
if resultExists {
copy := resultEntry
operation.Result = &copy
}
operations = append(operations, operation)
}
return operations
}
func changeSetRevision(changeSet ChangeSet) string {
copy := changeSet
copy.ID = ""
copy.Revision = ""
copy.Locator = ChangeSetLocator{}
encoded, _ := json.Marshal(copy)
return digestBytes(encoded)
}
func readChangeSet(path string) (ChangeSet, error) {
var changeSet ChangeSet
if err := readJSONFile(path, &changeSet); err != nil {
return ChangeSet{}, fmt.Errorf("agentworkspace: read change set: %w", err)
}
if changeSet.SchemaVersion != changeSetSchemaVersion ||
changeSet.Revision != changeSetRevision(changeSet) ||
changeSet.ID != agenttask.ChangeSetID(
"change-"+strings.TrimPrefix(changeSet.Revision, "sha256:"),
) ||
!validChangeSetIdentity(changeSet.Identity()) {
return ChangeSet{}, errors.New("agentworkspace: change-set record is corrupt")
}
if len(changeSet.Operations) == 0 ||
len(changeSet.Operations) != len(changeSet.WriteSet) ||
len(changeSet.ValidationEvidence) == 0 {
return ChangeSet{}, errors.New("agentworkspace: change-set record is incomplete")
}
for index, operation := range changeSet.Operations {
if operation.Path != changeSet.WriteSet[index] ||
filepath.ToSlash(filepath.Clean(operation.Path)) != operation.Path ||
operation.Path == "." ||
strings.HasPrefix(operation.Path, "../") {
return ChangeSet{}, errors.New("agentworkspace: invalid change-set write path")
}
if index > 0 && changeSet.Operations[index-1].Path >= operation.Path {
return ChangeSet{}, errors.New(
"agentworkspace: change-set operations are not strictly ordered",
)
}
switch operation.Kind {
case ChangeOperationAdd:
if operation.Base != nil || operation.Result == nil {
return ChangeSet{}, errors.New("agentworkspace: invalid add operation")
}
case ChangeOperationModify:
if operation.Base == nil || operation.Result == nil {
return ChangeSet{}, errors.New("agentworkspace: invalid modify operation")
}
case ChangeOperationDelete:
if operation.Base == nil || operation.Result != nil {
return ChangeSet{}, errors.New("agentworkspace: invalid delete operation")
}
default:
return ChangeSet{}, errors.New("agentworkspace: invalid operation kind")
}
if operation.Result != nil && operation.Result.Kind == SnapshotEntryRegular {
if operation.ContentFile != filepath.ToSlash(
filepath.Join("content", operation.Path),
) {
return ChangeSet{}, errors.New(
"agentworkspace: invalid regular content locator",
)
}
} else if operation.ContentFile != "" {
return ChangeSet{}, errors.New(
"agentworkspace: non-regular operation contains content",
)
}
}
evidence, err := normalizeValidationEvidence(changeSet.ValidationEvidence)
if err != nil || !reflect.DeepEqual(evidence, changeSet.ValidationEvidence) {
return ChangeSet{}, errors.New(
"agentworkspace: invalid change-set validation evidence",
)
}
cleanPath := filepath.Clean(path)
if changeSet.Locator.Record != cleanPath ||
changeSet.Locator.Root != filepath.Dir(cleanPath) ||
changeSet.Locator.ContentRoot != filepath.Join(filepath.Dir(cleanPath), "content") ||
!filepath.IsAbs(changeSet.CanonicalRoot) ||
filepath.Clean(changeSet.CanonicalRoot) != changeSet.CanonicalRoot {
return ChangeSet{}, errors.New("agentworkspace: invalid change-set locator")
}
return changeSet, nil
}
func validChangeSetIdentity(identity agenttask.ChangeSetIdentity) bool {
const prefix = "sha256:"
if !strings.HasPrefix(identity.Revision, prefix) {
return false
}
digest := strings.TrimPrefix(identity.Revision, prefix)
decoded, err := hex.DecodeString(digest)
return err == nil &&
len(decoded) == sha256.Size &&
string(identity.ID) == "change-"+digest &&
identity.ArtifactID != ""
}