clawdforge/clients/swift/Sources/Clawdforge/ForgeClient+Sessions.swift
Kayos fabc782c09 clients/swift: fileprivate → internal on URLSession bridge helpers
Sessions.swift is a separate file from ForgeClient.swift but in the same
module — fileprivate blocked the cross-file call. internal (default) is
the correct visibility: same-module accessible, not part of public API.

Also dropped the unused @preconcurrency on Sessions.swift's Foundation
imports — that file doesn't reference Sendable-warning-emitting types
directly (URLSession etc), so the attribute was a no-op generating
remarks. Kept @preconcurrency on ForgeClient.swift where it actually
suppresses URL/URLSession/JSONEncoder/JSONDecoder Sendable warnings.

Caught by crafting-table queue (job 77012573) — first real dogfood of
the build farm. exit_1, log surfaced the inaccessibility error in 6s.
2026-04-29 13:44:57 -07:00

241 lines
9.9 KiB
Swift

// ForgeClient+Sessions.swift
//
// v0.2 multi-turn session endpoints on `ForgeClient`. These extensions are
// purely additive v0.1 callers (`run`, `uploadFile`, `createToken`,
// `listTokens`, `revokeToken`, `healthz`) keep their byte-identical wire
// behavior.
//
// Endpoints wired here:
//
// POST /sessions createSession(_:)
// POST /sessions/{id}/turn sessionTurnInternal(...) (used by Session.turn)
// GET /sessions/{id} getSession(_:) (used by Session.state)
// DELETE /sessions/{id} sessionCloseInternal(...) (used by Session.close)
// GET /sessions listSessions()
//
// `createSession` returns an `actor Session` handle; the convenience
// `withSession(_:_:)` wraps create + work + auto-close (on success and on
// throw).
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
extension ForgeClient {
// MARK: Session lifecycle
/// `POST /sessions`. Open a new server-side multi-turn session.
///
/// The returned ``Session`` is an `actor` every method on it is
/// `async`. The session stays open server-side until ``Session/close()``
/// is called (or the TTL sweeper closes it after `CLAWDFORGE_SESSION_TTL_SECS`).
///
/// ```swift
/// let s = try await forge.createSession(.init(agent: "claude"))
/// defer { Task { try? await s.close() } }
/// _ = try await s.turn("hello")
/// ```
public func createSession(
_ opts: CreateSessionOptions = .init()
) async throws -> Session {
let body = CreateSessionWireBody(agent: opts.agent, meta: opts.meta)
let req = try makeSessionRequest(method: "POST", path: "/sessions", jsonBody: body)
let resp: CreateSessionResponse = try await sendSession(req)
return Session(
client: self,
id: resp.sessionId,
agent: resp.agent,
createdAt: resp.createdAt
)
}
/// Scoped session helper. Creates a session, runs `block`, and closes
/// the session on success **and** on throw the same auto-cleanup
/// shape as Python's `with`, Rust's `Drop`, or Go's `defer`.
///
/// The block's return value is forwarded; close errors during cleanup
/// are silently swallowed so they don't mask a real error from the
/// block. (This matches the behavior of the Python / Rust / Go SDKs.)
///
/// ```swift
/// let summary = try await forge.withSession { s in
/// try await s.turn("Read README.md and summarize").text()
/// }
/// ```
public func withSession<T>(
_ opts: CreateSessionOptions = .init(),
_ block: (Session) async throws -> T
) async throws -> T {
let s = try await createSession(opts)
do {
let result = try await block(s)
try? await s.close()
return result
} catch {
try? await s.close()
throw error
}
}
/// `GET /sessions`. List all sessions visible to the calling token.
///
/// Cross-token sessions are filtered server-side, so callers see only
/// their own. Closed sessions are included by default; pass
/// `includeClosed: false` to filter them out (server respects the
/// `include_closed` query param).
public func listSessions(includeClosed: Bool = true) async throws -> [SessionState] {
let path = "/sessions" + (includeClosed ? "?include_closed=true" : "?include_closed=false")
let req = try makeSessionRequest(method: "GET", path: path)
let envelope: SessionListResponse = try await sendSession(req)
return envelope.sessions
}
/// `GET /sessions/{id}`. Fetch a session's current state.
///
/// Returns 404 (mapped to ``ForgeError/api(statusCode:body:)``) for
/// unknown ids and for ids owned by a different token the server
/// deliberately does not distinguish, to avoid leaking existence.
public func getSession(_ id: String) async throws -> SessionState {
let path = "/sessions/\(Self.encodeSessionId(id))"
let req = try makeSessionRequest(method: "GET", path: path)
return try await sendSession(req)
}
// MARK: Internal helpers (used by Session)
/// Internal entry point for ``Session/turn(_:files:timeoutSecs:)``.
/// `internal` access (the default) so it's visible across the module
/// but not part of the SDK's public surface.
func sessionTurnInternal(
id: String,
prompt: String,
files: [String]?,
timeoutSecs: Int?
) async throws -> TurnResult {
let body = TurnRequestBody(prompt: prompt, files: files, timeoutSecs: timeoutSecs)
let path = "/sessions/\(Self.encodeSessionId(id))/turn"
let req = try makeSessionRequest(method: "POST", path: path, jsonBody: body)
return try await sendSession(req)
}
/// Internal entry point for ``Session/close()``. The `DELETE
/// /sessions/{id}` body is `{ok: true, already_closed?: bool}`. We
/// decode it for protocol-correctness but discard the result close is
/// `Void` on success.
func sessionCloseInternal(id: String) async throws {
let path = "/sessions/\(Self.encodeSessionId(id))"
let req = try makeSessionRequest(method: "DELETE", path: path)
let _: CloseSessionResponse = try await sendSession(req)
}
// MARK: Private request building + transport
//
// We intentionally re-implement the small request-building helpers here
// rather than expose the v0.1 private ones via `internal`, so the v0.1
// surface stays mechanically untouched. Both code paths share the same
// `URLSession` and JSON encoder/decoder configuration.
/// Percent-encode a session id for inclusion in a URL path. Server-side
/// session ids are UUIDv4 strings, but we treat them as opaque and
/// escape per `urlPathAllowed` minus a few extra characters that
/// `urlPathAllowed` leaves through (`/`, `;`, `?`, `#`). Audit lesson
/// from `revokeToken` relying on `urlPathAllowed` alone leaves
/// path-injection vectors open.
static func encodeSessionId(_ id: String) -> String {
// Build a CharacterSet limited to RFC 3986 path-segment safe chars:
// unreserved (A-Z a-z 0-9 - . _ ~) plus a small set of sub-delims
// that won't reinterpret the URL structure.
var allowed = CharacterSet()
allowed.insert(charactersIn: "abcdefghijklmnopqrstuvwxyz")
allowed.insert(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZ")
allowed.insert(charactersIn: "0123456789")
allowed.insert(charactersIn: "-._~")
return id.addingPercentEncoding(withAllowedCharacters: allowed) ?? id
}
/// Build a `URLRequest` for a JSON-bodied session call.
fileprivate func makeSessionRequest<Body: Encodable>(
method: String,
path: String,
jsonBody: Body
) throws -> URLRequest {
var req = try makeSessionRequest(method: method, path: path)
let encoder = JSONEncoder()
encoder.outputFormatting = [.withoutEscapingSlashes]
do {
req.httpBody = try encoder.encode(jsonBody)
} catch {
throw ForgeError.invalidArgument("failed to encode request body: \(error)")
}
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
return req
}
/// Build a header-only `URLRequest` (auth + accept).
fileprivate func makeSessionRequest(method: String, path: String) throws -> URLRequest {
guard let url = URL(string: baseURL.absoluteString + path) else {
throw ForgeError.invalidArgument("could not form URL for path \(path)")
}
var req = URLRequest(url: url)
req.httpMethod = method
if !token.isEmpty {
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
req.setValue("application/json", forHTTPHeaderField: "Accept")
return req
}
/// Execute a request and decode JSON. Cancellation-safe; maps non-2xx
/// responses into ``ForgeError`` cases the same way the v0.1 path does
/// so callers see one consistent error vocabulary.
fileprivate func sendSession<T: Decodable>(_ req: URLRequest) async throws -> T {
try Task.checkCancellation()
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.forgeData(for: req)
} catch let urlError as URLError {
throw ForgeError.transport(urlError)
} catch {
throw ForgeError.transport(URLError(.unknown))
}
try Self.ensure2xxSession(data: data, response: response)
let decoder = JSONDecoder()
do {
return try decoder.decode(T.self, from: data)
} catch let decErr as DecodingError {
throw ForgeError.decoding(decErr)
} catch {
throw ForgeError.api(
statusCode: -1,
body: String(data: data, encoding: .utf8) ?? ""
)
}
}
/// Mirror of the v0.1 `ensure2xx` kept on the type as a static so the
/// session helpers can share it without reaching into the v0.1
/// instance-private one.
fileprivate static func ensure2xxSession(data: Data, response: URLResponse) throws {
guard let http = response as? HTTPURLResponse else {
throw ForgeError.api(statusCode: -1, body: "non-HTTP response")
}
let code = http.statusCode
guard !(200..<300).contains(code) else { return }
// Cap body capture at 8 MiB so a misbehaving server can't burn RAM.
let cap = 8 * 1024 * 1024
let bodyData = data.count > cap ? data.prefix(cap) : data
let body = String(data: bodyData, encoding: .utf8) ?? ""
if code == 401 || code == 403 {
throw ForgeError.auth(statusCode: code, body: body)
}
throw ForgeError.api(statusCode: code, body: body)
}
}