syntax = "proto3";

package panel.v1;

option go_package = "github.com/dbledeez/panel/proto/panel/v1;panelv1";

import "google/protobuf/timestamp.proto";
import "panel/v1/common.proto";

// Agent is the gRPC service a Target agent dials on the Controller.
// A single persistent bidirectional stream carries all control-plane
// traffic between Controller and Target: commands downstream, state
// and events upstream.
service Agent {
  rpc Connect(stream AgentEnvelope) returns (stream ControllerEnvelope);
}

// ControllerEnvelope — messages from Controller to Target.
message ControllerEnvelope {
  string correlation_id = 1;
  google.protobuf.Timestamp sent_at = 2;
  oneof payload {
    ControllerHello hello = 10;
    Ping ping = 11;
    ExecRequest exec = 20;
    InstanceCreate instance_create = 30;
    InstanceStart instance_start = 31;
    InstanceStop instance_stop = 32;
    InstanceDelete instance_delete = 33;
    RCONCommand rcon = 40;
    FsListRequest fs_list = 50;
    FsReadRequest fs_read = 51;
    FsWriteRequest fs_write = 52;
    FsDeleteRequest fs_delete = 53;
    FsSymlinkRequest fs_symlink = 54;
    FsExtractRequest fs_extract = 55;
    FsCompressRequest fs_compress = 56;
    FsRenameRequest fs_rename = 57;
    FsWriteChunkRequest fs_write_chunk = 58;
    ArkSaveRestoreRequest ark_save_restore = 59;
    DayzModInstallRequest dayz_mod_install = 90;
    DayzModUninstallRequest dayz_mod_uninstall = 91;
    UpdateRequest update = 60;
    BackupRequest backup = 70;
    RestoreRequest restore = 71;
    BackupListEntriesRequest backup_list_entries = 72;
    BackupReadFileRequest backup_read_file = 73;
    EmpyrionScenarioInstallRequest empyrion_scenario_install = 100;
    EmpyrionDiscoveriesRequest empyrion_discoveries = 101;
    EmpyrionPlayerSummaryRequest empyrion_player_summary = 102;
    RegionScanRequest region_scan = 110;
    RegionHealRequest region_heal = 111;
    InstanceRenderConfigRequest instance_render_config = 112;
    SeedModsRequest seed_mods = 113;
    UpdateCheckRequest update_check = 114;
  }
}

// AgentEnvelope — messages from Target to Controller.
message AgentEnvelope {
  string correlation_id = 1;
  google.protobuf.Timestamp sent_at = 2;
  oneof payload {
    AgentHello hello = 10;
    Heartbeat heartbeat = 11;
    Pong pong = 12;
    CommandResult result = 20;
    InstanceStateUpdate instance_state = 30;
    AppStateUpdate app_state = 31;
    LogLine log = 32;
    PlayerEvent player = 33;
    RCONResult rcon_result = 40;
    FsListResult fs_list_result = 50;
    FsReadResult fs_read_result = 51;
    FsWriteResult fs_write_result = 52;
    FsDeleteResult fs_delete_result = 53;
    FsSymlinkResult fs_symlink_result = 54;
    FsExtractResult fs_extract_result = 55;
    FsCompressResult fs_compress_result = 56;
    FsRenameResult fs_rename_result = 57;
    FsWriteChunkResult fs_write_chunk_result = 58;
    ArkSaveRestoreResult ark_save_restore_result = 59;
    DayzModInstallResult dayz_mod_install_result = 90;
    DayzModUninstallResult dayz_mod_uninstall_result = 91;
    UpdateResult update_result = 60;
    BackupResult backup_result = 70;
    RestoreResult restore_result = 71;
    BackupListEntriesResult backup_list_entries_result = 72;
    BackupReadFileResult backup_read_file_result = 73;
    InstanceStatsUpdate instance_stats = 80;
    EmpyrionScenarioInstallResult empyrion_scenario_install_result = 100;
    EmpyrionDiscoveriesResult empyrion_discoveries_result = 101;
    EmpyrionPlayerSummaryResult empyrion_player_summary_result = 102;
    RegionScanResult region_scan_result = 110;
    RegionHealResult region_heal_result = 111;
    InstanceRenderConfigResult instance_render_config_result = 112;
    SeedModsResult seed_mods_result = 113;
    UpdateCheckResult update_check_result = 114;
  }
}

