Skip to content

Commit 57eafc4

Browse files
authored
Merge pull request #102 from mcintyre94/exec-based-claude-execution
Switch Claude execution from services to ephemeral exec sessions
2 parents 0ecd42b + 0ba52f0 commit 57eafc4

15 files changed

+361
-1007
lines changed

Wisp/Models/Local/SpriteChat.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ final class SpriteChat {
88
var chatNumber: Int
99
var customName: String?
1010
var currentServiceName: String?
11+
var execSessionId: String?
1112
var claudeSessionId: String?
1213
var workingDirectory: String
1314
var createdAt: Date

Wisp/Services/ChatSessionManager.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ final class ChatSessionManager {
2222
let vm = ChatViewModel(
2323
spriteName: spriteName,
2424
chatId: chat.id,
25-
currentServiceName: chat.currentServiceName,
2625
workingDirectory: chat.workingDirectory
2726
)
2827
vm.loadSession(apiClient: apiClient, modelContext: modelContext)

Wisp/Services/ExecSession.swift

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ private let logger = Logger(subsystem: "com.wisp.app", category: "Exec")
55

66
/// Events yielded by an exec session stream
77
enum ExecEvent: Sendable {
8-
/// Stdout/stderr data from the process
9-
case data(Data)
8+
/// Stdout data from the process (stream ID 1) — Claude NDJSON
9+
case stdout(Data)
10+
/// Stderr data from the process (stream ID 2) — debug/heartbeat noise
11+
case stderr(Data)
1012
/// Exec session ID from the session_info control frame
1113
case sessionInfo(id: String)
1214
/// Process exit code from the exec stream
@@ -74,10 +76,10 @@ final class ExecSession: Sendable {
7476

7577
switch streamId {
7678
case 1: // stdout
77-
continuation.yield(.data(Data(payload)))
79+
continuation.yield(.stdout(Data(payload)))
7880
case 2: // stderr — also yield for visibility
7981
logger.warning("stderr: \(preview)")
80-
continuation.yield(.data(Data(payload)))
82+
continuation.yield(.stderr(Data(payload)))
8183
case 3: // exit
8284
let exitCode = payload.first.map { Int($0) } ?? -1
8385
logger.info("Exit frame received, code=\(exitCode)")

Wisp/Services/SpritesAPIClient.swift

Lines changed: 65 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Foundation
22
import os
3+
import SwiftData
34

45
private let logger = Logger(subsystem: "com.wisp.app", category: "API")
56

@@ -139,172 +140,80 @@ final class SpritesAPIClient {
139140
return ExecSession(url: components.url!, token: spritesToken ?? "")
140141
}
141142

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+
)
212148
}
213149

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
271151

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+
)
276158
}
277159

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")
281162
}
282163

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+
}
286211
}
287-
}
288212

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
297213
}
298214

299-
extension SpritesAPIClient: ServiceLogsProvider {}
300-
301215
extension SpritesAPIClient {
302216

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-
308217
// MARK: - File Upload
309218

310219
struct FileUploadResponse: Codable, Sendable {
@@ -421,7 +330,9 @@ extension SpritesAPIClient {
421330

422331
do {
423332
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 {
425336
output.append(chunk)
426337
} else if case .exit(let code) = event {
427338
exitCode = code

Wisp/ViewModels/BashQuickViewModel.swift

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -55,25 +55,20 @@ final class BashQuickViewModel {
5555

5656
private func executeCommand(_ cmd: String, apiClient: SpritesAPIClient) async {
5757
let fullCommand = "cd \(workingDirectory) 2>/dev/null || true; \(cmd)"
58-
let serviceName = "wisp-quick-\(UUID().uuidString.prefix(8).lowercased())"
59-
let config = ServiceRequest(cmd: "bash", args: ["-c", fullCommand], needs: nil, httpPort: nil)
60-
let stream = apiClient.streamService(spriteName: spriteName, serviceName: serviceName, config: config)
58+
let session = apiClient.createExecSession(spriteName: spriteName, command: fullCommand)
59+
session.connect()
6160

6261
do {
63-
streamLoop: for try await event in stream {
62+
streamLoop: for try await event in session.events() {
6463
guard !Task.isCancelled else { break streamLoop }
65-
switch event.type {
66-
case .stdout, .stderr:
67-
if let text = event.data {
64+
switch event {
65+
case .stdout(let data), .stderr(let data):
66+
if let text = String(data: data, encoding: .utf8) {
6867
output += text
6968
}
70-
case .error:
71-
if output.isEmpty {
72-
error = event.data ?? "Service error"
73-
}
74-
case .complete:
69+
case .exit:
7570
break streamLoop
76-
default:
71+
case .sessionInfo:
7772
break
7873
}
7974
}
@@ -84,9 +79,7 @@ final class BashQuickViewModel {
8479
}
8580
}
8681

87-
Task {
88-
try? await apiClient.deleteService(spriteName: spriteName, serviceName: serviceName)
89-
}
82+
session.disconnect()
9083

9184
if !Task.isCancelled {
9285
isRunning = false

0 commit comments

Comments
 (0)