From 701dd0cef595f8d09afedaf4f24fe16b5504b849 Mon Sep 17 00:00:00 2001 From: Max Anderson Date: Sun, 13 Sep 2026 14:03:36 -0400 Subject: [PATCH] feat: switch active OpenCode accounts from all Tally surfaces OpenCode session ID: ses_f64815ab1ffexWF6igUlIRbp0j using model: openai/gpt-6-astra --- README.md | 2 + Sources/TallyApp/TallyApp.swift | 21 +++++ Sources/TallyCore/Models.swift | 1 + Sources/TallyCore/OpenCodeInventory.swift | 10 +- Sources/TallyCore/OpenCodeSelection.swift | 76 +++++++++++++++ Sources/TallyCore/TallyOwner.swift | 42 +++++++++ Sources/TallyHTTP/TallyHTTP.swift | 10 +- Tests/TallyTests/ActivityTests.swift | 4 +- Tests/TallyTests/HTTPTests.swift | 28 ++++++ Tests/TallyTests/RuntimeTests.swift | 107 +++++++++++++++++++++- companion/README.md | 3 + companion/src/contract.ts | 7 +- companion/src/tally.ts | 13 +++ companion/test/tally.test.ts | 21 ++++- docs/adr/0001-single-app-runtime.md | 8 ++ web/src/api.ts | 1 + web/src/main.tsx | 29 +++++- web/src/style.css | 2 + 18 files changed, 372 insertions(+), 13 deletions(-) create mode 100644 Sources/TallyCore/OpenCodeSelection.swift diff --git a/README.md b/README.md index 605773b..fea3e78 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ Click the menu bar glyph to open the popover. Tally refreshes on launch, on wake **Manage accounts in OpenCode.** Tally opens OpenCode's credential database read-only. OpenCode and its auth plugins own sign-in and token refresh. Tally uses the stored access token for usage collection, model discovery, and warm-up; it never refreshes tokens or writes to OpenCode's database. Collection works while OpenCode is stopped and stored tokens remain usable. +**Switch accounts from Tally.** Native and web account cards show **Active in OpenCode** and a **Use in OpenCode** button for inactive accounts. The companion supports `{"action":"activate","accountId":"opaque-ID"}` after resolving the account with `accounts`. Switching requires the local OpenCode service and uses its activation API so OpenCode reloads provider state. It changes the selected account for that provider on the Mac running Tally. Pinning and collection remain independent of selection. Selection follows Tally's inventory refresh; an unavailable inventory shows selection as unknown. + **Quota bars turn red when you are burning too fast.** A blue bar means your current pace fits inside the window. Red means your average usage projects that you will hit the limit before the window resets, and Tally shows how long you have. The small tick mark is the even-pace marker: where you would be if you spread the window evenly. **Pin what you care about.** Pinned accounts appear at the top and their percentages show directly in the menu bar. Everything else groups by provider below. Reorder accounts in Settings, and click an account's icon to change its color. diff --git a/Sources/TallyApp/TallyApp.swift b/Sources/TallyApp/TallyApp.swift index 201a931..9fe6f85 100644 --- a/Sources/TallyApp/TallyApp.swift +++ b/Sources/TallyApp/TallyApp.swift @@ -171,6 +171,17 @@ final class Runtime: ObservableObject { startServer() } + @Published var switchingAccount: String? + + func activate(_ account: Account) async { + guard switchingAccount == nil else { return } + switchingAccount = account.id + defer { switchingAccount = nil } + do { snapshot = try await owner.activate(accountID: account.id); settingsError = nil } + catch let fault as Fault { settingsError = fault.message; snapshot = await owner.snapshot() } + catch { settingsError = "Account switch was not confirmed. Refresh Accounts before trying again." } + } + func pin(_ account: Account) async { var ids = (snapshot?.accounts ?? []).filter(\.pinned).map(\.id) if account.pinned { ids.removeAll { $0 == account.id } } @@ -319,6 +330,16 @@ struct AccountCard: View { Button { details.toggle() } label: { Image(systemName: details ? "chevron.up" : "chevron.down") } .buttonStyle(.plain).accessibilityLabel("Details for \(account.name)") } + HStack { + if account.active == true { + Label("Active in OpenCode", systemImage: "checkmark.circle").font(.caption) + } else { + Button(runtime.switchingAccount == account.id ? "Switching…" : "Use in OpenCode") { + Task { await runtime.activate(account) } + }.font(.caption).disabled(runtime.switchingAccount != nil || account.active == nil) + if account.active == nil { Text("Selection unavailable").font(.caption).foregroundStyle(.secondary) } + } + } if let operation = runtime.resetOperations[account.id], operation.state != .pending, !operation.acknowledgementRequired || resetExplanation { VStack(alignment: .leading, spacing: 8) { diff --git a/Sources/TallyCore/Models.swift b/Sources/TallyCore/Models.swift index d86395c..34a73f4 100644 --- a/Sources/TallyCore/Models.swift +++ b/Sources/TallyCore/Models.swift @@ -126,6 +126,7 @@ public struct Account: Codable, Sendable, Identifiable { public var service = "opencode-go" public var name: String public var pinned = true + public var active: Bool? = nil @Null public var pinOrder: Int? = nil public var identityColorIndex = 0 public var pin = Pin() diff --git a/Sources/TallyCore/OpenCodeInventory.swift b/Sources/TallyCore/OpenCodeInventory.swift index b918ac1..26bc85a 100644 --- a/Sources/TallyCore/OpenCodeInventory.swift +++ b/Sources/TallyCore/OpenCodeInventory.swift @@ -10,6 +10,7 @@ struct StoredCredential: Sendable { var refresh: String? = nil var workspace: String? = nil var expiresAt: Date? = nil + var active = false var fingerprint: String { identityDigest(key) } var preferenceKey: String { identityDigest("\(provider)\u{0}\(storedID)") } @@ -63,7 +64,7 @@ public struct OpenCodeInventory: Sendable { sqlite3_busy_timeout(database, 1000) var statement: OpaquePointer? // Selecting required columns validates their presence while accepting additive schema changes. - let sql = "SELECT id, label, value, integration_id, time_created FROM credential WHERE integration_id IN ('anthropic', 'openai', 'opencode-go', 'xai') ORDER BY time_created, id" + let sql = "SELECT id, label, value, integration_id, time_created, id = (SELECT chosen.id FROM credential AS chosen WHERE chosen.integration_id = credential.integration_id ORDER BY chosen.active DESC, chosen.time_created DESC, chosen.id DESC LIMIT 1) FROM credential WHERE integration_id IN ('anthropic', 'openai', 'opencode-go', 'xai') ORDER BY time_created, id" guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else { throw Fault("inventory_schema_incompatible", "OpenCode's credential schema is not compatible with this Tally build.") } @@ -125,8 +126,11 @@ public struct OpenCodeInventory: Sendable { throw Fault("credentials_unavailable", "A stored credential is invalid. Manage this Account in OpenCode.") } let credential = StoredCredential(storedID: try text(0), name: try text(1), key: secret, provider: provider, - refresh: refresh, workspace: workspace.flatMap { $0.isEmpty ? nil : $0 }, expiresAt: expiresAt) - if !credentials.contains(where: { $0.evidence.relation(to: credential.evidence) == .same }) { credentials.append(credential) } + refresh: refresh, workspace: workspace.flatMap { $0.isEmpty ? nil : $0 }, expiresAt: expiresAt, + active: sqlite3_column_int(statement, 5) == 1) + if let duplicate = credentials.firstIndex(where: { $0.evidence.relation(to: credential.evidence) == .same }) { + credentials[duplicate].active = credentials[duplicate].active || credential.active + } else { credentials.append(credential) } } guard try databaseIdentity() == identity else { throw Fault("inventory_unavailable", "OpenCode database changed during the read.") } return InventoryRead(databaseIdentity: identity, credentials: credentials) diff --git a/Sources/TallyCore/OpenCodeSelection.swift b/Sources/TallyCore/OpenCodeSelection.swift new file mode 100644 index 0000000..f40abbc --- /dev/null +++ b/Sources/TallyCore/OpenCodeSelection.swift @@ -0,0 +1,76 @@ +import Foundation + +struct OpenCodeSelection: Sendable { + var environment = ProcessInfo.processInfo.environment + var home = FileManager.default.homeDirectoryForCurrentUser.path + var send: @Sendable (URLRequest) async throws -> (Data, HTTPURLResponse) = { request in + let session = URLSession(configuration: .ephemeral, delegate: SelectionRedirects(), delegateQueue: nil) + defer { session.finishTasksAndInvalidate() } + let (data, response) = try await session.data(for: request) + guard let response = response as? HTTPURLResponse else { throw Fault("opencode_unavailable", "OpenCode returned an invalid response.") } + return (data, response) + } + + func activate(_ credential: StoredCredential, databasePath: String) async throws { + struct Registration: Decodable { var url: URL; var password: String } + struct Configuration: Decodable { var env: [String: String]? } + let state = environment["XDG_STATE_HOME"] ?? home + "/.local/state" + let config = environment["XDG_CONFIG_HOME"] ?? home + "/.config" + let registration: Registration + do { + registration = try JSONDecoder().decode(Registration.self, from: Data(contentsOf: URL(fileURLWithPath: state + "/opencode/service.json"))) + } catch { throw Fault("opencode_unavailable", "Start the local OpenCode service before switching Accounts.") } + var serviceEnvironment = environment + let configURL = URL(fileURLWithPath: config + "/opencode/service.json") + if FileManager.default.fileExists(atPath: configURL.path) { + do { + let configuration = try JSONDecoder().decode(Configuration.self, from: Data(contentsOf: configURL)) + serviceEnvironment.merge(configuration.env ?? [:]) { _, new in new } + } catch { throw Fault("opencode_unavailable", "OpenCode's service configuration could not be read.") } + } + let expected = OpenCodeInventory.defaultPath(environment: serviceEnvironment, home: home) + guard try OpenCodeInventory(path: expected).databaseIdentity() == OpenCodeInventory(path: databasePath).databaseIdentity() else { + throw Fault("account_changed", "Tally must read the local OpenCode service's database to switch Accounts. Check the database path in Settings.") + } + guard registration.url.scheme == "http", let host = registration.url.host, + ["127.0.0.1", "localhost", "[::1]", "::1"].contains(host), + registration.url.user == nil, registration.url.password == nil, + registration.url.query == nil, registration.url.fragment == nil else { + throw Fault("opencode_unavailable", "Account switching requires a local OpenCode service.") + } + func request(_ path: String, method: String = "GET") -> URLRequest { + var request = URLRequest(url: registration.url.appendingPathComponent(path), timeoutInterval: 10) + request.httpMethod = method + request.setValue("Basic " + Data("opencode:\(registration.password)".utf8).base64EncodedString(), forHTTPHeaderField: "Authorization") + return request + } + // Verify the service knows this stored row before sending the explicit selection command. + struct Integration: Decodable { + var connections: [Connection] + struct Connection: Decodable { var id: String } + } + struct IntegrationResponse: Decodable { var data: Integration } + do { + let (data, response) = try await send(request("api/integration/" + credential.provider)) + guard response.statusCode == 200, + try JSONDecoder().decode(IntegrationResponse.self, from: data).data.connections.contains(where: { $0.id == credential.storedID }) else { + throw Fault("account_changed", "The Account is not available in the local OpenCode service. Refresh Accounts.") + } + } catch let fault as Fault { throw fault } + catch { throw Fault("opencode_unavailable", "Cannot read Accounts from OpenCode. Check that its local service is running.") } + do { + let (_, response) = try await send(request("api/credential/" + credential.storedID + "/activate", method: "POST")) + guard response.statusCode == 204 else { + throw Fault("account_switch_unconfirmed", "OpenCode did not confirm the switch. Refresh Accounts before trying again.") + } + } catch let fault as Fault { throw fault } + catch { throw Fault("account_switch_unconfirmed", "The switch response was lost. Refresh Accounts to check the selection before trying again.") } + } +} + +private final class SelectionRedirects: NSObject, URLSessionTaskDelegate { + func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void) { + completionHandler(nil) + } +} diff --git a/Sources/TallyCore/TallyOwner.swift b/Sources/TallyCore/TallyOwner.swift index 4ef5669..e344d41 100644 --- a/Sources/TallyCore/TallyOwner.swift +++ b/Sources/TallyCore/TallyOwner.swift @@ -30,6 +30,10 @@ public actor TallyOwner { private let warmupRunner = ProviderWarmup() private var warmupTask: Task? private var warmingAccount: String? + private var switchingAccount = false + private var activateCredential: @Sendable (StoredCredential, String) async throws -> Void = { credential, path in + try await OpenCodeSelection().activate(credential, databasePath: path) + } var warmupSend: (@Sendable (WarmupRequest) async throws -> Void)? private var warmupModelList: (@Sendable (StoredCredential) async throws -> [WarmupModel])? var warmupJitter: @Sendable () -> TimeInterval = { Double.random(in: 1...1200) } @@ -69,6 +73,7 @@ public actor TallyOwner { warmupSend: @escaping @Sendable (WarmupRequest) async throws -> Void = { _ in throw Fault("unexpected", "No test warm-up transport configured.") }, warmupJitter: @escaping @Sendable () -> TimeInterval = { 600 }, warmupModels: (@Sendable (StoredCredential) async throws -> [WarmupModel])? = nil, + activate: @escaping @Sendable (StoredCredential, String) async throws -> Void = { _, _ in throw Fault("unexpected", "No test Account switch configured.") }, quitWait: Duration = .seconds(15), inventory: @escaping @Sendable () throws -> InventoryRead, collections: (@Sendable (StoredCredential) -> [CollectionJob])? = nil, @@ -87,6 +92,7 @@ public actor TallyOwner { self.warmupSend = warmupSend self.warmupJitter = warmupJitter self.warmupModelList = warmupModels + self.activateCredential = activate self.quitWait = quitWait } @@ -95,6 +101,7 @@ public actor TallyOwner { var inventory = inventory; inventory.age(at: now) let display = accounts.map { original in var account = original + if inventory.stale { account.active = nil } if let block = redemptions.block(accountID: account.id, target: credentials[account.id]?.evidence) { account.command = CommandSummary(blockingOperationId: block.result.operationId, state: block.result.state.rawValue, acknowledgementRequired: block.result.acknowledgementRequired) @@ -136,6 +143,40 @@ public actor TallyOwner { public func settingsError() -> Fault? { storageError } + public func activate(accountID: String) async throws -> AccountsResponse { + guard !stopping, !switchingAccount else { + throw Fault("account_switch_busy", "An Account switch is already running or Tally is shutting down.") + } + guard let selected = credentials[accountID] else { throw Fault("account_not_found", "Account not found. Refresh the inventory.") } + let namespace = databaseIdentity + inventory.stale = true + let current = try inventorySource() + guard current.databaseIdentity == namespace, + let credential = current.credentials.first(where: { $0.storedID == selected.storedID && $0.provider == selected.provider }), + credential.evidence.relation(to: selected.evidence) == .same else { + throw Fault("account_changed", "The Account changed. Refresh the inventory before switching.") + } + reconcile(current) + if credential.active { return snapshot() } + switchingAccount = true + inventory.stale = true + defer { switchingAccount = false } + do { try await activateCredential(credential, databasePath) } + catch { + // A lost response can follow a successful switch. Refresh state, but never replay the command. + if databaseIdentity == namespace, let latest = try? inventorySource(), latest.databaseIdentity == namespace { reconcile(latest) } + throw error + } + guard databaseIdentity == namespace else { throw Fault("account_changed", "Tally's database changed during the switch. Refresh the inventory.") } + let latest = try inventorySource() + guard latest.databaseIdentity == namespace else { throw Fault("account_changed", "OpenCode's database changed during the switch. Refresh the inventory.") } + reconcile(latest) + guard snapshot().accounts.first(where: { $0.id == accountID })?.active == true else { + throw Fault("account_switch_unconfirmed", "OpenCode accepted the switch, but the Account is no longer selected. Refresh Accounts before trying again.") + } + return snapshot() + } + public func warmupStatuses() -> [String: WarmupStatus] { guard let databaseIdentity else { return [:] } return store.state.namespaces[databaseIdentity]?.warmups ?? [:] @@ -386,6 +427,7 @@ public actor TallyOwner { namespace.records.append(IdentityRecord(evidence: credential.evidence, account: account, present: true)) } account.name = credential.name + account.active = credential.active // Token rotation can replace the Account ID. Warm-up follows the stored row and workspace, // including its cooldown and interrupted-attempt state so rotation cannot replay a message. let warmupKey = identityDigest("\(credential.preferenceKey)\u{0}\(credential.workspace ?? "")") diff --git a/Sources/TallyHTTP/TallyHTTP.swift b/Sources/TallyHTTP/TallyHTTP.swift index a853d22..986bab2 100644 --- a/Sources/TallyHTTP/TallyHTTP.swift +++ b/Sources/TallyHTTP/TallyHTTP.swift @@ -71,6 +71,14 @@ public struct TallyResponder: HTTPResponder { } do { let parts = path.split(separator: "/", omittingEmptySubsequences: false) + if parts.count == 6, parts[1] == "api", parts[2] == "v1", parts[3] == "accounts", !parts[4].isEmpty, parts[5] == "activate" { + guard request.method == .post else { return failure(.methodNotAllowed, Fault("method_not_allowed", "Use POST to activate an Account.")) } + let buffer = try await request.body.collect(upTo: 16_384) + guard let object = try JSONSerialization.jsonObject(with: Data(buffer.readableBytesView)) as? [String: Any], object.isEmpty else { + throw Fault("invalid_request", "Account activation requires an empty JSON object.") + } + return try json(await owner.activate(accountID: String(parts[4]))) + } if parts.count == 6, parts[1] == "api", parts[2] == "v1", parts[3] == "accounts", !parts[4].isEmpty, parts[5] == "color" { guard request.method == .put else { return failure(.methodNotAllowed, Fault("method_not_allowed", "Use PUT to save an Account color.")) } let buffer = try await request.body.collect(upTo: 16_384) @@ -174,7 +182,7 @@ public struct TallyResponder: HTTPResponder { switch fault.code { case "invalid_request", "warmup_model", "warmup_unnecessary": status = .badRequest case "account_not_found", "operation_not_found": status = .notFound - case "operation_conflict", "account_blocked": status = .conflict + case "operation_conflict", "account_blocked", "account_switch_busy", "account_changed": status = .conflict default: status = .serviceUnavailable } return failure(status, fault) diff --git a/Tests/TallyTests/ActivityTests.swift b/Tests/TallyTests/ActivityTests.swift index fbc3bef..f9a03d2 100644 --- a/Tests/TallyTests/ActivityTests.swift +++ b/Tests/TallyTests/ActivityTests.swift @@ -207,7 +207,7 @@ private struct ActivityDatabase { try execute(""" CREATE TABLE session_v2 (id TEXT PRIMARY KEY, parent_id TEXT, fork_session_id TEXT, fork_boundary TEXT, time_created INTEGER); CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT, type TEXT, seq INTEGER, time_created INTEGER, data TEXT); - CREATE TABLE credential (id TEXT, label TEXT, value TEXT, integration_id TEXT, time_created INTEGER); + CREATE TABLE credential (id TEXT, label TEXT, value TEXT, integration_id TEXT, time_created INTEGER, active INTEGER); INSERT INTO session_v2 VALUES ('parent',NULL,NULL,NULL,0), ('child','parent',NULL,NULL,0), ('fork',NULL,'parent','{"type":"through","messageID":"original"}',1000), ('orphan',NULL,'deleted',NULL,1000), ('nested',NULL,'fork','{"type":"before","messageID":"own"}',2000); @@ -279,7 +279,7 @@ private struct ActivityDatabase { @Test func activityRemainsAvailableWhenCredentialSchemaFailsAndAccountsDisappear() async throws { let db = try ActivityDatabase(); defer { try? FileManager.default.removeItem(at: db.directory) } try db.add("retained", session: "parent", seq: 1, time: Int(Date().timeIntervalSince1970 * 1000) - 1000) - try db.execute("INSERT INTO credential VALUES ('account','Account','{\"type\":\"key\",\"key\":\"fixture\"}','opencode-go',0)") + try db.execute("INSERT INTO credential VALUES ('account','Account','{\"type\":\"key\",\"key\":\"fixture\"}','opencode-go',0,1)") let owner = TallyOwner(databasePath: db.path, appBuild: "test", storageURL: nil) try await owner.refresh(accountIDs: []); await owner.waitForCollection() #expect(await owner.snapshot().accounts.count == 1) diff --git a/Tests/TallyTests/HTTPTests.swift b/Tests/TallyTests/HTTPTests.swift index c286067..de21476 100644 --- a/Tests/TallyTests/HTTPTests.swift +++ b/Tests/TallyTests/HTTPTests.swift @@ -209,6 +209,34 @@ private struct AuthorityResponder: HTTPResponder { } } +@Test func accountSelectionRouteSharesStateAndRejectsInvalidCommands() async throws { + let scenario = SelectionScenario() + let owner = scenario.owner() + _ = try await owner.refresh(accountIDs: []) + let target = try #require(await owner.snapshot().accounts.first(where: { $0.name == "b" })) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + try Data("Fixture".utf8).write(to: directory.appendingPathComponent("index.html")) + let app = try Application(responder: AuthorityResponder(next: TallyResponder(owner: owner, policy: HTTPPolicy(port: 7483), assetDirectory: directory))) + try await app.test(.router) { client in + let path = "/api/v1/accounts/\(target.id)/activate" + let headers: HTTPFields = [testAuthority: "127.0.0.1:7483", .contentType: "application/json"] + try await client.execute(uri: path, method: .get, headers: headers) { #expect($0.status == .methodNotAllowed) } + try await client.execute(uri: path, method: .post, headers: headers, body: .init(string: "{\"active\":false}")) { #expect($0.status == .badRequest) } + try await client.execute(uri: path, method: .post, headers: [testAuthority: "127.0.0.1:7483", .contentType: "application/json", .origin: "https://evil.example"], body: .init(string: "{}")) { #expect($0.status == .forbidden) } + #expect(scenario.count() == 0) + try await client.execute(uri: path, method: .post, headers: headers, body: .init(string: "{}")) { response in + #expect(response.status == .ok) + let result = try Wire.decoder().decode(AccountsResponse.self, from: Data(response.body.readableBytesView)) + #expect(result.accounts.first(where: { $0.id == target.id })?.active == true) + #expect(!String(buffer: response.body).contains("synthetic")) + } + #expect(scenario.count() == 1) + } + await owner.shutdown() +} + @Test @MainActor func listenerCollisionAndFreshLifetime() async throws { let owner = TallyOwner(clock: { Date() }, inventory: { InventoryRead(databaseIdentity: "test-db", credentials: []) }, collect: { _ in throw Fault("unexpected", "No collection expected.") }) let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/Tests/TallyTests/RuntimeTests.swift b/Tests/TallyTests/RuntimeTests.swift index a1bbd1a..05fb967 100644 --- a/Tests/TallyTests/RuntimeTests.swift +++ b/Tests/TallyTests/RuntimeTests.swift @@ -931,6 +931,8 @@ private final class AnthropicProtocol: URLProtocol, @unchecked Sendable { #expect(credentials.prefix(2).map(\.key) == ["key-a", "key-b"]) #expect(credentials[2].expiresAt == Date(timeIntervalSince1970: 0)) #expect(credentials[0].expiresAt == nil) + // OpenCode ranks active, creation time, then ID across all methods, before Tally filters subscriptions. + #expect(credentials.map(\.active) == [true, false, false, false, false, true]) #expect(try Data(contentsOf: URL(fileURLWithPath: path)) == before) #expect(throws: Fault.self) { try OpenCodeInventory(path: path + "-missing").read() } #expect(!FileManager.default.fileExists(atPath: path + "-missing")) @@ -938,6 +940,107 @@ private final class AnthropicProtocol: URLProtocol, @unchecked Sendable { #expect(OpenCodeInventory.defaultPath(environment: ["OPENCODE_DB": "/custom.db"], home: "/home") == "/custom.db") } +final class SelectionScenario: @unchecked Sendable { + private let lock = NSLock() + private var active = "a" + private var calls = 0 + var loseResponse = false + func read() -> InventoryRead { + lock.withLock { + InventoryRead(databaseIdentity: "selection", credentials: ["a", "b"].map { + StoredCredential(storedID: $0, name: $0, key: "synthetic-" + $0, active: active == $0) + } + [StoredCredential(storedID: "other", name: "Other provider", key: "other", provider: "xai", active: true)]) + } + } + func activate(_ credential: StoredCredential) throws { + try lock.withLock { + calls += 1; active = credential.storedID + if loseResponse { throw Fault("account_switch_unconfirmed", "Synthetic response loss") } + } + } + func count() -> Int { lock.withLock { calls } } + func owner() -> TallyOwner { + TallyOwner(clock: { Date() }, activate: { credential, _ in try self.activate(credential) }, inventory: { self.read() }, collect: { _ in throw Fault("unused", "No provider request expected") }) + } +} + +@Test func accountSelectionSharesOwnerAndDoesNotReplayLostResponses() async throws { + let scenario = SelectionScenario() + let owner = scenario.owner() + _ = try await owner.refresh(accountIDs: []) + let before = await owner.snapshot() + let target = try #require(before.accounts.first(where: { $0.name == "b" })) + let switched = try await owner.activate(accountID: target.id) + #expect(switched.accounts.filter { $0.active == true }.map(\.name).sorted() == ["Other provider", "b"]) + #expect(switched.accounts.map(\.id) == before.accounts.map(\.id)) + #expect(switched.accounts.map(\.pinned) == before.accounts.map(\.pinned)) + _ = try await owner.activate(accountID: target.id) + #expect(scenario.count() == 1) + await #expect(throws: Fault.self) { try await owner.activate(accountID: "missing") } + #expect(scenario.count() == 1) + await owner.shutdown() + + let lost = SelectionScenario(); lost.loseResponse = true + let lostOwner = lost.owner() + _ = try await lostOwner.refresh(accountIDs: []) + let lostTarget = try #require(await lostOwner.snapshot().accounts.first(where: { $0.name == "b" })) + await #expect(throws: Fault.self) { try await lostOwner.activate(accountID: lostTarget.id) } + #expect(await lostOwner.snapshot().accounts.first(where: { $0.id == lostTarget.id })?.active == true) + #expect(lost.count() == 1) + await lostOwner.shutdown() +} + +@Test func accountSelectionUsesAuthenticatedOpenCodeAPIAndChecksDatabase() async throws { + let home = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: home) } + let state = home.appendingPathComponent(".local/state/opencode") + let data = home.appendingPathComponent(".local/share/opencode") + try FileManager.default.createDirectory(at: state, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: data, withIntermediateDirectories: true) + let database = data.appendingPathComponent("opencode.db") + try Data().write(to: database) + try Data(#"{"url":"http://127.0.0.1:4096","password":"synthetic-password"}"#.utf8).write(to: state.appendingPathComponent("service.json")) + let credential = StoredCredential(storedID: "cred_test", name: "Test", key: "private") + let selection = OpenCodeSelection(environment: [:], home: home.path, send: { request in + #expect(request.value(forHTTPHeaderField: "Authorization") == "Basic " + Data("opencode:synthetic-password".utf8).base64EncodedString()) + #expect(request.httpBody == nil) + let url = try #require(request.url) + if request.httpMethod == "GET" { + #expect(url.path == "/api/integration/opencode-go") + return (Data(#"{"data":{"connections":[{"type":"credential","id":"cred_test"}]},"location":{}}"#.utf8), HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + #expect(request.httpMethod == "POST") + #expect(url.path == "/api/credential/cred_test/activate") + return (Data(), HTTPURLResponse(url: url, statusCode: 204, httpVersion: nil, headerFields: nil)!) + }) + try await selection.activate(credential, databasePath: database.path) + let other = home.appendingPathComponent("copy.db"); try Data().write(to: other) + await #expect(throws: Fault.self) { try await selection.activate(credential, databasePath: other.path) } + try FileManager.default.removeItem(at: state.appendingPathComponent("service.json")) + await #expect(throws: Fault.self) { try await selection.activate(credential, databasePath: database.path) } +} + +@Test func accountSelectionSerializesCommandsAndHidesUnconfirmedState() async throws { + let scenario = SelectionScenario() + let (started, start) = AsyncStream.makeStream() + let (released, release) = AsyncStream.makeStream() + let owner = TallyOwner(clock: { Date() }, activate: { credential, _ in + start.yield(()); start.finish() + for await _ in released { break } + try scenario.activate(credential) + }, inventory: { scenario.read() }, collect: { _ in throw Fault("unused", "No provider requests expected") }) + _ = try await owner.refresh(accountIDs: []) + let target = try #require(await owner.snapshot().accounts.first(where: { $0.name == "b" })) + let pending = Task { try await owner.activate(accountID: target.id) } + for await _ in started { break } + #expect(await owner.snapshot().accounts.allSatisfy { $0.active == nil }) + await #expect(throws: Fault.self) { try await owner.activate(accountID: target.id) } + release.yield(()); release.finish() + #expect(try await pending.value.accounts.first(where: { $0.id == target.id })?.active == true) + #expect(scenario.count() == 1) + await owner.shutdown() +} + private final class Scenario: @unchecked Sendable { private let lock = NSLock() private var currentTime = Date(timeIntervalSince1970: 1_915_017_600) @@ -1282,7 +1385,7 @@ private final class InventoryScenario: @unchecked Sendable { var db: OpaquePointer? #expect(sqlite3_open(path, &db) == SQLITE_OK) defer { sqlite3_close(db) } - #expect(sqlite3_exec(db, "CREATE TABLE credential (id TEXT, label TEXT, value TEXT, integration_id TEXT, time_created INTEGER); INSERT INTO credential VALUES ('same-row', 'Same', '{\"type\":\"key\",\"key\":\"same-key\"}', 'opencode-go', 1); CREATE TABLE message (incompatible TEXT);", nil, nil, nil) == SQLITE_OK) + #expect(sqlite3_exec(db, "CREATE TABLE credential (id TEXT, label TEXT, value TEXT, integration_id TEXT, time_created INTEGER, active INTEGER); INSERT INTO credential VALUES ('same-row', 'Same', '{\"type\":\"key\",\"key\":\"same-key\"}', 'opencode-go', 1, 1); CREATE TABLE message (incompatible TEXT);", nil, nil, nil) == SQLITE_OK) } try create(path) let owner = TallyOwner(databasePath: path, appBuild: "test", storageURL: directory.appendingPathComponent("state.json")) @@ -1365,7 +1468,7 @@ private final class InventoryScenario: @unchecked Sendable { let path = directory.appendingPathComponent("opencode.db").path var db: OpaquePointer? #expect(sqlite3_open(path, &db) == SQLITE_OK) - #expect(sqlite3_exec(db, "CREATE TABLE credential (id TEXT, label TEXT, value TEXT, integration_id TEXT, time_created INTEGER); INSERT INTO credential VALUES ('row', 'Go', '{\"type\":\"key\",\"key\":\"secret\"}', 'opencode-go', 1);", nil, nil, nil) == SQLITE_OK) + #expect(sqlite3_exec(db, "CREATE TABLE credential (id TEXT, label TEXT, value TEXT, integration_id TEXT, time_created INTEGER, active INTEGER); INSERT INTO credential VALUES ('row', 'Go', '{\"type\":\"key\",\"key\":\"secret\"}', 'opencode-go', 1, 1);", nil, nil, nil) == SQLITE_OK) sqlite3_close(db) let source = OpenCodeInventory(path: path) let (observations, continuation) = AsyncStream.makeStream() diff --git a/companion/README.md b/companion/README.md index 0dc1fd4..c1808fd 100644 --- a/companion/README.md +++ b/companion/README.md @@ -49,11 +49,14 @@ Companion 0.1.0 supports **API major 1**. App and companion release numbers need ## Model actions +Accounts report `active: true` for the stored account selected in OpenCode, `false` for inactive accounts, and null or an absent field when selection is unknown. `activate` requires an explicit user request naming the account. It changes the selected account for that provider in the local OpenCode service on the Mac running Tally, including when the companion runs remotely. Resolve names with `accounts`; ask about ambiguous names. Never switch automatically because an account is low on quota. An unconfirmed response requires reading Accounts before another explicit switch; the companion does not retry it. + | Input | REST request | | --- | --- | | `{"action":"status"}` | `GET /api/v1/status` | | `{"action":"accounts"}` | `GET /api/v1/accounts` | | `{"action":"accounts","accountId":"opaque-ID"}` | `GET /api/v1/accounts/{accountId}` | +| `{"action":"activate","accountId":"opaque-ID"}` | Compatibility check, then `POST /api/v1/accounts/{accountId}/activate` with `{}` | | `{"action":"activity","range":"today"}` | `GET /api/v1/activity?range=today` | | `{"action":"refresh"}` | Compatibility check, then `POST /api/v1/refresh` with `{}` | | `{"action":"refresh","accountIds":[]}` | Compatibility check, then refresh activity only | diff --git a/companion/src/contract.ts b/companion/src/contract.ts index 3ea507f..531c775 100644 --- a/companion/src/contract.ts +++ b/companion/src/contract.ts @@ -41,6 +41,7 @@ const Credit = object({ export const Account = object({ id: text, provider: Provider, service: z.enum(['claude-subscription', 'chatgpt-subscription', 'opencode-go', 'grok-subscription']), name: text, pinned: flag, pinOrder: nullableNumber, identityColorIndex: number, + active: flag.nullable().optional(), pin: object({ lines: z.array(object({ windowId: text, label: text, remainingPercent: nullableNumber, stale: flag })), warning: flag }), groups: object({ plan: group(object({ name: text })), quotas: group(object({ windows: z.array(QuotaWindow) })), @@ -90,13 +91,14 @@ export const Redemption = object({ const operationId = z.string().regex(/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, 'Use the original operation UUID') // Anthropic requires an object root; action-specific field pairing is checked during execution. export const Input = z.strictObject({ - action: z.enum(['status', 'accounts', 'activity', 'refresh', 'redeem', 'redemption', 'acknowledge']), + action: z.enum(['status', 'accounts', 'activity', 'refresh', 'activate', 'redeem', 'redemption', 'acknowledge']).describe('activate switches the named accountId in the OpenCode service on the Mac running Tally. Use only when the user requests that account switch. Resolve names with accounts, which reports active: true/false (null or absent means unknown). Never switch automatically based on quota levels or retry a lost switch response; read accounts to check the selection.'), accountId: id.optional(), range: Range.optional(), accountIds: z.array(id).optional(), operationId: operationId.optional(), creditId: id.optional(), }) export const Command = z.discriminatedUnion('action', [ z.strictObject({ action: z.literal('status') }), z.strictObject({ action: z.literal('accounts'), accountId: id.optional() }), + z.strictObject({ action: z.literal('activate'), accountId: id }), z.strictObject({ action: z.literal('activity'), range: Range.optional() }), z.strictObject({ action: z.literal('refresh'), accountIds: z.array(id).optional() }), z.strictObject({ action: z.literal('redeem'), accountId: id, operationId, creditId: id.optional() }), @@ -107,11 +109,12 @@ export type TallyInput = z.infer export const Result = z.union([ object({ ok: z.literal(true), action: z.literal('status'), data: Status }), object({ ok: z.literal(true), action: z.literal('accounts'), data: z.union([AccountsResponse, AccountResponse]) }), + object({ ok: z.literal(true), action: z.literal('activate'), data: AccountsResponse }), object({ ok: z.literal(true), action: z.literal('activity'), data: ActivityResponse }), object({ ok: z.literal(true), action: z.literal('refresh'), data: RefreshResponse }), object({ ok: z.literal(true), action: z.literal('redeem'), data: Redemption }), object({ ok: z.literal(true), action: z.literal('redemption'), data: Redemption }), object({ ok: z.literal(true), action: z.literal('acknowledge'), data: Redemption }), - object({ ok: z.literal(false), action: z.enum(['status', 'accounts', 'activity', 'refresh', 'redeem', 'redemption', 'acknowledge']), error: Fault }), + object({ ok: z.literal(false), action: z.enum(['status', 'accounts', 'activity', 'refresh', 'activate', 'redeem', 'redemption', 'acknowledge']), error: Fault }), ]) export type TallyResult = z.infer diff --git a/companion/src/tally.ts b/companion/src/tally.ts index 89cbe08..86a855a 100644 --- a/companion/src/tally.ts +++ b/companion/src/tally.ts @@ -53,6 +53,19 @@ export function createTally(options: unknown = {}) { const validated = Command.safeParse(raw) if (!validated.success) throw fault('invalid_request', 'Use an action with its documented fields. Account and credit IDs must be nonempty; reset actions require the original operation UUID. Unrelated fields are not accepted.') const input = validated.data + if (input.action === 'activate') { + status(await request('/status')) + try { + const data = await request(`/accounts/${encodeURIComponent(input.accountId)}/activate`, {}) + const result = decode(AccountsResponse, data) + status(result.status) + return { ok: true, action: input.action, data: result } + } catch (error) { + const known = Fault.safeParse(error) + if (known.success && !['app_unavailable', 'invalid_response', 'http_error'].includes(known.data.code)) throw error + throw fault('account_switch_unconfirmed', 'The switch response was lost or incompatible. Read accounts to check active status; do not automatically resend the switch.') + } + } if (input.action === 'redeem' || input.action === 'redemption' || input.action === 'acknowledge') { status(await request('/status')) const path = `/redemptions/${encodeURIComponent(input.operationId)}` diff --git a/companion/test/tally.test.ts b/companion/test/tally.test.ts index 0dd71cd..4dd2773 100644 --- a/companion/test/tally.test.ts +++ b/companion/test/tally.test.ts @@ -71,6 +71,25 @@ test('refresh verifies major first and preserves all scheduling states, omitted/ }) }) +test('activate requires an explicit account, returns active state, and never retries an unconfirmed response', async () => { + assert.equal(Command.safeParse({ action: 'activate' }).success, false) + assert.equal(Command.safeParse({ action: 'activate', accountId: '' }).success, false) + const selected = { ...accounts, accounts: accounts.accounts.map((account, index) => ({ ...account, active: index === 0 })) } + await serve(async (baseURL, requests) => { + const result = await createTally({ baseURL }).execute({ action: 'activate', accountId: 'opaque /?#' }) + assert.deepEqual(result.output, { ok: true, action: 'activate', data: selected }) + assert.deepEqual(requests.map(request => [request.path, request.method, request.body]), [ + ['/api/v1/status', 'GET', ''], ['/api/v1/accounts/opaque%20%2F%3F%23/activate', 'POST', '{}'], + ]) + }, path => ({ data: path === '/api/v1/status' ? accounts.status : selected })) + await serve(async (baseURL, requests) => { + const result = await createTally({ baseURL }).execute({ action: 'activate', accountId: 'one' }) + assert.equal(result.output.ok, false) + if (!result.output.ok) assert.equal(result.output.error.code, 'account_switch_unconfirmed') + assert.equal(requests.filter(request => request.method === 'POST').length, 1) + }, path => ({ data: path === '/api/v1/status' ? accounts.status : {} })) +}) + test('command schema rejects invalid actions, IDs, ranges, nulls and unrelated action fields', () => { for (const value of [null, {}, { action: 'redeem' }, { action: 'status', range: 'today' }, { action: 'accounts', accountId: '' }, { action: 'accounts', accountId: null }, { action: 'activity', range: 'week' }, { action: 'refresh', accountIds: null }, { action: 'refresh', accountIds: [3] }, { action: 'refresh', accountIds: [''] }]) { assert.equal(Command.safeParse(value).success, false, JSON.stringify(value)) @@ -168,7 +187,7 @@ test('the output schema rejects success DTOs paired with the wrong action', () = assert.equal(Result.safeParse({ ok: true, action, data }).success, false) } const schema = z.toJSONSchema(Result) - assert(schema.anyOf && schema.anyOf.length === 8) + assert(schema.anyOf && schema.anyOf.length === 9) const inputSchema = z.toJSONSchema(createTally().input, { target: 'draft-2020-12', io: 'input' }) assert.equal(inputSchema.type, 'object') for (const keyword of ['anyOf', 'oneOf', 'allOf']) assert.equal(keyword in inputSchema, false) diff --git a/docs/adr/0001-single-app-runtime.md b/docs/adr/0001-single-app-runtime.md index 31dd811..ae6f320 100644 --- a/docs/adr/0001-single-app-runtime.md +++ b/docs/adr/0001-single-app-runtime.md @@ -4,6 +4,14 @@ Tally's resident Swift macOS app owns collection, cached readings, banked-reset Tally reads one local OpenCode V2 credential database read-only so collection can continue while OpenCode is stopped and stored credentials remain usable. OpenCode and its auth plugins own account names, sign-in, and token refresh. Warm-up reads the selected Account's current access token and makes one message request; model discovery is a separate read-only operation for Settings. Tally never calls OAuth refresh endpoints or writes to OpenCode's database. Expired or rejected credentials depend on OpenCode to supply a usable token. Tally accepts private-schema coupling rather than requiring a live plugin collector. Incompatible schema changes stop credential reads and leave last-known readings explicitly stale until compatibility is restored. +Account selection is the one OpenCode mutation exposed by Tally. All three surfaces call the owner, which asks the local OpenCode service to activate a stored credential, then rereads the database to confirm selection. OpenCode owns the write and its `credential.switched` event; direct SQL would skip provider-state reloads. Collection remains independent of the service. Switching discovers the standard local service registration, checks the configured database identity and stored connection, and requires a running service. It does not start or restart OpenCode. Failed or lost responses are not automatically retried. + +Source for activation and provider reload behavior: + +https://github.com/anomalyco/opencode/blob/beta/packages/core/src/credential.ts + +https://github.com/anomalyco/opencode/blob/beta/packages/core/src/plugin/provider/openai.ts + The canonical resolution, lifecycle and distribution choices, source evidence, and follow-up scope are recorded in **Define Tally's single-app architecture, credential access, and lifecycle**: https://github.com/MaxAnderson95/tally/issues/5 diff --git a/web/src/api.ts b/web/src/api.ts index 7b88274..11db7da 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -15,6 +15,7 @@ export type QuotaWindow = { export type Account = { id: string; provider: string; service: string; name: string; pinned: boolean; pinOrder: number | null identityColorIndex: number + active?: boolean | null pin: { lines: { windowId: string; label: string; remainingPercent: number | null; stale: boolean }[]; warning: boolean } groups: { plan: Group<{ name: string }>; quotas: Group<{ windows: QuotaWindow[] }> diff --git a/web/src/main.tsx b/web/src/main.tsx index bcf399b..11c70d4 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -10,7 +10,7 @@ import { AccountPreferences, ColorPicker, type PreferenceChange } from './Accoun import { PullToReload } from './PullToReload' import { useWarmups } from './WarmupPreferences' -function AccountCard({ account, timezone, disconnected, inventoryError, now, save, saving, warmupWarning }: { account: Account; timezone: string; disconnected: boolean; inventoryError: Fault | null; now: number; save: (change: PreferenceChange) => Promise; saving: boolean; warmupWarning?: string }) { +function AccountCard({ account, timezone, disconnected, inventoryError, now, save, saving, warmupWarning, activate, switching }: { account: Account; timezone: string; disconnected: boolean; inventoryError: Fault | null; now: number; save: (change: PreferenceChange) => Promise; saving: boolean; warmupWarning?: string; activate: (id: string) => Promise; switching?: string }) { const quota = account.groups.quotas const windows = overviewWindows(account) const extra = account.groups.extraUsage @@ -31,6 +31,12 @@ function AccountCard({ account, timezone, disconnected, inventoryError, now, sav {resets.warning} +
+ {account.active === true && !disconnected && !inventoryError ? ✓ Active in OpenCode : <> + + {(account.active == null || disconnected || !!inventoryError) && Selection unavailable} + } +
{resets.result} {warmupWarning &&

Auto warm-up: {warmupWarning} Open Settings to review.

} {windows.length === 0 &&
?

{quota.observedAt ? 'No quota windows reported' : 'Quota unavailable'}

} @@ -103,6 +109,8 @@ function App() { const [view, setView] = useState<'accounts' | 'activity'>('accounts') const [settingsOpen, setSettingsOpen] = useState(false) const [saving, setSaving] = useState(false) + const [switching, setSwitching] = useState() + const switchInFlight = useRef(false) const [preferenceError, setPreferenceError] = useState() const [schedule, setSchedule] = useState() const [activityObserved, setActivityObserved] = useState(null) @@ -152,6 +160,23 @@ function App() { } catch (failure) { setError(failure instanceof Error ? failure.message : 'Refresh failed.') } finally { setRefreshing(false) } } + async function activate(accountId: string) { + if (switchInFlight.current) return + switchInFlight.current = true + setSwitching(accountId) + setPreferenceError(undefined) + try { + const response = await fetch(`/api/v1/accounts/${encodeURIComponent(accountId)}/activate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', signal: AbortSignal.timeout(25_000) }) + if (!response.ok) { const result: { error: Fault } = await response.json(); throw new Error(result.error.message) } + setData(decodeAccounts(await response.text())) + } catch (failure) { + setPreferenceError(failure instanceof Error ? failure.message + ' Read the current selection before retrying.' : 'Account switch was not confirmed. Check the current selection before retrying.') + } finally { + switchInFlight.current = false + setSwitching(undefined) + window.dispatchEvent(new Event('tally:operation')) + } + } async function savePreference(change: PreferenceChange): Promise { setSaving(true) setPreferenceError(undefined) @@ -182,7 +207,7 @@ function App() {