// ---- Handshake ----

message AgentHello {
  string agent_id = 1;
  string agent_version = 2;
  string host_os = 3;          // "linux", "windows"
  string host_arch = 4;        // "amd64", "arm64"
  string hostname = 5;
  repeated string supported_runtimes = 6; // "docker", "host"
  repeated ModuleSummary modules = 7;
}

// ModuleSummary is what an agent advertises about one locally-loaded
// module at handshake time. The controller aggregates these across all
// agents so the dashboard can render a "known modules" picker without
// needing direct filesystem access to each agent's modules/ dir.
message ModuleSummary {
  string id = 1;
  string name = 2;
  string version = 3;
  repeated string supported_modes = 4;
  repeated ModuleUpdateProviderSummary update_providers = 5;
  bool has_rcon = 6;
  repeated string authors = 7;
}

message ModuleUpdateProviderSummary {
  string id = 1;
  string kind = 2;  // "steamcmd" | "github" | "direct"
  // True when the provider sets `requires_steam_login: true` on the
  // manifest (DayZ, Arma). Controller uses this to decide whether to
  // pop the Steam-login modal before forwarding an UpdateRequest.
  bool requires_steam_login = 3;
}

message ControllerHello {
  string controller_version = 1;
  google.protobuf.Timestamp server_time = 2;
}

message Heartbeat {
  google.protobuf.Timestamp at = 1;
  double cpu_percent = 2;
  uint64 mem_used_bytes = 3;
  uint64 mem_total_bytes = 4;
  uint32 instance_count = 5;
}

message Ping { string nonce = 1; }
message Pong { string nonce = 1; }

// ---- Exec / commands ----

message ExecRequest {
  string instance_id = 1;      // empty = run on Target host
  repeated string argv = 2;
  map<string, string> env = 3;
  string working_dir = 4;
  uint32 timeout_seconds = 5;
}

message CommandResult {
  int32 exit_code = 1;
  bytes stdout = 2;
  bytes stderr = 3;
  Error error = 4;
}

// ---- Instance lifecycle ----

message InstanceCreate {
  string instance_id = 1;
  string module_id = 2;
  string version = 3;
  RunMode run_mode = 4;
  ResourceLimits limits = 5;
  map<string, string> config_values = 6; // flattened key/value, module schema-validated
  repeated PortMap ports = 7;
  string data_path = 8;
  // mount_overrides lets the controller swap one of the module's
  // manifest-defined volume mounts at create time. Keyed by container
  // path (matching a volume entry's `container:` field in module.yaml).
  // Value is either a Docker named volume name (no leading slash) or an
  // absolute host path (leading slash = bind mount).
  //
  // Used by the ARK cluster feature to swap an instance's per-instance
  // `panel-<id>-cluster` named volume for a shared `panel-ark-cluster-<id>`
  // volume when the server joins a cluster. Generic enough for future
  // features — any module can benefit from per-instance mount swaps
  // without needing a manifest change.
  map<string, string> mount_overrides = 9;
}

message InstanceStart { string instance_id = 1; }
message InstanceStop {
  string instance_id = 1;
  uint32 grace_seconds = 2;
  bool force = 3;
}
// InstanceDelete tears down the instance record. If purge_volumes is
// true, every Docker volume labeled panel.instance_id=<instance_id> is
// removed too; otherwise volumes survive so a subsequent re-create can
// pick up the existing world/save data.
message InstanceDelete {
  string instance_id = 1;
  uint32 grace_seconds = 2;   // if the container is still running, stop with this grace first
  bool purge_volumes = 3;
}

// ---- State streams ----

message InstanceStateUpdate {
  string instance_id = 1;
  InstanceStatus status = 2;
  int32 exit_code = 3;
  google.protobuf.Timestamp at = 4;
  string detail = 5;
}

