iop/packages/go/agentconfig/runtime_config.go

973 lines
34 KiB
Go

package agentconfig
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
const RuntimeConfigSchemaVersion = "1"
// RepoGlobalRuntimeConfig is the version-controlled, secret-free input owned
// by a project repository. The runtime only reads this document.
type RepoGlobalRuntimeConfig struct {
Version string `yaml:"version"`
Catalog Catalog `yaml:"catalog,omitempty"`
Defaults RuntimeDefaults `yaml:"defaults,omitempty"`
Selection SelectionPolicy `yaml:"selection,omitempty"`
Isolation IsolationPolicy `yaml:"isolation,omitempty"`
Retention RetentionPolicy `yaml:"retention,omitempty"`
}
// UserLocalRuntimeConfig is the device-owned input applied after the
// repository input. It deliberately has no credential or raw environment
// value fields.
type UserLocalRuntimeConfig struct {
Version string `yaml:"version"`
Device DeviceRuntimeConfig `yaml:"device"`
Clients map[string]ClientProcessSpec `yaml:"clients,omitempty"`
Override RuntimeConfigOverride `yaml:"override,omitempty"`
Projects map[string]ProjectRegistrationOverlay `yaml:"projects,omitempty"`
}
// RuntimeConfig is the fully merged configuration captured by a
// RuntimeSnapshot.
type RuntimeConfig struct {
Version string `yaml:"version"`
Revision string `yaml:"-"`
SourceRevisions SourceRevisions `yaml:"-"`
Catalog Catalog `yaml:"catalog,omitempty"`
Device DeviceRuntimeConfig `yaml:"device"`
Clients map[string]ClientProcessSpec `yaml:"clients,omitempty"`
Defaults RuntimeDefaults `yaml:"defaults,omitempty"`
Selection SelectionPolicy `yaml:"selection,omitempty"`
Isolation IsolationPolicy `yaml:"isolation,omitempty"`
Retention RetentionPolicy `yaml:"retention,omitempty"`
Projects map[string]ProjectRegistration `yaml:"projects,omitempty"`
}
// RuntimeDefaults contains scalar and map defaults. ProfileAliases is merged
// by key, with the user-local value winning for duplicate aliases.
type RuntimeDefaults struct {
DefaultProfile string `yaml:"default_profile,omitempty"`
AutoResumeInterrupted bool `yaml:"auto_resume_interrupted,omitempty"`
ProfileAliases map[string]string `yaml:"profile_aliases,omitempty"`
}
// RuntimeDefaultsOverride uses pointers for scalars so an explicit false or
// empty value remains distinguishable from an omitted value.
type RuntimeDefaultsOverride struct {
DefaultProfile *string `yaml:"default_profile,omitempty"`
AutoResumeInterrupted *bool `yaml:"auto_resume_interrupted,omitempty"`
ProfileAliases map[string]string `yaml:"profile_aliases,omitempty"`
}
// TargetRef is a provider/model/profile identity consumed by the shared
// selector. Profile is the runtime target; provider and model preserve the
// declared identity when supplied.
type TargetRef struct {
Provider string `yaml:"provider,omitempty"`
Model string `yaml:"model,omitempty"`
Profile string `yaml:"profile,omitempty"`
}
// SelectionPolicy is ordered. Rules are evaluated by the selector in their
// stored order; the config registry never sorts them.
type SelectionPolicy struct {
Version string `yaml:"version,omitempty"`
Revision string `yaml:"-"`
Timezone string `yaml:"timezone,omitempty"`
Default TargetRef `yaml:"default,omitempty"`
Rules []SelectionRule `yaml:"rules,omitempty"`
}
// SelectionPolicyOverride replaces Rules as a whole when rules is present,
// including when it is explicitly an empty array.
type SelectionPolicyOverride struct {
Timezone *string `yaml:"timezone,omitempty"`
Default *TargetRef `yaml:"default,omitempty"`
Rules *[]SelectionRule `yaml:"rules,omitempty"`
}
// SelectionRule is a strict policy input. Evaluation belongs to the
// agentpolicy package; this package validates and preserves rule order.
type SelectionRule struct {
ID string `yaml:"id"`
Match SelectionMatch `yaml:"match,omitempty"`
Target TargetRef `yaml:"target"`
}
// SelectionMatch contains the SDD-defined policy predicates without assigning
// evaluator semantics to them.
type SelectionMatch struct {
TimeWindows []SelectionTimeWindow `yaml:"time_windows,omitempty"`
QuotaStates []string `yaml:"quota_states,omitempty"`
MinRemainingToken *int64 `yaml:"min_remaining_tokens,omitempty"`
Agents []string `yaml:"agents,omitempty"`
Stages []string `yaml:"stages,omitempty"`
Lanes []string `yaml:"lanes,omitempty"`
MinGrade int `yaml:"min_grade,omitempty"`
MaxGrade int `yaml:"max_grade,omitempty"`
Capabilities []string `yaml:"capabilities,omitempty"`
FailureCodes []string `yaml:"failure_codes,omitempty"`
}
// SelectionTimeWindow is interpreted in SelectionPolicy.Timezone.
type SelectionTimeWindow struct {
Days []string `yaml:"days,omitempty"`
Start string `yaml:"start"`
End string `yaml:"end"`
}
// IsolationPolicy defines the default isolation mode and its ordered fallback
// modes. FallbackModes is another whole-replacement ordered array.
type IsolationPolicy struct {
DefaultMode string `yaml:"default_mode,omitempty"`
FallbackModes []string `yaml:"fallback_modes,omitempty"`
}
type IsolationPolicyOverride struct {
DefaultMode *string `yaml:"default_mode,omitempty"`
FallbackModes *[]string `yaml:"fallback_modes,omitempty"`
}
// RetentionPolicy contains non-negative local retention limits. Zero leaves a
// limit disabled.
type RetentionPolicy struct {
CompletedDays int `yaml:"completed_days,omitempty"`
BlockedDays int `yaml:"blocked_days,omitempty"`
MaxProjectLogRecords int `yaml:"max_project_log_records,omitempty"`
}
type RetentionPolicyOverride struct {
CompletedDays *int `yaml:"completed_days,omitempty"`
BlockedDays *int `yaml:"blocked_days,omitempty"`
MaxProjectLogRecords *int `yaml:"max_project_log_records,omitempty"`
}
// DeviceRuntimeConfig contains device-owned roots. StateRoot, OverlayRoot, and
// LogRoot are required absolute clean paths; TempRoot and CacheRoot are
// optional but must meet the same rule when set.
type DeviceRuntimeConfig struct {
StateRoot string `yaml:"state_root"`
OverlayRoot string `yaml:"overlay_root"`
LogRoot string `yaml:"log_root"`
TempRoot string `yaml:"temp_root,omitempty"`
CacheRoot string `yaml:"cache_root,omitempty"`
}
// ClientProcessSpec is a device-local, argv-only launch policy for one
// daemon-owned desktop client. Environment and credential fields are
// intentionally absent.
type ClientProcessSpec struct {
Executable string `yaml:"executable"`
Args []string `yaml:"args,omitempty"`
WorkingDirectory string `yaml:"working_directory"`
LaunchOnStart bool `yaml:"launch_on_start,omitempty"`
RestartOnCrash bool `yaml:"restart_on_crash,omitempty"`
RestartLimit int `yaml:"restart_limit,omitempty"`
RestartBackoffMillis int `yaml:"restart_backoff_millis,omitempty"`
FocusArgs []string `yaml:"focus_args,omitempty"`
}
// RuntimeConfigOverride is applied field-by-field after its parent config.
// Scalar pointers replace scalars, maps merge local-wins, and ordered arrays
// replace rather than append.
type RuntimeConfigOverride struct {
Defaults RuntimeDefaultsOverride `yaml:"defaults,omitempty"`
Selection SelectionPolicyOverride `yaml:"selection,omitempty"`
Isolation IsolationPolicyOverride `yaml:"isolation,omitempty"`
Retention RetentionPolicyOverride `yaml:"retention,omitempty"`
}
// ProjectRegistrationOverlay is the user-local schema for one project.
type ProjectRegistrationOverlay struct {
Workspace string `yaml:"workspace"`
Enabled *bool `yaml:"enabled,omitempty"`
SelectedMilestone string `yaml:"selected_milestone,omitempty"`
Override RuntimeConfigOverride `yaml:"override,omitempty"`
}
// ProjectRegistration is an effective project configuration. It retains the
// project identity and the complete revision-pinned defaults/policies that
// apply to the registered workspace.
type ProjectRegistration struct {
ID string
Workspace string
Enabled bool
SelectedMilestone string
ConfigRevision string
Defaults RuntimeDefaults
Selection SelectionPolicy
Isolation IsolationPolicy
Retention RetentionPolicy
AutoResumeInterrupted bool
}
// SourceRevisions identifies the exact byte content of both configuration
// inputs.
type SourceRevisions struct {
RepoGlobal string
UserLocal string
}
// RuntimeSnapshot is immutable through its API. The merged configuration is
// private and every accessor returns a defensive deep copy.
type RuntimeSnapshot struct {
config RuntimeConfig
revision string
sources SourceRevisions
}
// LoadRuntimeConfig reads the repository input without opening it for writing,
// reads the user-local input, and returns one immutable merged snapshot.
func LoadRuntimeConfig(repoGlobalPath, userLocalPath string) (RuntimeSnapshot, error) {
repoGlobal, err := os.ReadFile(repoGlobalPath)
if err != nil {
return RuntimeSnapshot{}, fmt.Errorf("agentconfig: read repo-global runtime config: %w", err)
}
userLocal, err := os.ReadFile(userLocalPath)
if err != nil {
return RuntimeSnapshot{}, fmt.Errorf("agentconfig: read user-local runtime config: %w", err)
}
return LoadRuntimeConfigBytes(repoGlobal, userLocal)
}
// LoadRuntimeConfigBytes strictly decodes and composes one repo-global and one
// user-local document. It is pure and never writes either input.
func LoadRuntimeConfigBytes(repoGlobal, userLocal []byte) (RuntimeSnapshot, error) {
var global RepoGlobalRuntimeConfig
if err := decodeStrictRuntimeConfig(repoGlobal, "repo-global", &global); err != nil {
return RuntimeSnapshot{}, err
}
var local UserLocalRuntimeConfig
if err := decodeStrictRuntimeConfig(userLocal, "user-local", &local); err != nil {
return RuntimeSnapshot{}, err
}
normalizedGlobal, err := normalizeRepoGlobal(global)
if err != nil {
return RuntimeSnapshot{}, err
}
if err := validateUserLocal(local); err != nil {
return RuntimeSnapshot{}, err
}
merged, err := mergeRuntimeConfig(normalizedGlobal, local)
if err != nil {
return RuntimeSnapshot{}, err
}
sources := SourceRevisions{
RepoGlobal: digestRevision(repoGlobal),
UserLocal: digestRevision(userLocal),
}
revision := digestRevisionParts("runtime-config", sources.RepoGlobal, sources.UserLocal)
merged.Revision = revision
merged.SourceRevisions = sources
merged.Selection.Revision = digestRevisionParts("selection-policy", revision)
for projectID, project := range merged.Projects {
project.ConfigRevision = revision
project.Selection.Revision = digestRevisionParts("selection-policy", revision, projectID)
merged.Projects[projectID] = project
}
return RuntimeSnapshot{
config: cloneRuntimeConfig(merged),
revision: revision,
sources: sources,
}, nil
}
// Revision returns the immutable revision pinned to an invocation.
func (s RuntimeSnapshot) Revision() string {
return s.revision
}
// SourceRevisions returns the exact source revisions used by this snapshot.
func (s RuntimeSnapshot) SourceRevisions() SourceRevisions {
return s.sources
}
// Config returns a defensive deep copy of the merged runtime configuration.
func (s RuntimeSnapshot) Config() RuntimeConfig {
return cloneRuntimeConfig(s.config)
}
// Project returns a defensive copy of one effective project registration.
func (s RuntimeSnapshot) Project(projectID string) (ProjectRegistration, bool) {
project, ok := s.config.Projects[projectID]
if !ok {
return ProjectRegistration{}, false
}
return cloneProjectRegistration(project), true
}
func decodeStrictRuntimeConfig(data []byte, source string, destination any) error {
decoder := yaml.NewDecoder(strings.NewReader(string(data)))
decoder.KnownFields(true)
if err := decoder.Decode(destination); err != nil {
return fmt.Errorf("agentconfig: decode %s runtime config: %w", source, err)
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
if err == nil {
return fmt.Errorf("agentconfig: %s runtime config must contain exactly one YAML document", source)
}
return fmt.Errorf("agentconfig: decode trailing %s runtime document: %w", source, err)
}
return nil
}
func normalizeRepoGlobal(global RepoGlobalRuntimeConfig) (RepoGlobalRuntimeConfig, error) {
if global.Version != RuntimeConfigSchemaVersion {
return RepoGlobalRuntimeConfig{}, fmt.Errorf("agentconfig: unsupported repo-global runtime config version %q", global.Version)
}
if catalogConfigured(global.Catalog) {
catalog, err := Normalize(global.Catalog)
if err != nil {
return RepoGlobalRuntimeConfig{}, fmt.Errorf("agentconfig: repo-global catalog: %w", err)
}
global.Catalog = catalog
}
if err := validateRuntimeDefaults("repo-global defaults", global.Defaults); err != nil {
return RepoGlobalRuntimeConfig{}, err
}
if global.Selection.Version == "" {
global.Selection.Version = RuntimeConfigSchemaVersion
}
if err := validateSelectionPolicy("repo-global selection", global.Selection); err != nil {
return RepoGlobalRuntimeConfig{}, err
}
if err := validateIsolationPolicy("repo-global isolation", global.Isolation); err != nil {
return RepoGlobalRuntimeConfig{}, err
}
if err := validateRetentionPolicy("repo-global retention", global.Retention); err != nil {
return RepoGlobalRuntimeConfig{}, err
}
return global, nil
}
func validateUserLocal(local UserLocalRuntimeConfig) error {
if local.Version != RuntimeConfigSchemaVersion {
return fmt.Errorf("agentconfig: unsupported user-local runtime config version %q", local.Version)
}
if err := validateDeviceConfig(local.Device); err != nil {
return err
}
if err := validateClientProcessSpecs(local.Clients); err != nil {
return err
}
if err := validateRuntimeOverride("user-local override", local.Override); err != nil {
return err
}
for projectID, project := range local.Projects {
if err := validateID("project", projectID); err != nil {
return err
}
if err := validateCleanAbsolutePath("project "+projectID+" workspace", project.Workspace, true); err != nil {
return err
}
if err := validateRuntimeOverride("project "+projectID+" override", project.Override); err != nil {
return err
}
}
return nil
}
func mergeRuntimeConfig(global RepoGlobalRuntimeConfig, local UserLocalRuntimeConfig) (RuntimeConfig, error) {
merged := RuntimeConfig{
Version: RuntimeConfigSchemaVersion,
Catalog: cloneCatalog(global.Catalog),
Device: local.Device,
Clients: cloneClientProcessSpecs(local.Clients),
Defaults: cloneRuntimeDefaults(global.Defaults),
Selection: cloneSelectionPolicy(global.Selection),
Isolation: cloneIsolationPolicy(global.Isolation),
Retention: global.Retention,
Projects: make(map[string]ProjectRegistration, len(local.Projects)),
}
applyRuntimeOverride(&merged.Defaults, &merged.Selection, &merged.Isolation, &merged.Retention, local.Override)
if err := validateMergedRuntimeConfig("merged runtime config", merged.Defaults, merged.Selection, merged.Isolation, merged.Retention); err != nil {
return RuntimeConfig{}, err
}
if err := validateRuntimeCatalogBindings("merged runtime config", merged.Catalog, merged.Defaults, merged.Selection); err != nil {
return RuntimeConfig{}, err
}
for projectID, overlay := range local.Projects {
defaults := cloneRuntimeDefaults(merged.Defaults)
selection := cloneSelectionPolicy(merged.Selection)
isolation := cloneIsolationPolicy(merged.Isolation)
retention := merged.Retention
applyRuntimeOverride(&defaults, &selection, &isolation, &retention, overlay.Override)
if err := validateMergedRuntimeConfig("project "+projectID, defaults, selection, isolation, retention); err != nil {
return RuntimeConfig{}, err
}
if err := validateRuntimeCatalogBindings("project "+projectID, merged.Catalog, defaults, selection); err != nil {
return RuntimeConfig{}, err
}
enabled := true
if overlay.Enabled != nil {
enabled = *overlay.Enabled
}
merged.Projects[projectID] = ProjectRegistration{
ID: projectID,
Workspace: overlay.Workspace,
Enabled: enabled,
SelectedMilestone: overlay.SelectedMilestone,
Defaults: defaults,
Selection: selection,
Isolation: isolation,
Retention: retention,
AutoResumeInterrupted: defaults.AutoResumeInterrupted,
}
}
return merged, nil
}
func applyRuntimeOverride(
defaults *RuntimeDefaults,
selection *SelectionPolicy,
isolation *IsolationPolicy,
retention *RetentionPolicy,
override RuntimeConfigOverride,
) {
if override.Defaults.DefaultProfile != nil {
defaults.DefaultProfile = *override.Defaults.DefaultProfile
}
if override.Defaults.AutoResumeInterrupted != nil {
defaults.AutoResumeInterrupted = *override.Defaults.AutoResumeInterrupted
}
if override.Defaults.ProfileAliases != nil {
if defaults.ProfileAliases == nil {
defaults.ProfileAliases = make(map[string]string, len(override.Defaults.ProfileAliases))
}
for alias, profile := range override.Defaults.ProfileAliases {
defaults.ProfileAliases[alias] = profile
}
}
if override.Selection.Timezone != nil {
selection.Timezone = *override.Selection.Timezone
}
if override.Selection.Default != nil {
selection.Default = *override.Selection.Default
}
if override.Selection.Rules != nil {
selection.Rules = cloneSelectionRules(*override.Selection.Rules)
}
if override.Isolation.DefaultMode != nil {
isolation.DefaultMode = *override.Isolation.DefaultMode
}
if override.Isolation.FallbackModes != nil {
isolation.FallbackModes = append([]string(nil), (*override.Isolation.FallbackModes)...)
}
if override.Retention.CompletedDays != nil {
retention.CompletedDays = *override.Retention.CompletedDays
}
if override.Retention.BlockedDays != nil {
retention.BlockedDays = *override.Retention.BlockedDays
}
if override.Retention.MaxProjectLogRecords != nil {
retention.MaxProjectLogRecords = *override.Retention.MaxProjectLogRecords
}
}
func validateMergedRuntimeConfig(
label string,
defaults RuntimeDefaults,
selection SelectionPolicy,
isolation IsolationPolicy,
retention RetentionPolicy,
) error {
if err := validateRuntimeDefaults(label+" defaults", defaults); err != nil {
return err
}
if err := validateSelectionPolicy(label+" selection", selection); err != nil {
return err
}
if err := validateIsolationPolicy(label+" isolation", isolation); err != nil {
return err
}
return validateRetentionPolicy(label+" retention", retention)
}
func validateRuntimeOverride(label string, override RuntimeConfigOverride) error {
if override.Defaults.DefaultProfile != nil && *override.Defaults.DefaultProfile != "" {
if err := validateID(label+" default profile", *override.Defaults.DefaultProfile); err != nil {
return err
}
}
for alias, profile := range override.Defaults.ProfileAliases {
if err := validateAlias(label, alias, profile); err != nil {
return err
}
}
if override.Selection.Default != nil {
if err := validateTargetRef(label+" selection default", *override.Selection.Default); err != nil {
return err
}
}
if override.Selection.Rules != nil {
if err := validateSelectionRules(label+" selection rules", *override.Selection.Rules); err != nil {
return err
}
}
if override.Isolation.DefaultMode != nil {
if err := validateIsolationMode(label+" default isolation", *override.Isolation.DefaultMode, true); err != nil {
return err
}
}
if override.Isolation.FallbackModes != nil {
if err := validateIsolationModes(label+" isolation fallbacks", *override.Isolation.FallbackModes); err != nil {
return err
}
}
for field, value := range map[string]*int{
"completed_days": override.Retention.CompletedDays,
"blocked_days": override.Retention.BlockedDays,
"max_project_log_records": override.Retention.MaxProjectLogRecords,
} {
if value != nil && *value < 0 {
return fmt.Errorf("agentconfig: %s retention %s must be non-negative", label, field)
}
}
return nil
}
func validateRuntimeDefaults(label string, defaults RuntimeDefaults) error {
if defaults.DefaultProfile != "" {
if err := validateID(label+" default profile", defaults.DefaultProfile); err != nil {
return err
}
}
for alias, profile := range defaults.ProfileAliases {
if err := validateAlias(label, alias, profile); err != nil {
return err
}
}
return nil
}
func validateRuntimeCatalogBindings(
label string,
catalog Catalog,
defaults RuntimeDefaults,
selection SelectionPolicy,
) error {
if !catalogConfigured(catalog) {
return nil
}
profileIDs := make([]string, 0, 2+len(defaults.ProfileAliases)+len(selection.Rules))
if defaults.DefaultProfile != "" {
profileIDs = append(profileIDs, defaults.DefaultProfile)
}
for _, profileID := range defaults.ProfileAliases {
profileIDs = append(profileIDs, profileID)
}
for _, profileID := range profileIDs {
if _, ok := catalog.ResolveProfile(profileID); !ok {
return fmt.Errorf("agentconfig: %s references unknown profile %q", label, profileID)
}
}
if err := validateTargetCatalogBinding(label+" selection default", catalog, selection.Default); err != nil {
return err
}
for _, rule := range selection.Rules {
if err := validateTargetCatalogBinding("selection rule "+rule.ID+" target", catalog, rule.Target); err != nil {
return err
}
}
return nil
}
func validateTargetCatalogBinding(label string, catalog Catalog, target TargetRef) error {
if target.Profile == "" {
return nil
}
resolved, ok := catalog.ResolveProfile(target.Profile)
if !ok {
return fmt.Errorf("agentconfig: %s references unknown profile %q", label, target.Profile)
}
if target.Provider != "" && target.Provider != resolved.Provider.ID {
return fmt.Errorf(
"agentconfig: %s provider %q does not match profile provider %q",
label,
target.Provider,
resolved.Provider.ID,
)
}
if target.Model != "" && target.Model != resolved.Model.ID {
return fmt.Errorf(
"agentconfig: %s model %q does not match profile model %q",
label,
target.Model,
resolved.Model.ID,
)
}
return nil
}
func validateAlias(label, alias, profile string) error {
if err := validateID(label+" alias", alias); err != nil {
return err
}
if err := validateID(label+" alias profile", profile); err != nil {
return err
}
return nil
}
func validateSelectionPolicy(label string, policy SelectionPolicy) error {
if policy.Version != RuntimeConfigSchemaVersion {
return fmt.Errorf("agentconfig: %s has unsupported version %q", label, policy.Version)
}
if err := validateTargetRef(label+" default", policy.Default); err != nil {
return err
}
return validateSelectionRules(label+" rules", policy.Rules)
}
func validateSelectionRules(label string, rules []SelectionRule) error {
seen := make(map[string]struct{}, len(rules))
for _, rule := range rules {
if err := validateID("selection rule", rule.ID); err != nil {
return err
}
if _, exists := seen[rule.ID]; exists {
return fmt.Errorf("agentconfig: %s repeats rule id %q", label, rule.ID)
}
seen[rule.ID] = struct{}{}
if err := validateTargetRef("selection rule "+rule.ID+" target", rule.Target); err != nil {
return err
}
if rule.Target.Profile == "" {
return fmt.Errorf("agentconfig: selection rule %q target.profile is required", rule.ID)
}
if rule.Match.MinRemainingToken != nil && *rule.Match.MinRemainingToken < 0 {
return fmt.Errorf("agentconfig: selection rule %q min_remaining_tokens must be non-negative", rule.ID)
}
if rule.Match.MinGrade < 0 || rule.Match.MaxGrade < 0 {
return fmt.Errorf("agentconfig: selection rule %q grades must be non-negative", rule.ID)
}
if rule.Match.MinGrade != 0 && rule.Match.MaxGrade != 0 && rule.Match.MinGrade > rule.Match.MaxGrade {
return fmt.Errorf("agentconfig: selection rule %q min_grade exceeds max_grade", rule.ID)
}
for _, window := range rule.Match.TimeWindows {
if strings.TrimSpace(window.Start) == "" || strings.TrimSpace(window.End) == "" {
return fmt.Errorf("agentconfig: selection rule %q time window start and end are required", rule.ID)
}
}
}
return nil
}
func validateTargetRef(label string, target TargetRef) error {
if target.Profile == "" && (target.Provider != "" || target.Model != "") {
return fmt.Errorf("agentconfig: %s profile is required when provider or model is set", label)
}
for field, value := range map[string]string{
"provider": target.Provider,
"model": target.Model,
"profile": target.Profile,
} {
if value != "" {
if err := validateID(label+" "+field, value); err != nil {
return err
}
}
}
return nil
}
func validateIsolationPolicy(label string, policy IsolationPolicy) error {
if err := validateIsolationMode(label+" default_mode", policy.DefaultMode, false); err != nil {
return err
}
return validateIsolationModes(label+" fallback_modes", policy.FallbackModes)
}
func validateIsolationModes(label string, modes []string) error {
seen := make(map[string]struct{}, len(modes))
for _, mode := range modes {
if err := validateIsolationMode(label, mode, true); err != nil {
return err
}
if _, exists := seen[mode]; exists {
return fmt.Errorf("agentconfig: %s repeats mode %q", label, mode)
}
seen[mode] = struct{}{}
}
return nil
}
func validateIsolationMode(label, mode string, required bool) error {
if mode == "" && !required {
return nil
}
switch mode {
case "overlay", "worktree", "clone":
return nil
default:
return fmt.Errorf("agentconfig: %s has unsupported mode %q", label, mode)
}
}
func validateRetentionPolicy(label string, retention RetentionPolicy) error {
if retention.CompletedDays < 0 || retention.BlockedDays < 0 || retention.MaxProjectLogRecords < 0 {
return fmt.Errorf("agentconfig: %s values must be non-negative", label)
}
return nil
}
func validateDeviceConfig(device DeviceRuntimeConfig) error {
for label, path := range map[string]string{
"state_root": device.StateRoot,
"overlay_root": device.OverlayRoot,
"log_root": device.LogRoot,
} {
if err := validateCleanAbsolutePath("device "+label, path, true); err != nil {
return err
}
}
for label, path := range map[string]string{
"temp_root": device.TempRoot,
"cache_root": device.CacheRoot,
} {
if err := validateCleanAbsolutePath("device "+label, path, false); err != nil {
return err
}
}
return nil
}
func validateClientProcessSpecs(specs map[string]ClientProcessSpec) error {
for kind, spec := range specs {
switch kind {
case "flutter", "unity":
default:
return fmt.Errorf("agentconfig: unsupported client kind %q", kind)
}
if err := validateCleanAbsolutePath(
"client "+kind+" executable",
spec.Executable,
true,
); err != nil {
return err
}
if err := validateCleanAbsolutePath(
"client "+kind+" working_directory",
spec.WorkingDirectory,
true,
); err != nil {
return err
}
if err := validateClientArguments("client "+kind+" args", spec.Args); err != nil {
return err
}
if err := validateClientArguments(
"client "+kind+" focus_args",
spec.FocusArgs,
); err != nil {
return err
}
if kind != "flutter" && len(spec.FocusArgs) != 0 {
return fmt.Errorf(
"agentconfig: client %s cannot configure Flutter focus arguments",
kind,
)
}
if spec.RestartLimit < 0 || spec.RestartBackoffMillis < 0 {
return fmt.Errorf(
"agentconfig: client %s restart limits must be non-negative",
kind,
)
}
if spec.RestartLimit > 100 || spec.RestartBackoffMillis > 60_000 {
return fmt.Errorf(
"agentconfig: client %s restart policy exceeds its bound",
kind,
)
}
if spec.RestartOnCrash && spec.RestartLimit == 0 {
return fmt.Errorf(
"agentconfig: client %s restart_limit must be positive when restart_on_crash is enabled",
kind,
)
}
if !spec.RestartOnCrash &&
(spec.RestartLimit != 0 || spec.RestartBackoffMillis != 0) {
return fmt.Errorf(
"agentconfig: client %s restart policy requires restart_on_crash",
kind,
)
}
}
return nil
}
func validateClientArguments(label string, arguments []string) error {
if len(arguments) > 256 {
return fmt.Errorf("agentconfig: %s exceeds 256 arguments", label)
}
for _, argument := range arguments {
if len(argument) > 32<<10 || strings.ContainsRune(argument, '\x00') {
return fmt.Errorf("agentconfig: %s contains an invalid argument", label)
}
}
return nil
}
func validateCleanAbsolutePath(label, path string, required bool) error {
if path == "" && !required {
return nil
}
if strings.TrimSpace(path) == "" {
return fmt.Errorf("agentconfig: %s is required", label)
}
if !filepath.IsAbs(path) || filepath.Clean(path) != path {
return fmt.Errorf("agentconfig: %s must be an absolute clean path", label)
}
return nil
}
func catalogConfigured(catalog Catalog) bool {
return catalog.Version != "" || len(catalog.Providers) != 0 || len(catalog.Models) != 0 || len(catalog.Profiles) != 0
}
func digestRevision(data []byte) string {
sum := sha256.Sum256(data)
return "sha256:" + hex.EncodeToString(sum[:])
}
func digestRevisionParts(parts ...string) string {
hash := sha256.New()
var length [8]byte
for _, part := range parts {
binary.BigEndian.PutUint64(length[:], uint64(len(part)))
_, _ = hash.Write(length[:])
_, _ = hash.Write([]byte(part))
}
return "sha256:" + hex.EncodeToString(hash.Sum(nil))
}
func cloneRuntimeConfig(config RuntimeConfig) RuntimeConfig {
out := config
out.Catalog = cloneCatalog(config.Catalog)
out.Clients = cloneClientProcessSpecs(config.Clients)
out.Defaults = cloneRuntimeDefaults(config.Defaults)
out.Selection = cloneSelectionPolicy(config.Selection)
out.Isolation = cloneIsolationPolicy(config.Isolation)
if config.Projects != nil {
out.Projects = make(map[string]ProjectRegistration, len(config.Projects))
for id, project := range config.Projects {
out.Projects[id] = cloneProjectRegistration(project)
}
}
return out
}
func cloneClientProcessSpecs(
specs map[string]ClientProcessSpec,
) map[string]ClientProcessSpec {
if specs == nil {
return nil
}
out := make(map[string]ClientProcessSpec, len(specs))
for kind, spec := range specs {
spec.Args = append([]string(nil), spec.Args...)
spec.FocusArgs = append([]string(nil), spec.FocusArgs...)
out[kind] = spec
}
return out
}
func cloneProjectRegistration(project ProjectRegistration) ProjectRegistration {
out := project
out.Defaults = cloneRuntimeDefaults(project.Defaults)
out.Selection = cloneSelectionPolicy(project.Selection)
out.Isolation = cloneIsolationPolicy(project.Isolation)
return out
}
func cloneRuntimeDefaults(defaults RuntimeDefaults) RuntimeDefaults {
out := defaults
if defaults.ProfileAliases != nil {
out.ProfileAliases = make(map[string]string, len(defaults.ProfileAliases))
for alias, profile := range defaults.ProfileAliases {
out.ProfileAliases[alias] = profile
}
}
return out
}
func cloneSelectionPolicy(policy SelectionPolicy) SelectionPolicy {
out := policy
out.Rules = cloneSelectionRules(policy.Rules)
return out
}
func cloneSelectionRules(rules []SelectionRule) []SelectionRule {
if rules == nil {
return nil
}
out := make([]SelectionRule, len(rules))
for index, rule := range rules {
out[index] = rule
out[index].Match.TimeWindows = append([]SelectionTimeWindow(nil), rule.Match.TimeWindows...)
for windowIndex := range out[index].Match.TimeWindows {
out[index].Match.TimeWindows[windowIndex].Days = append(
[]string(nil),
rule.Match.TimeWindows[windowIndex].Days...,
)
}
out[index].Match.QuotaStates = append([]string(nil), rule.Match.QuotaStates...)
out[index].Match.Agents = append([]string(nil), rule.Match.Agents...)
out[index].Match.Stages = append([]string(nil), rule.Match.Stages...)
out[index].Match.Lanes = append([]string(nil), rule.Match.Lanes...)
out[index].Match.Capabilities = append([]string(nil), rule.Match.Capabilities...)
out[index].Match.FailureCodes = append([]string(nil), rule.Match.FailureCodes...)
if rule.Match.MinRemainingToken != nil {
value := *rule.Match.MinRemainingToken
out[index].Match.MinRemainingToken = &value
}
}
return out
}
func cloneIsolationPolicy(policy IsolationPolicy) IsolationPolicy {
out := policy
out.FallbackModes = append([]string(nil), policy.FallbackModes...)
return out
}
func cloneCatalog(catalog Catalog) Catalog {
out := catalog
if catalog.Providers != nil {
out.Providers = make([]Provider, len(catalog.Providers))
for index, provider := range catalog.Providers {
out.Providers[index] = provider
out.Providers[index].VersionProbe.Args = append([]string(nil), provider.VersionProbe.Args...)
out.Providers[index].Authentication.Args = append([]string(nil), provider.Authentication.Args...)
out.Providers[index].ModelProbe.Args = append([]string(nil), provider.ModelProbe.Args...)
out.Providers[index].Capabilities = append([]string(nil), provider.Capabilities...)
}
}
out.Models = append([]Model(nil), catalog.Models...)
if catalog.Profiles != nil {
out.Profiles = make([]Profile, len(catalog.Profiles))
for index, profile := range catalog.Profiles {
out.Profiles[index] = profile
out.Profiles[index].Args = append([]string(nil), profile.Args...)
out.Profiles[index].ResumeArgs = append([]string(nil), profile.ResumeArgs...)
out.Profiles[index].Env = append([]string(nil), profile.Env...)
out.Profiles[index].Capabilities = append([]string(nil), profile.Capabilities...)
}
}
return out
}