Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Wisp/Models/Local/SpriteChat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ final class SpriteChat {
var chatNumber: Int
var customName: String?
var currentServiceName: String?
var execSessionId: String?
var claudeSessionId: String?
var workingDirectory: String
var createdAt: Date
Expand Down
1 change: 0 additions & 1 deletion Wisp/Services/ChatSessionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ final class ChatSessionManager {
let vm = ChatViewModel(
spriteName: spriteName,
chatId: chat.id,
currentServiceName: chat.currentServiceName,
workingDirectory: chat.workingDirectory
)
vm.loadSession(apiClient: apiClient, modelContext: modelContext)
Expand Down
10 changes: 6 additions & 4 deletions Wisp/Services/ExecSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ private let logger = Logger(subsystem: "com.wisp.app", category: "Exec")

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

switch streamId {
case 1: // stdout
continuation.yield(.data(Data(payload)))
continuation.yield(.stdout(Data(payload)))
case 2: // stderr — also yield for visibility
logger.warning("stderr: \(preview)")
continuation.yield(.data(Data(payload)))
continuation.yield(.stderr(Data(payload)))
case 3: // exit
let exitCode = payload.first.map { Int($0) } ?? -1
logger.info("Exit frame received, code=\(exitCode)")
Expand Down
219 changes: 65 additions & 154 deletions Wisp/Services/SpritesAPIClient.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import os
import SwiftData

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

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

// MARK: - Services

/// Create or update a service and stream log events via NDJSON.
func streamService(
spriteName: String,
serviceName: String,
config: ServiceRequest,
duration: String = "3600s"
) -> AsyncThrowingStream<ServiceLogEvent, Error> {
AsyncThrowingStream { continuation in
let task = Task {
do {
guard let token = spritesToken else {
continuation.finish(throwing: AppError.noToken)
return
}

let path = "\(baseURL)/sprites/\(spriteName)/services/\(serviceName)?duration=\(duration)"
guard let url = URL(string: path) else {
continuation.finish(throwing: AppError.invalidURL)
return
}

var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "PUT"
urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
// Idle timeout: if no data arrives for 120s, assume connection dropped.
// The reconnect logic will re-establish from service logs.
urlRequest.timeoutInterval = 120
urlRequest.httpBody = try encoder.encode(config)

let (bytes, response) = try await URLSession.shared.bytes(for: urlRequest)

guard let httpResponse = response as? HTTPURLResponse else {
continuation.finish(throwing: AppError.networkError(URLError(.badServerResponse)))
return
}

guard (200...299).contains(httpResponse.statusCode) else {
switch httpResponse.statusCode {
case 401: continuation.finish(throwing: AppError.unauthorized)
case 404: continuation.finish(throwing: AppError.notFound)
case 409: continuation.finish(throwing: AppError.serverError(statusCode: 409, message: "Service conflict"))
default: continuation.finish(throwing: AppError.serverError(statusCode: httpResponse.statusCode, message: nil))
}
return
}

let decoder = JSONDecoder()
for try await line in bytes.lines {
guard !line.isEmpty, let data = line.data(using: .utf8) else { continue }
do {
let event = try decoder.decode(ServiceLogEvent.self, from: data)
continuation.yield(event)
} catch {
logger.warning("Failed to decode service event: \(error.localizedDescription, privacy: .public) line: \(line.prefix(200), privacy: .public)")
}
}
continuation.finish()
} catch {
logger.error("streamService error: \(error.localizedDescription, privacy: .public)")
continuation.finish(throwing: error)
}
}

continuation.onTermination = { _ in
task.cancel()
}
}
func killExecSession(spriteName: String, execSessionId: String) async throws {
let _: EmptyResponse = try await request(
method: "POST",
path: "/sprites/\(spriteName)/exec/\(execSessionId)/kill"
)
}

/// Reconnect to service logs (full history + continued streaming).
func streamServiceLogs(
spriteName: String,
serviceName: String,
duration: String = "3600s"
) -> AsyncThrowingStream<ServiceLogEvent, Error> {
AsyncThrowingStream { continuation in
let task = Task {
do {
guard let token = spritesToken else {
continuation.finish(throwing: AppError.noToken)
return
}

let path = "\(baseURL)/sprites/\(spriteName)/services/\(serviceName)/logs?duration=\(duration)"
guard let url = URL(string: path) else {
continuation.finish(throwing: AppError.invalidURL)
return
}

var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "GET"
urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
urlRequest.timeoutInterval = 120

let (bytes, response) = try await URLSession.shared.bytes(for: urlRequest)

guard let httpResponse = response as? HTTPURLResponse else {
continuation.finish(throwing: AppError.networkError(URLError(.badServerResponse)))
return
}

guard (200...299).contains(httpResponse.statusCode) else {
switch httpResponse.statusCode {
case 401: continuation.finish(throwing: AppError.unauthorized)
case 404: continuation.finish(throwing: AppError.notFound)
default: continuation.finish(throwing: AppError.serverError(statusCode: httpResponse.statusCode, message: nil))
}
return
}

let decoder = JSONDecoder()
for try await line in bytes.lines {
guard !line.isEmpty, let data = line.data(using: .utf8) else { continue }
do {
let event = try decoder.decode(ServiceLogEvent.self, from: data)
continuation.yield(event)
} catch {
logger.warning("Failed to decode service log event: \(error.localizedDescription, privacy: .public) line: \(line.prefix(200), privacy: .public)")
}
}
continuation.finish()
} catch {
logger.error("streamServiceLogs error: \(error.localizedDescription, privacy: .public)")
continuation.finish(throwing: error)
}
}
// MARK: - Legacy service cleanup

continuation.onTermination = { _ in
task.cancel()
}
}
private func deleteService(spriteName: String, serviceName: String) async {
let _: EmptyResponse? = try? await request(
method: "DELETE",
path: "/sprites/\(spriteName)/services/\(serviceName)",
timeout: 5
)
}

/// Check the status of a service.
func getServiceStatus(spriteName: String, serviceName: String) async throws -> ServiceInfo {
return try await request(method: "GET", path: "/sprites/\(spriteName)/services/\(serviceName)")
private func listServices(spriteName: String) async throws -> [ServiceInfo] {
return try await request(method: "GET", path: "/sprites/\(spriteName)/services")
}

// ServiceLogsProvider conformance — bridges the default-argument version to the protocol signature.
func streamServiceLogs(spriteName: String, serviceName: String) -> AsyncThrowingStream<ServiceLogEvent, Error> {
streamServiceLogs(spriteName: spriteName, serviceName: serviceName, duration: "3600s")
/// One-time migration: delete `wisp-claude-*` and `wisp-quick-*` services left by
/// the old service-based execution model. They restart on every sprite wake and
/// re-execute stale prompts / burn Claude tokens.
///
/// - Stored names (`currentServiceName` in SpriteChat) are cleared immediately so
/// this is a true one-time operation for the claude services.
/// - `spriteNames` drives a live sweep to also catch `wisp-quick-*` and any
/// services whose names weren't persisted.
///
/// TODO: Remove this function (and its call in DashboardView, and `listServices`,
/// `deleteService`, `ServiceTypes.swift`, and `SpriteChat.currentServiceName`) once
/// enough time has passed that no users are still running the service-based version.
func cleanupLegacyServices(spriteNames: [String] = [], modelContext: ModelContext) {
// Only run while there are chats that still have a stored service name.
// Once all are cleared (after first run post-migration), this becomes a no-op
// and no sprite API calls are made on subsequent launches.
let descriptor = FetchDescriptor<SpriteChat>(
predicate: #Predicate { $0.currentServiceName != nil }
)
guard let chats = try? modelContext.fetch(descriptor), !chats.isEmpty else { return }

// 1. Delete stored wisp-claude-* service names and clear them from the model
logger.info("Cleaning up \(chats.count) stored legacy service(s)")
for chat in chats {
guard let serviceName = chat.currentServiceName else { continue }
let sName = chat.spriteName
chat.currentServiceName = nil
Task {
await deleteService(spriteName: sName, serviceName: serviceName)
logger.info("Deleted legacy service \(serviceName) on \(sName)")
}
}
try? modelContext.save()

// 2. Sweep known sprites for any remaining wisp-* services (catches wisp-quick-*)
for spriteName in spriteNames {
let sName = spriteName
Task {
guard let services = try? await listServices(spriteName: sName) else { return }
let wispServices = services.filter { $0.name.hasPrefix("wisp-") }
guard !wispServices.isEmpty else { return }
logger.info("Sweeping \(wispServices.count) wisp-* service(s) on \(sName)")
for service in wispServices {
await deleteService(spriteName: sName, serviceName: service.name)
}
}
}
}
}

// MARK: - ServiceLogsProvider

/// Minimal protocol covering the two API calls used by the reconnect loop,
/// allowing the loop to be tested without a live network connection.
@MainActor
protocol ServiceLogsProvider {
func streamServiceLogs(spriteName: String, serviceName: String) -> AsyncThrowingStream<ServiceLogEvent, Error>
func getServiceStatus(spriteName: String, serviceName: String) async throws -> ServiceInfo
}

extension SpritesAPIClient: ServiceLogsProvider {}

extension SpritesAPIClient {

/// Delete a service (5s timeout to avoid blocking callers if sprite is unresponsive).
func deleteService(spriteName: String, serviceName: String) async throws {
let _: EmptyResponse = try await request(method: "DELETE", path: "/sprites/\(spriteName)/services/\(serviceName)", timeout: 5)
}

// MARK: - File Upload

struct FileUploadResponse: Codable, Sendable {
Expand Down Expand Up @@ -421,7 +330,9 @@ extension SpritesAPIClient {

do {
for try await event in session.events() {
if case .data(let chunk) = event {
if case .stdout(let chunk) = event {
output.append(chunk)
} else if case .stderr(let chunk) = event {
output.append(chunk)
} else if case .exit(let code) = event {
exitCode = code
Expand Down
25 changes: 9 additions & 16 deletions Wisp/ViewModels/BashQuickViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,25 +55,20 @@ final class BashQuickViewModel {

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

do {
streamLoop: for try await event in stream {
streamLoop: for try await event in session.events() {
guard !Task.isCancelled else { break streamLoop }
switch event.type {
case .stdout, .stderr:
if let text = event.data {
switch event {
case .stdout(let data), .stderr(let data):
if let text = String(data: data, encoding: .utf8) {
output += text
}
case .error:
if output.isEmpty {
error = event.data ?? "Service error"
}
case .complete:
case .exit:
break streamLoop
default:
case .sessionInfo:
break
}
}
Expand All @@ -84,9 +79,7 @@ final class BashQuickViewModel {
}
}

Task {
try? await apiClient.deleteService(spriteName: spriteName, serviceName: serviceName)
}
session.disconnect()

if !Task.isCancelled {
isRunning = false
Expand Down
Loading
Loading