message AppStateUpdate {
  string instance_id = 1;
  int32 players_online = 2;
  int32 players_max = 3;
  int64 uptime_seconds = 4;
  map<string, string> module_fields = 10; // world_seed, day_number, tps, etc.
  google.protobuf.Timestamp at = 20;
}

message LogLine {
  string instance_id = 1;
  string stream = 2;           // "stdout", "stderr", or a named log file
  google.protobuf.Timestamp at = 3;
  string line = 4;
}

// InstanceStatsUpdate is emitted periodically (default every 10s) by the
// agent's per-instance stats poller. Values come from Docker's stats
// stream API. Kept sparse — enough for UI gauges + future scheduler
// expressions like "cpu_percent > 80 sustained 5m -> notify".
message InstanceStatsUpdate {
  string instance_id = 1;
  double cpu_percent = 2;           // 100 = 1 vCPU saturated
  uint64 mem_used_bytes = 3;
  uint64 mem_limit_bytes = 4;       // 0 if no explicit limit set (then use container's view)
  uint64 net_rx_bytes = 5;          // cumulative since container start
  uint64 net_tx_bytes = 6;
  uint32 pids = 7;
  google.protobuf.Timestamp at = 8;
  // Host core count (docker OnlineCPUs) so UIs can translate the
  // docker-style cpu_percent (100 = 1 core) into percent-of-host.
  uint32 host_cpus = 9;
}

// ---- Ad-hoc RCON ----

message RCONCommand {
  string instance_id = 1;
  string command = 2;
}

message RCONResult {
  string output = 1;
  string error = 2;  // empty on success; short machine-readable on failure
}

// ---- File management (rooted at each instance's data_path) ----

message FsEntry {
  string name = 1;       // base name
  string path = 2;       // path relative to instance data_path
  bool is_dir = 3;
  int64 size = 4;        // bytes (for files)
  google.protobuf.Timestamp mod_time = 5;
}

message FsListRequest  { string instance_id = 1; string path = 2; }
message FsListResult {
  repeated FsEntry entries = 1;
  string path = 2;
  string error = 3;
}

message FsReadRequest  { string instance_id = 1; string path = 2; }
message FsReadResult {
  string path = 1;
  bytes content = 2;
  string error = 3;
}

message FsWriteRequest { string instance_id = 1; string path = 2; bytes content = 3; }
message FsWriteResult  { int64 bytes_written = 1; string error = 2; }

message FsDeleteRequest { string instance_id = 1; string path = 2; bool recursive = 3; }
message FsDeleteResult  { string error = 1; }

// FsSymlinkRequest creates (or replaces) a symlink inside the instance
// container. Used by the DayZ mod manager to link @ModName entries to
// the shared workshop tree at /game/steamapps/workshop/content/...
message FsSymlinkRequest { string instance_id = 1; string target = 2; string link_path = 3; }
message FsSymlinkResult  { string error = 1; }

// FsExtractRequest expands an archive that already lives inside the
// instance's volume. Format is auto-detected from the archive bytes
// (zip / tar / tar.gz / tar.bz2). dest_dir is rooted in the instance's
// browseable root just like every other Fs op; "" means same dir as
// the archive.
message FsExtractRequest {
  string instance_id = 1;
  string archive_path = 2;
  string dest_dir = 3;
  bool overwrite = 4;
}
message FsExtractResult {
  int64 entries_written = 1;
  int64 bytes_written = 2;
  string error = 3;
}

// FsCompressRequest produces a zip archive at dest_zip_path containing
// the contents of every path in `sources` (recursive for dirs). All
// paths must be inside the instance's browseable root.
message FsCompressRequest {
  string instance_id = 1;
  repeated string sources = 2;
  string dest_zip_path = 3;
  bool overwrite = 4;
}
message FsCompressResult {
  int64 entries_written = 1;
  int64 bytes_written = 2;
  string error = 3;
}

// FsRenameRequest is a path-safe `mv` inside the instance's volume —
// supports rename + move-into-sibling-dir within the same root.
message FsRenameRequest {
  string instance_id = 1;
  string from_path = 2;
  string to_path = 3;
}
message FsRenameResult { string error = 1; }

