Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions GraphcodeKit/Sources/Domain/GraphcodeSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable {
summaryUsesModel: Bool = false,
visualisesSummaries: Bool = false,
daemonHeartbeatEnabled: Bool = false,
artifactoryEnabled: Bool = false,
artifactoryEnabled: Bool = true,
keepsMacAwakeWhileLoopsRun: Bool = false,
worktreePolicies: [String: WorktreeHygienePolicy] = [:]
) {
Expand Down Expand Up @@ -490,11 +490,12 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable {
try container.decodeIfPresent(Bool.self, forKey: .visualisesSummaries) ?? false
daemonHeartbeatEnabled =
try container.decodeIfPresent(Bool.self, forKey: .daemonHeartbeatEnabled) ?? false
// The daemon-side half of the beta ramp: the app writes the ramp-resolved value,
// so absent means "no app has spoken yet" and takes the off every non-app flow
// (CLI-only machines, hand-edited files) already had.
// On unless somebody turned it off. Absent used to mean off while the board was
// beta-only — "no app has spoken yet" — and now means the default: a CLI-only
// machine or a hand-edited file gets the board the way every install does, and
// the app still writes an explicit value the moment a human flips the switch.
artifactoryEnabled =
try container.decodeIfPresent(Bool.self, forKey: .artifactoryEnabled) ?? false
try container.decodeIfPresent(Bool.self, forKey: .artifactoryEnabled) ?? true
// Absent means nobody has asked for it, which is the default. An update must never
// start holding a power assertion on a machine whose owner did not choose that.
keepsMacAwakeWhileLoopsRun =
Expand Down
9 changes: 8 additions & 1 deletion docs/ramps.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
{
"features": {
"codespaces": { "beta": 100, "stable": 100 }
"codespaces": {
"beta": 100,
"stable": 100
},
"artifactory": {
"beta": 100,
"stable": 100
}
}
}
2 changes: 1 addition & 1 deletion graphcode/Sources/Clients/FeatureRamps.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ enum FeatureRamps {
var defaultPercents: [String: Int] {
switch self {
case .codespaces: return ["beta": 100, "stable": 100]
case .artifactory: return ["beta": 100, "stable": 0]
case .artifactory: return ["beta": 100, "stable": 100]
}
}
}
Expand Down
24 changes: 7 additions & 17 deletions graphcode/Sources/Features/Settings/SettingsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,6 @@ final class SettingsModel {
}
}

/// Whether the Settings window offers the Artifactory switch at all — the `artifactory`
/// ramp (`FeatureRamps`), read once at construction for the same reason as
/// `AppSidebarView.offersCodespaces`: a ramp change applies from the next launch. A
/// switch a stable install was never offered can't have recorded a choice, so an
/// install that has chosen keeps its switch even if the ramp later pulls back.
let showsArtifactory: Bool

/// The Artifactory as a switch, following `betaUpdates`' shape — but the daemon
/// enforces this one, so a flip writes `artifactoryEnabled` into `settings` (which
/// saves the file the daemon reads) *and* records the explicit choice that then
Expand Down Expand Up @@ -73,7 +66,6 @@ final class SettingsModel {
GraphcodeSettingsStore.save(booted)
}
artifactoryEnabled = artifactory.enabled
showsArtifactory = artifactory.showsSwitch
let version =
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0"
betaUpdates =
Expand All @@ -85,24 +77,22 @@ final class SettingsModel {
/// The Artifactory's boot decision, separated so tests can pin it without touching
/// `UserDefaults`, the settings file, or the bundle.
///
/// An install that has never chosen boots on the ramp's answer — beta first, stable
/// only when `ramps.json` raises it — and that answer has to reach
/// `~/.graphcode/settings.json` when it differs, because the daemon enforces
/// An install that has never chosen boots on the ramp's answer — on everywhere since
/// the board shipped, with `ramps.json` kept as the kill switch — and that answer has
/// to reach `~/.graphcode/settings.json` when it differs, because the daemon enforces
/// `artifactoryEnabled` out of the file and cannot see ramps or `UserDefaults`. A
/// recorded choice outranks the ramp from then on, and keeps the switch offered so
/// the choice can always be undone. Rewriting a file that already agrees is churn.
/// recorded choice outranks the ramp from then on. The switch itself is always
/// offered: it is a setting now, not a beta gate, and a person who finds the board
/// too much turns it off here. Rewriting a file that already agrees is churn.
static func resolvesArtifactory(
loaded: Bool, explicitChoice: Bool?, rampedOn: Bool
) -> ArtifactoryResolution {
let enabled = explicitChoice ?? rampedOn
return ArtifactoryResolution(
enabled: enabled, fileNeedsWrite: enabled != loaded,
showsSwitch: rampedOn || explicitChoice != nil)
return ArtifactoryResolution(enabled: enabled, fileNeedsWrite: enabled != loaded)
}

struct ArtifactoryResolution: Equatable {
var enabled: Bool
var fileNeedsWrite: Bool
var showsSwitch: Bool
}
}
31 changes: 15 additions & 16 deletions graphcode/Sources/Features/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,22 +189,21 @@ struct SettingsView: View {
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)

// The `artifactory` ramp decides whether the switch is offered at all; the
// daemon-side bit it drives lives in `artifactoryEnabled` (`GraphcodeSettings`),
// which the model writes the ramp's answer to at launch.
if model.showsArtifactory {
Toggle("Artifactory", isOn: $model.artifactoryEnabled)
Text(
"Loops share a message board — a note dropped for whoever comes next, "
+ "discoverable by loops that didn't exist when it was written — "
+ "alongside the addressed `node send` and edges. The daemon enforces "
+ "this: off, it refuses every artifactory command. Beta installs start "
+ "on; a flip here is remembered even if the rollout later changes."
)
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
// Always offered. The daemon-side bit lives in `artifactoryEnabled`
// (`GraphcodeSettings`), on by default; a flip here is what turns the board
// off for a person who finds it too much, and is remembered over any rollout.
Toggle("Artifactory", isOn: $model.artifactoryEnabled)
Text(
"Loops share a message board — a note dropped for whoever comes next, "
+ "discoverable by loops that didn't exist when it was written — "
+ "alongside the addressed `node send` and edges. It also appears in a "
+ "loop's workspace rail, where you can read it and leave notes yourself. "
+ "Off, the daemon refuses every artifactory command and the rail section "
+ "goes away. On by default; your choice here is kept."
)
.font(.caption2)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
} footer: {
Text(
"A strip along the window's bottom listing passes, hand-offs and state changes "
Expand Down
10 changes: 5 additions & 5 deletions graphcode/Tests/FeatureRampsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,15 @@ struct FeatureRampsTests {
}

@Test
func artifactoryShipsBetaOnAndStableOff() {
// The Artifactory ramps the way codespaces no longer does: beta installs first,
// stable waiting for the fetched file to raise it. The baked default is the
// shipped posture, not the end state.
func artifactoryShipsOnEverywhere() {
// The Artifactory is ramped fully on, so — the codespaces rule — its baked default
// moved up with it: an offline first launch on either channel gets the board, and
// the served file is the kill switch rather than the opener.
let id = UUID().uuidString
#expect(
FeatureRamps.isEnabled(.artifactory, configuration: nil, channel: "beta", installID: id))
#expect(
!FeatureRamps.isEnabled(.artifactory, configuration: nil, channel: "stable", installID: id))
FeatureRamps.isEnabled(.artifactory, configuration: nil, channel: "stable", installID: id))
// The fetched file stays both the opener and the kill switch either way: raised
// to 100 everywhere it turns stable installs on, dropped to 0 it turns even beta
// installs off.
Expand Down
23 changes: 11 additions & 12 deletions graphcode/Tests/SettingsArtifactoryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,28 +23,27 @@ struct SettingsArtifactoryTests {
#expect(
resolution(loaded: false, choice: nil, rampedOn: true)
== SettingsModel.ArtifactoryResolution(
enabled: true, fileNeedsWrite: true, showsSwitch: true))
// A stable install the ramp hasn't reached boots off, and the file already
// agrees, so nothing is written.
enabled: true, fileNeedsWrite: true))
// Should the ramp ever be pulled, an install that never chose boots off, and a
// file that already agrees is left alone.
#expect(
resolution(loaded: false, choice: nil, rampedOn: false)
== SettingsModel.ArtifactoryResolution(
enabled: false, fileNeedsWrite: false, showsSwitch: false))
enabled: false, fileNeedsWrite: false))
}

