Skip to content

Commit f868760

Browse files
authored
Swift desktop: path updates, decoder hardening, no-ops (#5302 Day 3) (#5381)
## Summary Sub-PR for #5302 desktop migration Day 3 — Swift client adaptation for Python backend. **Path updates (5 endpoints):** - `v2/chat/initial-message` → `v2/initial-message` - `v2/agent/provision` → `v1/agent/vm-ensure` - `v2/agent/status` → `v1/agent/vm-status` - `v1/personas/check-username` → `v1/apps/check-username` - `v1/personas/generate-prompt` → `v1/app/generate-prompts` (POST→GET) **Decoder hardening:** - `ServerConversation.createdAt`: `decodeIfPresent` with `Date()` fallback - `ActionItemsListResponse`: custom decoder tries `"action_items"` then `"items"` key (Python action-items vs staged-tasks endpoints) - `AgentProvisionResponse`/`AgentStatusResponse`: fields made optional, added `hasVm` field for Python's minimal response shape - `UsernameAvailableResponse`: supports both `is_taken` (Python) and `available` (Rust) via custom decoder **Graceful no-ops (3 endpoints removed from Python):** - `recordLlmUsage()` → no-op with log - `fetchTotalOmiAICost()` → returns nil immediately - `getChatMessageCount()` → returns 0 immediately **Remove staged-tasks migration (no longer needed):** - Removed `migrateStagedTasks()` and `migrateConversationItemsToStaged()` from APIClient - Removed migration callers and helper functions from TasksStore ## Files changed - `desktop/Desktop/Sources/APIClient.swift` — all path/decoder/no-op changes - `desktop/Desktop/Sources/AgentVMService.swift` — adapt to optional AgentVM response fields - `desktop/Desktop/Sources/Stores/TasksStore.swift` — remove migration functions and callers ## Notes - `regeneratePersonaPrompt()` now points to `v1/app/generate-prompts` (GET). The response shape differs from the old `v1/personas/generate-prompt` — may need attention if called. - AgentVM endpoints (`vm-ensure`, `vm-status`) return minimal `{has_vm, status}` from Python vs full VM details from Rust. The service gracefully degrades but won't have `authToken`/`ip` until those are added to Python responses. ## Test plan - [ ] Verify Swift compiles (no build errors from optional changes) - [ ] Mac Mini desktop test: app launches, no crash on startup - [ ] Verify action items load (ActionItemsListResponse decoder) - [ ] Verify username check works on persona page 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents afe4477 + 7f46f31 commit f868760

File tree

3 files changed

+91
-148
lines changed

3 files changed

+91
-148
lines changed

desktop/Desktop/Sources/APIClient.swift

Lines changed: 76 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -405,14 +405,10 @@ extension APIClient {
405405
return response.count
406406
}
407407

408-
/// Gets the count of AI chat messages from PostHog
408+
/// Gets the count of AI chat messages
409409
func getChatMessageCount() async throws -> Int {
410-
struct CountResponse: Decodable {
411-
let count: Int
412-
}
413-
414-
let response: CountResponse = try await get("v1/users/stats/chat-messages")
415-
return response.count
410+
// No-op: chat-messages stats endpoint not available in Python backend
411+
return 0
416412
}
417413

418414
/// Merges multiple conversations into a new conversation
@@ -581,7 +577,7 @@ struct ServerConversation: Codable, Identifiable, Equatable {
581577
let container = try decoder.container(keyedBy: CodingKeys.self)
582578

583579
id = try container.decode(String.self, forKey: .id)
584-
createdAt = try container.decode(Date.self, forKey: .createdAt)
580+
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
585581
startedAt = try container.decodeIfPresent(Date.self, forKey: .startedAt)
586582
finishedAt = try container.decodeIfPresent(Date.self, forKey: .finishedAt)
587583
structured = try container.decode(Structured.self, forKey: .structured)
@@ -1454,14 +1450,26 @@ struct UserProfile: Codable {
14541450
// MARK: - Action Items API
14551451

14561452
/// Response wrapper for paginated action items list
1457-
struct ActionItemsListResponse: Codable {
1453+
struct ActionItemsListResponse: Decodable {
14581454
let items: [TaskActionItem]
14591455
let hasMore: Bool
14601456

14611457
enum CodingKeys: String, CodingKey {
1458+
case actionItems = "action_items"
14621459
case items
14631460
case hasMore = "has_more"
14641461
}
1462+
1463+
init(from decoder: Decoder) throws {
1464+
let container = try decoder.container(keyedBy: CodingKeys.self)
1465+
hasMore = try container.decodeIfPresent(Bool.self, forKey: .hasMore) ?? false
1466+
// Python action_items endpoint returns "action_items"; staged-tasks returns "items"
1467+
if let actionItems = try container.decodeIfPresent([TaskActionItem].self, forKey: .actionItems) {
1468+
items = actionItems
1469+
} else {
1470+
items = try container.decodeIfPresent([TaskActionItem].self, forKey: .items) ?? []
1471+
}
1472+
}
14651473
}
14661474

14671475
extension APIClient {
@@ -1890,17 +1898,6 @@ extension APIClient {
18901898
return try await post("v1/staged-tasks/promote")
18911899
}
18921900

1893-
/// One-time migration of existing AI tasks to staged_tasks
1894-
func migrateStagedTasks() async throws {
1895-
struct StatusResponse: Decodable { let status: String }
1896-
let _: StatusResponse = try await post("v1/staged-tasks/migrate")
1897-
}
1898-
1899-
/// Migrate conversation-extracted action items (no source field) to staged_tasks
1900-
func migrateConversationItemsToStaged() async throws {
1901-
struct MigrateResponse: Decodable { let status: String; let migrated: Int; let deleted: Int }
1902-
let _: MigrateResponse = try await post("v1/staged-tasks/migrate-conversation-items")
1903-
}
19041901
}
19051902

19061903
/// Response for staged task promotion
@@ -3148,13 +3145,12 @@ extension APIClient {
31483145

31493146
/// Regenerates persona prompt from current public memories
31503147
func regeneratePersonaPrompt() async throws -> GeneratePromptResponse {
3151-
struct EmptyRequest: Encodable {}
3152-
return try await post("v1/personas/generate-prompt", body: EmptyRequest())
3148+
return try await get("v1/app/generate-prompts")
31533149
}
31543150

31553151
/// Checks if a username is available
31563152
func checkPersonaUsername(_ username: String) async throws -> UsernameAvailableResponse {
3157-
return try await get("v1/personas/check-username?username=\(username)")
3153+
return try await get("v1/apps/check-username?username=\(username)")
31583154
}
31593155
}
31603156

@@ -3252,9 +3248,27 @@ struct GeneratePromptResponse: Codable {
32523248
}
32533249

32543250
/// Response for username availability check
3255-
struct UsernameAvailableResponse: Codable {
3251+
struct UsernameAvailableResponse: Decodable {
32563252
let available: Bool
3257-
let username: String
3253+
let username: String?
3254+
let isTaken: Bool?
3255+
3256+
enum CodingKeys: String, CodingKey {
3257+
case available, username
3258+
case isTaken = "is_taken"
3259+
}
3260+
3261+
init(from decoder: Decoder) throws {
3262+
let container = try decoder.container(keyedBy: CodingKeys.self)
3263+
username = try container.decodeIfPresent(String.self, forKey: .username)
3264+
isTaken = try container.decodeIfPresent(Bool.self, forKey: .isTaken)
3265+
// Python returns is_taken; Rust returned available. Support both.
3266+
if let isTaken = isTaken {
3267+
available = !isTaken
3268+
} else {
3269+
available = try container.decodeIfPresent(Bool.self, forKey: .available) ?? true
3270+
}
3271+
}
32583272
}
32593273

32603274
// MARK: - User Settings API
@@ -4087,7 +4101,7 @@ extension APIClient {
40874101
}
40884102

40894103
let body = InitialMessageRequest(sessionId: sessionId, appId: appId)
4090-
return try await post("v2/chat/initial-message", body: body)
4104+
return try await post("v2/initial-message", body: body)
40914105
}
40924106

40934107
/// Generate a title for a chat session based on its messages
@@ -4236,31 +4250,51 @@ extension APIClient {
42364250
// MARK: - Agent VM
42374251

42384252
struct AgentProvisionResponse: Decodable {
4239-
let status: String
4240-
let vmName: String
4253+
let hasVm: Bool
4254+
let status: String?
4255+
let vmName: String?
42414256
let ip: String?
4242-
let authToken: String
4243-
let agentStatus: String
4257+
let authToken: String?
4258+
let agentStatus: String?
4259+
4260+
enum CodingKeys: String, CodingKey {
4261+
case hasVm = "has_vm"
4262+
case status
4263+
case vmName = "vm_name"
4264+
case ip
4265+
case authToken = "auth_token"
4266+
case agentStatus = "agent_status"
4267+
}
42444268
}
42454269

42464270
/// Provision a cloud agent VM for the current user (fire-and-forget)
42474271
func provisionAgentVM() async throws -> AgentProvisionResponse {
4248-
return try await post("v2/agent/provision")
4272+
return try await post("v1/agent/vm-ensure")
42494273
}
42504274

42514275
struct AgentStatusResponse: Decodable {
4252-
let vmName: String
4253-
let zone: String
4276+
let hasVm: Bool
4277+
let vmName: String?
4278+
let zone: String?
42544279
let ip: String?
4255-
let status: String
4256-
let authToken: String
4257-
let createdAt: String
4280+
let status: String?
4281+
let authToken: String?
4282+
let createdAt: String?
42584283
let lastQueryAt: String?
4284+
4285+
enum CodingKeys: String, CodingKey {
4286+
case hasVm = "has_vm"
4287+
case vmName = "vm_name"
4288+
case zone, ip, status
4289+
case authToken = "auth_token"
4290+
case createdAt = "created_at"
4291+
case lastQueryAt = "last_query_at"
4292+
}
42594293
}
42604294

42614295
/// Get current agent VM status
42624296
func getAgentStatus() async throws -> AgentStatusResponse? {
4263-
return try await get("v2/agent/status")
4297+
return try await get("v1/agent/vm-status")
42644298
}
42654299
}
42664300

@@ -4365,41 +4399,13 @@ extension APIClient {
43654399
costUsd: Double,
43664400
account: String = "omi"
43674401
) async {
4368-
struct Req: Encodable {
4369-
let input_tokens: Int
4370-
let output_tokens: Int
4371-
let cache_read_tokens: Int
4372-
let cache_write_tokens: Int
4373-
let total_tokens: Int
4374-
let cost_usd: Double
4375-
let account: String
4376-
}
4377-
struct Res: Decodable { let status: String }
4378-
do {
4379-
let _: Res = try await post("v1/users/me/llm-usage", body: Req(
4380-
input_tokens: inputTokens,
4381-
output_tokens: outputTokens,
4382-
cache_read_tokens: cacheReadTokens,
4383-
cache_write_tokens: cacheWriteTokens,
4384-
total_tokens: totalTokens,
4385-
cost_usd: costUsd,
4386-
account: account
4387-
))
4388-
} catch {
4389-
log("APIClient: LLM usage record failed: \(error.localizedDescription)")
4390-
}
4402+
// No-op: LLM usage tracking endpoint not available in Python backend
4403+
log("APIClient: recordLlmUsage no-op (endpoint removed)")
43914404
}
43924405

43934406
func fetchTotalOmiAICost() async -> Double? {
4394-
struct Res: Decodable { let total_cost_usd: Double }
4395-
do {
4396-
log("APIClient: Fetching total Omi AI cost from backend")
4397-
let res: Res = try await get("v1/users/me/llm-usage/total")
4398-
log("APIClient: Total Omi AI cost from backend: $\(String(format: "%.4f", res.total_cost_usd))")
4399-
return res.total_cost_usd
4400-
} catch {
4401-
log("APIClient: LLM total cost fetch failed: \(error.localizedDescription)")
4402-
return nil
4403-
}
4407+
// No-op: LLM usage total endpoint not available in Python backend
4408+
log("APIClient: fetchTotalOmiAICost no-op (endpoint removed)")
4409+
return nil
44044410
}
44054411
}

desktop/Desktop/Sources/AgentVMService.swift

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,26 +23,28 @@ actor AgentVMService {
2323
do {
2424
let status = try await APIClient.shared.getAgentStatus()
2525
if let status = status, status.status == "ready", let ip = status.ip {
26-
log("AgentVMService: VM already ready — vmName=\(status.vmName) ip=\(ip)")
26+
let token = status.authToken ?? ""
27+
log("AgentVMService: VM already ready — vmName=\(status.vmName ?? "unknown") ip=\(ip)")
2728
// Only upload if the VM doesn't have a database yet
28-
if await checkVMNeedsDatabase(vmIP: ip, authToken: status.authToken) {
29-
await uploadDatabase(vmIP: ip, authToken: status.authToken)
29+
if await checkVMNeedsDatabase(vmIP: ip, authToken: token) {
30+
await uploadDatabase(vmIP: ip, authToken: token)
3031
} else {
3132
log("AgentVMService: VM already has database, skipping upload")
3233
}
33-
await startIncrementalSync(vmIP: ip, authToken: status.authToken)
34+
await startIncrementalSync(vmIP: ip, authToken: token)
3435
return
3536
}
3637
if let status = status,
3738
status.status == "provisioning" || status.status == "stopped" {
38-
log("AgentVMService: VM is \(status.status), polling until ready...")
39+
log("AgentVMService: VM is \(status.status ?? "unknown"), polling until ready...")
3940
if let result = await pollUntilReady(maxAttempts: 30, intervalSeconds: 5),
4041
let ip = result.ip {
42+
let token = result.authToken ?? ""
4143
log("AgentVMService: VM became ready — ip=\(ip)")
42-
if await checkVMNeedsDatabase(vmIP: ip, authToken: result.authToken) {
43-
await uploadDatabase(vmIP: ip, authToken: result.authToken)
44+
if await checkVMNeedsDatabase(vmIP: ip, authToken: token) {
45+
await uploadDatabase(vmIP: ip, authToken: token)
4446
}
45-
await startIncrementalSync(vmIP: ip, authToken: result.authToken)
47+
await startIncrementalSync(vmIP: ip, authToken: token)
4648
}
4749
return
4850
}
@@ -76,22 +78,22 @@ actor AgentVMService {
7678
let provisionResult: APIClient.AgentProvisionResponse
7779
do {
7880
provisionResult = try await APIClient.shared.provisionAgentVM()
79-
log("AgentVMService: Provision response — vmName=\(provisionResult.vmName) status=\(provisionResult.status) ip=\(provisionResult.ip ?? "none")")
81+
log("AgentVMService: Provision response — vmName=\(provisionResult.vmName ?? "unknown") status=\(provisionResult.status ?? "unknown") ip=\(provisionResult.ip ?? "none")")
8082
} catch {
8183
log("AgentVMService: Provision failed — \(error.localizedDescription)")
8284
return
8385
}
8486

8587
// Step 2: Poll until VM is ready with an IP
8688
var vmIP = provisionResult.ip
87-
var authToken = provisionResult.authToken
89+
var authToken = provisionResult.authToken ?? ""
8890

89-
if vmIP == nil || provisionResult.agentStatus == "provisioning" {
91+
if vmIP == nil || provisionResult.status == "provisioning" {
9092
log("AgentVMService: Waiting for VM to be ready...")
9193
let pollResult = await pollUntilReady(maxAttempts: 30, intervalSeconds: 5)
9294
if let result = pollResult {
9395
vmIP = result.ip
94-
authToken = result.authToken
96+
authToken = result.authToken ?? ""
9597
log("AgentVMService: VM ready — ip=\(vmIP ?? "none")")
9698
} else {
9799
log("AgentVMService: VM did not become ready in time")
@@ -111,7 +113,7 @@ actor AgentVMService {
111113
await startIncrementalSync(vmIP: ip, authToken: authToken)
112114
}
113115

114-
/// Poll GET /v2/agent/status until status is "ready" and IP is available.
116+
/// Poll GET /v1/agent/vm-status until status is "ready" and IP is available.
115117
private func pollUntilReady(maxAttempts: Int, intervalSeconds: UInt64) async -> APIClient.AgentStatusResponse? {
116118
for attempt in 1...maxAttempts {
117119
do {

desktop/Desktop/Sources/Stores/TasksStore.swift

Lines changed: 0 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -448,8 +448,6 @@ class TasksStore: ObservableObject {
448448
// Then retry pushing any locally-created tasks that failed to sync
449449
Task {
450450
await performFullSyncIfNeeded()
451-
await migrateAITasksToStagedIfNeeded()
452-
await migrateConversationItemsToStagedIfNeeded()
453451
await retryUnsyncedItems()
454452
}
455453
// Backfill relevance scores for unscored tasks (independent of full sync)
@@ -808,69 +806,6 @@ class TasksStore: ObservableObject {
808806
}
809807
}
810808

811-
/// In-memory guard to prevent duplicate migration calls within the same app session
812-
private static var isMigrating = false
813-
814-
/// One-time migration: tell backend to move excess AI tasks to staged_tasks subcollection.
815-
/// The SQLite migration handles local data; this handles Firestore.
816-
/// Sets the flag optimistically before the request to avoid retry loops on timeout.
817-
private func migrateAITasksToStagedIfNeeded() async {
818-
let userId = UserDefaults.standard.string(forKey: "auth_userId") ?? "unknown"
819-
let migrationKey = "stagedTasksMigrationCompleted_v4_\(userId)"
820-
821-
guard !UserDefaults.standard.bool(forKey: migrationKey) else {
822-
log("TasksStore: Staged tasks migration already completed for user \(userId)")
823-
return
824-
}
825-
826-
// In-memory guard: loadTasks() can be called from multiple pages
827-
guard !Self.isMigrating else {
828-
log("TasksStore: Staged tasks migration already in progress, skipping")
829-
return
830-
}
831-
Self.isMigrating = true
832-
833-
// Set flag optimistically — the migration is idempotent and safe to skip on re-run.
834-
// This prevents infinite retry loops when the backend succeeds but the client times out.
835-
UserDefaults.standard.set(true, forKey: migrationKey)
836-
837-
log("TasksStore: Starting staged tasks backend migration for user \(userId)")
838-
839-
do {
840-
try await APIClient.shared.migrateStagedTasks()
841-
log("TasksStore: Staged tasks backend migration completed")
842-
} catch {
843-
log("TasksStore: Staged tasks backend migration fired (may complete in background): \(error.localizedDescription)")
844-
}
845-
Self.isMigrating = false
846-
}
847-
848-
/// One-time migration of conversation-extracted action items (no source field) to staged_tasks.
849-
/// These were created by the old save_action_items path that bypassed the staging pipeline.
850-
private func migrateConversationItemsToStagedIfNeeded() async {
851-
let userId = UserDefaults.standard.string(forKey: "auth_userId") ?? "unknown"
852-
let migrationKey = "conversationItemsMigrationCompleted_v4_\(userId)"
853-
854-
guard !UserDefaults.standard.bool(forKey: migrationKey) else { return }
855-
856-
UserDefaults.standard.set(true, forKey: migrationKey)
857-
log("TasksStore: Starting conversation items migration for user \(userId)")
858-
859-
do {
860-
try await APIClient.shared.migrateConversationItemsToStaged()
861-
log("TasksStore: Conversation items migration completed, resetting full sync to clean up local SQLite")
862-
863-
// Reset full sync flag so it re-runs and marks migrated items as staged locally
864-
let syncKey = "tasksFullSyncCompleted_v9_\(userId)"
865-
UserDefaults.standard.set(false, forKey: syncKey)
866-
867-
// Run full sync now to clean up local SQLite
868-
await performFullSyncIfNeeded()
869-
} catch {
870-
log("TasksStore: Conversation items migration fired (may complete in background): \(error.localizedDescription)")
871-
}
872-
}
873-
874809
/// Retry syncing locally-created tasks that failed to push to the backend.
875810
/// These are records with backendSynced=false and no backendId — the API call
876811
/// failed during extraction and there was no retry mechanism.

0 commit comments

Comments
 (0)