// FsWriteChunkRequest streams a single chunk of a larger upload. Chunks
// are uploaded in order; each one carries the same upload_id (browser-
// generated UUID) so the agent can append to the same temp file. On
// is_final = true the agent finalizes (closes the temp file, ships it
// to the container via CopyToContainer, then unlinks).
message FsWriteChunkRequest {
  string instance_id = 1;
  string upload_id = 2;
  string path = 3;
  int64 offset = 4;
  bytes data = 5;
  int64 total_size = 6;  // optional; agent uses for sanity checks on final
  bool is_final = 7;
}
message FsWriteChunkResult {
  int64 bytes_received = 1;
  int64 total_received = 2;  // running total per upload_id
  bool finalized = 3;
  string error = 4;
}

// ArkSaveRestoreRequest swaps a timestamped rolling backup into the
// active save slot under SavedArks/<subdir>/<subdir>.ark. The previous
// active save is moved aside (NEVER deleted) by appending a "_replaced"
// timestamp suffix so even abandoned attempts are recoverable. Path is
// rooted at ShooterGame/Saved/SavedArks/<saved_arks_subdir>/ in the
// instance's browseable root.
//
// Caller (controller) is responsible for stopping the container before
// invoking this — the agent only performs file operations. If a backup
// rolling file (.arkrbf) is selected, it is renamed to .ark before
// installing as the active save.
message ArkSaveRestoreRequest {
  string instance_id = 1;
  string saved_arks_subdir = 2;  // e.g. "TheIsland_WP"
  string target_filename = 3;    // basename within saved_arks_subdir (e.g. "TheIsland_WP_30.04.2026_22.13.00.ark")
}
message ArkSaveRestoreResult {
  string aside_filename = 1;    // what the previously-active save was renamed to
  string installed_filename = 2; // the filename that's now active (always "<subdir>.ark")
  string error = 3;
}

// DayzModInstallRequest asks the agent to (1) copy the mod's bikeys into
// /game/keys and (2) create a @ModName symlink at /game/<folder_name>
// pointing at the shared workshop content at
// /game/steamapps/workshop/content/221100/<workshop_id>. Agent-side so
// it can scan the workshop dir and preserve file ownership.
message DayzModInstallRequest {
  string instance_id = 1;
  string workshop_id = 2;
  string folder_name = 3;    // e.g. "@CommunityOnlineTools"
}
message DayzModInstallResult {
  string error = 1;
  repeated string bikeys_copied = 2;   // filenames of bikeys dropped in /game/keys
  string resolved_mod_path = 3;        // /game/steamapps/workshop/content/221100/<id>
}

// DayzModUninstallRequest removes the @ModName symlink and the mod's
// bikeys from /game/keys (bikeys are matched by file hash — only keys
// whose content originated from this workshop item are removed).
message DayzModUninstallRequest {
  string instance_id = 1;
  string folder_name = 2;
}
message DayzModUninstallResult {
  string error = 1;
  repeated string bikeys_removed = 2;
}

// ---- Updates ----
//
// The agent runs the update asynchronously. The synchronous UpdateResult
// carries only the acknowledgement + ref-id for later correlation; real
// progress + final status flow through LogLine (stream="update") and the
// terminal "update:ok"/"update:error:<msg>" LogLine sentinel at the end.

message UpdateRequest {
  string instance_id = 1;
  string provider_id = 2;  // empty = use the module's first update_provider
  // Steam login credentials for apps whose SteamCMD refuses +login
  // anonymous (DayZ, Arma, etc. — modules with `requires_steam_login`
  // set on the provider). The controller pulls these from its
  // encrypted steam_credentials store and forwards them via the
  // mTLS-protected control stream. Agent never logs them.
  string steam_username = 3;
  string steam_password = 4;
}

message UpdateResult {
  bool accepted = 1;  // true = agent kicked off the update goroutine
  string error = 2;   // empty on accept; populated on immediate reject
  string provider_id = 3;
  string provider_kind = 4;
}

