Skip to content

Commit f92eec0

Browse files
beastoinclaude
andcommitted
Swift desktop: path updates, decoder hardening, no-ops, remove migrations
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: use decodeIfPresent with Date() fallback - ActionItemsListResponse: try "action_items" then "items" key (Python vs staged-tasks) - AgentProvisionResponse/AgentStatusResponse: make fields optional, add hasVm - UsernameAvailableResponse: support both is_taken (Python) and available (Rust) Graceful no-ops: - recordLlmUsage(): no-op with log (endpoint removed) - fetchTotalOmiAICost(): return nil immediately (endpoint removed) - getChatMessageCount(): return 0 immediately (endpoint removed) Remove staged-tasks migration: - Remove migrateStagedTasks() and migrateConversationItemsToStaged() from APIClient - Remove migration callers and functions from TasksStore Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2b8879a commit f92eec0

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 {
@@ -1917,17 +1925,6 @@ extension APIClient {
19171925
return try await post("v1/staged-tasks/promote")
19181926
}
19191927

1920-
/// One-time migration of existing AI tasks to staged_tasks
1921-
func migrateStagedTasks() async throws {
1922-
struct StatusResponse: Decodable { let status: String }
1923-
let _: StatusResponse = try await post("v1/staged-tasks/migrate")
1924-
}
1925-
1926-
/// Migrate conversation-extracted action items (no source field) to staged_tasks
1927-
func migrateConversationItemsToStaged() async throws {
1928-
struct MigrateResponse: Decodable { let status: String; let migrated: Int; let deleted: Int }
1929-
let _: MigrateResponse = try await post("v1/staged-tasks/migrate-conversation-items")
1930-
}
19311928
}
19321929

19331930
/// Response for staged task promotion
@@ -3175,13 +3172,12 @@ extension APIClient {
31753172

31763173
/// Regenerates persona prompt from current public memories
31773174
func regeneratePersonaPrompt() async throws -> GeneratePromptResponse {
3178-
struct EmptyRequest: Encodable {}
3179-
return try await post("v1/personas/generate-prompt", body: EmptyRequest())
3175+
return try await get("v1/app/generate-prompts")
31803176
}
31813177

31823178
/// Checks if a username is available
31833179
func checkPersonaUsername(_ username: String) async throws -> UsernameAvailableResponse {
3184-
return try await get("v1/personas/check-username?username=\(username)")
3180+
return try await get("v1/apps/check-username?username=\(username)")
31853181
}
31863182
}
31873183

@@ -3279,9 +3275,27 @@ struct GeneratePromptResponse: Codable {
32793275
}
32803276

32813277
/// Response for username availability check
3282-
struct UsernameAvailableResponse: Codable {
3278+
struct UsernameAvailableResponse: Decodable {
32833279
let available: Bool
3284-
let username: String
3280+
let username: String?
3281+
let isTaken: Bool?
3282+
3283+
enum CodingKeys: String, CodingKey {
3284+
case available, username
3285+
case isTaken = "is_taken"
3286+
}
3287+
3288+
init(from decoder: Decoder) throws {
3289+
let container = try decoder.container(keyedBy: CodingKeys.self)
3290+
username = try container.decodeIfPresent(String.self, forKey: .username)
3291+
isTaken = try container.decodeIfPresent(Bool.self, forKey: .isTaken)
3292+
// Python returns is_taken; Rust returned available. Support both.
3293+
if let isTaken = isTaken {
3294+
available = !isTaken
3295+
} else {
3296+
available = try container.decodeIfPresent(Bool.self, forKey: .available) ?? true
3297+
}
3298+
}
32853299
}
32863300

32873301
// MARK: - User Settings API
@@ -4119,7 +4133,7 @@ extension APIClient {
41194133
}
41204134

41214135
let body = InitialMessageRequest(sessionId: sessionId, appId: appId)
4122-
return try await post("v2/chat/initial-message", body: body)
4136+
return try await post("v2/initial-message", body: body)
41234137
}
41244138

