1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
//
// AgentsStore.swift
// AT-IRC
//
// State for the Agents section: the embedded kcp's socialweb availability,
// the agent ComputeContracts, and launch/release actions.
//
import Foundation
import Observation
import OSLog
@Observable
@MainActor
final class AgentsStore {
enum Availability: Equatable {
case unknown
case ready
case unavailable(String)
}
private(set) var contracts: [AgentContract] = []
private(set) var availability: Availability = .unknown
private(set) var identityPhase = ""
/// Short-lived authorization URL for the requester's MarketIdentity.
private(set) var signInURL: URL?
private(set) var loading = false
private(set) var launching = false
var errorText: String?
private let api = AgentsAPI()
private let log = Logger(subsystem: "com.fedfork.irc.AT-IRC", category: "agents")
@ObservationIgnored private var identityPollTask: Task<Void, Never>?
/// Agent contracts first, Released ones last.
var agentContracts: [AgentContract] {
contracts.filter { $0.isAgent }.sorted { a, b in
if (a.phase == "Released") != (b.phase == "Released") {
return a.phase != "Released"
}
return a.id < b.id
}
}
/// Start the requester's OAuth by re-applying its identity (generation
/// bump), then wait for the controller-issued authorization URL.
func beginSignIn(requesterConfigJSON: String) async {
guard let raw = requesterConfigJSON.data(using: .utf8),
let config = try? JSONSerialization.jsonObject(with: raw) as? [String: Any] else {
asksError("The requester config is not ready yet.")
return
}
let identity: [String: Any] = [
"mode": "oauthBrowser",
"handle": config["handle"] as? String ?? "",
"did": config["did"] as? String ?? "",
"pdsUrl": config["pdsUrl"] as? String ?? "",
"oauthClientID": config["oauthClientId"] as? String ?? "",
"oauthRedirectURI": config["oauthRedirectUri"] as? String ?? "",
]
let patch = ["spec": ["identity": identity]]
guard let body = try? JSONSerialization.data(withJSONObject: patch) else { return }
do {
try await api.patchRequesterIdentity(body)
log.notice("signin requester generation bumped")
} catch {
errorText = error.localizedDescription
log.error("signin patch failed: \(error.localizedDescription, privacy: .public)")
return
}
for _ in 0..<15 {
if let state = try? await api.marketIdentity() {
identityPhase = state.phase
if let url = state.pendingURL {
signInURL = url
// Never log the authorization URL itself.
log.notice("signin url ready")
exportSignInURLForTest(url)
pollIdentityUntilReady()
return
}
}
try? await Task.sleep(for: .seconds(1))
}
log.notice("signin url not issued phase=\(self.identityPhase, privacy: .public)")
errorText = "The requester did not issue a sign-in URL (identity \(identityPhase.isEmpty ? "unknown" : identityPhase)). Check the embedded kcp logs, then try again."
}
/// The OAuth callback flips the identity to Ready asynchronously; keep the
/// panel current without the user pulling to refresh.
private func pollIdentityUntilReady() {
identityPollTask?.cancel()
identityPollTask = Task { @MainActor [weak self] in
for _ in 0..<90 {
guard let self else { return }
if let state = try? await self.api.marketIdentity() {
self.identityPhase = state.phase
if state.phase == "Ready" {
self.signInURL = nil
self.log.notice("market identity ready")
// Persist the new OAuth session now: a restart before
// the next launch/release must not lose the sign-in.
KCPHost.persistSnapshot()
await self.refresh()
return
}
self.signInURL = state.pendingURL
}
try? await Task.sleep(for: .seconds(2))
}
}
}
private func asksError(_ message: String) {
errorText = message
}
/// Scripted tests only: hand the authorization URL to a headless browser
/// through a 0600 app-container file, never through logs or the channel.
private func exportSignInURLForTest(_ url: URL) {
#if DEBUG
guard ProcessInfo.processInfo.environment["ATIRC_MARKET_SIGNIN_EXPORT"] == "1" else { return }
guard let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
let path = dir.appendingPathComponent(".market-signin-url")
try? url.absoluteString.write(to: path, atomically: true, encoding: .utf8)
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: path.path)
#endif
}
func refresh() async {
guard !loading else { return }
loading = true
defer { loading = false }
do {
guard try await api.socialwebAvailable() else {
availability = .unavailable("The embedded kcp has no socialweb requester yet (plan 0038 I4), so the market flow cannot run in this build.")
contracts = []
errorText = nil
return
}
contracts = try await api.contracts()
availability = .ready
errorText = nil
if let identity = try? await api.marketIdentity() {
identityPhase = identity.phase
signInURL = identity.phase == "Ready" ? nil : identity.pendingURL
}
} catch {
availability = .unavailable(error.localizedDescription)
errorText = error.localizedDescription
}
}
func launch() async {
guard !launching else { return }
launching = true
defer { launching = false }
do {
try await api.launch()
errorText = nil
// Persist the session EphemeralInfra now: a Release must finish
// from persisted state even if the app is killed after this.
KCPHost.persistSnapshot()
await refresh()
} catch {
errorText = error.localizedDescription
log.error("launch failed: \(error.localizedDescription, privacy: .public)")
}
}
func release(_ contract: AgentContract) async {
do {
try await api.release(contract)
errorText = nil
KCPHost.persistSnapshot()
await refresh()
} catch {
errorText = error.localizedDescription
}
}
/// Launch and return the new contract's id once it appears (the list can be
/// empty before the first launch).
func launchAndWait(timeout: Int = 60) async -> String? {
let before = Set(contracts.map(\.id))
await launch()
for _ in 0..<timeout {
await refresh()
if let fresh = contracts.first(where: { !before.contains($0.id) }) {
return fresh.id
}
try? await Task.sleep(for: .seconds(2))
}
return nil
}
/// Send guest.agentStart for a held contract and report the settled phase.
func agentStart(contractID: String) async {
do {
let settled = try await api.agentStart(contractID: contractID)
log.notice("agentStart \(contractID, privacy: .public) \(settled, privacy: .public)")
errorText = nil
} catch {
log.error("agentStart failed: \(error.localizedDescription, privacy: .public)")
errorText = error.localizedDescription
}
}
/// The embedded kcp was stopped out from under us: show it as unavailable
/// until Agents starts it again on the next visit.
func refreshAfterStop() {
availability = .unavailable("The embedded kcp is stopped.")
contracts = []
identityPhase = ""
signInURL = nil
errorText = nil
}
/// Debug: release every contract entry the session EphemeralInfra holds.
func releaseAllHeld() async {
do {
let released = try await api.releaseAllHeld()
log.notice("released contracts=\(released.joined(separator: ","), privacy: .public)")
errorText = nil
KCPHost.persistSnapshot()
await refresh()
} catch {
log.error("release all failed: \(error.localizedDescription, privacy: .public)")
errorText = error.localizedDescription
}
}
}