// ---- Backup + Restore ----
//
// Synchronous over the bidi stream. The sidecar tars the instance's
// volume into a .tar.gz on the agent's --backup-dir; the terminal
// BackupResult carries the path + size. Restore takes a backup id
// (path resolved by the controller via DB) and reverses.

message BackupRequest {
  string instance_id = 1;
  string description = 2;
}

message BackupResult {
  string backup_id = 1;  // bkp_<hex>
  string path = 2;
  int64 size_bytes = 3;
  string error = 4;
}

message RestoreRequest {
  string instance_id = 1;
  string backup_id = 2;
  string backup_path = 3;  // absolute path on the agent
}

message RestoreResult {
  string error = 1;
  int64 bytes_restored = 2;
}

// BackupListEntriesRequest asks the agent to enumerate the contents of a
// backup tarball without extracting it. Used by the backup-browser UI so
// operators can drill into a snapshot to confirm what's inside before
// restoring (or to recover a single file).
message BackupListEntriesRequest {
  string instance_id = 1;
  string backup_id = 2;
  string backup_path = 3;  // absolute path on the agent (resolved controller-side)
}

// BackupTarEntry — one row from `tar -tvzf`. Size is bytes-on-disk, not
// compressed size. is_dir distinguishes directories so the UI can render
// a tree without a second pass.
message BackupTarEntry {
  string path = 1;       // path inside the tarball, no leading slash
  int64 size = 2;
  bool is_dir = 3;
  string mode = 4;       // permissions string, e.g. "-rw-r--r--"
  google.protobuf.Timestamp mod_time = 5;
}

message BackupListEntriesResult {
  repeated BackupTarEntry entries = 1;
  int64 total_bytes = 2;  // sum of file sizes (not compressed size of the tarball)
  string error = 3;
}

// BackupReadFileRequest extracts a single entry's bytes to memory by
// streaming `tar -xzOf <archive> <path>` into a buffer. Caller-supplied
// max_bytes caps the read so a malicious / corrupt archive can't OOM the
// agent — the result includes truncated=true if max_bytes was hit.
message BackupReadFileRequest {
  string instance_id = 1;
  string backup_id = 2;
  string backup_path = 3;
  string entry_path = 4;
  int64 max_bytes = 5;     // 0 → controller-side default (8 MiB)
}

message BackupReadFileResult {
  bytes content = 1;
  int64 size = 2;          // actual entry size from tar header (may be > len(content) if truncated)
  bool truncated = 3;
  bool is_binary = 4;      // heuristic: NUL byte in first 8 KiB
  string error = 5;
}

message PlayerEvent {
  enum Kind {
    KIND_UNSPECIFIED = 0;
    KIND_JOIN = 1;
    KIND_LEAVE = 2;
    KIND_CHAT = 3;
    KIND_DEATH = 4;
    KIND_CUSTOM = 99;
  }
  string instance_id = 1;
  Kind kind = 2;
  string player_id = 3;        // SteamID / UUID where available
  string player_name = 4;
  string detail = 5;
  google.protobuf.Timestamp at = 6;
}

// EmpyrionScenarioInstallRequest is sent controller → agent to install
// (or refresh) a workshop scenario into a specific Empyrion instance's
// game volume. The agent runs SteamCMD on its OWN Docker daemon — the
// previous (single-host) implementation ran on the controller's daemon
// which has no view of an agent's volumes.
message EmpyrionScenarioInstallRequest {
  string job_id = 1;          // controller-generated; echoed in result + log lines
  string instance_id = 2;     // target empyrion instance (must live on this agent)
  string workshop_id = 3;     // Steam workshop item ID (e.g. "3041847672" for RE2)
  string scenario_name = 4;   // folder name under Content/Scenarios; sanitized by controller
  string app_id = 5;          // empyrion app id; defaults to 530870 if empty
  // Steam credentials for the workshop_download_item call. Steam policy
  // (~2023+) requires a logged-in account for most app workshops, even
  // for public/free items — anonymous returns "Access Denied". Forwarded
  // from the controller's encrypted steam_credentials store. Same shape
  // as UpdateRequest's steam_username/steam_password fields.
  string steam_username = 6;
  string steam_password = 7;
}

