888 lines
50 KiB
Swift
888 lines
50 KiB
Swift
import Foundation
|
|
|
|
// MARK: - Project domain model (RUNTIME §4, PLAN "Core domain model")
|
|
// ProjectID moved to NucleicProtocol/CoreIdentifiers.swift (shared with the iOS client).
|
|
|
|
/// A git ref: a branch name, tag, or commit-ish. A thin wrapper so call sites read
|
|
/// intentionally (`GitRef("main")`) rather than passing bare strings everywhere.
|
|
public struct GitRef: Hashable, Sendable, Codable, CustomStringConvertible, ExpressibleByStringLiteral {
|
|
public let value: String
|
|
public init(_ value: String) { self.value = value }
|
|
public init(stringLiteral value: String) { self.value = value }
|
|
public var description: String { value }
|
|
}
|
|
|
|
/// Setup-script failure policy (WORKTREE_MANAGER §5).
|
|
public enum SetupPolicy: String, Sendable, Codable {
|
|
/// Session can't start if setup fails (default — don't run agents against a broken env).
|
|
case block
|
|
/// Proceed but flag the failure.
|
|
case warn
|
|
}
|
|
|
|
/// Per-project execution-sandbox settings. When enabled, the project's sessions run
|
|
/// `claude` inside an isolated Linux VM (built on Apple's `containerization` framework) instead of
|
|
/// on the host, with the worktree bind-mounted (CONTAINER_SANDBOX). `nil` on a Project means
|
|
/// "off" — the default, host-spawned behavior.
|
|
public struct ProjectSandbox: Sendable, Codable, Equatable {
|
|
public var enabled: Bool
|
|
/// Image reference to run. `nil` → the bundled default image (built on first use).
|
|
public var image: String?
|
|
/// Stop the per-session container after this many seconds of inactivity; the next
|
|
/// turn transparently restarts it.
|
|
public var idleTimeoutSeconds: Int
|
|
/// Allow the sandboxed agent to build and run executables on the *host* machine via the
|
|
/// `host_exec` MCP tool, escaping the Linux container (e.g. to compile/run a macOS binary
|
|
/// the container can't). On by default for newly configured sandboxes; every host command
|
|
/// is approval-gated and is never auto-approved (HOST_EXEC). Only meaningful when `enabled`
|
|
/// is true. (Sandboxes persisted before this field existed still decode to `false` below.)
|
|
public var allowHostExec: Bool
|
|
|
|
/// Per-session sandbox containers. When false (the default), a Nucleic Control project's
|
|
/// sessions all share Nucleic's single **primary** managed container; when true, each
|
|
/// session gets its own container (today's per-session model). The choice is fixed at
|
|
/// project creation. NOT YET USER-SETTABLE — surfaced as a disabled placeholder toggle in
|
|
/// a control project's Settings; the creation-time migration that lets a project opt into
|
|
/// per-session containers is deferred. Only meaningful for Nucleic Control projects.
|
|
public var perSessionContainers: Bool
|
|
|
|
/// The default sandbox image, used when `image` is nil — a **registry reference** to the
|
|
/// prebuilt `nucleic-sandbox` image (built in CI from `containers/nucleic-sandbox/Dockerfile`
|
|
/// and pushed to GHCR; must be public for anonymous pulls). `ContainerEngine` pulls + unpacks it
|
|
/// and caches the rootfs by this exact ref, so bump the version tag whenever the Dockerfile
|
|
/// changes — the new tag has no cache yet, so the next session pulls it fresh and launch-time
|
|
/// reconcile prunes the superseded cache. (`v2` added the cross-language build tools:
|
|
/// build-essential/make, python3/pip. `v3` added the GitHub CLI `gh` plus `curl`. `v4` added the
|
|
/// Codex + Grok CLIs and `control-bridge.js` — the latter is REQUIRED by the now-default vsock
|
|
/// control plane, so `v4` must ship before `vsockControlPlaneEnabled` defaults on. `v5` added
|
|
/// `openssh-client` for `ssh-keygen`, so the agent can sign commits with `gpg.format=ssh`.)
|
|
/// Keep in lockstep with `.github/workflows/sandbox-image.yml`'s `IMAGE_TAG`.
|
|
public static let defaultImage = "ghcr.io/abkslm/nucleic-sandbox:v5"
|
|
|
|
/// Where the VM's Linux kernel is fetched from when it isn't already on disk. Published as a GHCR
|
|
/// **package** — an OCI artifact carrying the single `vmlinux-arm64` blob, pushed by
|
|
/// `.github/workflows/kernel-image.yml` (NOT a GitHub release asset, which would inherit the repo's
|
|
/// visibility). A GHCR package's visibility is independent of the repo's, so this can be made
|
|
/// **public for anonymous pulls** while the repo stays private — exactly like the sandbox image
|
|
/// (`defaultImage`). When the package is private the app authenticates the pull with the user's
|
|
/// GitHub token (`read:packages`), host-scoped by the same `ContainerEngine.registryAuth` rule.
|
|
/// `ContainerEngine` first reuses a local/CLI kernel if present, so machines with Apple's
|
|
/// `container` installed download nothing. Bump the tag when the kernel version changes (and
|
|
/// publish the matching package).
|
|
public static let kernelImage = "ghcr.io/abkslm/nucleic-kernel:6.18.15-186"
|
|
|
|
public static let defaultIdleTimeoutSeconds = 900
|
|
|
|
public init(
|
|
enabled: Bool = false,
|
|
image: String? = nil,
|
|
idleTimeoutSeconds: Int = ProjectSandbox.defaultIdleTimeoutSeconds,
|
|
allowHostExec: Bool = true,
|
|
perSessionContainers: Bool = false
|
|
) {
|
|
self.enabled = enabled
|
|
self.image = image
|
|
self.idleTimeoutSeconds = idleTimeoutSeconds
|
|
self.allowHostExec = allowHostExec
|
|
self.perSessionContainers = perSessionContainers
|
|
}
|
|
|
|
// Tolerant decode: rows persisted before `allowHostExec` / `perSessionContainers` existed
|
|
// decode with the default rather than failing the whole blob — the store decodes sandbox
|
|
// config with `try?`, so a strict miss would silently drop the entire sandbox config.
|
|
public init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? false
|
|
image = try c.decodeIfPresent(String.self, forKey: .image)
|
|
idleTimeoutSeconds =
|
|
try c.decodeIfPresent(Int.self, forKey: .idleTimeoutSeconds)
|
|
?? ProjectSandbox.defaultIdleTimeoutSeconds
|
|
allowHostExec = try c.decodeIfPresent(Bool.self, forKey: .allowHostExec) ?? false
|
|
perSessionContainers =
|
|
try c.decodeIfPresent(Bool.self, forKey: .perSessionContainers) ?? false
|
|
}
|
|
|
|
/// The image actually used to run containers (resolved override or bundled default).
|
|
public var resolvedImage: String {
|
|
if let image, !image.trimmingCharacters(in: .whitespaces).isEmpty { return image }
|
|
return ProjectSandbox.defaultImage
|
|
}
|
|
}
|
|
|
|
/// Per-project **nvrsion** settings (NVRSION, Beta). When active a control project's sessions
|
|
/// share a single `nucleic/trunk` checkout and land each edit into it immediately — instead of
|
|
/// each session forking an isolated worktree/branch held until ship (LOCKING). `nil` on a Project
|
|
/// (or `enabled == false`) means off — the default, classic per-session-worktree behavior.
|
|
///
|
|
/// Persisted as `nvrsion_config` JSON on the `project` row (mirrors `ProjectSandbox`), so new
|
|
/// fields are additive without a migration. Only meaningful for Nucleic Control projects that use
|
|
/// the shared control container — see `Project.nvrsionActive` (NVRSION §10).
|
|
public struct ProjectNvrsion: Sendable, Codable, Equatable {
|
|
/// Master switch for the mode. Default off (Beta, opt-in).
|
|
public var enabled: Bool
|
|
/// The shared trunk branch all the project's nvrsion sessions edit and land into.
|
|
public var trunkBranch: String
|
|
/// Optional fast validation command run after an edit is written and **before** it lands in
|
|
/// trunk (NVRSION §5). Receives the edited paths; non-zero exit rejects the edit back to the
|
|
/// agent (kept warm, not committed). `nil`/empty → land immediately (the default).
|
|
public var prelandHook: String?
|
|
/// Keep-warm idle window in seconds (NVRSION §4): a held file untouched this long is released
|
|
/// mid-turn so a waiter can take it. Lower = faster hand-off, more re-reads.
|
|
public var keepWarmIdleSeconds: Int
|
|
|
|
public static let defaultTrunkBranch = "nucleic/trunk"
|
|
public static let defaultKeepWarmIdleSeconds = 4
|
|
|
|
public init(
|
|
enabled: Bool = false,
|
|
trunkBranch: String = ProjectNvrsion.defaultTrunkBranch,
|
|
prelandHook: String? = nil,
|
|
keepWarmIdleSeconds: Int = ProjectNvrsion.defaultKeepWarmIdleSeconds
|
|
) {
|
|
self.enabled = enabled
|
|
self.trunkBranch = trunkBranch
|
|
self.prelandHook = prelandHook
|
|
self.keepWarmIdleSeconds = keepWarmIdleSeconds
|
|
}
|
|
|
|
// Tolerant decode: rows persisted before a field existed decode to its default rather than
|
|
// failing the whole blob (the store decodes with `try?`, so a strict miss drops the config).
|
|
public init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? false
|
|
trunkBranch =
|
|
try c.decodeIfPresent(String.self, forKey: .trunkBranch).flatMap {
|
|
$0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0
|
|
} ?? ProjectNvrsion.defaultTrunkBranch
|
|
prelandHook = try c.decodeIfPresent(String.self, forKey: .prelandHook)
|
|
keepWarmIdleSeconds =
|
|
try c.decodeIfPresent(Int.self, forKey: .keepWarmIdleSeconds)
|
|
?? ProjectNvrsion.defaultKeepWarmIdleSeconds
|
|
}
|
|
|
|
/// The pre-land hook to actually run, or `nil` when unset/blank (→ land immediately).
|
|
public var resolvedPrelandHook: String? {
|
|
guard let h = prelandHook?.trimmingCharacters(in: .whitespaces), !h.isEmpty else { return nil }
|
|
return h
|
|
}
|
|
}
|
|
|
|
/// App-wide container preferences, persisted in `UserDefaults`. These gate and seed the
|
|
/// *per-project* `ProjectSandbox` config above — they never sandbox sessions on their own.
|
|
/// Surfaced in Settings → General; read here so `NucleicCore` (e.g. `SessionController`,
|
|
/// `AppStore`) and the SwiftUI layer agree on the same keys.
|
|
/// How a Nucleic Control container session authenticates `claude`.
|
|
public enum ControlAuthMode: String, CaseIterable, Sendable {
|
|
/// Reuse the host's Claude subscription login — the macOS Keychain OAuth credentials,
|
|
/// exported into the per-session claude-home as `.credentials.json`. The default.
|
|
case oauth
|
|
/// Use a user-supplied Anthropic API key (stored in the Keychain via `ControlAPIKeyStore`),
|
|
/// forwarded as `ANTHROPIC_API_KEY`. For when the host OAuth login doesn't work in the
|
|
/// container, or the user would rather bill an API key.
|
|
case apiKey
|
|
}
|
|
|
|
/// How aggressively a running container's VM hands unused memory back to the host. The engine
|
|
/// attaches a virtio memory balloon to each VM and periodically retargets it from the guest's live
|
|
/// working set; this level chooses how much headroom to leave, how low it may shrink, and how often
|
|
/// it adjusts — single descriptive words rather than raw numeric knobs (those live in
|
|
/// ``BalloonPolicy``). Replaces the old "the VM never returns freed memory; restart to reclaim"
|
|
/// limitation with automatic reclamation.
|
|
public enum MemoryManagementLevel: String, CaseIterable, Sendable {
|
|
/// The default — reclaim gently: generous headroom, a high floor, slow cadence. Safest against
|
|
/// in-guest memory pressure; frees the least.
|
|
case conservative
|
|
/// Reclaim steadily with a comfortable safety margin.
|
|
case balanced
|
|
/// Reclaim tightly and react quickly — frees the most host memory, likeliest to squeeze a
|
|
/// spiky workload.
|
|
case aggressive
|
|
|
|
/// User-facing label (single word, capitalized).
|
|
public var label: String { rawValue.capitalized }
|
|
|
|
/// The concrete autoballoon parameters this level maps to.
|
|
public var policy: BalloonPolicy {
|
|
switch self {
|
|
case .conservative:
|
|
return BalloonPolicy(
|
|
enabled: true, headroom: 2.0, reserveBytes: 3 * 1_073_741_824 / 2,
|
|
floorBytes: 2 * 1_073_741_824, cadenceSeconds: 30, hysteresisBytes: 256 * 1_048_576)
|
|
case .balanced:
|
|
return BalloonPolicy(
|
|
enabled: true, headroom: 1.5, reserveBytes: 1_073_741_824,
|
|
floorBytes: 1_073_741_824, cadenceSeconds: 10, hysteresisBytes: 128 * 1_048_576)
|
|
case .aggressive:
|
|
return BalloonPolicy(
|
|
enabled: true, headroom: 1.25, reserveBytes: 3 * 1_073_741_824 / 4,
|
|
floorBytes: 512 * 1_048_576, cadenceSeconds: 5, hysteresisBytes: 64 * 1_048_576)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The concrete memory-balloon parameters a ``MemoryManagementLevel`` maps to. A pure value type so
|
|
/// the target math is unit-testable without booting a VM.
|
|
public struct BalloonPolicy: Sendable, Equatable {
|
|
/// `false` means no balloon is attached and memory is never reclaimed. Every current
|
|
/// ``MemoryManagementLevel`` enables it; the flag remains so a policy can still be built disabled.
|
|
public let enabled: Bool
|
|
/// Multiplier applied to the working set when picking the target (>1 leaves slack so the guest
|
|
/// isn't squeezed the instant it allocates again). Always ≥ 1, so the target never drops below
|
|
/// the working set — inflating a balloon below resident memory would force guest reclaim/OOM.
|
|
public let headroom: Double
|
|
/// Absolute bytes always kept available **on top of** the working set. The working set we measure
|
|
/// is the per-*container* cgroup figure, but the target is applied to the *whole VM*, which also
|
|
/// holds the guest kernel, vminitd, the init, the virtio overhead and the virtiofs page cache —
|
|
/// none of which are in that cgroup. Reserving a fixed whole-VM margin keeps the balloon from
|
|
/// inflating below the guest's true footprint (the bug that drove a swapless guest into a
|
|
/// direct-reclaim CPU spin). Sized to comfortably cover the kernel + vminitd + overhead + cache.
|
|
public let reserveBytes: UInt64
|
|
/// Never shrink the guest below this many bytes, however idle it looks — a floor against
|
|
/// thrashing the page cache or starving the kernel.
|
|
public let floorBytes: UInt64
|
|
/// Seconds between adjustment passes.
|
|
public let cadenceSeconds: UInt64
|
|
/// Only move the target when it changes by at least this much, to avoid churning the balloon.
|
|
public let hysteresisBytes: UInt64
|
|
|
|
public init(
|
|
enabled: Bool, headroom: Double, reserveBytes: UInt64, floorBytes: UInt64,
|
|
cadenceSeconds: UInt64, hysteresisBytes: UInt64
|
|
) {
|
|
self.enabled = enabled
|
|
self.headroom = headroom
|
|
self.reserveBytes = reserveBytes
|
|
self.floorBytes = floorBytes
|
|
self.cadenceSeconds = cadenceSeconds
|
|
self.hysteresisBytes = hysteresisBytes
|
|
}
|
|
|
|
/// Desired balloon target (guest memory in bytes) for a live container: leave the larger of a
|
|
/// `headroom` multiple of the working set and the working set plus a fixed whole-VM `reserveBytes`
|
|
/// margin — but never below `floorBytes` nor above the VM's configured `ceilingBytes`. The
|
|
/// additive reserve is what keeps the whole-VM target above the guest's real footprint when the
|
|
/// cgroup working set is small (where a pure multiplier would leave too little for the kernel/
|
|
/// page-cache and force reclaim).
|
|
public func target(workingSetBytes: UInt64, ceilingBytes: UInt64) -> UInt64 {
|
|
let scaledDouble = Double(workingSetBytes) * headroom
|
|
// Saturating cast: a huge product clamps to ceiling below anyway.
|
|
let scaled = scaledDouble >= Double(UInt64.max) ? UInt64.max : UInt64(scaledDouble)
|
|
let reserved = workingSetBytes.addingReportingOverflow(reserveBytes)
|
|
let withReserve = reserved.overflow ? UInt64.max : reserved.partialValue
|
|
return min(max(max(scaled, withReserve), floorBytes), ceilingBytes)
|
|
}
|
|
}
|
|
|
|
public enum ContainerServiceSettings {
|
|
/// Carries a UserDefaults across the task-local boundary: the type is documented thread-safe
|
|
/// but the SDK withholds its Sendable conformance, so the box vouches for it.
|
|
private struct DefaultsBox: @unchecked Sendable { let defaults: UserDefaults }
|
|
@TaskLocal private static var defaultsBox = DefaultsBox(defaults: .standard)
|
|
|
|
/// The store every accessor reads and `enableService()` writes. Production always resolves
|
|
/// to `.standard`; tests bind a fresh isolated suite via ``withDefaults(_:isolation:operation:)``
|
|
/// so they can flip these switches without ever mutating the process-global domain. Task-local
|
|
/// because swift-testing runs suites in parallel: a global (even save/restored) override of
|
|
/// `serviceEnabledKey` is visible to every concurrently running test, and a leaked `true`
|
|
/// sends SessionController tests down the real sandbox-provisioning path — seeding a
|
|
/// claude-home from the developer's `~/.claude` and blocking forever on the login-Keychain
|
|
/// ACL prompt when run headless.
|
|
public static var defaults: UserDefaults { defaultsBox.defaults }
|
|
|
|
/// Run `operation` with every ContainerServiceSettings accessor backed by `store`. The
|
|
/// binding is task-local: it flows into child tasks and `Task {}`s created inside, and is
|
|
/// invisible to concurrent tasks — the isolation seam tests bind a per-test suite through.
|
|
public static func withDefaults<R>(
|
|
_ store: UserDefaults,
|
|
isolation: isolated (any Actor)? = #isolation,
|
|
operation: () async throws -> R
|
|
) async rethrows -> R {
|
|
try await $defaultsBox.withValue(
|
|
DefaultsBox(defaults: store), operation: operation, isolation: isolation)
|
|
}
|
|
|
|
/// Master switch. Off → the per-project "run sessions in a sandbox container" option is
|
|
/// unavailable and no session runs in a container, regardless of its stored setting.
|
|
/// Turning it on only enables the *ability* to opt a project in; it does not sandbox
|
|
/// anything by itself. Off by default — the container runtime adds 2+ GB of memory
|
|
/// overhead per running session.
|
|
public static let serviceEnabledKey = "nucleic.container.serviceEnabled"
|
|
|
|
/// When on, newly added projects start with sandboxing enabled. Only takes effect while
|
|
/// the container service (above) is also on. Off by default.
|
|
public static let sandboxByDefaultKey = "nucleic.container.sandboxByDefault"
|
|
|
|
/// When on, newly *cloned* projects default to Nucleic Control (cloned under
|
|
/// `~/.nucleic/control/`, managed by Nucleic — shared sandbox, git interceptor,
|
|
/// autoship-eligible). Off by default. The Add-Project sheet seeds its Nucleic Control
|
|
/// switch from this.
|
|
public static let controlByDefaultKey = "nucleic.container.controlByDefault"
|
|
|
|
/// When on, new Nucleic Control projects start with **nvrsion** enabled (NVRSION) — the
|
|
/// shared-trunk versioning mode. Beta, off by default. Only meaningful for Control projects with
|
|
/// the container service on; a per-project toggle still overrides it.
|
|
public static let nvrsionByDefaultKey = "nucleic.container.nvrsionByDefault"
|
|
|
|
/// CPU count and memory (GiB) for Nucleic container VMs, split into two pools: the shared
|
|
/// primary Nucleic Control container serves *every* control session at once (so it wants
|
|
/// more), while a per-session container serves one. Applied via `container run --cpus N
|
|
/// --memory Ng`; `0`/unset → the sensible defaults. (`container`'s own defaults are 4 / 1 GiB.)
|
|
public static let controlContainerCPUsKey = "nucleic.container.control.cpus"
|
|
public static let controlContainerMemoryGiBKey = "nucleic.container.control.memoryGiB"
|
|
public static let sessionContainerCPUsKey = "nucleic.container.session.cpus"
|
|
public static let sessionContainerMemoryGiBKey = "nucleic.container.session.memoryGiB"
|
|
/// Per-session defaults (one session per VM) — also the `ContainerSpec` fallback.
|
|
public static let defaultContainerCPUs = 4
|
|
public static let defaultContainerMemoryGiB = 4
|
|
/// Shared primary control-container defaults (serves every control session) — more generous.
|
|
/// The memory constant is the documented floor; the *effective* default is host-aware (see
|
|
/// `recommendedControlContainerMemoryGiB`).
|
|
public static let defaultControlContainerCPUs = 8
|
|
public static let defaultControlContainerMemoryGiB = 8
|
|
|
|
/// Username for pulling the sandbox image from a private registry. The image is pulled with
|
|
/// HTTP Basic credentials = (this username, a token). On GHCR the username can be the GitHub
|
|
/// account (or any non-empty value with a valid token); when empty, the engine falls back to the
|
|
/// image-reference owner (e.g. `abkslm` from `ghcr.io/abkslm/…`). The token itself is the
|
|
/// app's GitHub token (`GitHubCredentialStore`, needs `read:packages` for GHCR) unless overridden
|
|
/// by the `NUCLEIC_REGISTRY_USER`/`NUCLEIC_REGISTRY_TOKEN` env. Public images (incl. Apple's
|
|
/// vminitd) are pulled anonymously regardless.
|
|
public static let registryUsernameKey = "nucleic.container.registry.username"
|
|
|
|
/// Configured private-registry username (UserDefaults), or `nil`/empty when unset.
|
|
public static var registryUsername: String? {
|
|
let v = (defaults.string(forKey: registryUsernameKey) ?? "")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return v.isEmpty ? nil : v
|
|
}
|
|
|
|
/// Default memory (GiB) for the shared Nucleic Control container: the host's *total* physical
|
|
/// RAM. It serves every Control session's `claude` process at once, so a fixed 8 GiB is the
|
|
/// most OOM-prone limit in the app — the in-VM Linux OOM-killer SIGKILLs a process the moment
|
|
/// the combined RSS hits the ceiling ("exited with status 137"). `--memory` is a *ceiling*, not
|
|
/// a reservation: Apple's `container` VM only consumes what it actually uses, so setting it to
|
|
/// the full system RAM just stops the in-VM OOM-killer from firing prematurely. The trade-off
|
|
/// (Apple's docs): the VM doesn't relinquish freed pages to the host while running, so a
|
|
/// long-lived busy container's footprint only ratchets up — reclaim it with `restartShared`
|
|
/// (Settings → Control → "Restart container"). The default argument reads the live host, so
|
|
/// call sites use `()`; tests pass `physicalBytes` explicitly.
|
|
public static func recommendedControlContainerMemoryGiB(
|
|
physicalBytes: UInt64 = ProcessInfo.processInfo.physicalMemory
|
|
) -> Int {
|
|
let physGiB = Int(physicalBytes / 1_073_741_824)
|
|
// = total system memory; fall back to the documented floor only on a bogus (0) reading.
|
|
return physGiB > 0 ? physGiB : defaultControlContainerMemoryGiB
|
|
}
|
|
|
|
/// Whether the container service is enabled app-wide.
|
|
public static var serviceEnabled: Bool {
|
|
defaults.bool(forKey: serviceEnabledKey)
|
|
}
|
|
|
|
/// Set once the app has finished its first-launch bootstrap, so the one-time remnant scan
|
|
/// (`AppStore.reconcileContainerService`) runs only on a genuinely fresh install and never
|
|
/// again. `bool(forKey:)` is false when absent, which is exactly "haven't launched yet".
|
|
public static let didCompleteFirstLaunchKey = "nucleic.firstLaunchCompleted"
|
|
|
|
/// Force the container-service master switch on. Called when a Nucleic Control project
|
|
/// exists — registered, or a leftover remnant on disk from a prior install — because Control
|
|
/// cannot function with the service off, so its presence pins the switch. Idempotent.
|
|
public static func enableService() {
|
|
defaults.set(true, forKey: serviceEnabledKey)
|
|
}
|
|
|
|
/// Whether new projects should default to sandboxing on. Only meaningful — and only
|
|
/// reported true — while the container service is enabled.
|
|
public static var sandboxByDefault: Bool {
|
|
serviceEnabled && defaults.bool(forKey: sandboxByDefaultKey)
|
|
}
|
|
|
|
/// Whether newly cloned projects default to Nucleic Control. Nucleic Control requires the
|
|
/// container service (its whole point is the shared sandbox container + git interceptor), so
|
|
/// this is only reported true while the service is on.
|
|
public static var controlByDefault: Bool {
|
|
serviceEnabled && defaults.bool(forKey: controlByDefaultKey)
|
|
}
|
|
|
|
/// Whether new Nucleic Control projects should start with nvrsion on (NVRSION). Beta; only
|
|
/// reported true while the container service is enabled (nvrsion needs the shared control
|
|
/// container). A per-project setting still overrides this default.
|
|
public static var nvrsionByDefault: Bool {
|
|
serviceEnabled && defaults.bool(forKey: nvrsionByDefaultKey)
|
|
}
|
|
|
|
/// A stored integer for `key`, clamped to ≥ 1, falling back when unset/invalid.
|
|
private static func resource(_ key: String, default fallback: Int) -> Int {
|
|
let v = defaults.integer(forKey: key)
|
|
return v > 0 ? v : fallback
|
|
}
|
|
|
|
/// CPUs / memory (GiB) for the shared primary Nucleic Control container.
|
|
public static var controlContainerCPUs: Int {
|
|
resource(controlContainerCPUsKey, default: defaultControlContainerCPUs)
|
|
}
|
|
public static var controlContainerMemoryGiB: Int {
|
|
// Unset → the host-aware recommendation (not the flat floor), so the OOM-prone shared
|
|
// container gets headroom by default on capable hosts.
|
|
resource(controlContainerMemoryGiBKey, default: recommendedControlContainerMemoryGiB())
|
|
}
|
|
|
|
/// CPUs / memory (GiB) for a per-session container.
|
|
public static var sessionContainerCPUs: Int {
|
|
resource(sessionContainerCPUsKey, default: defaultContainerCPUs)
|
|
}
|
|
public static var sessionContainerMemoryGiB: Int {
|
|
resource(sessionContainerMemoryGiBKey, default: defaultContainerMemoryGiB)
|
|
}
|
|
|
|
/// How Nucleic Control container sessions authenticate `claude` — the host subscription
|
|
/// login (OAuth, default) or a user-supplied API key (`ControlAPIKeyStore`). App-wide for
|
|
/// now; a per-project override is planned (see the hint in project settings).
|
|
public static let controlAuthModeKey = "nucleic.container.control.authMode"
|
|
|
|
public static var controlAuthMode: ControlAuthMode {
|
|
ControlAuthMode(rawValue: defaults.string(forKey: controlAuthModeKey) ?? "")
|
|
?? .oauth
|
|
}
|
|
|
|
/// When on, each agent family gets its *own* shared Nucleic Control container instead of all
|
|
/// piling into the one `nucleic-control`. The model families can otherwise turn competitive in
|
|
/// a shared sandbox — killing each other's processes ("agenticide") — so this isolates them into
|
|
/// `nucleic-control-claude` (Claude), `nucleic-control-codex` (GPT/Codex), and
|
|
/// `nucleic-control-xai` (xAI/Grok). Off by default; only consulted for shared-container control
|
|
/// sessions (per-session containers are already isolated).
|
|
/// See `ContainerManager.sharedContainerName(for:split:)`.
|
|
public static let splitControlContainersByBackendKey = "nucleic.container.control.splitByBackend"
|
|
|
|
public static var splitControlContainersByBackend: Bool {
|
|
defaults.bool(forKey: splitControlContainersByBackendKey)
|
|
}
|
|
|
|
/// When on, a shared Nucleic Control container's control plane (the approval MCP server + the
|
|
/// git/gh/command interceptor endpoint) runs over a **vsock-relayed unix socket** instead of
|
|
/// TCP/HTTP on the VM gateway — so macOS raises no incoming-connection / local-network prompts.
|
|
/// The agent and the interceptor shims reach the host through an in-container loopback bridge
|
|
/// that forwards to the relayed socket (see `docs/VSOCK_CONTROL_PLANE.md`). **On by default** as
|
|
/// of sandbox image `v4` (which ships `control-bridge.js`); set the key to `false` to fall back to
|
|
/// the legacy gateway-TCP path. Because it requires the bridge in the image, the default must move
|
|
/// in lockstep with `ProjectSandbox.defaultImage` being a bridge-bearing tag (≥ `v4`).
|
|
public static let vsockControlPlaneEnabledKey = "nucleic.container.vsockControlPlane"
|
|
|
|
/// Defaults to `true` when unset (so a fresh install gets the vsock control plane); an explicit
|
|
/// stored `false` still wins. `object(forKey:)` distinguishes "unset" from a stored `false`,
|
|
/// which `UserDefaults.bool(forKey:)` (always `false` when absent) cannot.
|
|
public static var vsockControlPlaneEnabled: Bool {
|
|
defaults.object(forKey: vsockControlPlaneEnabledKey) as? Bool ?? true
|
|
}
|
|
|
|
/// Whether the in-container **bash command tracer** is active. The tracer installs a `DEBUG`/
|
|
/// `EXIT` trap (sourced via `BASH_ENV`) that records EVERY command the agent runs inside a Bash
|
|
/// tool call and batch-posts the metadata to the GUI command feed. Because the `DEBUG` trap fires
|
|
/// before every simple command, it adds per-command shell overhead during build/test-heavy turns
|
|
/// — so it is **opt-in (default off)**. This gates only the command tracer: the git/gh
|
|
/// interception the conflict-detection / merge-queue / autoship system relies on is always on.
|
|
/// Gated at the env level (the `BASH_ENV`/command-hook vars are injected only when this is on),
|
|
/// so toggling takes effect for agent turns started after the change — no container rebuild.
|
|
public static let commandTracingEnabledKey = "nucleic.container.commandTracing"
|
|
|
|
public static var commandTracingEnabled: Bool {
|
|
defaults.bool(forKey: commandTracingEnabledKey)
|
|
}
|
|
|
|
/// How aggressively running container VMs reclaim unused memory to the host (via each VM's
|
|
/// virtio memory balloon, driven by `ContainerEngine`'s autoballoon loop). Defaults to
|
|
/// `.conservative`.
|
|
///
|
|
/// Conservative by default — the gentlest reclaiming level (generous headroom, a high floor, slow
|
|
/// cadence). Ballooning was historically off out of the box because the balloon's target is
|
|
/// derived from the per-**container** cgroup working set but applied to the **whole VM**
|
|
/// (`targetVirtualMachineMemorySize`), which also hosts the guest kernel, vminitd, init and the
|
|
/// virtiofs page cache. With no guest swap, a turn that allocated past the (cadence-stale) target
|
|
/// sent the guest into a perpetual direct-reclaim spin — a guest vCPU pegged at ~100% and the
|
|
/// agent wedged mid-allocation (output froze and never recovered, ending in OOM-137).
|
|
/// ``BalloonPolicy/target(workingSetBytes:ceilingBytes:)`` now reserves a whole-VM margin so every
|
|
/// level clears the guest's true footprint, so the default reclaims conservatively rather than not
|
|
/// at all. Unset/invalid → the default.
|
|
public static let memoryManagementKey = "nucleic.container.memoryManagement"
|
|
|
|
public static var memoryManagement: MemoryManagementLevel {
|
|
MemoryManagementLevel(rawValue: defaults.string(forKey: memoryManagementKey) ?? "")
|
|
?? .conservative
|
|
}
|
|
}
|
|
|
|
/// App-wide settings for the **macOS-guest VM** service (Apple `Virtualization` framework) — the
|
|
/// Mac-native sibling of ``ContainerServiceSettings``. Where the Linux container gives agents the
|
|
/// cross-platform toolchain, a macOS VM gives them a real, isolated Mac (Xcode, the simulators,
|
|
/// codesign) for end-to-end testing — one per session, so several agents run Mac work at once
|
|
/// without colliding on the single shared host the way `host_exec` does.
|
|
///
|
|
/// Reads through ``ContainerServiceSettings/defaults`` so the same task-local test-suite binding
|
|
/// (`ContainerServiceSettings.withDefaults`) isolates these switches too, and so production always
|
|
/// resolves to `.standard`.
|
|
public enum MacVMSettings {
|
|
private static var defaults: UserDefaults { ContainerServiceSettings.defaults }
|
|
|
|
/// Master switch. Off → the `mac_vm_exec` tool is never exposed and no macOS VM is booted,
|
|
/// regardless of any per-session setting. Off by default: a macOS guest is heavy (several GB of
|
|
/// host RAM each, plus a large one-time base-image install), so it's strictly opt-in.
|
|
public static let serviceEnabledKey = "nucleic.macvm.serviceEnabled"
|
|
|
|
/// When on, sandboxed sessions expose `mac_vm_exec` (their own isolated macOS VM) by default.
|
|
/// Only meaningful while the macOS-VM service (above) is on. On by default.
|
|
public static let exposeByDefaultKey = "nucleic.macvm.exposeByDefault"
|
|
|
|
/// When on, sandboxed sessions also expose `mac_vm_computer` — GUI computer-use (screenshots +
|
|
/// mouse/keyboard) driving the VM as a Mac dev simulator. A SEPARATE opt-in from `exposeByDefault`.
|
|
/// Capture + input run **host-side** through the guest's virtual display + virtual HID
|
|
/// (docs/MACOS_VM.md §12), so no in-guest permissions and no SIP are required. On by default;
|
|
/// only meaningful while the service is on.
|
|
public static let computerUseByDefaultKey = "nucleic.macvm.computerUseByDefault"
|
|
|
|
/// When on, the base image ALSO installs the optional in-guest **semantic AX agent**
|
|
/// (`NucleicVMAgent`): Accessibility-based control (act on a UI element by identity rather than
|
|
/// screen coordinates). It needs the guest's TCC grants, which are written automatically during the
|
|
/// base build (docs/MACOS_VM.md §12.5). OFF by default: the DEFAULT computer-use path is host-side
|
|
/// virtual IO, which needs neither the agent nor those grants. Only meaningful while the service is on.
|
|
public static let axAgentEnabledKey = "nucleic.macvm.axAgentEnabled"
|
|
|
|
/// Advanced/diagnostic: when on, the guest-screen **monitor** is opened automatically for every
|
|
/// per-session macOS VM the instant it boots — no matter what launched it (`mac_vm_exec`,
|
|
/// `mac_vm_computer`, or provisioning). Normally the monitor is opened on demand (the Control
|
|
/// panel's "Observe"); this forces it always-on, which is handy for Nucleic developers or anyone
|
|
/// diagnosing a VM that won't behave. OFF by default. Only meaningful while the service is on.
|
|
public static let alwaysOpenGuestDisplayKey = "nucleic.macvm.alwaysOpenGuestDisplay"
|
|
|
|
/// Optional path to a **prebuilt** golden base VM bundle the user produced out-of-band. When set
|
|
/// and present, the engine clones it directly and skips the install+provision path entirely. Empty
|
|
/// → the engine builds its own golden base under the storage root (install from the restore image,
|
|
/// then provision the toolchain).
|
|
public static let basePrebuiltPathKey = "nucleic.macvm.basePrebuiltPath"
|
|
|
|
/// Optional path to a local macOS restore `.ipsw`. Empty → the engine fetches
|
|
/// `VZMacOSRestoreImage.latestSupported` over the network (~14 GB) the first time it builds a base.
|
|
/// The `.ipsw` you supply pins the guest macOS version (e.g. a macOS 27 restore image).
|
|
public static let restoreImagePathKey = "nucleic.macvm.restoreImagePath"
|
|
|
|
/// Optional **remote** `.ipsw` URL to pin a specific guest macOS version without pre-downloading it
|
|
/// by hand — e.g. a macOS 27 UniversalMac restore image. The engine downloads it,
|
|
/// caches it keyed by filename (so switching versions doesn't collide), and installs from it.
|
|
/// Takes precedence over `latestSupported` but not over an explicit local `restoreImagePath`. Empty
|
|
/// → use `latestSupported` (the newest the host can run; see docs/MACOS_VM.md §guest versions).
|
|
public static let restoreImageURLKey = "nucleic.macvm.restoreImageURL"
|
|
|
|
/// UI-only: which entry is selected in the guest-image picker (a stable id from the hardcoded
|
|
/// IPSW catalog, or "" for “latest supported”). The engine reads `restoreImageURL` /
|
|
/// `restoreImagePath`; this just remembers the picker position so a chosen entry stays selected
|
|
/// across launches (including placeholder entries whose `.ipsw` isn't published yet).
|
|
public static let restoreImageChoiceKey = "nucleic.macvm.restoreImageChoice"
|
|
|
|
/// Per-session guest resource ceilings. Applied to the cloned VM at boot; `0`/unset → the defaults.
|
|
public static let vmCPUsKey = "nucleic.macvm.cpus"
|
|
public static let vmMemoryGiBKey = "nucleic.macvm.memoryGiB"
|
|
/// Base-disk size (GiB) the golden base is installed into; clones inherit it copy-on-write.
|
|
public static let baseDiskGiBKey = "nucleic.macvm.baseDiskGiB"
|
|
/// Ceiling on concurrently *running* macOS guests. macOS has historically capped simultaneous
|
|
/// macOS-guest VMs (2 on recent releases); the manager enforces this so a fan-out of agents
|
|
/// queues rather than failing an over-limit `VZVirtualMachine.start`. Adjust if a future OS lifts it.
|
|
public static let maxConcurrentVMsKey = "nucleic.macvm.maxConcurrent"
|
|
/// The in-guest account Nucleic SSHes in as (baked into the base by provisioning).
|
|
public static let sshUserKey = "nucleic.macvm.sshUser"
|
|
|
|
public static let defaultVMCPUs = 4
|
|
/// macOS guests want more than a Linux container: 8 GiB keeps Xcode/simulator responsive.
|
|
public static let defaultVMMemoryGiB = 8
|
|
public static let defaultBaseDiskGiB = 96
|
|
public static let defaultIdleTimeoutSeconds = 900
|
|
public static let defaultMaxConcurrentVMs = 2
|
|
public static let defaultSSHUser = "agent"
|
|
|
|
/// Whether the macOS-VM service is enabled app-wide.
|
|
public static var serviceEnabled: Bool { defaults.bool(forKey: serviceEnabledKey) }
|
|
|
|
/// Whether sandboxed sessions expose `mac_vm_exec` by default (only while the service is on).
|
|
/// Defaults to `true` when unset; an explicit stored `false` still wins (`object(forKey:)`
|
|
/// distinguishes "unset" from a stored `false`, which `bool(forKey:)` cannot).
|
|
public static var exposeByDefault: Bool {
|
|
serviceEnabled && (defaults.object(forKey: exposeByDefaultKey) as? Bool ?? true)
|
|
}
|
|
|
|
/// Whether sandboxed sessions expose `mac_vm_computer` (GUI computer-use) by default (only while
|
|
/// the service is on). Host-side virtual IO, so any installed base can serve it — no provisioning.
|
|
/// Defaults to `true` when unset; an explicit stored `false` still wins.
|
|
public static var computerUseByDefault: Bool {
|
|
serviceEnabled && (defaults.object(forKey: computerUseByDefaultKey) as? Bool ?? true)
|
|
}
|
|
|
|
/// Whether the base image installs the optional in-guest semantic AX agent (the by-identity
|
|
/// control add-on; see ``axAgentEnabledKey``). Builds on virtual computer use, so it requires
|
|
/// both the service and ``computerUseByDefault`` to be on.
|
|
public static var axAgentEnabled: Bool {
|
|
computerUseByDefault && defaults.bool(forKey: axAgentEnabledKey)
|
|
}
|
|
|
|
/// Whether the guest-screen monitor should auto-open for every session VM as it boots (see
|
|
/// ``alwaysOpenGuestDisplayKey``). Only meaningful while the service is on.
|
|
public static var alwaysOpenGuestDisplay: Bool {
|
|
serviceEnabled && defaults.bool(forKey: alwaysOpenGuestDisplayKey)
|
|
}
|
|
|
|
/// Configured prebuilt-base path, or `nil`/empty when unset.
|
|
public static var basePrebuiltPath: String? {
|
|
let v = (defaults.string(forKey: basePrebuiltPathKey) ?? "")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return v.isEmpty ? nil : v
|
|
}
|
|
|
|
/// Configured local restore-image path, or `nil`/empty when unset.
|
|
public static var restoreImagePath: String? {
|
|
let v = (defaults.string(forKey: restoreImagePathKey) ?? "")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return v.isEmpty ? nil : v
|
|
}
|
|
|
|
/// Configured remote restore-image `.ipsw` URL (version pin), or `nil`/empty when unset.
|
|
public static var restoreImageURL: String? {
|
|
let v = (defaults.string(forKey: restoreImageURLKey) ?? "")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return v.isEmpty ? nil : v
|
|
}
|
|
|
|
/// Configured SSH account name, or the default.
|
|
public static var sshUser: String {
|
|
let v = (defaults.string(forKey: sshUserKey) ?? "")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return v.isEmpty ? defaultSSHUser : v
|
|
}
|
|
|
|
/// Optional password for the in-guest `agent` account, used by the macOS-27 **declarative
|
|
/// first-boot provisioning** path (`VZMacGuestProvisioningOptions` — the guest account,
|
|
/// auto-login, and Remote Login are created unattended when host AND guest are macOS 27+).
|
|
/// Empty → the engine generates a random password and persists it next to the host SSH key
|
|
/// (`macvms/ssh/agent-password`), so the fully-unattended path needs no configuration. The
|
|
/// guest is a NAT-isolated, disposable VM — this is a lab credential, not a secret vault.
|
|
public static let agentPasswordKey = "nucleic.macvm.agentPassword"
|
|
|
|
/// Configured agent-account password, or `nil`/empty when unset (engine then generates one).
|
|
public static var agentPassword: String? {
|
|
let v = (defaults.string(forKey: agentPasswordKey) ?? "")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return v.isEmpty ? nil : v
|
|
}
|
|
|
|
private static func resource(_ key: String, default fallback: Int) -> Int {
|
|
let v = defaults.integer(forKey: key)
|
|
return v > 0 ? v : fallback
|
|
}
|
|
|
|
public static var vmCPUs: Int { resource(vmCPUsKey, default: defaultVMCPUs) }
|
|
public static var vmMemoryGiB: Int { resource(vmMemoryGiBKey, default: defaultVMMemoryGiB) }
|
|
public static var baseDiskGiB: Int { resource(baseDiskGiBKey, default: defaultBaseDiskGiB) }
|
|
public static var maxConcurrentVMs: Int {
|
|
resource(maxConcurrentVMsKey, default: defaultMaxConcurrentVMs)
|
|
}
|
|
}
|
|
|
|
/// A registered git repository plus per-project configuration.
|
|
public struct Project: Identifiable, Sendable, Codable, Equatable {
|
|
public let id: ProjectID
|
|
public var name: String
|
|
public var rootPath: String
|
|
public var defaultBranch: GitRef
|
|
/// The branch autoship merges this project's sessions into. `nil` → the project root
|
|
/// repo's `defaultBranch` (the default). A per-session override (`Session.shipBranch`)
|
|
/// takes precedence over this. See `resolvedAutoShipBranch`.
|
|
public var autoShipBranch: GitRef?
|
|
public var defaultBackend: BackendID?
|
|
/// Override for where session worktrees are created. `nil` → the default
|
|
/// sibling dir on the same volume (WORKTREE_MANAGER §2).
|
|
public var worktreeBase: String?
|
|
public var setupScript: String?
|
|
public var setupPolicy: SetupPolicy
|
|
/// Execution-sandbox settings. `nil` → sessions run on the host (default).
|
|
public var sandbox: ProjectSandbox?
|
|
/// nvrsion (Beta) settings. `nil`/`enabled == false` → classic per-session-worktree behavior
|
|
/// (the default). See `nvrsionActive` for the full gate (NVRSION §10).
|
|
public var nvrsion: ProjectNvrsion?
|
|
public var createdAt: Date
|
|
/// When the project was archived, or `nil` if it's active. Archiving hides the project
|
|
/// from the active list and stops its live sessions, but leaves all files and records in
|
|
/// place so it can be restored (see `AppStore.setProjectArchived`).
|
|
public var archivedAt: Date?
|
|
|
|
public init(
|
|
id: ProjectID = .generate(),
|
|
name: String,
|
|
rootPath: String,
|
|
defaultBranch: GitRef,
|
|
autoShipBranch: GitRef? = nil,
|
|
defaultBackend: BackendID? = .claudeCode,
|
|
worktreeBase: String? = nil,
|
|
setupScript: String? = nil,
|
|
setupPolicy: SetupPolicy = .block,
|
|
sandbox: ProjectSandbox? = nil,
|
|
nvrsion: ProjectNvrsion? = nil,
|
|
createdAt: Date = Date(),
|
|
archivedAt: Date? = nil
|
|
) {
|
|
self.id = id
|
|
self.name = name
|
|
self.rootPath = rootPath
|
|
self.defaultBranch = defaultBranch
|
|
self.autoShipBranch = autoShipBranch
|
|
self.defaultBackend = defaultBackend
|
|
self.worktreeBase = worktreeBase
|
|
self.setupScript = setupScript
|
|
self.setupPolicy = setupPolicy
|
|
self.sandbox = sandbox
|
|
self.nvrsion = nvrsion
|
|
self.createdAt = createdAt
|
|
self.archivedAt = archivedAt
|
|
}
|
|
|
|
/// Whether this project is currently archived (hidden from the active list).
|
|
public var isArchived: Bool { archivedAt != nil }
|
|
|
|
/// The branch autoship merges this project's sessions into, resolved: the configured
|
|
/// `autoShipBranch` if set, else the project root repo's `defaultBranch`.
|
|
public var resolvedAutoShipBranch: GitRef { autoShipBranch ?? defaultBranch }
|
|
|
|
/// Whether this repo lives under Nucleic's control directory (`~/.nucleic/control/`) —
|
|
/// a repo Nucleic clones and manages itself, kept out of iCloud and away from manual
|
|
/// edits. Derived from `rootPath`, so a move into/out of the control dir flips it.
|
|
public var isNucleicControlled: Bool {
|
|
ProjectCloner.isControlled(rootPath)
|
|
}
|
|
|
|
/// The sandbox config that actually governs this project's sessions, resolving the Nucleic
|
|
/// Control default: a **controlled** project is sandboxed project-wide by default — even
|
|
/// with no stored `sandbox`, and even if a stored one is disabled — so it always runs in
|
|
/// Nucleic's managed container (its `image`/`idleTimeout`/`perSessionContainers` carry
|
|
/// through when set). A non-controlled project keeps the explicit opt-in: its stored
|
|
/// `sandbox` when enabled, else `nil` (host-spawned).
|
|
///
|
|
/// This is the single source of truth both the spawn gate (`SessionController`) and the
|
|
/// teardown paths (`AppStore`) read, so they can't drift. It is still **subordinate to**
|
|
/// the app-wide `ContainerServiceSettings.serviceEnabled` master switch, which callers
|
|
/// check first — so with the container service off, even a control project runs on the host.
|
|
public var effectiveSandbox: ProjectSandbox? {
|
|
if isNucleicControlled {
|
|
var s = sandbox ?? ProjectSandbox()
|
|
s.enabled = true
|
|
return s
|
|
}
|
|
if let sandbox, sandbox.enabled { return sandbox }
|
|
return nil
|
|
}
|
|
|
|
/// Whether this project's sandboxed sessions share Nucleic's single **primary** managed
|
|
/// container rather than each getting their own. True only for a Nucleic Control project
|
|
/// that hasn't opted into per-session containers — the default for control projects.
|
|
public var usesSharedControlContainer: Bool {
|
|
isNucleicControlled && !(effectiveSandbox?.perSessionContainers ?? false)
|
|
}
|
|
|
|
/// Whether **nvrsion** (Beta) governs this project's sessions — the single gate both the spawn
|
|
/// path and teardown read so they can't drift (NVRSION §10). Requires a Nucleic Control project,
|
|
/// the mode enabled, and the shared control container (v0 doesn't support per-session
|
|
/// containers). Like `effectiveSandbox` it stays subordinate to the app-wide container-service
|
|
/// master switch, which callers check first.
|
|
public var nvrsionActive: Bool {
|
|
isNucleicControlled && (nvrsion?.enabled ?? false) && usesSharedControlContainer
|
|
}
|
|
|
|
/// Host path of the shared nvrsion trunk checkout: `<repoRoot>/.nucleic/trunk` — kept inside the
|
|
/// project under `.nucleic/` (git-excluded) on the same volume, beside the worktrees dir.
|
|
public var resolvedTrunkPath: String {
|
|
let nucleicDir = (rootPath as NSString).appendingPathComponent(".nucleic")
|
|
return (nucleicDir as NSString).appendingPathComponent("trunk")
|
|
}
|
|
|
|
/// `<repoSlug>` used in the worktree path and branch derivation.
|
|
public var repoSlug: String {
|
|
let last = (rootPath as NSString).lastPathComponent
|
|
return Slug.sanitize(last, fallback: "repo")
|
|
}
|
|
|
|
/// Resolved base directory for this project's session worktrees:
|
|
/// `<override>/<repoSlug>` or the default `<repoRoot>/.nucleic/worktrees` — kept
|
|
/// inside the project under `.nucleic/` (git-excluded so the primary checkout never
|
|
/// sees it) on the same volume (WORKTREE_MANAGER §2).
|
|
public var resolvedWorktreeBase: String {
|
|
if let worktreeBase {
|
|
return (worktreeBase as NSString).appendingPathComponent(repoSlug)
|
|
}
|
|
let nucleicDir = (rootPath as NSString).appendingPathComponent(".nucleic")
|
|
return (nucleicDir as NSString).appendingPathComponent("worktrees")
|
|
}
|
|
}
|
|
|
|
/// A compact, `Sendable` view of a project *anywhere in the mesh* for the sidebar — the project
|
|
/// analogue of `SessionSummary`. Local projects and projects mirrored from peer Macs both map
|
|
/// into this one shape, so the sidebar renders a single list through a single code path and
|
|
/// differentiates origin only by a globe badge. Semantics a local `Project` *derives* from its
|
|
/// paths/defaults (`isNucleicControlled`, `nvrsionActive`) are carried as plain values here,
|
|
/// because a remote project's truth rides the wire, not this Mac's filesystem.
|
|
public struct ProjectSummary: Identifiable, Sendable, Equatable, Hashable {
|
|
public let id: ProjectID
|
|
public var name: String
|
|
public var isNucleicControlled: Bool
|
|
public var nvrsionActive: Bool
|
|
/// Whether sessions run in a sandbox container (drives the shield badge). Always false for
|
|
/// remote projects — the wire doesn't carry it, and the shield describes *this* Mac's runtime.
|
|
public var sandboxed: Bool
|
|
/// The `HostID` of the peer Mac this project lives on, or `nil` for a project on this Mac.
|
|
public var hostID: String?
|
|
/// The owning Mac's friendly name (resolved when the summary is built), for the globe
|
|
/// badge's tooltip. `nil` for local projects.
|
|
public var hostLabel: String?
|
|
|
|
public var isRemote: Bool { hostID != nil }
|
|
|
|
/// List-row identity, origin-qualified for the same reason as `SessionSummary.sidebarRowID`.
|
|
public var rowID: String { "\(hostID ?? "local")-\(id.rawValue)" }
|
|
|
|
public init(
|
|
id: ProjectID, name: String, isNucleicControlled: Bool = false,
|
|
nvrsionActive: Bool = false, sandboxed: Bool = false,
|
|
hostID: String? = nil, hostLabel: String? = nil
|
|
) {
|
|
self.id = id
|
|
self.name = name
|
|
self.isNucleicControlled = isNucleicControlled
|
|
self.nvrsionActive = nvrsionActive
|
|
self.sandboxed = sandboxed
|
|
self.hostID = hostID
|
|
self.hostLabel = hostLabel
|
|
}
|
|
|
|
public init(_ project: Project) {
|
|
self.init(
|
|
id: project.id, name: project.name,
|
|
isNucleicControlled: project.isNucleicControlled,
|
|
nvrsionActive: project.nvrsionActive,
|
|
sandboxed: project.sandbox?.enabled == true)
|
|
}
|
|
}
|
|
|
|
// MARK: - Slug derivation (WORKTREE_MANAGER §2)
|
|
|
|
public enum Slug {
|
|
/// Lowercased, kebab-cased, alphanumerics + dashes only, collapsed and trimmed.
|
|
public static func sanitize(_ raw: String, fallback: String = "session") -> String {
|
|
var out = ""
|
|
var lastWasDash = false
|
|
for scalar in raw.lowercased().unicodeScalars {
|
|
if scalar == "-" || scalar == "_" || scalar == " " || scalar == "." || scalar == "/" {
|
|
if !lastWasDash && !out.isEmpty { out.append("-"); lastWasDash = true }
|
|
} else if scalar.properties.isAlphabetic || ("0"..."9").contains(scalar) {
|
|
out.unicodeScalars.append(scalar)
|
|
lastWasDash = false
|
|
}
|
|
// anything else is dropped
|
|
}
|
|
while out.hasSuffix("-") { out.removeLast() }
|
|
return out.isEmpty ? fallback : out
|
|
}
|
|
}
|