@Test
func aRecordedChoiceOutranksTheRamp() {
// An explicit on survives the ramp never — or no longer — offering it, and the
// switch stays offered so the choice can always be undone.
// An explicit on survives the ramp being pulled back.
#expect(
resolution(loaded: true, choice: true, rampedOn: false)
== SettingsModel.ArtifactoryResolution(
enabled: true, fileNeedsWrite: false, showsSwitch: true))
enabled: true, fileNeedsWrite: false))
// An explicit off survives the ramp turning everyone on.
#expect(
resolution(loaded: false, choice: false, rampedOn: true)
== SettingsModel.ArtifactoryResolution(
enabled: false, fileNeedsWrite: false, showsSwitch: true))
enabled: false, fileNeedsWrite: false))
}

@Test
Expand All @@ -54,17 +53,17 @@ struct SettingsArtifactoryTests {
#expect(
resolution(loaded: true, choice: nil, rampedOn: true)
== SettingsModel.ArtifactoryResolution(
enabled: true, fileNeedsWrite: false, showsSwitch: true))
enabled: true, fileNeedsWrite: false))
}

@Test
func aRampPulledToZeroReachesTheFile() {
// The kill-switch posture: the ramp drops to 0 under a choice-less install whose
// file still says on — the app is the only writer that can resolve this, and the
// switch goes away with it.
// file still says on — the app is the only writer that can resolve this. The
// switch stays: it is a setting, and a person can turn the board back on.
#expect(
resolution(loaded: true, choice: nil, rampedOn: false)
== SettingsModel.ArtifactoryResolution(
enabled: false, fileNeedsWrite: true, showsSwitch: false))
enabled: false, fileNeedsWrite: true))
}
}
Loading