|
1 | 1 | import Foundation |
2 | 2 | import os |
| 3 | +import SwiftData |
3 | 4 |
|
4 | 5 | private let logger = Logger(subsystem: "com.wisp.app", category: "API") |
5 | 6 |
|
@@ -139,172 +140,80 @@ final class SpritesAPIClient { |
139 | 140 | return ExecSession(url: components.url!, token: spritesToken ?? "") |
140 | 141 | } |
141 | 142 |
|
142 | | - // MARK: - Services |
143 | | - |
144 | | - /// Create or update a service and stream log events via NDJSON. |
145 | | - func streamService( |
146 | | - spriteName: String, |
147 | | - serviceName: String, |
148 | | - config: ServiceRequest, |
149 | | - duration: String = "3600s" |
150 | | - ) -> AsyncThrowingStream<ServiceLogEvent, Error> { |
151 | | - AsyncThrowingStream { continuation in |
152 | | - let task = Task { |
153 | | - do { |
154 | | - guard let token = spritesToken else { |
155 | | - continuation.finish(throwing: AppError.noToken) |
156 | | - return |
157 | | - } |
158 | | - |
159 | | - let path = "\(baseURL)/sprites/\(spriteName)/services/\(serviceName)?duration=\(duration)" |
160 | | - guard let url = URL(string: path) else { |
161 | | - continuation.finish(throwing: AppError.invalidURL) |
162 | | - return |
163 | | - } |
164 | | - |
165 | | - var urlRequest = URLRequest(url: url) |
166 | | - urlRequest.httpMethod = "PUT" |
167 | | - urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") |
168 | | - urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") |
169 | | - // Idle timeout: if no data arrives for 120s, assume connection dropped. |
170 | | - // The reconnect logic will re-establish from service logs. |
171 | | - urlRequest.timeoutInterval = 120 |
172 | | - urlRequest.httpBody = try encoder.encode(config) |
173 | | - |
174 | | - let (bytes, response) = try await URLSession.shared.bytes(for: urlRequest) |
175 | | - |
176 | | - guard let httpResponse = response as? HTTPURLResponse else { |
177 | | - continuation.finish(throwing: AppError.networkError(URLError(.badServerResponse))) |
178 | | - return |
179 | | - } |
180 | | - |
181 | | - guard (200...299).contains(httpResponse.statusCode) else { |
182 | | - switch httpResponse.statusCode { |
183 | | - case 401: continuation.finish(throwing: AppError.unauthorized) |
184 | | - case 404: continuation.finish(throwing: AppError.notFound) |
185 | | - case 409: continuation.finish(throwing: AppError.serverError(statusCode: 409, message: "Service conflict")) |
186 | | - default: continuation.finish(throwing: AppError.serverError(statusCode: httpResponse.statusCode, message: nil)) |
187 | | - } |
188 | | - return |
189 | | - } |
190 | | - |
191 | | - let decoder = JSONDecoder() |
192 | | - for try await line in bytes.lines { |
193 | | - guard !line.isEmpty, let data = line.data(using: .utf8) else { continue } |
194 | | - do { |
195 | | - let event = try decoder.decode(ServiceLogEvent.self, from: data) |
196 | | - continuation.yield(event) |
197 | | - } catch { |
198 | | - logger.warning("Failed to decode service event: \(error.localizedDescription, privacy: .public) line: \(line.prefix(200), privacy: .public)") |
199 | | - } |
200 | | - } |
201 | | - continuation.finish() |
202 | | - } catch { |
203 | | - logger.error("streamService error: \(error.localizedDescription, privacy: .public)") |
204 | | - continuation.finish(throwing: error) |
205 | | - } |
206 | | - } |
207 | | - |
208 | | - continuation.onTermination = { _ in |
209 | | - task.cancel() |
210 | | - } |
211 | | - } |
| 143 | + func killExecSession(spriteName: String, execSessionId: String) async throws { |
| 144 | + let _: EmptyResponse = try await request( |
| 145 | + method: "POST", |
| 146 | + path: "/sprites/\(spriteName)/exec/\(execSessionId)/kill" |
| 147 | + ) |
212 | 148 | } |
213 | 149 |
|
214 | | - /// Reconnect to service logs (full history + continued streaming). |
215 | | - func streamServiceLogs( |
216 | | - spriteName: String, |
217 | | - serviceName: String, |
218 | | - duration: String = "3600s" |
219 | | - ) -> AsyncThrowingStream<ServiceLogEvent, Error> { |
220 | | - AsyncThrowingStream { continuation in |
221 | | - let task = Task { |
222 | | - do { |
223 | | - guard let token = spritesToken else { |
224 | | - continuation.finish(throwing: AppError.noToken) |
225 | | - return |
226 | | - } |
227 | | - |
228 | | - let path = "\(baseURL)/sprites/\(spriteName)/services/\(serviceName)/logs?duration=\(duration)" |
229 | | - guard let url = URL(string: path) else { |
230 | | - continuation.finish(throwing: AppError.invalidURL) |
231 | | - return |
232 | | - } |
233 | | - |
234 | | - var urlRequest = URLRequest(url: url) |
235 | | - urlRequest.httpMethod = "GET" |
236 | | - urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") |
237 | | - urlRequest.timeoutInterval = 120 |
238 | | - |
239 | | - let (bytes, response) = try await URLSession.shared.bytes(for: urlRequest) |
240 | | - |
241 | | - guard let httpResponse = response as? HTTPURLResponse else { |
242 | | - continuation.finish(throwing: AppError.networkError(URLError(.badServerResponse))) |
243 | | - return |
244 | | - } |
245 | | - |
246 | | - guard (200...299).contains(httpResponse.statusCode) else { |
247 | | - switch httpResponse.statusCode { |
248 | | - case 401: continuation.finish(throwing: AppError.unauthorized) |
249 | | - case 404: continuation.finish(throwing: AppError.notFound) |
250 | | - default: continuation.finish(throwing: AppError.serverError(statusCode: httpResponse.statusCode, message: nil)) |
251 | | - } |
252 | | - return |
253 | | - } |
254 | | - |
255 | | - let decoder = JSONDecoder() |
256 | | - for try await line in bytes.lines { |
257 | | - guard !line.isEmpty, let data = line.data(using: .utf8) else { continue } |
258 | | - do { |
259 | | - let event = try decoder.decode(ServiceLogEvent.self, from: data) |
260 | | - continuation.yield(event) |
261 | | - } catch { |
262 | | - logger.warning("Failed to decode service log event: \(error.localizedDescription, privacy: .public) line: \(line.prefix(200), privacy: .public)") |
263 | | - } |
264 | | - } |
265 | | - continuation.finish() |
266 | | - } catch { |
267 | | - logger.error("streamServiceLogs error: \(error.localizedDescription, privacy: .public)") |
268 | | - continuation.finish(throwing: error) |
269 | | - } |
270 | | - } |
| 150 | + // MARK: - Legacy service cleanup |
271 | 151 |
|
272 | | - continuation.onTermination = { _ in |
273 | | - task.cancel() |
274 | | - } |
275 | | - } |
| 152 | + private func deleteService(spriteName: String, serviceName: String) async { |
| 153 | + let _: EmptyResponse? = try? await request( |
| 154 | + method: "DELETE", |
| 155 | + path: "/sprites/\(spriteName)/services/\(serviceName)", |
| 156 | + timeout: 5 |
| 157 | + ) |
276 | 158 | } |
277 | 159 |
|
278 | | - /// Check the status of a service. |
279 | | - func getServiceStatus(spriteName: String, serviceName: String) async throws -> ServiceInfo { |
280 | | - return try await request(method: "GET", path: "/sprites/\(spriteName)/services/\(serviceName)") |
| 160 | + private func listServices(spriteName: String) async throws -> [ServiceInfo] { |
| 161 | + return try await request(method: "GET", path: "/sprites/\(spriteName)/services") |
281 | 162 | } |
282 | 163 |
|
283 | | - // ServiceLogsProvider conformance — bridges the default-argument version to the protocol signature. |
284 | | - func streamServiceLogs(spriteName: String, serviceName: String) -> AsyncThrowingStream<ServiceLogEvent, Error> { |
285 | | - streamServiceLogs(spriteName: spriteName, serviceName: serviceName, duration: "3600s") |
| 164 | + /// One-time migration: delete `wisp-claude-*` and `wisp-quick-*` services left by |
| 165 | + /// the old service-based execution model. They restart on every sprite wake and |
| 166 | + /// re-execute stale prompts / burn Claude tokens. |
| 167 | + /// |
| 168 | + /// - Stored names (`currentServiceName` in SpriteChat) are cleared immediately so |
| 169 | + /// this is a true one-time operation for the claude services. |
| 170 | + /// - `spriteNames` drives a live sweep to also catch `wisp-quick-*` and any |
| 171 | + /// services whose names weren't persisted. |
| 172 | + /// |
| 173 | + /// TODO: Remove this function (and its call in DashboardView, and `listServices`, |
| 174 | + /// `deleteService`, `ServiceTypes.swift`, and `SpriteChat.currentServiceName`) once |
| 175 | + /// enough time has passed that no users are still running the service-based version. |
| 176 | + func cleanupLegacyServices(spriteNames: [String] = [], modelContext: ModelContext) { |
| 177 | + // Only run while there are chats that still have a stored service name. |
| 178 | + // Once all are cleared (after first run post-migration), this becomes a no-op |
| 179 | + // and no sprite API calls are made on subsequent launches. |
| 180 | + let descriptor = FetchDescriptor<SpriteChat>( |
| 181 | + predicate: #Predicate { $0.currentServiceName != nil } |
| 182 | + ) |
| 183 | + guard let chats = try? modelContext.fetch(descriptor), !chats.isEmpty else { return } |
| 184 | + |
| 185 | + // 1. Delete stored wisp-claude-* service names and clear them from the model |
| 186 | + logger.info("Cleaning up \(chats.count) stored legacy service(s)") |
| 187 | + for chat in chats { |
| 188 | + guard let serviceName = chat.currentServiceName else { continue } |
| 189 | + let sName = chat.spriteName |
| 190 | + chat.currentServiceName = nil |
| 191 | + Task { |
| 192 | + await deleteService(spriteName: sName, serviceName: serviceName) |
| 193 | + logger.info("Deleted legacy service \(serviceName) on \(sName)") |
| 194 | + } |
| 195 | + } |
| 196 | + try? modelContext.save() |
| 197 | + |
| 198 | + // 2. Sweep known sprites for any remaining wisp-* services (catches wisp-quick-*) |
| 199 | + for spriteName in spriteNames { |
| 200 | + let sName = spriteName |
| 201 | + Task { |
| 202 | + guard let services = try? await listServices(spriteName: sName) else { return } |
| 203 | + let wispServices = services.filter { $0.name.hasPrefix("wisp-") } |
| 204 | + guard !wispServices.isEmpty else { return } |
| 205 | + logger.info("Sweeping \(wispServices.count) wisp-* service(s) on \(sName)") |
| 206 | + for service in wispServices { |
| 207 | + await deleteService(spriteName: sName, serviceName: service.name) |
| 208 | + } |
| 209 | + } |
| 210 | + } |
286 | 211 | } |
287 | | -} |
288 | 212 |
|
289 | | -// MARK: - ServiceLogsProvider |
290 | | - |
291 | | -/// Minimal protocol covering the two API calls used by the reconnect loop, |
292 | | -/// allowing the loop to be tested without a live network connection. |
293 | | -@MainActor |
294 | | -protocol ServiceLogsProvider { |
295 | | - func streamServiceLogs(spriteName: String, serviceName: String) -> AsyncThrowingStream<ServiceLogEvent, Error> |
296 | | - func getServiceStatus(spriteName: String, serviceName: String) async throws -> ServiceInfo |
297 | 213 | } |
298 | 214 |
|
299 | | -extension SpritesAPIClient: ServiceLogsProvider {} |
300 | | - |
301 | 215 | extension SpritesAPIClient { |
302 | 216 |
|
303 | | - /// Delete a service (5s timeout to avoid blocking callers if sprite is unresponsive). |
304 | | - func deleteService(spriteName: String, serviceName: String) async throws { |
305 | | - let _: EmptyResponse = try await request(method: "DELETE", path: "/sprites/\(spriteName)/services/\(serviceName)", timeout: 5) |
306 | | - } |
307 | | - |
308 | 217 | // MARK: - File Upload |
309 | 218 |
|
310 | 219 | struct FileUploadResponse: Codable, Sendable { |
@@ -421,7 +330,9 @@ extension SpritesAPIClient { |
421 | 330 |
|
422 | 331 | do { |
423 | 332 | for try await event in session.events() { |
424 | | - if case .data(let chunk) = event { |
| 333 | + if case .stdout(let chunk) = event { |
| 334 | + output.append(chunk) |
| 335 | + } else if case .stderr(let chunk) = event { |
425 | 336 | output.append(chunk) |
426 | 337 | } else if case .exit(let code) = event { |
427 | 338 | exitCode = code |
|
0 commit comments