41254139
/// Generate a title for a chat session based on its messages
@@ -4268,31 +4282,51 @@ extension APIClient {
42684282
// MARK: - Agent VM
42694283

42704284
struct AgentProvisionResponse: Decodable {
4271-
let status: String
4272-
let vmName: String
4285+
let hasVm: Bool
4286+
let status: String?
4287+
let vmName: String?
42734288
let ip: String?
4274-
let authToken: String
4275-
let agentStatus: String
4289+
let authToken: String?
4290+
let agentStatus: String?
4291+
4292+
enum CodingKeys: String, CodingKey {
4293+
case hasVm = "has_vm"
4294+
case status
4295+
case vmName = "vm_name"
4296+
case ip
4297+
case authToken = "auth_token"
4298+
case agentStatus = "agent_status"
4299+
}
42764300
}
42774301

42784302
/// Provision a cloud agent VM for the current user (fire-and-forget)
42794303
func provisionAgentVM() async throws -> AgentProvisionResponse {
4280-
return try await post("v2/agent/provision")
4304+
return try await post("v1/agent/vm-ensure")
42814305
}
42824306

42834307
struct AgentStatusResponse: Decodable {
4284-
let vmName: String
4285-
let zone: String
4308+
let hasVm: Bool
4309+
let vmName: String?
4310+
let zone: String?
42864311
let ip: String?
4287-
let status: String
4288-
let authToken: String
4289-
let createdAt: String
4312+
let status: String?
4313+
let authToken: String?
4314+
let createdAt: String?
42904315
let lastQueryAt: String?
4316+
4317+
enum CodingKeys: String, CodingKey {
4318+
case hasVm = "has_vm"
4319+
case vmName = "vm_name"
4320+
case zone, ip, status
4321+
case authToken = "auth_token"
4322+
case createdAt = "created_at"
4323+
case lastQueryAt = "last_query_at"
4324+
}
42914325
}
42924326

42934327
/// Get current agent VM status
42944328
func getAgentStatus() async throws -> AgentStatusResponse? {
4295-
return try await get("v2/agent/status")
4329+
return try await get("v1/agent/vm-status")
42964330
}
42974331
}
42984332

@@ -4397,41 +4431,13 @@ extension APIClient {
43974431
costUsd: Double,
43984432
account: String = "omi"
43994433
) async {
4400-
struct Req: Encodable {
4401-
let input_tokens: Int
4402-
let output_tokens: Int
4403-
let cache_read_tokens: Int
4404-
let cache_write_tokens: Int
4405-
let total_tokens: Int
4406-
let cost_usd: Double
4407-
let account: String
4408-
}
4409-
struct Res: Decodable { let status: String }
4410-
do {
4411-
let _: Res = try await post("v1/users/me/llm-usage", body: Req(
4412-
input_tokens: inputTokens,
4413-
output_tokens: outputTokens,
4414-
cache_read_tokens: cacheReadTokens,
4415-
cache_write_tokens: cacheWriteTokens,
4416-
total_tokens: totalTokens,
4417-
cost_usd: costUsd,
4418-
account: account
4419-
))
4420-
} catch {
4421-
log("APIClient: LLM usage record failed: \(error.localizedDescription)")
4422-
}
4434+
// No-op: LLM usage tracking endpoint not available in Python backend
4435+
log("APIClient: recordLlmUsage no-op (endpoint removed)")
44234436
}
44244437

44254438
func fetchTotalOmiAICost() async -> Double? {
4426-
struct Res: Decodable { let total_cost_usd: Double }
4427-
do {
4428-
log("APIClient: Fetching total Omi AI cost from backend")
4429-
let res: Res = try await get("v1/users/me/llm-usage/total")
4430-
log("APIClient: Total Omi AI cost from backend: $\(String(format: "%.4f", res.total_cost_usd))")
4431-
return res.total_cost_usd
4432-
} catch {
4433-
log("APIClient: LLM total cost fetch failed: \(error.localizedDescription)")
4434-
return nil
4435-
}
4439+
// No-op: LLM usage total endpoint not available in Python backend
4440+
log("APIClient: fetchTotalOmiAICost no-op (endpoint removed)")
4441+
return nil
44364442
}
44374443
}

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)