message EmpyrionScenarioInstallResult {
  string job_id = 1;
  bool ok = 2;
  string error = 3;           // empty when ok
  string scenario_name = 4;
}

// EmpyrionDiscoveriesRequest queries the empyrion server's global.db
// (SQLite) for what a specific player has discovered. The agent mounts
// the instance's saves volume into a tiny alpine+sqlite sidecar, runs
// a join query against DiscoveredPOIs / Entities / Playfields /
// SolarSystems / LoginLogoff, and returns the rows.
//
// `mode = "personal"` returns POIs the named player personally
// discovered. `mode = "faction"` returns POIs anyone in their faction
// has discovered (the in-game shared-map view). Default: personal.
message EmpyrionDiscoveriesRequest {
  string instance_id = 1;
  string steam_id = 2;        // 17-digit SteamID64 of the player
  string mode = 3;            // "personal" | "faction"
}

message DiscoveredPOI {
  string solar_system = 1;
  string playfield = 2;
  string poi_name = 3;
  int32  poi_type = 4;        // Entities.etype enum
  int64  game_time = 5;       // ticks since save start
  string discoverer_name = 6; // who first discovered (when known)
  string discoverer_steam_id = 7;
  int32  faction_group = 8;
  int32  faction_id = 9;
}

message EmpyrionDiscoveriesResult {
  bool ok = 1;
  string error = 2;
  repeated DiscoveredPOI pois = 3;
  string mode = 4;            // echoed back from request
  string steam_id = 5;
  string player_name = 6;     // last-known name from LoginLogoff
}

// EmpyrionPlayerSummaryRequest pulls everything we know about a single
// player from the empyrion savegame DB: stats, login sessions, current
// inventory snapshot. Used by the public /me page on AMP-Monitor.
message EmpyrionPlayerSummaryRequest {
  string instance_id = 1;
  string steam_id = 2;        // 17-digit SteamID64
  int32 max_login_rows = 3;   // limit on session history; default 50
}

message PlayerStatsRow {
  int64 killed_enemies = 1;
  int64 killed_allied = 2;
  int64 killed_animals = 3;
  int64 killed_drones = 4;
  int64 killed_players = 5;
  int64 killed_allied_players = 6;
  int64 died = 7;
  int64 score = 8;
  int64 blocks_placed = 9;
  int64 blocks_digged = 10;
  int64 walked_meters = 11;
  int64 jetpack_meters = 12;
  int64 hv_meters = 13;
  int64 sv_meters = 14;
  int64 cv_meters = 15;
  double playtime = 16;       // hours per the schema
  double light_years = 17;    // travly
  double astronomical_units = 18; // travau
}

message PlayerLoginRow {
  int64 login_ticks = 1;
  int64 logoff_ticks = 2;
  string player_name = 3;
  int64 build_nr = 4;
  string ip = 5;
  string os = 6;
  string gfx = 7;
  string cpu = 8;
}

message PlayerInventoryItem {
  int32 item_id = 1;
  int32 count = 2;
}

message EmpyrionPlayerSummaryResult {
  bool ok = 1;
  string error = 2;
  string steam_id = 3;
  string player_name = 4;
  int64 entity_id = 5;
  int32 faction_group = 6;
  int32 faction_id = 7;

  // Aggregate stats. nil when player has no PlayerStatistics row yet
  // (very new player).
  PlayerStatsRow stats = 8;

  // Recent login sessions, newest first.
  repeated PlayerLoginRow sessions = 9;

  // Most recent inventory snapshot. May be empty for players without
  // a recorded snapshot.
  int64 inventory_gametime = 10;
  repeated PlayerInventoryItem inventory = 11;

  // Last-known position.
  string last_playfield = 12;
  double last_x = 13;
  double last_y = 14;
  double last_z = 15;
  int64 credits = 16;
}

// ---- Region Medic (7DTD .7rg region-corruption detect + heal) ----

// RegionScanRequest asks the agent to scan an instance's active-world Region
// directory for corruption evidence (error_backup salvage files + invalid
// region files).
message RegionScanRequest {
  string instance_id = 1;
}

