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.
241 lines
9.9 KiB
Swift
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)
|
|
}
|
|
}
|