From 9138a9f9e445f6bd30696947f0f57ca937bd5e94 Mon Sep 17 00:00:00 2001 From: scgopi Date: Tue, 1 Sep 2026 14:11:03 -0700 Subject: [PATCH] Artifactory for everyone: ramp to 100 on both channels, and a real off switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ramp: docs/ramps.json — served at graphcode.app/ramps.json — now lists artifactory at 100/100. It listed nothing before, so every install fell back to the code default of stable:0. Takes effect on each install's next launch, no release needed, and stays the kill switch. FeatureRamps.defaultPercents moves up with it (the codespaces rule: a feature ramped fully on carries its default up, so an offline first launch isn't the one place it's missing). The setting: a switch existed, but as a beta gate — offered only while the ramp was on, and the daemon read an absent key as off. It is a real setting now: always offered, on by default everywhere (GraphcodeSettings .artifactoryEnabled defaults to true, absent key included, so CLI-only machines get the board too), and a person's choice is kept over any later ramp change. Off, the daemon refuses every artifactory verb and the rail section disappears. One switch, not two: the bit the daemon enforces is the setting. Suite: 1497 tests / 155 suites pass; swiftlint 0 errors, format clean. Signed-off-by: scgopi Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019A6NULwEiBXEdRXwEKKcRH --- .../Sources/Domain/GraphcodeSettings.swift | 11 ++++--- docs/ramps.json | 9 +++++- graphcode/Sources/Clients/FeatureRamps.swift | 2 +- .../Features/Settings/SettingsModel.swift | 24 +++++--------- .../Features/Settings/SettingsView.swift | 31 +++++++++---------- graphcode/Tests/FeatureRampsTests.swift | 10 +++--- .../Tests/SettingsArtifactoryTests.swift | 23 +++++++------- 7 files changed, 53 insertions(+), 57 deletions(-) diff --git a/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift b/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift index 90b2d2da..a17376a5 100644 --- a/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift +++ b/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift @@ -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] = [:] ) { @@ -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 = diff --git a/docs/ramps.json b/docs/ramps.json index 44f08c45..7b18c1b1 100644 --- a/docs/ramps.json +++ b/docs/ramps.json @@ -1,5 +1,12 @@ { "features": { - "codespaces": { "beta": 100, "stable": 100 } + "codespaces": { + "beta": 100, + "stable": 100 + }, + "artifactory": { + "beta": 100, + "stable": 100 + } } } diff --git a/graphcode/Sources/Clients/FeatureRamps.swift b/graphcode/Sources/Clients/FeatureRamps.swift index b78439cc..69a46f13 100644 --- a/graphcode/Sources/Clients/FeatureRamps.swift +++ b/graphcode/Sources/Clients/FeatureRamps.swift @@ -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] } } } diff --git a/graphcode/Sources/Features/Settings/SettingsModel.swift b/graphcode/Sources/Features/Settings/SettingsModel.swift index ab177dbc..f31d1b4d 100644 --- a/graphcode/Sources/Features/Settings/SettingsModel.swift +++ b/graphcode/Sources/Features/Settings/SettingsModel.swift @@ -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 @@ -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 = @@ -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 } } diff --git a/graphcode/Sources/Features/Settings/SettingsView.swift b/graphcode/Sources/Features/Settings/SettingsView.swift index 16501577..2020933a 100644 --- a/graphcode/Sources/Features/Settings/SettingsView.swift +++ b/graphcode/Sources/Features/Settings/SettingsView.swift @@ -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 " diff --git a/graphcode/Tests/FeatureRampsTests.swift b/graphcode/Tests/FeatureRampsTests.swift index 31a7059f..8fc98612 100644 --- a/graphcode/Tests/FeatureRampsTests.swift +++ b/graphcode/Tests/FeatureRampsTests.swift @@ -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. diff --git a/graphcode/Tests/SettingsArtifactoryTests.swift b/graphcode/Tests/SettingsArtifactoryTests.swift index 67b7aee8..64a65669 100644 --- a/graphcode/Tests/SettingsArtifactoryTests.swift +++ b/graphcode/Tests/SettingsArtifactoryTests.swift @@ -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 @@ -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)) } }