// AffectedRegionMsg is one region flagged by a scan.
message AffectedRegionMsg {
  string region = 1;        // canonical id, e.g. "r.-2.0"
  int32 error_backups = 2;  // salvage files whose chunks fall in this region
  bool file_present = 3;    // r.X.Z.7rg exists on disk
  bool file_corrupt = 4;    // region file failed structural validation
  int32 bad_chunks = 5;     // count of invalid chunks if validated
}

message RegionScanResult {
  int32 error_backups = 1;             // total salvage files found
  repeated AffectedRegionMsg affected = 2;
  string region_dir = 3;               // resolved active Region dir (container path)
  Error error = 4;
}

// RegionHealRequest asks the agent to heal one region from the newest clean
// backup. The agent stops the instance (graceful save first), swaps the region
// file in place, quarantines the region's error_backups, then restarts (if
// restart=true).
message RegionHealRequest {
  string instance_id = 1;
  string region = 2;   // "r.-2.0"
  bool restart = 3;    // restart the instance after healing
}

message RegionHealResult {
  string region = 1;
  string source_backup = 2;  // backup id the clean copy came from
  int64 bytes_written = 3;
  int32 quarantined = 4;     // error_backup files moved aside
  string snapshot_path = 5;  // where the corrupt original was saved
  bool healed = 6;
  Error error = 7;
}

// ---- Durable config re-render (no container recreate) ----

// InstanceRenderConfigRequest asks the agent to re-render the module's
// declared config_files (templates) into the instance's host DataPath,
// WITHOUT recreating or restarting the container. For modules whose
// rendered output is bind-mounted into the container (7dtd's
// serverconfig.xml.rendered), the entrypoint picks the new content up on
// the instance's NEXT boot — this is the durable, no-recreate config path.
//
// config_values is the FULL desired value map (the controller sends its
// merged DB row, not a delta), mirroring what InstanceCreate would render.
message InstanceRenderConfigRequest {
  string instance_id = 1;
  map<string, string> config_values = 2;
}

message InstanceRenderConfigResult {
  repeated string rendered_files = 1;  // config_file paths written (manifest-relative)
  string error = 2;
}

// SeedModsRequest asks an agent to clone the Mods/ folder of src_instance_id
// (the cluster master) onto instance_id (a member), live — no container
// recreate. Per-server state (RefugeBot homes.json) is excluded from the copy
// and the member's own homes.json is preserved. The member keeps running on
// its currently-loaded mods; the new files take effect on its NEXT restart.
// Both instances must live on the same agent (the sidecar mounts local volumes).
message SeedModsRequest {
  string instance_id = 1;      // destination member to receive the mods
  string src_instance_id = 2;  // source cluster master to copy mods from
}

message SeedModsResult {
  string instance_id = 1;
  bool ok = 2;
  string error = 3;
  int32 mod_count = 4;  // number of mods present on the member after the copy
}

// ---- Update-available check (WI-14) ----
//
// CHECK-ONLY: this RPC never triggers an update or restart. The agent
// reads the installed buildid from the Steam appmanifest_<appid>.acf in
// the instance volume and fetches the latest buildid for the instance's
// branch via a one-shot `steamcmd +app_info_print` sidecar (cached per
// app_id for 15 minutes agent-side).

message UpdateCheckRequest {
  string instance_id = 1;
  // provider_id selects the module update_provider (steamcmd kind) whose
  // app_id/beta define what "installed vs latest" means. Empty = same
  // default the Update RPC uses (first provider in the manifest).
  string provider_id = 2;
  // refresh forces a fresh steamcmd app_info fetch, bypassing the
  // agent-side 15-minute cache.
  bool refresh = 3;
}

message UpdateCheckResult {
  string instance_id = 1;
  string app_id = 2;
  string branch = 3;             // Steam branch compared ("public" when no -beta)
  string installed_buildid = 4;  // from appmanifest ACF; "" if unreadable
  string latest_buildid = 5;     // from app_info branches map; "" if fetch failed
  bool update_available = 6;     // installed != latest (both non-empty)
  bool cached = 7;               // latest came from the agent-side cache
  string error = 8;              // non-empty on failure; other fields best-effort
}
