Files
agent-pet-companion/apps/macos/Sources/AgentPetCompanion/App/AppStore.swift
T
xjxandgd-ops 1807246b7e Add portable skill management and harden protected delivery (#10)
## What changed

- add App and PetCore portable Agent Pet Maker skill management, with
fd-relative ownership and race-safe install/uninstall
- improve Pi no-model failure projection, session bubble message
selection, child-session filtering, onboarding rendering, and related
App behavior
- delete verified PR source branches with exact leases after merge while
dispatching exact-main CI first
- document direct/train multi-agent delivery, explicit
add/commit/clean/push checks, post-merge worktree audits, and exact-SHA
release proof

## Validation

- full `cargo test -p petcore`
- Swift package suite: 734 tests
- Pi connector privacy tests
- promotion/release Python tests and actionlint
- `./script/validate_pre_push.sh`
- independent specification and safety reviews: no remaining blockers

This PR changes the protected CI control plane, so it is intended for
full CI followed by trusted exact-head squash merge.

---------

Co-authored-by: gd-ops <gd-ops@xmfunny.com>
2026-08-12 01:49:51 +08:00

7417 lines
284 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AgentPetCompanionCore
import AppKit
import Combine
import Foundation
import UniformTypeIdentifiers
enum OverlayBubbleDisclosureDirection: Equatable {
case expanding
case collapsing
}
enum OverlayBubbleDisclosureAction: Equatable {
case revealBubble
case revealCollapsedStandaloneStack
case expandStandaloneStack
case collapseStandaloneStack
case dismissBubble
static func resolve(
groupSessionsByAgent: Bool,
sessionCount: Int,
bubbleDismissed: Bool,
standaloneStackExpanded: Bool,
standaloneStackDirection: OverlayBubbleDisclosureDirection
) -> Self? {
guard sessionCount > 0 else { return nil }
guard !groupSessionsByAgent, sessionCount > 1 else {
return bubbleDismissed ? .revealBubble : .dismissBubble
}
if bubbleDismissed {
return .revealCollapsedStandaloneStack
}
if standaloneStackExpanded {
return .collapseStandaloneStack
}
return standaloneStackDirection == .expanding
? .expandStandaloneStack
: .dismissBubble
}
var revealsMoreContent: Bool {
switch self {
case .revealBubble, .revealCollapsedStandaloneStack, .expandStandaloneStack:
true
case .collapseStandaloneStack, .dismissBubble:
false
}
}
}
struct AppStoreBootstrapHooks {
typealias EnsureRunning = @Sendable () async -> ServiceStartResult
typealias Recover = @Sendable () async -> ServiceStartResult
typealias FetchInitialBehavior = @MainActor (AppStore) async throws -> Any
typealias RefreshSnapshot = @MainActor (AppStore) async throws -> Void
typealias OnReady = @MainActor (AppStore) async -> Void
let ensureRunning: EnsureRunning
let recover: Recover
let fetchInitialBehavior: FetchInitialBehavior?
let refreshSnapshot: RefreshSnapshot
let onReady: OnReady
let requiresAuthoritativeSnapshotOnReady: Bool
init(
ensureRunning: @escaping EnsureRunning,
recover: @escaping Recover,
fetchInitialBehavior: FetchInitialBehavior? = nil,
refreshSnapshot: @escaping RefreshSnapshot,
onReady: @escaping OnReady,
requiresAuthoritativeSnapshotOnReady: Bool = false
) {
self.ensureRunning = ensureRunning
self.recover = recover
self.fetchInitialBehavior = fetchInitialBehavior
self.refreshSnapshot = refreshSnapshot
self.onReady = onReady
self.requiresAuthoritativeSnapshotOnReady =
requiresAuthoritativeSnapshotOnReady
}
}
private struct OverlayPetFrameHitTestProjection {
var hitTest: OverlayPetFrameHitTest?
var petID: String
var semanticOwnerEntryID: String
}
enum PetCoreRuntimePhase: Equatable {
case checking
case running
case failed
}
enum PetStudioCodexAvailability: Equatable, Sendable {
case checking
case available
case missing
case unavailable
var permitsGeneration: Bool {
self == .available
}
}
struct PetCoreRuntimeInfo: Equatable {
var phase: PetCoreRuntimePhase
var version: String?
var appBuild: String?
var buildID: String?
var rpcProtocol: String?
var releaseChannel: String?
var databaseSchemaRange: String?
var instanceID: String?
var errorMessage: String?
static func initial(manifest: RuntimeReleaseManifest?) -> Self {
Self(
phase: .checking,
version: manifest?.appVersion,
appBuild: manifest?.appBuild,
buildID: manifest?.buildID,
rpcProtocol: manifest?.petCoreRPCProtocol,
releaseChannel: manifest?.releaseChannel,
databaseSchemaRange: manifest.map {
$0.minimumDatabaseSchemaVersion == $0.maximumDatabaseSchemaVersion
? String($0.minimumDatabaseSchemaVersion)
: "\($0.minimumDatabaseSchemaVersion)–\($0.maximumDatabaseSchemaVersion)"
},
instanceID: nil,
errorMessage: nil
)
}
static func running(
healthValue: Any,
expectedManifest: RuntimeReleaseManifest? = PetCoreRuntimeContract.requiredManifest
) -> Self? {
guard PetCoreRuntimeContract.acceptsHealth(
healthValue,
expectedBuildID: expectedManifest?.buildID ?? PetCoreRuntimeContract.requiredBuildID,
expectedManifest: expectedManifest
), let health = healthValue as? [String: Any]
else { return nil }
let manifest = RuntimeReleaseManifest.decodeHealthValue(health["runtime_manifest"])
?? expectedManifest
var info = initial(manifest: manifest)
info.phase = .running
info.version = health["version"] as? String ?? info.version
info.buildID = health["build_id"] as? String ?? info.buildID
info.rpcProtocol = health["rpc_protocol"] as? String ?? info.rpcProtocol
info.instanceID = health["instance_id"] as? String
return info
}
mutating func markChecking() {
phase = .checking
}
mutating func markRunning() {
phase = .running
errorMessage = nil
}
mutating func markFailed(_ reason: String) {
phase = .failed
errorMessage = reason
}
}
enum AgentSessionDeepLink {
static func url(source: AgentSource?, sessionID: String?) -> URL? {
guard let source else { return nil }
guard let sessionID = sessionID?.trimmingCharacters(in: .whitespacesAndNewlines),
sessionID.count == 36,
let uuid = UUID(uuidString: sessionID)
else {
return nil
}
let canonical = uuid.uuidString.lowercased()
guard canonical.caseInsensitiveCompare(sessionID) == .orderedSame else {
return nil
}
switch source {
case .codex:
return URL(string: "codex://threads/\(canonical)")
// Claude Desktop's `claude://resume` imports a CLI transcript as a new
// Desktop session rather than locating the existing one, so there is no
// truthful exact-session link to build for Claude Code.
case .claudeCode, .pi, .opencode:
return nil
}
}
}
enum AgentSessionOpenRoute: Equatable {
case url(URL)
case application(bundleIdentifiers: [String], paths: [String])
}
enum AgentSessionOpenFailure: Equatable, Sendable {
case urlOpenRejected
case applicationUnavailable
case applicationLaunchFailed
}
enum AgentSessionOpenOutcome: Equatable, Sendable {
case openedExactSession
case openedAgentHost
case failed(AgentSessionOpenFailure)
var didOpen: Bool {
switch self {
case .openedExactSession, .openedAgentHost: true
case .failed: false
}
}
}
private struct OverlaySessionNavigationNoticeRecord: Equatable {
let identity: OverlaySessionProjectionIdentity
let notice: OverlaySessionNavigationNotice
}
private struct OverlaySessionProjectionIdentity: Equatable, Sendable {
let eventID: String
let acknowledgementID: String?
}
enum AgentSessionRouter {
private static let chatGPTBundleIdentifiers = ["com.openai.codex"]
private static let chatGPTPaths = ["/Applications/ChatGPT.app", "/Applications/Codex.app"]
private static let claudeBundleIdentifiers = ["com.anthropic.claudefordesktop"]
private static let claudePaths = ["/Applications/Claude.app"]
private static let openCodeBundleIdentifiers = [
"ai.opencode.desktop",
"ai.opencode.desktop.beta",
"ai.opencode.desktop.dev"
]
private static let openCodePaths = [
"/Applications/OpenCode.app",
"/Applications/OpenCode Beta.app",
"/Applications/OpenCode Dev.app"
]
private static let terminalTargets: [String: ([String], [String])] = [
"warp": (["dev.warp.Warp-Stable", "dev.warp.Warp-Preview"], ["/Applications/Warp.app", "/Applications/WarpPreview.app"]),
"terminal": (["com.apple.Terminal"], ["/System/Applications/Utilities/Terminal.app"]),
"iterm2": (["com.googlecode.iterm2"], ["/Applications/iTerm.app"]),
"ghostty": (["com.mitchellh.ghostty"], ["/Applications/Ghostty.app"])
]
static func route(
source: AgentSource?,
sessionID: String?,
navigation: AgentSessionNavigation
) -> AgentSessionOpenRoute? {
guard !navigation.explicitlyClosed else { return nil }
switch navigation.capability {
case .exactSession:
return exactSessionRoute(source: source, navigation: navigation)
case .agentHost:
return agentHostRoute(source: source, navigation: navigation)
case .unavailable:
return nil
}
}
static func validatedCapability(
source: AgentSource?,
sessionID: String?,
navigation: AgentSessionNavigation
) -> NavigationCapability {
route(source: source, sessionID: sessionID, navigation: navigation) == nil
? .unavailable
: navigation.capability
}
static func hostFallbackRoute(
source: AgentSource?,
navigation: AgentSessionNavigation
) -> AgentSessionOpenRoute? {
guard navigation.capability == .exactSession,
!navigation.explicitlyClosed else { return nil }
return agentHostRoute(source: source, navigation: navigation)
}
private static func exactSessionRoute(
source: AgentSource?,
navigation: AgentSessionNavigation
) -> AgentSessionOpenRoute? {
if navigation.surface == "cli_terminal",
navigation.terminalApp == "warp",
let openURL = validatedSessionOpenURL(navigation.openURL) {
return .url(openURL)
}
if source == .codex,
navigation.surface == "chatgpt_app",
navigation.sessionOpen == true,
let deepLink = AgentSessionDeepLink.url(
source: source,
// Only PetCore's dedicated, strictly validated routing field
// may cross back into a Codex task URL. Never reinterpret the
// generic projected session identity as a routable raw ID.
sessionID: navigation.routableSessionID
)
{
return .url(deepLink)
}
return nil
}
private static func agentHostRoute(
source: AgentSource?,
navigation: AgentSessionNavigation
) -> AgentSessionOpenRoute? {
guard let source else { return nil }
if navigation.surface == "cli_terminal" {
guard let terminalApp = navigation.terminalApp,
let target = terminalTargets[terminalApp]
else {
return nil
}
return .application(bundleIdentifiers: target.0, paths: target.1)
}
if source == .codex, navigation.surface == "chatgpt_app" {
return .application(
bundleIdentifiers: chatGPTBundleIdentifiers,
paths: chatGPTPaths
)
}
if source == .claudeCode, navigation.surface == "claude_app" {
return .application(
bundleIdentifiers: claudeBundleIdentifiers,
paths: claudePaths
)
}
if source == .opencode, navigation.surface == "opencode_app" {
return .application(
bundleIdentifiers: openCodeBundleIdentifiers,
paths: openCodePaths
)
}
return nil
}
static func validatedSessionOpenURL(_ value: String?) -> URL? {
guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines),
let url = URL(string: value),
["warp", "warppreview"].contains(url.scheme?.lowercased() ?? ""),
url.host?.lowercased() == "session",
url.user == nil,
url.password == nil,
url.port == nil,
url.query == nil,
url.fragment == nil
else {
return nil
}
let identifier = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard identifier.range(
of: "^[0-9A-Fa-f]{32}$",
options: .regularExpression
) != nil
else {
return nil
}
return url
}
}
extension AgentEvent {
/// App Server lease renewal advances `created_at` even when every visible
/// field is unchanged. Treat that timestamp as transport metadata so the
/// overlay does not redraw once per poll while still publishing real
/// state, message, navigation, or activity changes.
func hasSamePresentation(as other: AgentEvent) -> Bool {
id == other.id
&& source == other.source
&& sessionID == other.sessionID
&& eventType == other.eventType
&& title == other.title
&& detail == other.detail
&& payloadJSON == other.payloadJSON
}
}
extension ActiveAgentState {
func hasSamePresentation(as other: ActiveAgentState) -> Bool {
let current = OverlaySessionContent(state: self)
let next = OverlaySessionContent(state: other)
return state == other.state
&& officialStatus == other.officialStatus
&& sessionActive == other.sessionActive
&& leaseSeconds == other.leaseSeconds
// `expiresAt`, persisted sequence, event identity, and connector
// lifecycle aliases are transport/audit metadata. Compare the
// values the bubble actually renders plus the pet's semantic
// state-entry identity so equivalent terminal edges cannot cause
// a second redraw or animation restart.
&& current.id == next.id
&& current.source == next.source
&& current.sessionID == next.sessionID
&& current.eventType == next.eventType
&& current.sessionTitle == next.sessionTitle
&& current.activityText == next.activityText
&& current.messageText == next.messageText
&& current.statusText == next.statusText
&& current.actionLabel == next.actionLabel
&& current.navigation == next.navigation
&& OverlayPetAnimationIdentity.stateEntryID(for: self)
== OverlayPetAnimationIdentity.stateEntryID(for: other)
}
}
private func optionalEventHasSamePresentation(_ lhs: AgentEvent?, _ rhs: AgentEvent?) -> Bool {
switch (lhs, rhs) {
case (nil, nil):
true
case let (.some(lhs), .some(rhs)):
lhs.hasSamePresentation(as: rhs)
default:
false
}
}
private func activeStatesHaveSamePresentation(
_ lhs: [ActiveAgentState],
_ rhs: [ActiveAgentState]
) -> Bool {
lhs.count == rhs.count
&& zip(lhs, rhs).allSatisfy { $0.hasSamePresentation(as: $1) }
}
private func eventsHaveSamePresentation(_ lhs: [AgentEvent], _ rhs: [AgentEvent]) -> Bool {
lhs.count == rhs.count
&& zip(lhs, rhs).allSatisfy { $0.hasSamePresentation(as: $1) }
}
struct OverlayBubbleLayoutSignature: Equatable {
struct Group: Equatable {
let id: String
let visibleSessionCount: Int
let isStacked: Bool
}
let bubbleDismissed: Bool
let groups: [Group]
init(contents: [OverlayBubbleContent], bubbleDismissed: Bool) {
self.bubbleDismissed = bubbleDismissed
groups = contents.map { content in
Group(
id: content.id,
visibleSessionCount: content.visibleSessions.count,
isStacked: content.isStacked
)
}
}
}
enum PetCoreServiceFailureCode: String, Codable, Equatable, Sendable {
case none
case petCoreBinaryMissing = "petcore_binary_missing"
case cliMissing = "cli_missing"
case launchAgentDisabled = "launch_agent_disabled"
case runtimePathsFailed = "runtime_paths_failed"
case launchctlFailed = "launchctl_failed"
case candidateHealthFailed = "candidate_health_failed"
case directLaunchFailed = "direct_launch_failed"
case updateRollbackFailed = "update_rollback_failed"
case unknown
}
enum PetCoreServiceFailureClassifier {
static func classify(_ reason: String) -> PetCoreServiceFailureCode {
if reason.contains("PetCore 更新失败且回滚未完成")
|| reason.contains("PetCore 更新失败,已恢复")
{
return .updateRollbackFailed
}
if reason.contains("未找到 petcore-cli 可执行文件") {
return .cliMissing
}
if reason.contains("未找到 petcore 可执行文件") {
return .petCoreBinaryMissing
}
if reason.contains("LaunchAgent 已由 APC_DISABLE_LAUNCH_AGENT 禁用") {
return .launchAgentDisabled
}
if reason.contains("准备 petcore 运行目录失败") {
return .runtimePathsFailed
}
if reason.contains("PetCore LaunchAgent 命令失败") {
return .launchctlFailed
}
if reason.contains("候选 PetCore 启动后未通过版本与健康检查")
|| reason.contains("候选 PetCore 预检失败")
|| reason.contains("LaunchAgent 已启动,但 PetCore 未在限定时间内就绪")
{
return .candidateHealthFailed
}
if reason.contains("启动 petcore 失败")
|| reason.contains("PetCore 直接启动后未在限定时间内就绪")
{
return .directLaunchFailed
}
return .unknown
}
}
private enum PetAssetDiagnosticCategory: String {
case assetsInvalid = "assets_invalid"
case unknown
static func classify(_ warning: PetAssetWarning) -> Self {
warning.code == "pet_assets_invalid" ? .assetsInvalid : .unknown
}
}
enum OverlayKeyboardFocusAction: CaseIterable, Hashable {
case bubbleSessions
func isAvailable(overlayEnabled: Bool, bubbleSessionCount: Int) -> Bool {
guard overlayEnabled else { return false }
return bubbleSessionCount > 0
}
static func availableActions(
overlayEnabled: Bool,
bubbleSessionCount: Int
) -> Set<Self> {
Set(allCases.filter {
$0.isAvailable(
overlayEnabled: overlayEnabled,
bubbleSessionCount: bubbleSessionCount
)
})
}
}
private enum LatestGenerationRestoreAttemptState: Equatable {
case notAttempted
case inFlight
case resolved
}
private struct ActiveGenerationRecoveryProjectionIdentity: Equatable {
var jobID: String
var form: GenerationForm
var referenceReselectionCount: Int
}
private struct SanitizedGenerationRecoveryProjection {
var identity: ActiveGenerationRecoveryProjectionIdentity
var form: GenerationForm?
var referenceReselectionCount: Int
}
enum OnboardingOperationFailure: Equatable {
case serviceUnavailable
case petActivation
case revisionConflict
case requestRejected
}
enum IncludedCompanionRestoreState: Equatable {
case idle
case restoring
case restored
case failed
}
enum PetAssetRepairState: Equatable {
case idle
case repairing
case repaired
case failed
}
enum AppUpdateConvergenceAttention: Equatable {
case bundledPets
case connectors([ProductConnectorConvergenceIssue])
case service
var sources: [AgentSource] {
if case let .connectors(issues) = self {
issues.map(\.source)
} else {
[]
}
}
}
enum AppUpdateConvergenceState: Equatable {
case idle
case waitingForActiveWork
case updating
case completed(version: String)
case needsAttention(AppUpdateConvergenceAttention)
}
@MainActor
final class AppStore: ObservableObject {
typealias BundledPetSeeder = @MainActor () async -> Bool
typealias BundledPetSeedSleeper = @Sendable (Duration) async throws -> Void
typealias InitialAppearanceFallbackSleeper = @Sendable (Duration) async throws -> Void
typealias OverlayPlacementRetrySleeper = @Sendable (Duration) async throws -> Void
typealias AgentSessionRouteOpener = @MainActor (
AgentSessionOpenRoute
) async -> AgentSessionOpenOutcome
typealias ProductConvergenceSleeper = @Sendable (Duration) async throws -> Void
typealias ProductConvergenceUpgradeEvidence = @MainActor (
RuntimeReleaseManifest
) -> Bool
typealias RuntimeHandoffCheck = @MainActor () -> Bool
typealias ApplicationAppearanceApplier = @MainActor (AppearanceTheme) -> Void
typealias ApplicationLanguageApplier = @MainActor (InterfaceLanguage) -> Void
typealias OverlayPresenter = @MainActor (PetOverlayController, AppStore) -> Void
typealias OverlayKeyboardFocusHandler = @MainActor (
_ controller: PetOverlayController,
_ action: OverlayKeyboardFocusAction
) -> Void
typealias PetCoreRequestOverride = @MainActor (
_ method: String,
_ params: Any,
_ timeout: Duration?
) async throws -> Any
@Published var selection: NavigationSection = .library
@Published private(set) var descriptionText = AIPetMakerDefaults.descriptionText
@Published private(set) var selectedStyle = AIPetMakerDefaults.style
@Published private(set) var selectedQuality = AIPetMakerDefaults.quality
@Published private(set) var referenceImages: [String] = []
@Published private(set) var referenceImageIssue: MakerReferenceImageIssue?
@Published private(set) var referenceReselectionCount = 0
@Published private(set) var petStudioCodexAvailability =
PetStudioCodexAvailability.checking
@Published var behavior = BehaviorSettings()
@Published private(set) var activeAgentState: ActiveAgentState?
@Published private(set) var activeAgentSessions: [ActiveAgentState] = []
@Published private(set) var activeAgentSessionsOmittedCount = 0
@Published private(set) var overlayVisibility = OverlayVisibility()
@Published var pets: [PetSummary] = []
@Published private(set) var hasLoadedStateSnapshot = false
@Published private(set) var initialAppearanceReadiness = InitialAppearanceReadiness.pending
@Published private(set) var petAssetWarningIndex = PetAssetWarningIndex()
@Published var events: [AgentEvent] = []
@Published var recentEvents: [AgentEvent] = []
@Published var connections: [AgentConnectionStatus] = []
@Published private(set) var onboarding: VersionedOnboardingProgress?
@Published private(set) var onboardingMutationInFlight = false
@Published private(set) var onboardingDismissedForCurrentLaunch = false
@Published private(set) var onboardingOperationFailure: OnboardingOperationFailure?
@Published private(set) var includedCompanionRestoreState =
IncludedCompanionRestoreState.idle
@Published private(set) var petAssetRepairStates: [String: PetAssetRepairState] = [:]
@Published private(set) var generationSession = GenerationSession()
@Published private(set) var generationHistorySnapshot =
GenerationStudioHistorySnapshot()
@Published private(set) var selectedGenerationHistoryJobID: String?
@Published private(set) var generationHistoryDetail: GenerationStudioHistoryDetail?
@Published private(set) var generationHistoryMessages: [GenerationMessage] = []
@Published private(set) var generationHistoryMessagesHasMore = false
@Published private(set) var generationHistoryMessagesIsLoading = false
@Published private(set) var makerDraftIsActive = false
@Published private(set) var generationHistoryIsLoading = false
@Published private(set) var generationHistoryHasLoaded = false
@Published private(set) var generationHistoryDetailIsLoading = false
@Published private(set) var generationHistoryLoadFailed = false
@Published private(set) var generationHistoryDeleteInFlightJobID: String?
@Published private(set) var generationHistoryMutationError: String?
@Published var generationReplyText = ""
@Published var statusText = "正在初始化"
@Published var serviceStatusText = "正在初始化"
@Published private(set) var petCoreOperationalState = PetCoreOperationalState.checking
@Published private(set) var petCoreRuntimeInfo = PetCoreRuntimeInfo.initial(
manifest: PetCoreRuntimeContract.requiredManifest
)
@Published private(set) var lastServiceFailureCode = PetCoreServiceFailureCode.none
@Published private(set) var overlayDisplayWidthPt =
OverlayGeometry.defaultDisplayWidthPt
@Published var overlayVisible = true
@Published var overlayScreenFrame = CGRect(x: 780, y: 140, width: 704, height: 640)
@Published var overlayScreenVisibleFrame = NSScreen.main?.visibleFrame ?? .zero
@Published var overlayPetScreenCenter = CGPoint.zero
var overlayPresentedPetScreenCenter: CGPoint {
overlayPetDragPresentationCenter ?? overlayPetScreenCenter
}
private(set) var overlayPetVisualEnvelope: OverlayPetVisualEnvelope?
private var overlayPetFrameHitTestProjection: OverlayPetFrameHitTestProjection?
var overlayPetFrameHitTest: OverlayPetFrameHitTest? {
guard let projection = overlayPetFrameHitTestProjection,
activePet?.id == projection.petID,
OverlayPetAnimationIdentity.stateEntryID(for: presentedActiveAgentState)
== projection.semanticOwnerEntryID else {
return nil
}
return projection.hitTest
}
var overlayPetPointerMaskState: OverlayPointerMaskState {
guard let projection = overlayPetFrameHitTestProjection else {
return .missing
}
guard activePet?.id == projection.petID,
OverlayPetAnimationIdentity.stateEntryID(
for: presentedActiveAgentState
) == projection.semanticOwnerEntryID else {
return .stale
}
return projection.hitTest == nil ? .missing : .valid
}
var overlayActivePointerInteractionID: UUID? {
overlayDragInteractionID
}
@Published var overlayBubbleDismissed = false
/// Presentation-only side of the pet used to keep flat session cards and
/// the disclosure chevron ordered toward the pet. It changes only when a
/// placement edge actually flips, never for ordinary drag samples.
@Published private(set) var overlayBubbleAnchorDirection: OverlayBubbleAnchorDirection = .above
@Published var overlayDismissedBubbleEventIDs: Set<String> = []
@Published private var overlaySessionNavigationNotices:
[String: OverlaySessionNavigationNoticeRecord] = [:]
private var overlaySessionProjectionIdentities:
[String: OverlaySessionProjectionIdentity] = [:]
@Published private(set) var overlayAgentGroupExpansionOverrides: [AgentSource: Bool] = [:]
/// Oldest-to-newest stable slots for the non-grouped tray. Status churn
/// within one activation epoch never mutates this order.
private var overlayStandaloneSessionOrder: [String] = []
private var overlayStandaloneSessionActivationIDs: [String: String] = [:]
@Published private(set) var overlayStandaloneStackExpansionOverride: Bool?
private var overlayStandaloneStackDisclosureDirection:
OverlayBubbleDisclosureDirection = .expanding
@Published var overlayPointerNearPet = false
@Published var overlayPetDragInProgress = false
@Published var petOperationIDs: Set<String> = []
@Published var isImportingPetpack = false
@Published private(set) var petpackImportProgress: PetLibraryImportProgress?
@Published private(set) var petLibraryNotice: PetLibraryNotice?
@Published private(set) var diagnosticsExportState = DiagnosticsExportState.idle
@Published private(set) var connectionOperationState = AgentConnectionOperationState.idle
@Published private(set) var portableMakerSkillStatus: PortableMakerSkillStatus?
@Published private(set) var portableMakerSkillOperation = PortableMakerSkillOperation.idle
@Published private(set) var portableMakerSkillFailure: PortableMakerSkillFailure?
@Published private(set) var manualAppInstallationRequest: AppManualInstallationRequest?
@Published private(set) var appUpdateConvergenceState = AppUpdateConvergenceState.idle
private let client: PetCoreClient
private let overlayController: PetOverlayController
private let bootstrapHooks: AppStoreBootstrapHooks
private let diagnostics: AppDiagnostics
private let bundledPetSeederOverride: BundledPetSeeder?
private let bundledPetSeedSleeper: BundledPetSeedSleeper
private let initialAppearanceFallbackSleeper: InitialAppearanceFallbackSleeper
private let overlayPlacementRetrySleeper: OverlayPlacementRetrySleeper
private let overlayPlacementJournalStore: OverlayPlacementJournalStore
private let agentSessionRouteOpener: AgentSessionRouteOpener
private let runtimeHandoffIfNeeded: RuntimeHandoffCheck
private let applicationAppearanceApplier: ApplicationAppearanceApplier
private let applicationLanguageApplier: ApplicationLanguageApplier
private let overlayPresenter: OverlayPresenter
private let overlayKeyboardFocusHandler: OverlayKeyboardFocusHandler
private let petCoreRequestOverride: PetCoreRequestOverride?
let appUpdater: AppUpdateController
private let productConvergenceSleeper: ProductConvergenceSleeper
private let productConvergenceNoticePreferences: ProductConvergenceNoticePreferences
private let productConvergenceManifest: RuntimeReleaseManifest?
private let productConvergenceUpgradeEvidence: ProductConvergenceUpgradeEvidence
private var refreshTask: Task<Void, Never>?
private var petpackImportTask: Task<Void, Never>?
private var overlayPetPositionInitialized = false
private var overlayPlacementAuthority = OverlayPlacementAuthority()
private var overlayPlacementSaveTask: Task<Void, Never>?
private var overlayPlacementExhaustedGeneration: UInt64?
private var overlayPlacementJournalDidLoad = false
private let overlayPlacementPreviewDriver =
OverlayDisplayLinkCoalescer<CGFloat>()
private var overlayDisplayWidthCommitTask: Task<Void, Never>?
private var overlayDisplayWidthCommitDeadline: TimeInterval?
private var overlayPetDragPresentationCenter: CGPoint?
private var overlayBubbleProjectionCache: (
inputs: OverlayBubbleProjectionInputs,
contents: [OverlayBubbleContent]
)?
private var overlayDragInteractionID: UUID?
private var overlayLostMouseUpFallbackTask: Task<Void, Never>?
private var pendingDisplayWidthPt: CGFloat?
private var stateRevision = ""
private(set) var behaviorRevision = "0"
private var authoritativeBehavior = BehaviorSettings()
private var overlayLastReopenIDBySession: [String: String] = [:]
private var behaviorMutationTask: Task<Void, Never>?
private var behaviorMutationSequence: UInt64 = 0
private var pendingBehaviorMutationCount = 0
private var mainWindowPresenter: (() -> Void)?
private weak var controlCenterWindow: NSWindow?
private var controlCenterCloseObserver: AnyCancellable?
private(set) var controlCenterIsOpen = false
private var pendingMainWindowPresentation = false
private var pendingMainWindowPresentationChecksRuntimeHandoff = true
private var generationMessagesTask: Task<Void, Never>?
private var latestGenerationRestoreAttemptState = LatestGenerationRestoreAttemptState.notAttempted
private var latestGenerationRestoreAttemptSequence: UInt64 = 0
private var latestGenerationRestoreInFlight: (id: UInt64, task: Task<Void, Never>)?
private var makerUserMutationRevision: UInt64 = 0
private var automaticLatestGenerationRestoreInvalidated = false
private var generationHistoryListSequence: UInt64 = 0
private var generationHistoryDetailSequence: UInt64 = 0
private var generationHistoryMessagesSequence: UInt64 = 0
/// A job ID returned by a successful start/edit/retry RPC is authoritative
/// before it appears in an eventually refreshed history list. Keep that
/// selection stable until a list snapshot catches up.
private var generationHistorySelectionIntentJobID: String?
private var reselectedReferenceImagePaths: Set<String> = []
private var activeGenerationRecoveryProjection: SanitizedGenerationRecoveryProjection?
private var runtimeBootstrapCompleted = false
private var runtimeBootstrapRequiresFullRecovery = false
private var runtimeBootstrapSequence: UInt64 = 0
private var runtimeBootstrap: (id: UInt64, task: Task<Bool, Never>)?
private var runtimeBootstrapRetryTask: Task<Void, Never>?
private var runtimeBootstrapRetryDelaySeconds: UInt64 = 2
private var bundledPetSeedRetryTask: Task<Void, Never>?
private var initialAppearanceFallbackTask: Task<Void, Never>?
private var recoverySequence: UInt64 = 0
private var serviceRecovery: (id: UInt64, task: Task<Bool, Never>)?
private var connectionOperationGate = AgentConnectionOperationGate()
private var automaticConnectionCheckRequested = false
private var automaticConnectionCheckTask: Task<Void, Never>?
private var hasPresentedOverlay = false
private var productConvergenceTask: Task<Void, Never>?
private var productConvergenceVisibilityTask: Task<Void, Never>?
private var inFlightProtectedMutationCount = 0
static let bundledPetSeedRetryDelays: [Duration] = [
.seconds(2),
.seconds(4),
.seconds(8)
]
static let initialAppearanceFallbackDelay: Duration = .milliseconds(500)
static func stateWaitTimeoutMilliseconds(
generationIsActive: Bool,
hasActiveAgentState: Bool
) -> Int {
if generationIsActive || hasActiveAgentState {
return 1_000
}
return 30_000
}
static let controlCenterWindowIdentifier = NSUserInterfaceItemIdentifier(
"dev.agentpet.companion.control-center"
)
private static let defaultOverlayKeyboardFocusHandler: OverlayKeyboardFocusHandler = { controller, action in
switch action {
case .bubbleSessions:
controller.focusBubbleForKeyboardNavigation()
}
}
init(diagnostics: AppDiagnostics = .shared) {
let processManager = PetCoreProcessManager()
let bootstrapCoordinator = PetCoreAppBootstrapCoordinator(
ensureRunning: { await processManager.ensureRunning() }
)
client = PetCoreClient()
overlayController = PetOverlayController()
self.diagnostics = diagnostics
bundledPetSeederOverride = nil
bundledPetSeedSleeper = { duration in
try await Task.sleep(for: duration)
}
initialAppearanceFallbackSleeper = { duration in
try await Task.sleep(for: duration)
}
overlayPlacementRetrySleeper = { duration in
try await Task.sleep(for: duration)
}
overlayPlacementJournalStore = .fileBacked()
agentSessionRouteOpener = Self.openAgentSessionRoute
runtimeHandoffIfNeeded = {
AppUpdateHandoffCoordinator.shared.restartIfInstalledBuildChanged()
}
applicationAppearanceApplier = { theme in
APCApplicationAppearance.apply(theme)
}
applicationLanguageApplier = { language in
APCLocalization.applyInterfaceLanguage(language)
}
overlayPresenter = { controller, store in
controller.show(store: store)
}
overlayKeyboardFocusHandler = Self.defaultOverlayKeyboardFocusHandler
petCoreRequestOverride = nil
appUpdater = AppUpdateController(diagnostics: diagnostics)
productConvergenceSleeper = { duration in
try await Task.sleep(for: duration)
}
productConvergenceNoticePreferences = ProductConvergenceNoticePreferences()
productConvergenceManifest = PetCoreRuntimeContract.requiredManifest
productConvergenceUpgradeEvidence = { manifest in
PetCoreRuntimeUpgradeEvidence.hasManagedUpdateContext(
currentBuildID: manifest.buildID
)
}
bootstrapHooks = AppStoreBootstrapHooks(
ensureRunning: { await bootstrapCoordinator.ensureRunning() },
recover: { await bootstrapCoordinator.recover() },
fetchInitialBehavior: { store in
try await store.requestPetCore(method: "behavior.get")
},
refreshSnapshot: { store in try await store.refreshSnapshot() },
onReady: { store in await store.completeRuntimeBootstrap() },
requiresAuthoritativeSnapshotOnReady: true
)
}
init(
client: PetCoreClient = PetCoreClient(),
bootstrapHooks: AppStoreBootstrapHooks,
diagnostics: AppDiagnostics = .disabled,
bundledPetSeeder: BundledPetSeeder? = nil,
bundledPetSeedSleeper: @escaping BundledPetSeedSleeper = { duration in
try await Task.sleep(for: duration)
},
initialAppearanceFallbackSleeper: @escaping InitialAppearanceFallbackSleeper = { duration in
try await Task.sleep(for: duration)
},
overlayPlacementRetrySleeper: @escaping OverlayPlacementRetrySleeper = { duration in
try await Task.sleep(for: duration)
},
overlayPlacementJournalStore: OverlayPlacementJournalStore = .disabled(),
agentSessionRouteOpener: @escaping AgentSessionRouteOpener = { _ in
.failed(.applicationUnavailable)
},
runtimeHandoffIfNeeded: @escaping RuntimeHandoffCheck = { false },
applicationAppearanceApplier: @escaping ApplicationAppearanceApplier = { theme in
APCApplicationAppearance.apply(theme)
},
applicationLanguageApplier: @escaping ApplicationLanguageApplier = { language in
APCLocalization.applyInterfaceLanguage(language)
},
overlayPresenter: @escaping OverlayPresenter = { controller, store in
controller.show(store: store)
},
overlayKeyboardFocusHandler: OverlayKeyboardFocusHandler? = nil,
petCoreRequestOverride: PetCoreRequestOverride? = nil,
initialPetStudioCodexAvailability: PetStudioCodexAvailability = .checking,
appUpdater: AppUpdateController? = nil,
productConvergenceSleeper: @escaping ProductConvergenceSleeper = { duration in
try await Task.sleep(for: duration)
},
productConvergenceNoticePreferences: ProductConvergenceNoticePreferences =
ProductConvergenceNoticePreferences(),
productConvergenceManifest: RuntimeReleaseManifest? =
PetCoreRuntimeContract.requiredManifest,
productConvergenceUpgradeEvidence: @escaping ProductConvergenceUpgradeEvidence = {
manifest in
PetCoreRuntimeUpgradeEvidence.hasPriorManagedBuild(
currentBuildID: manifest.buildID
)
}
) {
self.client = client
overlayController = PetOverlayController()
self.bootstrapHooks = bootstrapHooks
self.diagnostics = diagnostics
bundledPetSeederOverride = bundledPetSeeder
self.bundledPetSeedSleeper = bundledPetSeedSleeper
self.initialAppearanceFallbackSleeper = initialAppearanceFallbackSleeper
self.overlayPlacementRetrySleeper = overlayPlacementRetrySleeper
self.overlayPlacementJournalStore = overlayPlacementJournalStore
self.agentSessionRouteOpener = agentSessionRouteOpener
self.runtimeHandoffIfNeeded = runtimeHandoffIfNeeded
self.applicationAppearanceApplier = applicationAppearanceApplier
self.applicationLanguageApplier = applicationLanguageApplier
self.overlayPresenter = overlayPresenter
self.overlayKeyboardFocusHandler = overlayKeyboardFocusHandler
?? Self.defaultOverlayKeyboardFocusHandler
self.petCoreRequestOverride = petCoreRequestOverride
petStudioCodexAvailability = initialPetStudioCodexAvailability
self.appUpdater = appUpdater ?? AppUpdateController(
automaticChecksEnabled: false,
diagnostics: diagnostics
)
self.productConvergenceSleeper = productConvergenceSleeper
self.productConvergenceNoticePreferences = productConvergenceNoticePreferences
self.productConvergenceManifest = productConvergenceManifest
self.productConvergenceUpgradeEvidence = productConvergenceUpgradeEvidence
}
var activePet: PetSummary? {
pets.first(where: \.active)
}
func petSummary(id: String?) -> PetSummary? {
guard let id, !id.isEmpty else { return nil }
return pets.first { $0.id == id }
}
var interfaceLocaleIdentifier: String {
APCLocalization.resolvedInterfaceLocaleIdentifier(
interfaceLanguage: behavior.interfaceLanguage
)
}
private var hasProtectedUserWork: Bool {
let diagnosticsBusy = switch diagnosticsExportState {
case .exporting, .saving:
true
case .idle, .ready, .succeeded, .failed, .saveFailed:
false
}
return generationSession.isActive
|| connectionOperationState.isRunning
|| isImportingPetpack
|| !petOperationIDs.isEmpty
|| diagnosticsBusy
|| inFlightProtectedMutationCount > 0
|| pendingBehaviorMutationCount > 0
|| overlayPlacementSaveTask != nil
|| overlayPlacementAuthority.pending != nil
|| overlayPlacementPreviewDriver.hasPending
|| overlayDisplayWidthCommitTask != nil
|| overlayPetDragInProgress
|| runtimeBootstrap != nil
|| serviceRecovery != nil
}
private var isSafeForProductConvergence: Bool {
!hasProtectedUserWork
}
var isSafeForAppUpdateHandoff: Bool {
isSafeForProductConvergence && productConvergenceTask == nil
}
var shouldBlockForAppUpdateConvergence: Bool {
guard !hasLoadedStateSnapshot else { return false }
return switch appUpdateConvergenceState {
case .waitingForActiveWork, .updating:
true
case .idle, .completed, .needsAttention:
false
}
}
var onboardingCompanionCandidates: [PetSummary] {
let candidates = Dictionary(
pets
.filter(\.isIncludedCompanionCandidate)
.map { ($0.id, $0) },
uniquingKeysWith: { first, _ in first }
)
return PetSummary.includedCompanionIDs.compactMap {
candidates[$0]
}
}
var shouldPresentOnboarding: Bool {
guard !onboardingDismissedForCurrentLaunch,
let onboarding
else {
return false
}
return !onboarding.progress.stage.isTerminal
}
var onboardingAvailability: OnboardingFlowAvailability {
petCoreOperationalState == .online ? .ready : .serviceUnavailable
}
/// Compatibility projection for older callers. The typed operation state
/// remains the single source of truth for serialization and failure UI.
var connectionOperationSources: Set<AgentSource> {
Set(connectionOperationState.runningOperation?.sources ?? [])
}
var canStartConnectionOperation: Bool {
!connectionOperationState.isRunning && productConvergenceTask == nil
}
var activeOverlayEvent: AgentEvent? {
presentedActiveAgentState?.event
}
var presentedActiveAgentState: ActiveAgentState? {
OverlayPresentedAgentState.resolve(
canonicalState: activeAgentState,
activeSessions: activeAgentSessions,
dismissedSessionIDs: overlayDismissedBubbleEventIDs
)
}
var activeAgentEventText: String {
activeOverlayEvent.map { "\($0.source.title) · \($0.title)" } ?? "暂无活跃 Agent 事件"
}
var overlayBubbleEvents: [AgentEvent] {
activeAgentSessions
.filter {
!overlayDismissedBubbleEventIDs.contains(OverlaySessionContent.stableID(
source: $0.source,
sessionID: $0.sessionID ?? $0.event.sessionID,
anonymousSessionAlias: $0.anonymousSessionAlias,
fallbackEventID: $0.event.id
))
}
.map(\.event)
}
/// Everything the bubble projection reads. Comparing this is far cheaper
/// than rebuilding the projection, which localizes and normalizes text for
/// every session.
private struct OverlayBubbleProjectionInputs: Equatable {
let statusBubbleVisible: Bool
let sessions: [ActiveAgentState]
let omittedCount: Int
let dismissedSessionIDs: Set<String>
let groupExpansionOverrides: [AgentSource: Bool]
let groupSessionsByAgent: Bool
let standaloneSessionOrder: [String]
let standaloneStackExpanded: Bool
let sessionGroupDisplay: SessionGroupDisplay
let navigationNotices: [String: OverlaySessionNavigationNoticeRecord]
let projectionIdentities: [String: OverlaySessionProjectionIdentity]
}
private var currentOverlayBubbleProjectionInputs: OverlayBubbleProjectionInputs {
OverlayBubbleProjectionInputs(
statusBubbleVisible: overlayVisibility.statusBubbleVisible,
sessions: activeAgentSessions,
omittedCount: activeAgentSessionsOmittedCount,
dismissedSessionIDs: overlayDismissedBubbleEventIDs,
groupExpansionOverrides: overlayAgentGroupExpansionOverrides,
groupSessionsByAgent: behavior.groupSessionsByAgent,
standaloneSessionOrder: overlayStandaloneSessionOrder,
standaloneStackExpanded: overlayStandaloneStackIsExpanded,
sessionGroupDisplay: behavior.sessionGroupDisplay,
navigationNotices: overlaySessionNavigationNotices,
projectionIdentities: overlaySessionProjectionIdentities
)
}
/// Memoizes the projection so the several reads that happen inside a single
/// SwiftUI pass — bubble contents, availability, session count, keyboard
/// focus — share one build instead of repeating it. Purely derived state,
/// so nothing is published when the cache is refreshed.
var overlayAvailableBubbleContents: [OverlayBubbleContent] {
let inputs = currentOverlayBubbleProjectionInputs
if let cached = overlayBubbleProjectionCache, cached.inputs == inputs {
return cached.contents
}
let contents = buildOverlayAvailableBubbleContents(inputs)
overlayBubbleProjectionCache = (inputs, contents)
return contents
}
private func buildOverlayAvailableBubbleContents(
_ inputs: OverlayBubbleProjectionInputs
) -> [OverlayBubbleContent] {
guard inputs.statusBubbleVisible else { return [] }
var contents = OverlayBubbleProjection.contents(
states: inputs.sessions,
omittedCount: inputs.omittedCount,
dismissedSessionIDs: inputs.dismissedSessionIDs,
groupSessionsByAgent: inputs.groupSessionsByAgent,
standaloneSessionOrder: inputs.standaloneSessionOrder,
standaloneStackExpanded: inputs.standaloneStackExpanded,
isExpanded: { source in
inputs.groupExpansionOverrides[source]
?? (inputs.sessionGroupDisplay == .expanded)
}
)
for contentIndex in contents.indices {
for sessionIndex in contents[contentIndex].sessions.indices {
let session = contents[contentIndex].sessions[sessionIndex]
guard let record = inputs.navigationNotices[session.id],
record.identity == inputs.projectionIdentities[session.id]
else { continue }
contents[contentIndex].sessions[sessionIndex].navigationNotice = record.notice
}
}
return contents
}
var overlayBubbleContents: [OverlayBubbleContent] {
guard !overlayBubbleDismissed else { return [] }
return overlayAvailableBubbleContents
}
var hasAvailableOverlayBubbleContent: Bool {
!overlayAvailableBubbleContents.isEmpty
}
var overlayBubbleSessionCount: Int {
overlayAvailableBubbleContents
.reduce(0) { $0 + $1.representedSessionCount }
}
var canFocusOverlayBubbleForKeyboardNavigation: Bool {
overlayKeyboardFocusActions.contains(.bubbleSessions)
}
private var overlayKeyboardFocusActions: Set<OverlayKeyboardFocusAction> {
OverlayKeyboardFocusAction.availableActions(
overlayEnabled: behavior.enabled,
bubbleSessionCount: overlayBubbleSessionCount
)
}
var overlayBubbleStatusTone: OverlaySessionGroupTone {
OverlaySessionGroupTone.aggregate(
overlayAvailableBubbleContents.flatMap(\.sessions)
)
}
var overlayBubbleIsCollapsed: Bool {
overlayBubbleDismissed || overlayBubbleContents.isEmpty
}
func overlayAgentGroupIsExpanded(_ source: AgentSource) -> Bool {
overlayAgentGroupExpansionOverrides[source]
?? (behavior.sessionGroupDisplay == .expanded)
}
var overlayStandaloneStackIsExpanded: Bool {
overlayStandaloneStackExpansionOverride
?? (behavior.sessionGroupDisplay == .expanded)
}
var canStartGeneration: Bool {
canStartNewGenerationWork
&& petStudioCodexAvailability.permitsGeneration
&& !generationSession.isActive
&& GenerationPromptPolicy.isValid(descriptionText)
}
var generationStartBlockingDetail: String? {
guard !canStartGeneration else { return nil }
guard canStartNewGenerationWork else {
return APCLocalization.text(.appUpdateConvergenceMakerBlocked)
}
if generationSession.isActive {
return generationStateTitle
}
switch petStudioCodexAvailability {
case .checking:
return APCLocalization.text(.studioCodexCheckingDetail)
case .missing:
return APCLocalization.text(.studioCodexMissingDetail)
case .unavailable:
return APCLocalization.text(.studioCodexUnavailableDetail)
case .available:
return GenerationPromptPolicy.isValid(descriptionText)
? nil
: APCLocalization.text(.studioDescriptionRequired)
}
}
var generationStartPresentationDetail: String {
generationStartBlockingDetail
?? APCLocalization.text(.studioWelcomeDetail)
}
var canStartNewGenerationWork: Bool {
guard productConvergenceTask == nil else { return false }
return switch appUpdateConvergenceState {
case .idle, .completed:
true
case let .needsAttention(.connectors(issues)):
!issues.contains { $0.source == .codex }
case .needsAttention(.bundledPets), .needsAttention(.service):
false
case .waitingForActiveWork, .updating:
false
}
}
/// Continuing the one preserved Studio job is different from starting new
/// protected work. An update waiting for that job to finish must not make
/// the job impossible to resume; the convergence task will keep waiting
/// for the resumed generation to reach a terminal state.
private var canResumeExistingGenerationWork: Bool {
return switch appUpdateConvergenceState {
case .idle, .completed, .waitingForActiveWork:
true
case let .needsAttention(.connectors(issues)):
!issues.contains { $0.source == .codex }
case .needsAttention(.bundledPets), .needsAttention(.service), .updating:
false
}
}
var canClearStudioForm: Bool {
!generationSession.isActive
&& (
descriptionText != AIPetMakerDefaults.descriptionText
|| selectedStyle != AIPetMakerDefaults.style
|| selectedQuality != AIPetMakerDefaults.quality
|| !referenceImages.isEmpty
|| referenceImageIssue != nil
|| referenceReselectionCount != 0
|| !generationReplyText.isEmpty
)
}
var isWaitingForGenerationInput: Bool {
generationSession.state == .waitingForInput
}
var canSendGenerationReply: Bool {
generationSession.canSendReply
}
var canRetryGeneration: Bool {
generationSession.canRetry
&& petStudioCodexAvailability.permitsGeneration
&& referenceReselectionCount == 0
&& (generationSession.operation == .modify
|| !descriptionText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
var canResumeGeneration: Bool {
generationSession.canResume
&& canResumeExistingGenerationWork
&& petStudioCodexAvailability.permitsGeneration
&& referenceReselectionCount == 0
}
var canResumeSelectedGenerationHistory: Bool {
guard let detail = generationHistoryDetail,
detail.found,
let jobID = detail.jobID,
jobID == selectedGenerationHistoryJobID,
detail.capabilities?.canResume == true
else { return false }
return canResumeExistingGenerationWork
&& petStudioCodexAvailability.permitsGeneration
}
var canCopySelectedGenerationHistoryBrief: Bool {
guard !generationSession.isActive,
let detail = selectedGenerationHistoryDetail,
let description = detail.description,
!description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
detail.quality?.isStudioSupported == true
else { return false }
return detail.style != nil
}
var canRetrySelectedGenerationHistory: Bool {
guard canCopySelectedGenerationHistoryBrief,
canStartNewGenerationWork,
petStudioCodexAvailability.permitsGeneration,
let status = selectedGenerationHistoryDetail?.status
else { return false }
return switch status {
case .failed:
selectedGenerationHistoryDetail?.recoverable != true
case .completed, .canceled:
false
case .pending, .running, .waitingForUser:
false
}
}
var selectedGenerationHistoryResultPet: PetSummary? {
petSummary(id: selectedGenerationHistoryDetail?.resultPetID)
}
var selectedGenerationHistoryResultPetIsActive: Bool {
selectedGenerationHistoryResultPet?.active == true
}
var selectedGenerationHistoryDetail: GenerationStudioHistoryDetail? {
guard let generationHistoryDetail,
generationHistoryDetail.found,
generationHistoryDetail.jobID == selectedGenerationHistoryJobID
else { return nil }
return generationHistoryDetail
}
var generationStateTitle: String {
if generationSession.operation == .modify {
return switch generationSession.state {
case .idle: "尚未开始"
case .starting: "正在启动修改"
case .running: "正在修改"
case .waitingForInput: "修改等待补充信息"
case .paused: "修改已中断,等待继续"
case .recoverableFailed: "修改失败,可继续"
case .cancelling: "正在取消修改"
case .cancelCleanup: "正在清理修改会话"
case .succeeded: "修改完成"
case .failed: "修改失败"
case .cancelled: "修改已取消"
}
}
return switch generationSession.state {
case .idle: "尚未开始"
case .starting: "正在启动"
case .running: "正在生成"
case .waitingForInput: "等待补充信息"
case .paused: "已中断,等待继续"
case .recoverableFailed: "生成失败,可继续"
case .cancelling: "正在取消"
case .cancelCleanup: "正在清理会话"
case .succeeded: "生成完成"
case .failed: "生成失败"
case .cancelled: "已取消"
}
}
func setMainWindowPresenter(_ presenter: @escaping () -> Void) {
mainWindowPresenter = presenter
guard pendingMainWindowPresentation else { return }
let checksRuntimeHandoff = pendingMainWindowPresentationChecksRuntimeHandoff
pendingMainWindowPresentation = false
pendingMainWindowPresentationChecksRuntimeHandoff = true
guard !checksRuntimeHandoff || !runtimeHandoffIfNeeded() else { return }
presenter()
NSApp?.activate(ignoringOtherApps: true)
}
func registerControlCenterWindow(_ window: NSWindow) {
guard controlCenterWindow !== window else { return }
controlCenterCloseObserver?.cancel()
window.identifier = Self.controlCenterWindowIdentifier
controlCenterWindow = window
controlCenterIsOpen = true
overlayController.controlCenterDidOpen()
controlCenterCloseObserver = NotificationCenter.default.publisher(
for: NSWindow.willCloseNotification,
object: window
)
.sink { [weak self, weak window] _ in
Task { @MainActor in
guard let self, self.controlCenterWindow === window else {
return
}
self.controlCenterIsOpen = false
self.overlayController.controlCenterDidClose()
}
}
}
func presentManualAppInstallation(_ request: AppManualInstallationRequest) {
manualAppInstallationRequest = request
appUpdater.dismissSheet()
presentMainWindow(checkRuntimeHandoff: false)
}
func dismissManualAppInstallation() {
manualAppInstallationRequest = nil
}
func presentDeferredAppUpdateHandoff() {
appUpdateConvergenceState = .waitingForActiveWork
presentMainWindow(checkRuntimeHandoff: false)
}
func presentFailedAppUpdateHandoff(_ request: AppManualInstallationRequest) {
if appUpdateConvergenceState == .waitingForActiveWork {
appUpdateConvergenceState = .idle
}
presentManualAppInstallation(request)
}
func dismissAppUpdateConvergenceNotice() {
if let buildID = productConvergenceManifest?.buildID {
productConvergenceNoticePreferences.acknowledge(buildID: buildID)
}
appUpdateConvergenceState = .idle
}
func retryProductConvergence() {
guard productConvergenceTask == nil,
let manifest = productConvergenceManifest
else { return }
appUpdateConvergenceState = .updating
let task = Task { @MainActor [weak self] in
guard let self else { return }
let bundledPetsReady = await self.performBundledPetSeed()
if bundledPetsReady {
_ = await self.refresh()
}
await self.performProductConvergence(
manifest: manifest,
force: true,
bundledPetsReady: bundledPetsReady
)
self.productConvergenceTask = nil
}
productConvergenceTask = task
}
func beginManualAppUpdateDownload(_ release: AppReleaseUpdate) {
guard NSWorkspace.shared.open(release.asset.downloadURL) else {
diagnostics.log(
.error,
category: "update",
event: "release_download_open_failed",
metadata: ["version": .string(release.version.description)]
)
appUpdater.reportDownloadOpenFailure()
return
}
let installationRequest = AppManualInstallationRequest(
origin: .updateDownload,
version: release.version.description,
candidateBundlePath: nil
)
manualAppInstallationRequest = installationRequest
appUpdater.dismissSheet()
}
func checkForAppUpdatesManually() {
appUpdater.checkManually()
presentMainWindow()
}
func presentAvailableAppUpdate() {
appUpdater.presentAvailableUpdate()
presentMainWindow()
}
func presentMainWindow() {
presentMainWindow(checkRuntimeHandoff: true)
}
private func presentMainWindow(checkRuntimeHandoff: Bool) {
guard !checkRuntimeHandoff || !runtimeHandoffIfNeeded() else { return }
if frontExistingMainWindow() {
pendingMainWindowPresentation = false
pendingMainWindowPresentationChecksRuntimeHandoff = true
NSApp?.activate(ignoringOtherApps: true)
return
}
if let mainWindowPresenter {
pendingMainWindowPresentation = false
pendingMainWindowPresentationChecksRuntimeHandoff = true
mainWindowPresenter()
} else {
// A secondary launch or Dock reopen can arrive before either scene
// has installed its openWindow presenter. Replay exactly once when
// the first presenter becomes available.
pendingMainWindowPresentation = true
pendingMainWindowPresentationChecksRuntimeHandoff = checkRuntimeHandoff
}
NSApp?.activate(ignoringOtherApps: true)
}
func presentAgentSession(
source: AgentSource?,
sessionID: String? = nil,
navigation: AgentSessionNavigation = AgentSessionNavigation()
) async -> AgentSessionOpenOutcome {
guard source != nil else {
presentMainWindow()
return .openedAgentHost
}
guard let route = AgentSessionRouter.route(
source: source,
sessionID: sessionID,
navigation: navigation
) else {
return .failed(.applicationUnavailable)
}
let outcome = await agentSessionRouteOpener(route)
if !outcome.didOpen,
let fallback = AgentSessionRouter.hostFallbackRoute(
source: source,
navigation: navigation
),
fallback != route {
return await agentSessionRouteOpener(fallback)
}
return outcome
}
func activateOverlaySession(_ session: OverlaySessionContent) {
let capturedIdentity = overlaySessionProjectionIdentities[session.id]
?? OverlaySessionProjectionIdentity(
eventID: session.eventID,
acknowledgementID: session.acknowledgementID
)
let actionSession = currentOverlayActionSession(
stableID: session.id,
identity: capturedIdentity
) ?? session
guard actionSession.canOpen else {
setOverlaySessionNavigationNotice(
.unavailable,
sessionID: actionSession.id,
identity: capturedIdentity
)
return
}
Task { @MainActor [weak self] in
guard let self else { return }
let outcome = await presentAgentSession(
source: actionSession.source,
sessionID: actionSession.sessionID,
navigation: actionSession.navigation
)
guard overlaySessionProjectionIsCurrent(
sessionID: actionSession.id,
identity: capturedIdentity
) else {
return
}
let reachedRequestedDestination: Bool
switch outcome {
case .openedExactSession:
reachedRequestedDestination = true
case .openedAgentHost:
reachedRequestedDestination = actionSession.navigationCapability != .exactSession
if !reachedRequestedDestination {
setOverlaySessionNavigationNotice(
.degradedToHost,
sessionID: actionSession.id,
identity: capturedIdentity
)
}
case .failed:
reachedRequestedDestination = false
setOverlaySessionNavigationNotice(
.failed,
sessionID: actionSession.id,
identity: capturedIdentity
)
}
guard reachedRequestedDestination else {
return
}
clearOverlaySessionNavigationNotice(
sessionID: actionSession.id,
identity: capturedIdentity
)
if actionSession.dismissesAfterActivation {
guard await acknowledgeOverlaySession(
acknowledgementID: capturedIdentity.acknowledgementID
),
overlaySessionProjectionIsCurrent(
sessionID: actionSession.id,
identity: capturedIdentity
)
else {
return
}
dismissOverlayBubble(eventID: actionSession.id)
}
}
}
private func currentOverlayActionSession(
stableID: String,
identity: OverlaySessionProjectionIdentity
) -> OverlaySessionContent? {
activeAgentSessions.lazy
.map(OverlaySessionContent.init(state:))
.first { candidate in
candidate.id == stableID
&& candidate.eventID == identity.eventID
&& candidate.acknowledgementID == identity.acknowledgementID
}
}
private func overlaySessionProjectionIsCurrent(
sessionID: String,
identity: OverlaySessionProjectionIdentity
) -> Bool {
overlaySessionProjectionIdentities[sessionID] == identity
}
private func setOverlaySessionNavigationNotice(
_ notice: OverlaySessionNavigationNotice,
sessionID: String,
identity: OverlaySessionProjectionIdentity
) {
overlaySessionNavigationNotices[sessionID] =
OverlaySessionNavigationNoticeRecord(
identity: identity,
notice: notice
)
overlayController.updateLayout(animateBubble: true)
}
private func clearOverlaySessionNavigationNotice(
sessionID: String,
identity: OverlaySessionProjectionIdentity
) {
guard overlaySessionNavigationNotices[sessionID]?.identity == identity
else { return }
guard overlaySessionNavigationNotices.removeValue(
forKey: sessionID
) != nil else { return }
overlayController.updateLayout(animateBubble: true)
}
private func reconcileOverlaySessionNavigationNotices() {
guard !overlaySessionNavigationNotices.isEmpty else { return }
overlaySessionNavigationNotices = overlaySessionNavigationNotices.filter {
sessionID, record in
overlaySessionProjectionIdentities[sessionID] == record.identity
}
}
private func acknowledgeOverlaySession(
acknowledgementID: String?
) async -> Bool {
guard let acknowledgementID else {
return true
}
do {
let result = try await requestPetCore(
method: "agent.session.acknowledge",
params: ["acknowledgement_id": acknowledgementID]
)
guard let result = result as? [String: Any],
result["acknowledged"] as? Bool == true,
result["acknowledgement_id"] as? String == acknowledgementID
else {
throw PetCoreClientError.invalidResponse
}
return true
} catch {
return false
}
}
private static func openAgentSessionRoute(
_ route: AgentSessionOpenRoute
) async -> AgentSessionOpenOutcome {
let workspace = NSWorkspace.shared
switch route {
case let .url(url):
return workspace.open(url)
? .openedExactSession
: .failed(.urlOpenRejected)
case let .application(bundleIdentifiers, paths):
return await openAgentApplication(
bundleIdentifiers: bundleIdentifiers,
paths: paths,
workspace: workspace
)
}
}
private static func openAgentApplication(
bundleIdentifiers: [String],
paths: [String],
workspace: NSWorkspace
) async -> AgentSessionOpenOutcome {
if let running = workspace.runningApplications
.filter({ application in
application.bundleIdentifier.map(bundleIdentifiers.contains) == true
&& !application.isTerminated
})
.sorted(by: { ($0.isActive ? 1 : 0) > ($1.isActive ? 1 : 0) })
.first,
running.activate(options: [])
{
return .openedAgentHost
}
let applicationURL = bundleIdentifiers.lazy
.compactMap { workspace.urlForApplication(withBundleIdentifier: $0) }
.first
?? paths
.map { URL(fileURLWithPath: $0, isDirectory: true) }
.first(where: { FileManager.default.fileExists(atPath: $0.path) })
guard let applicationURL else {
return .failed(.applicationUnavailable)
}
let configuration = NSWorkspace.OpenConfiguration()
configuration.activates = true
return await withCheckedContinuation { continuation in
workspace.openApplication(
at: applicationURL,
configuration: configuration
) { @Sendable application, error in
continuation.resume(returning:
application != nil && error == nil
? .openedAgentHost
: .failed(.applicationLaunchFailed)
)
}
}
}
private func frontExistingMainWindow() -> Bool {
guard let application = NSApp else {
return false
}
let window = if let controlCenterWindow,
Self.isMainWindowCandidate(controlCenterWindow) {
controlCenterWindow
} else {
application.windows.first(where: Self.isMainWindowCandidate)
}
guard let window else { return false }
if window.isMiniaturized {
window.deminiaturize(nil)
}
window.makeKeyAndOrderFront(nil)
controlCenterIsOpen = true
overlayController.controlCenterDidOpen()
return true
}
static func isMainWindowCandidate(_ window: NSWindow) -> Bool {
window.identifier == controlCenterWindowIdentifier
&& !(window is NSPanel)
&& window.level == .normal
&& window.styleMask.contains(.titled)
}
private enum RuntimeBootstrapStartMode {
case ensureRunning
case recover
}
func bootstrapIfNeeded() async {
_ = await runRuntimeBootstrapIfNeeded(startMode: .ensureRunning)
}
private func runRuntimeBootstrapIfNeeded(
startMode: RuntimeBootstrapStartMode
) async -> Bool {
if runtimeBootstrapCompleted { return true }
if let runtimeBootstrap {
return await runtimeBootstrap.task.value
}
runtimeBootstrapSequence &+= 1
let id = runtimeBootstrapSequence
let task = Task { @MainActor [weak self] in
await self?.performRuntimeBootstrap(startMode: startMode) ?? false
}
runtimeBootstrap = (id, task)
let succeeded = await task.value
if runtimeBootstrap?.id == id {
runtimeBootstrap = nil
}
return succeeded
}
private func performRuntimeBootstrap(
startMode: RuntimeBootstrapStartMode
) async -> Bool {
diagnostics.log(.info, category: "service", event: "petcore_bootstrap_started")
if let manifest = productConvergenceManifest,
manifest.releaseChannel == "release",
productConvergenceUpgradeEvidence(manifest),
!productConvergenceNoticePreferences.hasAcknowledged(
buildID: manifest.buildID
)
{
appUpdateConvergenceState = .updating
}
setServiceChecking()
// Bound the invisible window gate from bootstrap start. Runtime
// replacement and rollback can take much longer than appearance
// hydration, and must not make the App look dead while it is checking.
scheduleInitialAppearanceFallback()
let startResult = switch startMode {
case .ensureRunning:
await bootstrapHooks.ensureRunning()
case .recover:
await bootstrapHooks.recover()
}
switch startResult {
case .alreadyHealthy, .started:
diagnostics.log(.notice, category: "service", event: "petcore_bootstrap_ready")
if appUpdateConvergenceState == .waitingForActiveWork {
appUpdateConvergenceState = .updating
}
setServiceOnline()
runtimeBootstrapRetryTask?.cancel()
runtimeBootstrapRetryTask = nil
runtimeBootstrapRetryDelaySeconds = 2
await prepareInitialAppearance()
await bootstrapHooks.onReady(self)
if bootstrapHooks.requiresAuthoritativeSnapshotOnReady,
(!hasLoadedStateSnapshot || petCoreOperationalState != .online)
{
diagnostics.log(
.error,
category: "service",
event: "petcore_bootstrap_snapshot_not_ready",
throttleKey: "petcore_bootstrap_snapshot_not_ready",
minimumInterval: 30
)
runtimeBootstrapCompleted = false
runtimeBootstrapRequiresFullRecovery = true
setServiceFailure(
"本地服务尚未返回完整状态",
status: "PetCore 状态同步失败",
operationalState: .offline
)
resolveInitialAppearanceAsUnavailable()
scheduleRuntimeBootstrapRetry()
return false
}
// Prefer the first authoritative snapshot when behavior.get could
// not provide a theme, but never hold the window beyond the
// bounded appearance fallback scheduled above.
if initialAppearanceReadiness == .pending {
resolveInitialAppearanceAsUnavailable()
}
runtimeBootstrapCompleted = true
runtimeBootstrapRequiresFullRecovery = false
presentOverlayAfterFirstSnapshotIfNeeded()
return true
case let .deferred(reason):
diagnostics.log(
.notice,
category: "update",
event: "runtime_update_deferred_for_active_work"
)
appUpdateConvergenceState = .waitingForActiveWork
setServiceChecking()
setServiceStatusText(reason)
runtimeBootstrapRequiresFullRecovery = false
runtimeBootstrapRetryDelaySeconds = 2
resolveInitialAppearanceAsUnavailable()
scheduleRuntimeBootstrapRetry()
return false
case let .failed(reason):
let failureCode = PetCoreServiceFailureClassifier.classify(reason)
diagnostics.log(
.error,
category: "service",
event: "petcore_bootstrap_failed",
metadata: ["failure_code": .string(failureCode.rawValue)],
throttleKey: "petcore_bootstrap_failed",
minimumInterval: 30
)
setServiceFailure(reason, failureCode: failureCode)
runtimeBootstrapRequiresFullRecovery = true
resolveInitialAppearanceAsUnavailable()
scheduleRuntimeBootstrapRetry()
return false
}
}
private func scheduleInitialAppearanceFallback() {
guard initialAppearanceReadiness == .pending,
initialAppearanceFallbackTask == nil
else { return }
let sleeper = initialAppearanceFallbackSleeper
initialAppearanceFallbackTask = Task { @MainActor [weak self] in
do {
try await sleeper(Self.initialAppearanceFallbackDelay)
} catch {
return
}
guard !Task.isCancelled,
let self,
self.initialAppearanceReadiness == .pending
else { return }
self.initialAppearanceFallbackTask = nil
self.initialAppearanceReadiness = .unavailable
}
}
private func resolveInitialAppearanceAsUnavailable() {
initialAppearanceFallbackTask?.cancel()
initialAppearanceFallbackTask = nil
if initialAppearanceReadiness == .pending {
initialAppearanceReadiness = .unavailable
}
}
private func resolveInitialAppearanceAsAuthoritative() {
initialAppearanceFallbackTask?.cancel()
initialAppearanceFallbackTask = nil
initialAppearanceReadiness = .authoritative
}
private func prepareInitialAppearance() async {
guard initialAppearanceReadiness != .authoritative else { return }
guard let fetchInitialBehavior = bootstrapHooks.fetchInitialBehavior else {
return
}
do {
let result = try await fetchInitialBehavior(self)
let data = try JSONSerialization.data(withJSONObject: result)
let versioned = try JSONDecoder().decode(
VersionedBehaviorSettings.self,
from: data
)
// A full snapshot may arrive through an explicit refresh while the
// focused behavior request is suspended. Once that snapshot is
// authoritative, never publish this older focused result.
guard initialAppearanceReadiness != .authoritative else { return }
// The publication order is deliberate: every window gate observing
// readiness must see behavior, revision, and AppKit appearance as
// one fully prepared initial presentation state.
authoritativeBehavior = versioned.behavior
behavior = versioned.behavior
behaviorRevision = versioned.revision
applyCurrentPresentation()
resolveInitialAppearanceAsAuthoritative()
} catch {
diagnostics.logFailure(
error,
category: "service",
event: "initial_appearance_unavailable",
throttleKey: "initial_appearance_unavailable",
minimumInterval: 30
)
}
}
func retryPetCoreStartup() {
guard petCoreRuntimeInfo.phase != .running else { return }
diagnostics.log(.notice, category: "service", event: "petcore_retry_requested")
runtimeBootstrapRetryTask?.cancel()
runtimeBootstrapRetryTask = nil
setServiceChecking()
Task { @MainActor [weak self] in
guard let self else { return }
_ = await self.recoverServiceConnection()
}
}
private func scheduleRuntimeBootstrapRetry() {
guard !runtimeBootstrapCompleted, runtimeBootstrapRetryTask == nil else { return }
let delay = runtimeBootstrapRetryDelaySeconds
diagnostics.log(
.info,
category: "service",
event: "petcore_retry_scheduled",
metadata: ["delay_seconds": .integer(Int64(delay))],
throttleKey: "petcore_retry_scheduled",
minimumInterval: 5
)
runtimeBootstrapRetryDelaySeconds = min(delay * 2, 30)
runtimeBootstrapRetryTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled, let self else { return }
self.runtimeBootstrapRetryTask = nil
await self.bootstrapIfNeeded()
}
}
private func completeRuntimeBootstrap() async {
await refreshPetCoreRuntimeInfo()
diagnostics.log(
.notice,
category: "service",
event: "petcore_runtime_connected",
metadata: [
"version": .string(petCoreRuntimeInfo.version ?? "unknown"),
"build_id": .string(petCoreRuntimeInfo.buildID ?? "unknown"),
"rpc_protocol": .string(petCoreRuntimeInfo.rpcProtocol ?? "unknown")
]
)
let bundledPetsReady = await performBundledPetSeed()
let bundledPetFailureStatus = bundledPetsReady ? nil : statusText
let snapshotReady = await refreshDuringRuntimeBootstrap()
if let bundledPetFailureStatus, snapshotReady {
// A healthy state snapshot must not hide a failed inventory seed.
// Keep the actionable library error visible while bounded retries
// continue independently of PetCore runtime rollback.
statusText = bundledPetFailureStatus
}
if !bundledPetsReady {
scheduleBundledPetSeedRetry()
}
guard snapshotReady else {
return
}
refreshTask?.cancel()
refreshTask = Task { [weak self] in
while !Task.isCancelled {
await self?.waitForStateChange()
}
}
scheduleProductConvergence(bundledPetsReady: bundledPetsReady)
appUpdater.checkAutomaticallyIfDue()
}
@discardableResult
func scheduleProductConvergence(
force: Bool = false,
bundledPetsReady: Bool = true
) -> Task<Void, Never>? {
guard productConvergenceTask == nil,
let manifest = productConvergenceManifest
else { return productConvergenceTask }
let task = Task { @MainActor [weak self] in
guard let self else { return }
await self.performProductConvergence(
manifest: manifest,
force: force,
bundledPetsReady: bundledPetsReady
)
self.productConvergenceTask = nil
}
productConvergenceTask = task
return task
}
private func performProductConvergence(
manifest: RuntimeReleaseManifest,
force: Bool,
bundledPetsReady: Bool
) async {
guard bundledPetsReady else {
finishProductConvergenceAttention(
attention: .bundledPets,
failure: "bundled_pet_verification_incomplete"
)
return
}
scheduleProductConvergenceVisibility()
do {
let priorReceipt = try await productConvergenceReceipt()
if !force, priorReceipt?.exactlyMatches(manifest: manifest) == true {
finishProductConvergenceSuccess(
manifest: manifest,
presentsCompletion: productConvergenceUpgradeEvidence(manifest)
)
return
}
let presentsCompletion = (
priorReceipt.map { $0.buildID != manifest.buildID } ?? false
) || productConvergenceUpgradeEvidence(manifest)
try await waitForProductConvergenceSafePoint()
if appUpdateConvergenceState == .waitingForActiveWork {
appUpdateConvergenceState = .updating
}
let refreshValue = try await requestPetCore(
method: "connections.refresh_installed",
timeout: .seconds(180)
)
let report: ProductConnectorRefreshReport = try decodeProductConvergenceValue(
refreshValue
)
guard report.isExactlyConverged else {
let issues = report.attentionIssues
finishProductConvergenceAttention(
attention: issues.isEmpty ? .service : .connectors(issues),
failure: issues.isEmpty
? "managed_connector_report_inconsistent"
: "managed_connector_verification_incomplete"
)
_ = await refreshSnapshotAfterProductConvergence()
return
}
let reportValue = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(report)
)
let receiptValue = try await requestPetCore(
method: "product.convergence.update",
params: [
"schema_version": ProductConvergenceReceipt.schemaVersion,
"build_id": manifest.buildID,
"app_version": manifest.appVersion,
"connector_report": reportValue,
]
)
let receipt: ProductConvergenceReceipt = try decodeProductConvergenceValue(
receiptValue
)
guard receipt.exactlyMatches(manifest: manifest, report: report) else {
throw PetCoreClientError.invalidResponse
}
guard await refreshSnapshotAfterProductConvergence() else {
finishProductConvergenceAttention(
attention: .service,
failure: "authoritative_snapshot_refresh_failed"
)
return
}
finishProductConvergenceSuccess(
manifest: manifest,
presentsCompletion: presentsCompletion
)
} catch is CancellationError {
productConvergenceVisibilityTask?.cancel()
productConvergenceVisibilityTask = nil
} catch {
finishProductConvergenceAttention(
attention: .service,
failure: "convergence_rpc_failed",
error: error
)
}
}
private func productConvergenceReceipt() async throws -> ProductConvergenceReceipt? {
let value = try await requestPetCore(method: "product.convergence.get")
guard !(value is NSNull) else { return nil }
return try decodeProductConvergenceValue(value)
}
private func waitForProductConvergenceSafePoint() async throws {
while !Task.isCancelled {
if !isSafeForProductConvergence {
publishProductConvergenceWaiting()
try await productConvergenceSleeper(.seconds(1))
continue
}
let value = try await requestPetCore(method: "product.convergence.preflight")
let preflight: ProductConvergencePreflight = try decodeProductConvergenceValue(value)
guard !preflight.safe else { return }
publishProductConvergenceWaiting()
try await productConvergenceSleeper(.seconds(1))
}
throw CancellationError()
}
private func scheduleProductConvergenceVisibility() {
productConvergenceVisibilityTask?.cancel()
let sleeper = productConvergenceSleeper
productConvergenceVisibilityTask = Task { @MainActor [weak self] in
do {
try await sleeper(.seconds(1))
} catch {
return
}
guard !Task.isCancelled,
let self,
self.productConvergenceTask != nil,
self.appUpdateConvergenceState != .waitingForActiveWork
else { return }
self.appUpdateConvergenceState = .updating
}
}
private func publishProductConvergenceWaiting() {
productConvergenceVisibilityTask?.cancel()
productConvergenceVisibilityTask = nil
appUpdateConvergenceState = .waitingForActiveWork
}
private func finishProductConvergenceSuccess(
manifest: RuntimeReleaseManifest,
presentsCompletion: Bool
) {
productConvergenceVisibilityTask?.cancel()
productConvergenceVisibilityTask = nil
if presentsCompletion,
manifest.releaseChannel == "release",
!productConvergenceNoticePreferences.hasAcknowledged(
buildID: manifest.buildID
)
{
appUpdateConvergenceState = .completed(version: manifest.appVersion)
} else {
appUpdateConvergenceState = .idle
}
diagnostics.log(
.notice,
category: "update",
event: "product_convergence_completed",
metadata: [
"build_id": .string(manifest.buildID),
"version": .string(manifest.appVersion)
]
)
}
private func finishProductConvergenceAttention(
attention: AppUpdateConvergenceAttention,
failure: String,
error: Error? = nil
) {
productConvergenceVisibilityTask?.cancel()
productConvergenceVisibilityTask = nil
appUpdateConvergenceState = .needsAttention(attention)
var metadata: [String: AppDiagnosticMetadataValue] = [
"failure": .string(failure),
"sources": .string(attention.sources.map(\.rawValue).joined(separator: ","))
]
if let error {
metadata["error_type"] = .string(String(describing: type(of: error)))
}
diagnostics.log(
.error,
category: "update",
event: "product_convergence_needs_attention",
metadata: metadata,
throttleKey: "product_convergence_needs_attention",
minimumInterval: 30
)
}
private func refreshSnapshotAfterProductConvergence() async -> Bool {
do {
try await bootstrapHooks.refreshSnapshot(self)
return true
} catch {
diagnostics.logFailure(
error,
category: "update",
event: "product_convergence_snapshot_refresh_failed",
throttleKey: "product_convergence_snapshot_refresh_failed",
minimumInterval: 30
)
return false
}
}
private func decodeProductConvergenceValue<Value: Decodable>(
_ value: Any
) throws -> Value {
guard JSONSerialization.isValidJSONObject(value) else {
throw PetCoreClientError.invalidResponse
}
return try JSONDecoder().decode(
Value.self,
from: JSONSerialization.data(withJSONObject: value)
)
}
/// Converges the closed, content-pinned App inventory before the first
/// state snapshot is presented. PetCore preserves ordinary same-ID pets
/// and appends a revision only for an identity it previously marked as
/// bundled when the release digest changes.
private func seedBundledPets() async -> Bool {
guard BundledPetInventory.hasCompleteResources() else {
diagnostics.log(
.error,
category: "library",
event: "bundled_pet_seed_failed",
metadata: ["failure_code": .string("resource_missing")]
)
statusText = "App 内置宠物资源不完整"
return false
}
do {
let result = try await requestPetCore(
method: "petpack.seed_bundled",
params: BundledPetInventory.rpcParameters
)
let response = try BundledPetInventory.validatedSeedResponse(result)
let installedCount = response.outcomes.lazy.filter {
$0.status == .installed
}.count
let revisedCount = response.outcomes.lazy.filter {
$0.status == .installedNewRevision
}.count
diagnostics.log(
.info,
category: "library",
event: "bundled_pet_seed_completed",
metadata: [
"inventory_count": .integer(Int64(BundledPetInventory.fileNames.count)),
"installed_count": .integer(Int64(installedCount)),
"revised_count": .integer(Int64(revisedCount))
]
)
return true
} catch {
diagnostics.logFailure(
error,
category: "library",
event: "bundled_pet_seed_failed",
throttleKey: "bundled_pet_seed_failed",
minimumInterval: 30
)
statusText = "App 内置宠物加载失败:\(error.localizedDescription)"
return false
}
}
private func performBundledPetSeed() async -> Bool {
if let bundledPetSeederOverride {
return await bundledPetSeederOverride()
}
return await seedBundledPets()
}
private func scheduleBundledPetSeedRetry() {
guard bundledPetSeedRetryTask == nil else { return }
bundledPetSeedRetryTask = Task { @MainActor [weak self] in
guard let store = self else { return }
defer { store.bundledPetSeedRetryTask = nil }
_ = await store.retryBundledPetSeedAfterBootstrapFailure()
}
}
/// Retries only the App inventory ensure operation. This intentionally
/// does not call the runtime launcher or last-known-good rollback path.
/// The injected seeder and sleeper keep the bounded behavior testable
/// without launching the App UI or taking over user input.
func retryBundledPetSeedAfterBootstrapFailure() async -> Bool {
for delay in Self.bundledPetSeedRetryDelays {
do {
try await bundledPetSeedSleeper(delay)
} catch {
return false
}
guard !Task.isCancelled else { return false }
if await performBundledPetSeed() {
let snapshotReady = await refresh()
if snapshotReady, Self.isBundledPetSeedFailureStatus(statusText) {
statusText = "App 内置宠物已加载"
}
if snapshotReady {
scheduleProductConvergence(bundledPetsReady: true)
}
return snapshotReady
}
}
return false
}
private static func isBundledPetSeedFailureStatus(_ value: String) -> Bool {
value == "App 内置宠物资源不完整"
|| value.hasPrefix("App 内置宠物加载失败")
}
@discardableResult
func refresh() async -> Bool {
await refresh(recoveryMode: .coordinated)
}
private enum SnapshotRecoveryMode {
case coordinated
case runtimeBootstrap
}
private func refreshDuringRuntimeBootstrap() async -> Bool {
await refresh(recoveryMode: .runtimeBootstrap)
}
private func refresh(recoveryMode: SnapshotRecoveryMode) async -> Bool {
do {
try await bootstrapHooks.refreshSnapshot(self)
setServiceOnline()
return true
} catch {
diagnostics.logFailure(
error,
category: "service",
event: "petcore_snapshot_failed",
throttleKey: "petcore_snapshot_failed",
minimumInterval: 30
)
setServiceFailure(
"本地服务连接失败:\(error.localizedDescription)",
status: "PetCore 连接失败",
operationalState: .offline
)
switch recoveryMode {
case .coordinated:
return await recoverServiceConnection()
case .runtimeBootstrap:
return await runServiceRecovery()
}
}
}
func recoverServiceConnection() async -> Bool {
if let runtimeBootstrap {
runtimeBootstrapRetryTask?.cancel()
runtimeBootstrapRetryTask = nil
return await runtimeBootstrap.task.value
}
if runtimeBootstrapRequiresFullRecovery {
runtimeBootstrapRetryTask?.cancel()
runtimeBootstrapRetryTask = nil
return await runRuntimeBootstrapIfNeeded(startMode: .recover)
}
if let serviceRecovery {
return await serviceRecovery.task.value
}
recoverySequence &+= 1
let id = recoverySequence
let task = Task { @MainActor [weak self] in
await self?.runServiceRecovery() ?? false
}
serviceRecovery = (id, task)
let recovered = await task.value
if serviceRecovery?.id == id {
serviceRecovery = nil
}
return recovered
}
private func runServiceRecovery() async -> Bool {
setServiceRecovering()
diagnostics.log(.info, category: "service", event: "petcore_recovery_started")
switch await bootstrapHooks.recover() {
case .alreadyHealthy, .started:
do {
try await bootstrapHooks.refreshSnapshot(self)
diagnostics.log(.notice, category: "service", event: "petcore_recovery_succeeded")
setServiceOnline()
return true
} catch {
diagnostics.logFailure(
error,
category: "service",
event: "petcore_recovery_snapshot_failed",
throttleKey: "petcore_recovery_snapshot_failed",
minimumInterval: 30
)
setServiceFailure(
"本地服务连接失败:\(error.localizedDescription)",
status: "PetCore 连接失败",
operationalState: .offline
)
return false
}
case let .deferred(reason):
appUpdateConvergenceState = .waitingForActiveWork
setServiceChecking()
setServiceStatusText(reason)
runtimeBootstrapCompleted = false
runtimeBootstrapRequiresFullRecovery = true
runtimeBootstrapRetryDelaySeconds = 2
scheduleRuntimeBootstrapRetry()
return false
case let .failed(reason):
let failureCode = PetCoreServiceFailureClassifier.classify(reason)
diagnostics.log(
.error,
category: "service",
event: "petcore_recovery_failed",
metadata: ["failure_code": .string(failureCode.rawValue)],
throttleKey: "petcore_recovery_failed",
minimumInterval: 30
)
setServiceFailure(reason, failureCode: failureCode)
return false
}
}
private func refreshSnapshot() async throws {
let result = try await requestPetCore(method: "state.snapshot")
try applyStateSnapshot(result)
if latestGenerationRestoreAttemptState != .resolved {
await restoreLatestGenerationSessionIfNeeded()
}
setServiceOnline()
}
func waitForStateChange() async {
// The daemon revision wakes this request immediately. Active work uses
// a one-second hydration budget so an asynchronous host display read
// becomes visible promptly; idle waits remain long to avoid decoding
// and republishing unchanged full snapshots.
let timeoutMs = Self.stateWaitTimeoutMilliseconds(
generationIsActive: generationSession.isActive,
hasActiveAgentState: activeAgentState != nil
)
do {
let result = try await requestPetCore(
method: "state.wait",
params: [
"after_revision": stateRevision,
"timeout_ms": timeoutMs
],
timeout: .seconds(35)
)
try applyStateSnapshot(result)
setServiceOnline()
} catch {
if hasProtectedUserWork,
(try? await requestPetCore(
method: "petcore.health",
timeout: .seconds(2)
)) != nil
{
diagnostics.log(
.info,
category: "service",
event: "petcore_state_wait_deferred_during_protected_work",
metadata: [:]
)
setServiceOnline()
return
}
diagnostics.logFailure(
error,
category: "service",
event: "petcore_state_wait_failed",
throttleKey: "petcore_state_wait_failed",
minimumInterval: 30
)
setServiceFailure(
"本地服务连接失败:\(error.localizedDescription)",
status: "PetCore 连接失败",
operationalState: .offline
)
if !(await recoverServiceConnection()) {
try? await Task.sleep(for: .seconds(1))
}
}
}
private func refreshPetCoreRuntimeInfo() async {
guard let result = try? await requestPetCore(
method: "petcore.health",
timeout: .seconds(1)
), let info = PetCoreRuntimeInfo.running(healthValue: result)
else { return }
petCoreRuntimeInfo = info
}
private func setServiceChecking() {
petCoreOperationalState = .checking
var nextRuntimeInfo = petCoreRuntimeInfo
nextRuntimeInfo.markChecking()
if petCoreRuntimeInfo != nextRuntimeInfo {
petCoreRuntimeInfo = nextRuntimeInfo
}
setServiceStatusText("正在检查本地服务版本与兼容性")
}
private func setServiceRecovering() {
petCoreOperationalState = .recovering
setServiceStatusText("正在恢复本地服务")
}
private func setServiceFailure(
_ reason: String,
status: String = "PetCore 启动失败",
failureCode: PetCoreServiceFailureCode? = nil,
operationalState: PetCoreOperationalState? = nil
) {
let resolvedFailureCode = failureCode ?? PetCoreServiceFailureClassifier.classify(reason)
lastServiceFailureCode = resolvedFailureCode
petCoreOperationalState = operationalState ?? .failure(for: resolvedFailureCode)
var nextRuntimeInfo = petCoreRuntimeInfo
nextRuntimeInfo.markFailed(reason)
if petCoreRuntimeInfo != nextRuntimeInfo {
petCoreRuntimeInfo = nextRuntimeInfo
}
setServiceStatusText(status)
}
private func setServiceOnline() {
let reconnected = petCoreOperationalState != .online
lastServiceFailureCode = .none
petCoreOperationalState = .online
var nextRuntimeInfo = petCoreRuntimeInfo
nextRuntimeInfo.markRunning()
if petCoreRuntimeInfo != nextRuntimeInfo {
petCoreRuntimeInfo = nextRuntimeInfo
}
setServiceStatusText("本地服务运行中")
if reconnected {
resumePendingOverlayPlacementSaveIfNeeded(reason: .reconnect)
}
}
private func setServiceStatusText(_ value: String) {
let shouldMirrorToStatus = statusText == serviceStatusText
|| statusText == "正在初始化"
|| statusText.hasPrefix("本地服务")
|| statusText.hasPrefix("PetCore")
if serviceStatusText != value {
serviceStatusText = value
}
if shouldMirrorToStatus, statusText != value {
statusText = value
}
}
func applyStateSnapshot(_ result: Any) throws {
let isBootstrapSnapshot = !hasLoadedStateSnapshot
let previousOverlayBubbleLayout = OverlayBubbleLayoutSignature(
contents: overlayBubbleContents,
bubbleDismissed: overlayBubbleDismissed
)
let data = try JSONSerialization.data(withJSONObject: result)
let snapshot = try JSONDecoder().decode(StateSnapshot.self, from: data)
guard let overlayPlacementRevision = OverlayPlacementRevisionCodec.parse(
snapshot.overlayPlacementRevision
) else {
throw PetCoreClientError.invalidResponse
}
authoritativeBehavior = snapshot.behavior
if let snapshotOnboarding = snapshot.onboarding,
onboarding != snapshotOnboarding {
onboarding = snapshotOnboarding
}
if let activeGeneration = snapshot.activeGeneration {
reconcileActiveGeneration(activeGeneration)
}
let previousSessionGroupDisplay = behavior.sessionGroupDisplay
let previousGroupSessionsByAgent = behavior.groupSessionsByAgent
let previousBubbleFontScale = behavior.bubbleFontScale
let previousAppearanceTheme = behavior.appearanceTheme
let previousInterfaceLanguage = behavior.interfaceLanguage
let behaviorChanged = behavior != snapshot.behavior
if behaviorChanged {
behavior = snapshot.behavior
if behavior.sessionGroupDisplay != previousSessionGroupDisplay
|| behavior.groupSessionsByAgent != previousGroupSessionsByAgent
{
overlayAgentGroupExpansionOverrides.removeAll()
overlayStandaloneStackExpansionOverride = nil
overlayStandaloneStackDisclosureDirection = .expanding
}
}
behaviorRevision = snapshot.behaviorRevision ?? behaviorRevision
if behavior.appearanceTheme != previousAppearanceTheme
|| initialAppearanceReadiness != .authoritative
{
applyCurrentAppearance()
}
if behavior.interfaceLanguage != previousInterfaceLanguage
|| initialAppearanceReadiness != .authoritative
{
applyCurrentLanguage()
}
let activeStateChanged = switch (activeAgentState, snapshot.activeAgentState) {
case (nil, nil):
false
case let (.some(current), .some(next)):
!current.hasSamePresentation(as: next)
default:
true
}
if activeStateChanged {
activeAgentState = snapshot.activeAgentState
}
let nextActiveAgentSessions = snapshot.activeAgentSessions
?? snapshot.activeAgentState.map { [$0] }
?? []
overlaySessionProjectionIdentities = Dictionary(
nextActiveAgentSessions.map { state in
(
OverlaySessionContent.stableID(
source: state.source,
sessionID: state.sessionID ?? state.event.sessionID,
anonymousSessionAlias: state.anonymousSessionAlias,
fallbackEventID: state.event.id
),
OverlaySessionProjectionIdentity(
eventID: state.event.id,
acknowledgementID: state.acknowledgementID
)
)
},
uniquingKeysWith: { _, latest in latest }
)
let nextActiveAgentSessionsOmittedCount = max(
0,
snapshot.activeAgentSessionsOmittedCount ?? 0
)
let activeSessionsChanged = !activeStatesHaveSamePresentation(
activeAgentSessions,
nextActiveAgentSessions
)
reconcileOverlayStandaloneSessionOrder(with: nextActiveAgentSessions)
if activeSessionsChanged {
activeAgentSessions = nextActiveAgentSessions
}
reconcileOverlaySessionNavigationNotices()
if activeAgentSessionsOmittedCount != nextActiveAgentSessionsOmittedCount {
activeAgentSessionsOmittedCount = nextActiveAgentSessionsOmittedCount
}
let nextOverlayVisibility = snapshot.overlayVisibility ?? OverlayVisibility(
petVisible: snapshot.behavior.enabled,
statusBubbleVisible: snapshot.behavior.enabled
&& snapshot.behavior.statusBubble
&& (!nextActiveAgentSessions.isEmpty
|| (!snapshot.behavior.autoHide && snapshot.activeAgentState == nil))
)
let overlayVisibilityChanged = overlayVisibility != nextOverlayVisibility
if overlayVisibilityChanged {
overlayVisibility = nextOverlayVisibility
}
if pets != snapshot.pets {
pets = snapshot.pets
}
let assetWarnings = snapshot.petAssetWarnings ?? []
let nextPetAssetWarningIndex = PetAssetWarningIndex(assetWarnings)
if petAssetWarningIndex != nextPetAssetWarningIndex {
petAssetWarningIndex = nextPetAssetWarningIndex
if !assetWarnings.isEmpty {
let categories = Set(assetWarnings.map(PetAssetDiagnosticCategory.classify))
.map(\.rawValue)
.sorted()
.joined(separator: ",")
diagnostics.log(
.warning,
category: "render",
event: "pet_asset_warnings_changed",
metadata: [
"warning_count": .integer(Int64(assetWarnings.count)),
"warning_categories": .string(categories)
],
throttleKey: "pet_asset_warnings_changed",
minimumInterval: 30
)
}
}
if !eventsHaveSamePresentation(events, snapshot.events) {
events = snapshot.events
}
let nextReopenIDBySession = Dictionary(
nextActiveAgentSessions.map { state in
(
OverlaySessionContent.stableID(
source: state.source,
sessionID: state.sessionID ?? state.event.sessionID,
anonymousSessionAlias: state.anonymousSessionAlias,
fallbackEventID: state.event.id
),
OverlaySessionContent.reopenID(for: state)
)
},
uniquingKeysWith: { _, latest in latest }
)
let newlyActivatedDismissalIDs = OverlayPresentedAgentState.newlyActivatedDismissalIDs(
activeSessions: nextActiveAgentSessions,
lastReopenIDBySession: overlayLastReopenIDBySession
)
let hasNewOverlayActivation = !newlyActivatedDismissalIDs.isEmpty
if snapshot.behavior.enabled {
var nextDismissedBubbleEventIDs = overlayDismissedBubbleEventIDs
nextDismissedBubbleEventIDs.subtract(newlyActivatedDismissalIDs)
if overlayDismissedBubbleEventIDs != nextDismissedBubbleEventIDs {
overlayDismissedBubbleEventIDs = nextDismissedBubbleEventIDs
}
}
// A projected-session gap is not evidence of new work. Preserve each
// session's last activation identity until that same session returns
// with a genuinely different one.
overlayLastReopenIDBySession.merge(nextReopenIDBySession) {
_, latest in latest
}
if hasNewOverlayActivation, overlayBubbleDismissed {
overlayBubbleDismissed = false
}
let nextRecentEvents = snapshot.recentEvents ?? snapshot.events
if !eventsHaveSamePresentation(recentEvents, nextRecentEvents) {
recentEvents = nextRecentEvents
}
applyAuthoritativeConnectionSnapshot(snapshot.connections)
stateRevision = snapshot.revision ?? stateRevision
let snapshotPlacement = snapshot.overlayPlacement ?? OverlayPlacement()
applyOverlayPlacement(
snapshotPlacement,
remoteRevision: overlayPlacementRevision,
remoteIntent: snapshot.overlayPlacementIntent
)
syncOverlayVisibilityForBehavior()
let overlayBubbleLayoutChanged = previousOverlayBubbleLayout
!= OverlayBubbleLayoutSignature(
contents: overlayBubbleContents,
bubbleDismissed: overlayBubbleDismissed
)
if overlayBubbleLayoutChanged
|| overlayVisibilityChanged
|| behavior.groupSessionsByAgent != previousGroupSessionsByAgent
|| behavior.sessionGroupDisplay != previousSessionGroupDisplay
|| behavior.bubbleFontScale != previousBubbleFontScale
{
overlayController.updateLayout()
}
if initialAppearanceReadiness != .authoritative {
resolveInitialAppearanceAsAuthoritative()
}
if !hasLoadedStateSnapshot {
hasLoadedStateSnapshot = true
}
resumePendingOverlayPlacementSaveIfNeeded(
reason: isBootstrapSnapshot ? .bootstrap : .ordinarySnapshot
)
presentOverlayAfterFirstSnapshotIfNeeded()
}
private func presentOverlayAfterFirstSnapshotIfNeeded() {
guard runtimeBootstrapCompleted,
hasLoadedStateSnapshot,
!hasPresentedOverlay
else { return }
hasPresentedOverlay = true
overlayPresenter(overlayController, self)
}
private func reconcileActiveGeneration(_ snapshot: ActiveGenerationSnapshot) {
let previousJobID = generationSession.jobID
var restore = GenerationSessionRestore(snapshot: snapshot)
let projectionIdentity = ActiveGenerationRecoveryProjectionIdentity(
jobID: snapshot.jobID,
form: snapshot.form,
referenceReselectionCount: snapshot.referenceReselectionCount
)
let projectionChanged = activeGenerationRecoveryProjection?.identity != projectionIdentity
if projectionChanged {
restore = sanitizedGenerationRestore(restore)
activeGenerationRecoveryProjection = SanitizedGenerationRecoveryProjection(
identity: projectionIdentity,
form: restore.submittedForm,
referenceReselectionCount: restore.referenceReselectionCount
)
} else if let projection = activeGenerationRecoveryProjection {
restore.submittedForm = projection.form
restore.referenceReselectionCount = projection.referenceReselectionCount
}
_ = reduceGeneration(.restore(restore))
if projectionChanged {
applyRestoredGenerationForm(
restore.submittedForm,
referenceReselectionCount: restore.referenceReselectionCount
)
}
latestGenerationRestoreAttemptState = .resolved
if previousJobID != snapshot.jobID {
generationReplyText = ""
}
}
/// Coalesces concurrent recovery callers. A failed or malformed local RPC
/// remains retryable on the next successful state refresh; only an empty
/// valid response or an applied valid session resolves the launch restore.
func restoreLatestGenerationSessionIfNeeded() async {
guard !automaticLatestGenerationRestoreInvalidated,
generationDraftIsPristineForAutomaticRestore
else { return }
switch latestGenerationRestoreAttemptState {
case .resolved:
return
case .inFlight:
if let task = latestGenerationRestoreInFlight?.task {
await task.value
}
return
case .notAttempted:
break
}
latestGenerationRestoreAttemptSequence &+= 1
let attemptID = latestGenerationRestoreAttemptSequence
let mutationRevision = makerUserMutationRevision
latestGenerationRestoreAttemptState = .inFlight
let task = Task { @MainActor [weak self] in
guard let self else { return }
await self.performLatestGenerationRestore(
attemptID: attemptID,
makerMutationRevision: mutationRevision
)
}
latestGenerationRestoreInFlight = (attemptID, task)
await task.value
}
private func performLatestGenerationRestore(
attemptID: UInt64,
makerMutationRevision: UInt64
) async {
do {
let result = try await requestPetCore(method: "generation.latest")
let data = try JSONSerialization.data(withJSONObject: result)
let snapshot = try JSONDecoder().decode(
LatestGenerationSessionSnapshot.self,
from: data
)
if !snapshot.found {
finishLatestGenerationRestoreWithoutSession(attemptID: attemptID)
return
}
guard let decodedRestore = GenerationSessionRestore(snapshot: snapshot) else {
throw PetCoreClientError.invalidResponse
}
finishLatestGenerationRestore(
sanitizedGenerationRestore(decodedRestore),
attemptID: attemptID,
makerMutationRevision: makerMutationRevision
)
} catch {
diagnostics.logFailure(
error,
category: "generation",
event: "latest_generation_restore_failed",
throttleKey: "latest_generation_restore_failed",
minimumInterval: 30
)
finishRetryableLatestGenerationRestore(attemptID: attemptID)
}
}
private func finishLatestGenerationRestoreWithoutSession(attemptID: UInt64) {
guard latestGenerationRestoreInFlight?.id == attemptID else { return }
latestGenerationRestoreInFlight = nil
guard latestGenerationRestoreAttemptState == .inFlight else { return }
latestGenerationRestoreAttemptState = .resolved
}
private func finishRetryableLatestGenerationRestore(attemptID: UInt64) {
guard latestGenerationRestoreInFlight?.id == attemptID else { return }
latestGenerationRestoreInFlight = nil
guard latestGenerationRestoreAttemptState == .inFlight else { return }
latestGenerationRestoreAttemptState = .notAttempted
}
private func finishLatestGenerationRestore(
_ restore: GenerationSessionRestore,
attemptID: UInt64,
makerMutationRevision: UInt64
) {
guard latestGenerationRestoreInFlight?.id == attemptID else { return }
latestGenerationRestoreInFlight = nil
guard latestGenerationRestoreAttemptState == .inFlight else { return }
guard !automaticLatestGenerationRestoreInvalidated,
self.makerUserMutationRevision == makerMutationRevision,
generationDraftIsPristineForAutomaticRestore
else {
latestGenerationRestoreAttemptState = .notAttempted
return
}
_ = reduceGeneration(.restore(restore))
applyRestoredGenerationForm(
restore.submittedForm,
referenceReselectionCount: restore.referenceReselectionCount
)
generationReplyText = ""
latestGenerationRestoreAttemptState = .resolved
}
private var generationDraftIsPristineForAutomaticRestore: Bool {
generationSession == GenerationSession()
&& descriptionText == AIPetMakerDefaults.descriptionText
&& selectedStyle == AIPetMakerDefaults.style
&& selectedQuality == AIPetMakerDefaults.quality
&& referenceImages.isEmpty
&& referenceImageIssue == nil
}
private func sanitizedGenerationRestore(
_ restore: GenerationSessionRestore
) -> GenerationSessionRestore {
guard let form = restore.submittedForm else { return restore }
let projectedPaths = form.referenceImages
let validatedPaths = projectedPaths.enumerated().compactMap { index, path in
MakerReferenceImagePolicy.validatedRecoveryProjectionPath(
path,
jobID: restore.jobID,
index: index
)
}
var sanitized = restore
let safePaths: [String]
if validatedPaths.count == projectedPaths.count {
safePaths = validatedPaths
} else {
// Projection is all-or-nothing: if even one supposedly safe copy
// disappeared or fails local validation, retain no projected path.
safePaths = []
sanitized.referenceReselectionCount = min(
MakerReferenceImagePolicy.maximumCount,
restore.referenceReselectionCount + projectedPaths.count
)
}
sanitized.submittedForm = GenerationForm(
description: form.description,
style: form.style,
quality: form.quality,
referenceImages: safePaths
)
return sanitized
}
private func applyRestoredGenerationForm(
_ form: GenerationForm?,
referenceReselectionCount: Int
) {
guard let form else { return }
descriptionText = form.description
if let style = StylePreset(rawValue: form.style) {
selectedStyle = style
}
selectedQuality = form.quality
referenceImages = form.referenceImages
self.referenceReselectionCount = referenceReselectionCount
reselectedReferenceImagePaths.removeAll()
referenceImageIssue = referenceReselectionCount > 0
? .reselectionRequired(referenceReselectionCount)
: nil
}
private func recordMakerUserMutation() {
makerUserMutationRevision &+= 1
automaticLatestGenerationRestoreInvalidated = true
}
private func refreshReferenceImageIssue(
fallback: MakerReferenceImageIssue? = nil
) {
if referenceReselectionCount > 0 {
referenceImageIssue = .reselectionRequired(referenceReselectionCount)
} else {
referenceImageIssue = fallback ?? MakerReferenceImagePolicy.issue(for: referenceImages)
}
}
func updateGenerationDescription(_ value: String) {
guard !generationSession.isActive else { return }
recordMakerUserMutation()
descriptionText = GenerationPromptPolicy.truncate(value)
}
func selectGenerationStyle(_ style: StylePreset) {
guard !generationSession.isActive else { return }
recordMakerUserMutation()
selectedStyle = style
}
func selectGenerationQuality(_ quality: QualityLevel) {
guard !generationSession.isActive, quality.isStudioSupported else { return }
recordMakerUserMutation()
selectedQuality = quality
}
func refreshPetStudioCodexAvailability() async {
if generationSession.isActive {
petStudioCodexAvailability = .available
return
}
guard petStudioCodexAvailability != .available else { return }
petStudioCodexAvailability = .checking
do {
let result = try await requestPetCore(method: "codex.app_server.probe")
guard let payload = result as? [String: Any] else {
petStudioCodexAvailability = .unavailable
return
}
if payload["initialized"] as? Bool == true {
petStudioCodexAvailability = .available
return
}
let errorInfo = payload["error_info"] as? [String: Any]
let kind = errorInfo?["kind"] as? String
petStudioCodexAvailability = payload["mode"] as? String == "missing"
|| kind == "not_configured"
? .missing
: .unavailable
} catch {
petStudioCodexAvailability = .unavailable
diagnostics.logFailure(
error,
category: "generation",
event: "pet_studio_codex_probe_failed",
throttleKey: "pet_studio_codex_probe_failed",
minimumInterval: 30
)
}
}
func startGeneration() {
guard canStartNewGenerationWork else {
statusText = APCLocalization.text(.appUpdateConvergenceMakerBlocked)
return
}
guard canStartGeneration else {
statusText = generationSession.isActive
? generationStateTitle
: petStudioGenerationBlockedStatus
return
}
if let issue = MakerReferenceImagePolicy.issue(for: referenceImages) {
referenceImageIssue = issue
return
}
referenceImageIssue = nil
let description = descriptionText.trimmingCharacters(in: .whitespacesAndNewlines)
let form = GenerationForm(
description: description,
style: selectedStyle.rawValue,
quality: selectedQuality,
referenceImages: referenceImages
)
beginGeneration(
with: form,
initialMessage: APCLocalization.format(
.studioMessageCreateRequestedFormat,
APCLocalizedPresentation.styleTitle(selectedStyle)
)
)
}
func startPetEdit(
_ pet: PetSummary,
baselineRevisionID: String? = nil,
instruction: String
) {
guard canStartNewGenerationWork else {
statusText = APCLocalization.text(.appUpdateConvergenceMakerBlocked)
return
}
guard !pet.isBundled else {
statusText = "App 内置宠物不可原地修改;请导出并使用新的宠物 ID 创建副本"
return
}
guard pet.quality.isStudioSupported else {
statusText = APCLocalization.text(.studioHighQualityUnsupported)
return
}
let instruction = instruction.trimmingCharacters(in: .whitespacesAndNewlines)
guard !instruction.isEmpty else {
statusText = "请先填写希望如何修改宠物"
return
}
guard GenerationPromptPolicy.scalarCount(instruction)
<= AIPetMakerDefaults.maximumDescriptionCharacters
else {
statusText = "宠物修改要求不能超过 8000 个字符"
return
}
guard !generationSession.isActive else {
statusText = "请先完成或取消当前 AI 制作任务"
return
}
guard petStudioCodexAvailability.permitsGeneration else {
selection = .maker
statusText = petStudioGenerationBlockedStatus
return
}
recordMakerUserMutation()
referenceReselectionCount = 0
reselectedReferenceImagePaths.removeAll()
referenceImageIssue = nil
let form = GenerationForm(
description: instruction,
style: pet.style,
quality: pet.quality,
referenceImages: []
)
let initialUserMessage = GenerationMessage(
role: "user",
content: instruction,
progress: 0.01,
createdAt: ""
)
_ = reduceGeneration(.editRequested(
form: form,
initialMessage: initialUserMessage,
petID: pet.id,
baselineRevisionID: baselineRevisionID
))
generationReplyText = ""
selection = .maker
statusText = "正在建立 \(pet.name) 的修改会话"
Task {
do {
var parameters: [String: Any] = [
"pet_id": pet.id,
"instruction": instruction,
]
if let baselineRevisionID {
parameters["baseline_revision_id"] = baselineRevisionID
}
let result = try await requestPetCore(
method: "generation.edit",
params: parameters
)
guard let dict = result as? [String: Any],
let jobID = dict["job_id"] as? String,
!jobID.isEmpty
else {
throw PetCoreClientError.invalidResponse
}
let acceptedBaselineRevisionID = dict["baseline_revision_id"] as? String
?? baselineRevisionID
_ = reduceGeneration(.startAccepted(
jobID: jobID,
baselineRevisionID: acceptedBaselineRevisionID
))
await selectAcceptedGenerationHistoryJobAndWait(jobID)
statusText = "正在修改 \(pet.name)"
} catch {
let failure = GenerationMessage(
role: "assistant",
content: APCLocalization.text(.studioMessageStartModifyFailed),
progress: 1,
createdAt: "",
kind: "generation_failed"
)
_ = reduceGeneration(.startFailed(message: failure))
statusText = "修改启动失败:\(error.localizedDescription)"
}
}
}
func preparePetCustomizationCopy(_ pet: PetSummary) {
guard pet.isBundled else {
statusText = APCLocalization.text(.libraryCopyBundledOnly)
return
}
guard !generationSession.isActive else {
statusText = APCLocalization.text(.libraryCopyActiveTask)
return
}
recordMakerUserMutation()
let draft = PetLibraryCopyDraft.make(
for: pet,
existingPetIDs: Set(pets.map(\.id))
)
let referencePath = PetAssetLocator.coverURL(for: pet)
.flatMap(Self.safeMakerReferenceImagePath)
_ = reduceGeneration(.reset)
descriptionText = draft.brief
selectedStyle = draft.style
selectedQuality = draft.quality
referenceImages = referencePath.map { [$0] } ?? []
referenceReselectionCount = 0
reselectedReferenceImagePaths.removeAll()
referenceImageIssue = nil
generationReplyText = ""
clearGenerationHistorySelection()
makerDraftIsActive = true
selection = .maker
statusText = APCLocalization.format(.libraryCopyPreparedFormat, pet.name)
}
func clearStudioForm() {
guard !generationSession.isActive else {
statusText = "活动任务使用已提交表单,完成或取消后才能清空草稿"
return
}
recordMakerUserMutation()
descriptionText = AIPetMakerDefaults.descriptionText
selectedStyle = AIPetMakerDefaults.style
selectedQuality = AIPetMakerDefaults.quality
referenceImages.removeAll()
referenceReselectionCount = 0
reselectedReferenceImagePaths.removeAll()
referenceImageIssue = nil
generationReplyText = ""
statusText = "已清空新建表单"
}
func showNewPetDraft() {
guard !generationSession.isActive else {
statusText = "请先完成或取消当前 AI 制作任务"
return
}
recordMakerUserMutation()
_ = reduceGeneration(.reset)
descriptionText = AIPetMakerDefaults.descriptionText
selectedStyle = AIPetMakerDefaults.style
selectedQuality = AIPetMakerDefaults.quality
referenceImages.removeAll()
referenceReselectionCount = 0
reselectedReferenceImagePaths.removeAll()
referenceImageIssue = MakerReferenceImagePolicy.issue(for: referenceImages)
generationReplyText = ""
selection = .maker
statusText = "可以开始制作新宠物"
}
func retryGeneration() {
guard canStartNewGenerationWork else {
statusText = APCLocalization.text(.appUpdateConvergenceMakerBlocked)
return
}
guard petStudioCodexAvailability.permitsGeneration else {
statusText = petStudioGenerationBlockedStatus
return
}
guard generationSession.submittedForm != nil else {
startGeneration()
return
}
guard generationSession.canRetry else {
statusText = generationSession.isActive ? generationStateTitle : "当前会话不可重试"
return
}
guard referenceReselectionCount == 0 else {
refreshReferenceImageIssue()
statusText = APCLocalizedPresentation.referenceImageIssue(
.reselectionRequired(referenceReselectionCount)
)
return
}
let modifying = generationSession.operation == .modify
if !modifying, let issue = MakerReferenceImagePolicy.issue(for: referenceImages) {
referenceImageIssue = issue
return
}
referenceImageIssue = nil
guard let form = PetStudioDraftPolicy.retryForm(
session: generationSession,
descriptionText: descriptionText,
style: selectedStyle,
quality: selectedQuality,
referenceImages: referenceImages
) else { return }
if modifying, generationSession.resultPetID == nil {
statusText = "修改会话缺少宠物 ID,无法安全重试"
return
}
if modifying, generationSession.jobID == nil {
guard let petID = generationSession.resultPetID,
let pet = pets.first(where: { $0.id == petID }),
let instruction = generationSession.messages.first(where: { $0.role == "user" })?
.content
else {
statusText = "修改会话缺少可重试的宠物基线"
return
}
startPetEdit(
pet,
baselineRevisionID: generationSession.baselineRevisionID,
instruction: instruction
)
return
}
beginGeneration(
with: form,
initialMessage: modifying
? APCLocalization.text(.studioMessageRetryModify)
: APCLocalization.format(
.studioMessageRetryCreateFormat,
APCLocalizedPresentation.styleTitle(selectedStyle)
),
retryOfJobID: generationSession.jobID,
operation: generationSession.operation,
resultPetID: generationSession.resultPetID
)
statusText = modifying ? "正在重试宠物修改" : "正在重试 AI 辅助会话"
}
func resumeGeneration() {
guard canStartNewGenerationWork else {
statusText = APCLocalization.text(.appUpdateConvergenceMakerBlocked)
return
}
guard petStudioCodexAvailability.permitsGeneration else {
statusText = petStudioGenerationBlockedStatus
return
}
guard canResumeGeneration,
let jobID = generationSession.jobID
else {
statusText = generationSession.isActive
? generationStateTitle
: APCLocalization.text(.studioResumeUnavailable)
return
}
let previousState = generationSession.state
let instruction = generationReplyText.trimmingCharacters(in: .whitespacesAndNewlines)
_ = reduceGeneration(.resumeRequested)
statusText = APCLocalization.text(.studioResumeStarting)
Task {
do {
var parameters: [String: Any] = [
"job_id": jobID,
"request_id": UUID().uuidString,
]
if !instruction.isEmpty {
parameters["instruction"] = instruction
}
let result = try await requestPetCore(
method: "generation.resume",
params: parameters
)
guard let object = result as? [String: Any],
let resumedJobID = object["job_id"] as? String,
resumedJobID == jobID
else {
throw PetCoreClientError.invalidResponse
}
_ = reduceGeneration(.startAccepted(
jobID: resumedJobID,
baselineRevisionID: object["baseline_revision_id"] as? String
))
generationReplyText = ""
statusText = APCLocalization.text(.studioResumeAccepted)
} catch {
_ = reduceGeneration(.resumeFailed(restoring: previousState))
statusText = APCLocalization.format(
.studioResumeFailedFormat,
error.localizedDescription
)
}
}
}
private func beginGeneration(
with form: GenerationForm,
initialMessage: String,
retryOfJobID: String? = nil,
operation: GenerationOperation = .create,
resultPetID: String? = nil,
baselineRevisionID: String? = nil
) {
guard canStartNewGenerationWork else {
statusText = APCLocalization.text(.appUpdateConvergenceMakerBlocked)
return
}
guard petStudioCodexAvailability.permitsGeneration else {
statusText = petStudioGenerationBlockedStatus
return
}
let initialUserMessage = GenerationMessage(
role: "user",
content: initialMessage,
progress: 0.05,
createdAt: ""
)
if retryOfJobID != nil {
_ = reduceGeneration(.retryRequested(
form: form,
initialMessage: initialUserMessage
))
} else if operation == .modify, let resultPetID {
_ = reduceGeneration(.editRequested(
form: form,
initialMessage: initialUserMessage,
petID: resultPetID,
baselineRevisionID: baselineRevisionID
))
} else {
_ = reduceGeneration(.startRequested(form: form, initialMessage: initialUserMessage))
}
generationReplyText = ""
Task {
do {
let formData = try JSONEncoder().encode(form)
let formObject = try JSONSerialization.jsonObject(with: formData)
let result: Any
if let retryOfJobID {
var parameters: [String: Any] = ["job_id": retryOfJobID]
if GenerationRetryRequestPolicy.includesForm(for: operation) {
parameters["form"] = formObject
}
result = try await requestPetCore(
method: "generation.retry",
params: parameters
)
} else {
result = try await requestPetCore(method: "generation.start", params: formObject)
}
guard let dict = result as? [String: Any],
let jobID = dict["job_id"] as? String,
!jobID.isEmpty
else {
throw PetCoreClientError.invalidResponse
}
_ = reduceGeneration(.startAccepted(
jobID: jobID,
baselineRevisionID: dict["baseline_revision_id"] as? String
))
await selectAcceptedGenerationHistoryJobAndWait(jobID)
} catch {
let failure = GenerationMessage(
role: "assistant",
content: APCLocalization.text(
operation == .modify
? .studioMessageStartModifyFailed
: .studioMessageStartCreateFailed
),
progress: 1,
createdAt: "",
kind: "generation_failed"
)
_ = reduceGeneration(.startFailed(message: failure))
}
}
}
private var petStudioGenerationBlockedStatus: String {
generationStartBlockingDetail
?? APCLocalization.text(.studioDescriptionRequired)
}
func refreshGenerationMessages() async {
guard let generationJobID = generationSession.jobID else { return }
do {
let result = try await requestPetCore(method: "generation.messages", params: ["job_id": generationJobID])
let data = try JSONSerialization.data(withJSONObject: result)
let messages = try JSONDecoder().decode([GenerationMessage].self, from: data)
await applyGenerationMessages(messages)
} catch {
statusText = "生成消息暂不可用"
}
}
private func startGenerationMessageStream(jobID: String) {
generationMessagesTask?.cancel()
generationMessagesTask = Task { [weak self] in
while !Task.isCancelled {
let shouldContinue = await self?.waitForGenerationMessages(jobID: jobID) ?? false
if !shouldContinue {
break
}
}
}
}
private func waitForGenerationMessages(jobID: String) async -> Bool {
guard generationSession.jobID == jobID else { return false }
do {
let result = try await requestPetCore(
method: "generation.messages.wait",
params: [
"job_id": jobID,
"after_revision": generationSession.messageRevision,
"timeout_ms": 30_000
],
timeout: .seconds(35)
)
let data = try JSONSerialization.data(withJSONObject: result)
let snapshot = try JSONDecoder().decode(GenerationMessagesSnapshot.self, from: data)
await applyGenerationMessages(
snapshot.messages,
revision: snapshot.revision,
resultMetadata: snapshot.resultMetadata,
heartbeatAt: snapshot.heartbeatAt
)
return generationSession.isActive && generationSession.jobID == jobID
} catch {
statusText = "生成消息暂不可用"
try? await Task.sleep(for: .seconds(1))
return generationSession.isActive && generationSession.jobID == jobID
}
}
private func applyGenerationMessages(
_ messages: [GenerationMessage],
revision: String? = nil,
resultMetadata: GenerationResultMetadata? = nil,
heartbeatAt: String? = nil
) async {
if let heartbeatAt {
_ = reduceGeneration(.heartbeatReceived(heartbeatAt))
}
if let resultMetadata, !resultMetadata.isEmpty {
_ = reduceGeneration(.resultMetadataReceived(resultMetadata))
}
let previousState = generationSession.state
let effects = reduceGeneration(.messagesReceived(messages, revision: revision))
if previousState != generationSession.state {
notifyForGenerationTransition(
from: previousState,
to: generationSession.state,
messages: messages
)
}
if let jobID = generationSession.jobID,
selectedGenerationHistoryJobID == jobID {
generationHistoryMessages = messages.filter {
$0.kind != "generation_heartbeat" && $0.kind != "jsonl_diagnostic"
}
if previousState != generationSession.state {
await refreshGenerationHistory()
await loadGenerationHistoryDetail(jobID: jobID)
}
}
if effects.contains(.refreshSnapshot) {
await refresh()
}
}
private func notifyForGenerationTransition(
from previousState: GenerationSessionState,
to state: GenerationSessionState,
messages: [GenerationMessage]
) {
guard previousState != state,
let jobID = generationSession.jobID,
!(NSApp.isActive
&& selection == .maker
&& selectedGenerationHistoryJobID == jobID)
else { return }
let notification: (String, String)? = switch state {
case .waitingForInput:
(
APCLocalization.text(.studioNotificationWaitingTitle),
messages.last(where: { $0.kind == "input_request" })?.content
?? APCLocalization.text(.studioNotificationWaitingBody)
)
case .paused:
(
APCLocalization.text(.studioNotificationPausedTitle),
generationSession.pauseReason
?? APCLocalization.text(.studioNotificationPausedBody)
)
case .recoverableFailed:
(
APCLocalization.text(.studioNotificationRecoverableTitle),
generationSession.pauseReason
?? APCLocalization.text(.studioNotificationRecoverableBody)
)
case .succeeded:
(
APCLocalization.text(.studioNotificationCompletedTitle),
APCLocalization.text(.studioNotificationCompletedBody)
)
case .failed:
(
APCLocalization.text(.studioNotificationFailedTitle),
APCLocalization.text(.studioNotificationFailedBody)
)
case .idle, .starting, .running, .cancelling, .cancelCleanup, .cancelled:
nil
}
guard let notification else { return }
MakerNotificationCoordinator.shared.notify(
jobID: jobID,
state: state,
title: notification.0,
body: notification.1
)
}
func sendGenerationReply() {
let content = generationReplyText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else { return }
guard generationSession.canSendReply else {
statusText = "请先等待 AI 追问或生成成功"
return
}
guard let generationJobID = generationSession.jobID else {
statusText = "请先发起 AI 辅助会话"
return
}
let previousState = generationSession.state
_ = reduceGeneration(.replySubmitted)
generationReplyText = ""
Task {
do {
let result = try await requestPetCore(
method: "generation.reply",
params: [
"job_id": generationJobID,
"content": content,
"request_id": UUID().uuidString,
]
)
let data = try JSONSerialization.data(withJSONObject: result)
let messages = try JSONDecoder().decode([GenerationMessage].self, from: data)
await applyGenerationMessages(messages)
if generationSession.isActive {
_ = reduceGeneration(.resetMessageRevision)
startGenerationMessageStream(jobID: generationJobID)
}
} catch {
_ = reduceGeneration(.replyFailed(restoring: previousState))
statusText = "发送失败:\(error.localizedDescription)"
generationReplyText = content
}
}
}
func cancelGeneration() {
guard generationSession.canCancel,
let generationJobID = generationSession.jobID
else {
return
}
_ = reduceGeneration(.cancelRequested)
statusText = "正在取消生成"
Task {
do {
let result = try await requestPetCore(method: "generation.cancel", params: ["job_id": generationJobID])
let data = try JSONSerialization.data(withJSONObject: result)
let messages = try JSONDecoder().decode([GenerationMessage].self, from: data)
if !messages.isEmpty {
await applyGenerationMessages(messages)
} else {
let effects = reduceGeneration(.cancelConfirmed)
if effects.contains(.refreshSnapshot) {
await refresh()
}
}
statusText = generationSession.operation == .modify ? "已取消修改" : "已取消生成"
} catch {
_ = reduceGeneration(.cancelFailed)
let action = generationSession.operation == .modify ? "修改" : "生成"
statusText = "取消\(action)失败:\(error.localizedDescription)"
}
}
}
func fetchGenerationHistory(for pet: PetSummary) async throws -> GenerationHistory {
let result = try await requestPetCore(
method: "generation.for_pet",
params: ["pet_id": pet.id]
)
let data = try JSONSerialization.data(withJSONObject: result)
return try JSONDecoder().decode(GenerationHistory.self, from: data)
}
func refreshGenerationHistory(limit: Int = 24) async {
generationHistoryListSequence &+= 1
let sequence = generationHistoryListSequence
generationHistoryIsLoading = true
generationHistoryLoadFailed = false
do {
let result = try await requestPetCore(
method: "generation.history.list",
params: ["limit": min(max(limit, 1), 32)]
)
let data = try JSONSerialization.data(withJSONObject: result)
let snapshot = try JSONDecoder().decode(
GenerationStudioHistorySnapshot.self,
from: data
)
guard sequence == generationHistoryListSequence else { return }
generationHistorySnapshot = snapshot
if let selectedGenerationHistoryJobID,
!snapshot.jobs.contains(where: { $0.jobID == selectedGenerationHistoryJobID }) {
if generationHistorySelectionIntentJobID != selectedGenerationHistoryJobID {
clearGenerationHistorySelection()
}
} else if let generationHistorySelectionIntentJobID,
snapshot.jobs.contains(where: {
$0.jobID == generationHistorySelectionIntentJobID
}) {
self.generationHistorySelectionIntentJobID = nil
}
} catch {
guard sequence == generationHistoryListSequence else { return }
generationHistoryLoadFailed = true
diagnostics.logFailure(
error,
category: "generation",
event: "generation_history_list_failed",
throttleKey: "generation_history_list_failed",
minimumInterval: 10
)
}
guard sequence == generationHistoryListSequence else { return }
generationHistoryIsLoading = false
generationHistoryHasLoaded = true
}
func selectGenerationHistoryJob(_ jobID: String) {
guard generationHistorySnapshot.jobs.contains(where: { $0.jobID == jobID }) else {
return
}
generationHistorySelectionIntentJobID = nil
activateGenerationHistorySelection(jobID)
Task { [weak self] in
guard let self else { return }
async let detail: Void = loadGenerationHistoryDetail(jobID: jobID)
async let messages: Void = loadGenerationHistoryMessages(jobID: jobID)
_ = await (detail, messages)
}
}
func selectGenerationHistoryJobAndWait(_ jobID: String) async {
guard generationHistorySnapshot.jobs.contains(where: { $0.jobID == jobID }) else {
return
}
generationHistorySelectionIntentJobID = nil
activateGenerationHistorySelection(jobID)
async let detail: Void = loadGenerationHistoryDetail(jobID: jobID)
async let messages: Void = loadGenerationHistoryMessages(jobID: jobID)
_ = await (detail, messages)
}
/// Selects the exact job acknowledged by PetCore immediately, without
/// waiting for the eventually consistent history list to expose it.
private func selectAcceptedGenerationHistoryJobAndWait(_ jobID: String) async {
generationHistorySelectionIntentJobID = jobID
activateGenerationHistorySelection(jobID)
async let history: Void = refreshGenerationHistory()
async let detail: Void = loadGenerationHistoryDetail(jobID: jobID)
async let messages: Void = loadGenerationHistoryMessages(jobID: jobID)
_ = await (history, detail, messages)
}
private func activateGenerationHistorySelection(_ jobID: String) {
if selectedGenerationHistoryJobID != jobID {
generationReplyText = ""
}
generationHistoryDetailSequence &+= 1
generationHistoryMessagesSequence &+= 1
selectedGenerationHistoryJobID = jobID
makerDraftIsActive = false
generationHistoryDetail = nil
generationHistoryMessages = []
generationHistoryMessagesHasMore = false
generationHistoryDetailIsLoading = false
generationHistoryMessagesIsLoading = false
}
func clearGenerationHistorySelection() {
generationHistoryDetailSequence &+= 1
generationHistoryMessagesSequence &+= 1
generationHistorySelectionIntentJobID = nil
selectedGenerationHistoryJobID = nil
generationHistoryDetail = nil
generationHistoryMessages = []
generationHistoryMessagesHasMore = false
generationHistoryDetailIsLoading = false
generationHistoryMessagesIsLoading = false
generationReplyText = ""
}
func loadGenerationHistoryDetail(jobID: String) async {
generationHistoryDetailSequence &+= 1
let sequence = generationHistoryDetailSequence
generationHistoryDetailIsLoading = true
generationHistoryLoadFailed = false
do {
let result = try await requestPetCore(
method: "generation.history.detail",
params: ["job_id": jobID],
timeout: .seconds(15)
)
let data = try JSONSerialization.data(withJSONObject: result)
let detail = try JSONDecoder().decode(
GenerationStudioHistoryDetail.self,
from: data
)
guard sequence == generationHistoryDetailSequence,
selectedGenerationHistoryJobID == jobID
else { return }
generationHistoryDetail = detail.found ? detail : nil
generationHistoryLoadFailed = !detail.found
} catch {
guard sequence == generationHistoryDetailSequence,
selectedGenerationHistoryJobID == jobID
else { return }
generationHistoryLoadFailed = true
diagnostics.logFailure(
error,
category: "generation",
event: "generation_history_detail_failed",
throttleKey: "generation_history_detail_failed",
minimumInterval: 10
)
}
guard sequence == generationHistoryDetailSequence,
selectedGenerationHistoryJobID == jobID
else { return }
generationHistoryDetailIsLoading = false
}
func loadGenerationHistoryMessages(
jobID: String,
beforeSequence: UInt64? = nil,
limit: Int = 50
) async {
generationHistoryMessagesSequence &+= 1
let sequence = generationHistoryMessagesSequence
generationHistoryMessagesIsLoading = true
defer {
if sequence == generationHistoryMessagesSequence {
generationHistoryMessagesIsLoading = false
}
}
var params: [String: Any] = [
"job_id": jobID,
"limit": min(max(limit, 1), 200),
]
if let beforeSequence {
params["before_sequence"] = beforeSequence
}
do {
let result = try await requestPetCore(
method: "generation.messages.list",
params: params,
timeout: .seconds(15)
)
let data = try JSONSerialization.data(withJSONObject: result)
let page = try JSONDecoder().decode(GenerationMessagesPage.self, from: data)
guard sequence == generationHistoryMessagesSequence,
selectedGenerationHistoryJobID == jobID,
page.jobID == jobID
else { return }
if beforeSequence == nil {
generationHistoryMessages = page.messages
} else {
let currentIDs = Set(generationHistoryMessages.map(\.id))
generationHistoryMessages = page.messages.filter { !currentIDs.contains($0.id) }
+ generationHistoryMessages
}
generationHistoryMessagesHasMore = page.hasMore
} catch {
guard sequence == generationHistoryMessagesSequence,
selectedGenerationHistoryJobID == jobID
else { return }
diagnostics.logFailure(
error,
category: "generation",
event: "generation_history_messages_failed",
throttleKey: "generation_history_messages_failed",
minimumInterval: 5
)
}
}
func loadOlderGenerationHistoryMessages() async {
guard generationHistoryMessagesHasMore,
!generationHistoryMessagesIsLoading,
let jobID = selectedGenerationHistoryJobID,
let before = generationHistoryMessages.first?.sequence
else { return }
await loadGenerationHistoryMessages(jobID: jobID, beforeSequence: before)
}
func prepareMakerWorkspace() async {
await refreshGenerationHistory()
if let selectedGenerationHistoryJobID,
generationHistorySelectionIntentJobID == selectedGenerationHistoryJobID {
async let detail: Void = loadGenerationHistoryDetail(
jobID: selectedGenerationHistoryJobID
)
async let messages: Void = loadGenerationHistoryMessages(
jobID: selectedGenerationHistoryJobID
)
_ = await (detail, messages)
return
}
if let selectedGenerationHistoryJobID,
generationHistorySnapshot.jobs.contains(where: {
$0.jobID == selectedGenerationHistoryJobID
}) {
await selectGenerationHistoryJobAndWait(selectedGenerationHistoryJobID)
return
}
if let unfinished = generationHistorySnapshot.jobs.first(where: {
makerHistoryJobIsUnfinished($0)
}) {
await selectGenerationHistoryJobAndWait(unfinished.jobID)
return
}
if makerDraftIsActive { return }
if let mostRecent = generationHistorySnapshot.jobs.first {
await selectGenerationHistoryJobAndWait(mostRecent.jobID)
return
}
beginMakerDraft()
}
func refreshMakerAfterLifecycleEvent() async {
// didBecomeActive is also delivered during the first launch, before a
// clean-home PetCore has necessarily finished staging its runtime and
// seeding bundled pets. Join that single bootstrap pipeline instead of
// issuing an early snapshot request that briefly marks the service
// offline and starts a duplicate recovery path.
guard await runRuntimeBootstrapIfNeeded(startMode: .ensureRunning) else {
return
}
_ = await refresh()
await refreshGenerationHistory()
guard let jobID = selectedGenerationHistoryJobID,
generationHistorySnapshot.jobs.contains(where: { $0.jobID == jobID })
else { return }
async let detail: Void = loadGenerationHistoryDetail(jobID: jobID)
async let messages: Void = loadGenerationHistoryMessages(jobID: jobID)
_ = await (detail, messages)
}
func beginMakerDraft() {
let unfinishedExists = generationHistorySnapshot.jobs.contains {
makerHistoryJobIsUnfinished($0)
}
guard !unfinishedExists else {
statusText = APCLocalization.text(.studioWorkspaceActiveTaskBlocksNew)
return
}
clearGenerationHistorySelection()
showNewPetDraft()
makerDraftIsActive = true
}
func discardMakerDraft() {
guard makerDraftIsActive else { return }
clearStudioForm()
_ = reduceGeneration(.reset)
makerDraftIsActive = false
clearGenerationHistorySelection()
if let mostRecent = generationHistorySnapshot.jobs.first {
selectGenerationHistoryJob(mostRecent.jobID)
}
}
private func makerHistoryJobIsUnfinished(_ job: GenerationStudioHistoryRecord) -> Bool {
if job.cancellationPending == true { return true }
if let capabilities = job.capabilities {
return capabilities.canCancel || capabilities.canReply || capabilities.canResume
}
return job.status == .pending
|| job.status == .running
|| job.status == .waitingForUser
|| (job.status == .failed && job.recoverable == true)
}
@discardableResult
func copySelectedGenerationHistoryBriefToNewDraft() -> Bool {
guard canCopySelectedGenerationHistoryBrief,
let detail = selectedGenerationHistoryDetail,
let form = generationHistoryDraft(from: detail)
else {
statusText = APCLocalization.text(.studioHistoryCopyBriefUnavailable)
return false
}
recordMakerUserMutation()
_ = reduceGeneration(.reset)
applyGenerationHistoryDraft(
form,
referenceReselectionCount: detail.referenceCount
)
clearGenerationHistorySelection()
makerDraftIsActive = true
generationReplyText = ""
selection = .maker
statusText = detail.referenceCount > 0
? APCLocalization.format(
.studioHistoryCopyBriefReferencesNoticeFormat,
detail.referenceCount
)
: APCLocalization.text(.studioHistoryCopyBriefSuccess)
return true
}
func retrySelectedGenerationHistory() {
guard canRetrySelectedGenerationHistory,
let detail = selectedGenerationHistoryDetail,
let jobID = detail.jobID,
let status = detail.status,
let operation = detail.operation,
let form = generationHistoryDraft(from: detail)
else {
statusText = APCLocalization.text(.studioHistoryRetryUnavailable)
return
}
guard detail.referenceCount == 0 else {
statusText = APCLocalization.format(
.studioHistoryCopyBriefReferencesNoticeFormat,
detail.referenceCount
)
return
}
recordMakerUserMutation()
let state = restoredGenerationState(
status: status,
messages: detail.progressMessages
)
_ = reduceGeneration(.restore(GenerationSessionRestore(
state: state,
jobID: jobID,
submittedForm: form,
messages: detail.progressMessages,
progress: detail.progressMessages.last?.progress ?? 1,
messageRevision: "",
operation: operation,
resultPetID: detail.resultPetID,
resultRevisionID: detail.revisionID,
validationSummary: detail.validationSummary,
referenceReselectionCount: detail.referenceCount
)))
applyGenerationHistoryDraft(
form,
referenceReselectionCount: detail.referenceCount
)
generationReplyText = ""
selection = .maker
let initialMessage = operation == .modify
? APCLocalization.text(.studioMessageRetryModify)
: APCLocalization.format(
.studioMessageRetryCreateFormat,
APCLocalizedPresentation.styleTitle(selectedStyle)
)
beginGeneration(
with: form,
initialMessage: initialMessage,
retryOfJobID: jobID,
operation: operation,
resultPetID: detail.resultPetID
)
statusText = APCLocalization.text(
operation == .modify
? .studioHistoryRetryStartingModify
: .studioHistoryRetryStartingCreate
)
}
@discardableResult
func deleteGenerationHistory(jobID: String) async -> Bool {
guard !jobID.isEmpty,
generationHistoryDeleteInFlightJobID == nil
else { return false }
generationHistoryDeleteInFlightJobID = jobID
generationHistoryMutationError = nil
defer { generationHistoryDeleteInFlightJobID = nil }
let jobsBeforeDeletion = generationHistorySnapshot.jobs
let deletedIndex = jobsBeforeDeletion.firstIndex { $0.jobID == jobID }
let deletedSelection = selectedGenerationHistoryJobID == jobID
let expectedResultPetID = jobsBeforeDeletion.first {
$0.jobID == jobID
}?.resultPetID
do {
let result = try await requestPetCore(
method: "generation.history.delete",
params: ["job_id": jobID],
timeout: .seconds(15)
)
let data = try JSONSerialization.data(withJSONObject: result)
let receipt = try JSONDecoder().decode(
GenerationStudioHistoryDeleteReceipt.self,
from: data
)
let deletedStatusIsTerminal = switch receipt.deletedStatus {
case .completed, .failed, .canceled:
true
case .pending, .running, .waitingForUser:
false
}
guard receipt.ok,
receipt.jobID == jobID,
deletedStatusIsTerminal,
receipt.deletedMessageCount >= 0,
receipt.retryChildrenRelinked >= 0,
!receipt.stateRevision.isEmpty,
expectedResultPetID.map({ $0 == receipt.retainedResultPetID }) ?? true
else {
throw NSError(
domain: "AgentPetCompanion.GenerationHistoryDelete",
code: 1,
userInfo: [
NSLocalizedDescriptionKey: APCLocalization.text(
.studioHistoryDeleteInvalidResponse
),
]
)
}
generationHistoryListSequence &+= 1
generationHistoryDetailSequence &+= 1
var localSnapshot = generationHistorySnapshot
localSnapshot.jobs.removeAll { $0.jobID == jobID }
generationHistorySnapshot = localSnapshot
generationHistoryHasLoaded = true
if deletedSelection {
clearGenerationHistorySelection()
}
await refreshGenerationHistory()
if deletedSelection, selectedGenerationHistoryJobID == nil {
let remainingJobs = generationHistorySnapshot.jobs
let preferredIndex = min(
deletedIndex ?? 0,
max(remainingJobs.count - 1, 0)
)
if remainingJobs.indices.contains(preferredIndex) {
let nextJobID = remainingJobs[preferredIndex].jobID
await selectGenerationHistoryJobAndWait(nextJobID)
}
}
statusText = APCLocalization.text(
generationHistoryLoadFailed
? .studioHistoryDeleteRefreshWarning
: .studioHistoryDeleteSuccess
)
return true
} catch {
let message = error.localizedDescription
generationHistoryMutationError = message
statusText = APCLocalization.format(
.studioHistoryDeleteFailedFormat,
message
)
diagnostics.logFailure(
error,
category: "generation",
event: "generation_history_delete_failed",
throttleKey: "generation_history_delete_failed",
minimumInterval: 5
)
return false
}
}
@discardableResult
func showSelectedGenerationHistoryResultPetInLibrary() async -> Bool {
guard let pet = selectedGenerationHistoryResultPet else {
statusText = APCLocalization.text(.studioHistoryResultPetMissing)
return false
}
guard await activatePetAndWait(pet) else { return false }
selection = .library
return true
}
private func generationHistoryDraft(
from detail: GenerationStudioHistoryDetail
) -> GenerationForm? {
guard let description = detail.description,
let style = detail.style,
let quality = detail.quality,
quality.isStudioSupported,
GenerationPromptPolicy.isValid(description)
else { return nil }
return GenerationForm(
description: GenerationPromptPolicy.truncate(description),
style: style,
quality: quality,
referenceImages: []
)
}
private func applyGenerationHistoryDraft(
_ form: GenerationForm,
referenceReselectionCount: Int
) {
descriptionText = form.description
selectedStyle = StylePreset(rawValue: form.style) ?? .unspecified
selectedQuality = form.quality
referenceImages = []
self.referenceReselectionCount = min(
MakerReferenceImagePolicy.maximumCount,
max(0, referenceReselectionCount)
)
reselectedReferenceImagePaths.removeAll()
referenceImageIssue = self.referenceReselectionCount > 0
? .reselectionRequired(self.referenceReselectionCount)
: nil
}
func openGenerationHistorySession() {
guard generationHistoryDetail?.capabilities?.canOpenSession == true,
let session = generationHistoryDetail?.session,
session.availability == .available,
session.canOpen,
let url = AgentSessionDeepLink.url(
source: .codex,
sessionID: session.routableSessionID
)
else {
statusText = APCLocalization.text(.studioHistorySessionUnavailable)
return
}
Task { [weak self] in
guard let self else { return }
let outcome = await agentSessionRouteOpener(.url(url))
statusText = outcome.didOpen
? APCLocalization.text(.studioHistorySessionOpened)
: APCLocalization.text(.studioHistorySessionOpenFailed)
}
}
func resumeSelectedGenerationHistory() {
guard canResumeSelectedGenerationHistory,
let jobID = generationHistoryDetail?.jobID
else {
statusText = generationSession.isActive
? generationStateTitle
: APCLocalization.text(.studioResumeUnavailable)
return
}
recordMakerUserMutation()
let instruction = generationReplyText.trimmingCharacters(in: .whitespacesAndNewlines)
statusText = APCLocalization.text(.studioResumeStarting)
Task {
do {
var parameters: [String: Any] = [
"job_id": jobID,
"request_id": UUID().uuidString,
]
if !instruction.isEmpty {
parameters["instruction"] = instruction
}
let result = try await requestPetCore(
method: "generation.resume",
params: parameters
)
guard let object = result as? [String: Any],
object["job_id"] as? String == jobID
else {
throw PetCoreClientError.invalidResponse
}
// The resumed job is now the authoritative latest job. Read
// its complete recovery projection so selecting an older
// history row replaces the currently displayed terminal job
// without inventing a form or dropping its message history.
let latestResult = try await requestPetCore(method: "generation.latest")
let data = try JSONSerialization.data(withJSONObject: latestResult)
let snapshot = try JSONDecoder().decode(
LatestGenerationSessionSnapshot.self,
from: data
)
guard snapshot.jobID == jobID,
let restore = GenerationSessionRestore(snapshot: snapshot)
else {
throw PetCoreClientError.invalidResponse
}
let sanitizedRestore = sanitizedGenerationRestore(restore)
_ = reduceGeneration(.restore(sanitizedRestore))
applyRestoredGenerationForm(
sanitizedRestore.submittedForm,
referenceReselectionCount: sanitizedRestore.referenceReselectionCount
)
generationReplyText = ""
selection = .maker
statusText = APCLocalization.text(.studioResumeAccepted)
await refreshGenerationHistory()
await loadGenerationHistoryDetail(jobID: jobID)
await loadGenerationHistoryMessages(jobID: jobID)
} catch {
statusText = APCLocalization.format(
.studioResumeFailedFormat,
error.localizedDescription
)
}
}
}
func sendSelectedGenerationHistoryReply() {
let content = generationReplyText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty,
let detail = selectedGenerationHistoryDetail,
detail.capabilities?.canReply == true,
let jobID = detail.jobID
else { return }
generationReplyText = ""
Task {
do {
_ = try await requestPetCore(
method: "generation.reply",
params: [
"job_id": jobID,
"content": content,
"request_id": UUID().uuidString,
]
)
await refreshGenerationHistory()
await loadGenerationHistoryDetail(jobID: jobID)
await loadGenerationHistoryMessages(jobID: jobID)
await refreshGenerationSessionAfterExternalAction(jobID: jobID)
} catch {
generationReplyText = content
statusText = "发送失败:\(error.localizedDescription)"
}
}
}
func cancelSelectedGenerationHistory() {
guard let detail = selectedGenerationHistoryDetail,
detail.capabilities?.canCancel == true,
let jobID = detail.jobID
else { return }
statusText = "正在取消生成"
Task {
do {
_ = try await requestPetCore(
method: "generation.cancel",
params: ["job_id": jobID],
timeout: .seconds(15)
)
await refreshGenerationHistory()
await loadGenerationHistoryDetail(jobID: jobID)
await loadGenerationHistoryMessages(jobID: jobID)
await refreshGenerationSessionAfterExternalAction(jobID: jobID)
} catch {
statusText = "取消失败:\(error.localizedDescription)"
}
}
}
private func refreshGenerationSessionAfterExternalAction(jobID: String) async {
do {
let result = try await requestPetCore(method: "generation.latest")
let data = try JSONSerialization.data(withJSONObject: result)
let snapshot = try JSONDecoder().decode(
LatestGenerationSessionSnapshot.self,
from: data
)
guard snapshot.jobID == jobID,
let restore = GenerationSessionRestore(snapshot: snapshot)
else { return }
_ = reduceGeneration(.restore(sanitizedGenerationRestore(restore)))
} catch {
diagnostics.logFailure(
error,
category: "generation",
event: "generation_selected_action_refresh_failed",
throttleKey: "generation_selected_action_refresh_failed",
minimumInterval: 5
)
}
}
func fetchPetHistory(
for pet: PetSummary,
limit: Int = 16
) async throws -> PetHistorySnapshot {
let boundedLimit = min(max(limit, 1), 32)
let result = try await requestPetCore(
method: "pet.history",
params: ["pet_id": pet.id, "limit": boundedLimit]
)
let data = try JSONSerialization.data(withJSONObject: result)
return try JSONDecoder().decode(PetHistorySnapshot.self, from: data)
}
func openGenerationHistory(for pet: PetSummary) {
guard !generationSession.isActive else {
statusText = "请先完成或取消当前 AI 制作任务,再查看其他制作记录"
return
}
recordMakerUserMutation()
statusText = "正在打开 \(pet.name) 的生成会话"
Task {
do {
let history = try await fetchGenerationHistory(for: pet)
guard history.found, let jobID = history.jobId else {
statusText = "\(pet.name) 没有关联的 App 内制作记录"
return
}
guard !generationSession.isActive else {
statusText = "当前 AI 制作任务已开始,未打开其他制作记录"
return
}
let restoredState = restoredGenerationState(
status: history.status,
messages: history.messages
)
let restore = GenerationSessionRestore(
state: restoredState,
jobID: jobID,
submittedForm: history.form,
messages: history.messages,
progress: history.messages.last?.progress ?? (restoredState.isTerminal ? 1 : 0),
messageRevision: "",
operation: history.operation ?? .create,
resultPetID: history.resultPetId,
baselineRevisionID: history.baselineRevisionID,
resultRevisionID: history.revisionId,
validationSummary: history.validationSummary,
referenceReselectionCount: history.referenceReselectionCount
)
let sanitizedRestore = sanitizedGenerationRestore(restore)
_ = reduceGeneration(.restore(sanitizedRestore))
applyRestoredGenerationForm(
sanitizedRestore.submittedForm,
referenceReselectionCount: sanitizedRestore.referenceReselectionCount
)
generationReplyText = ""
selection = .maker
statusText = "已打开 \(pet.name) 的生成会话"
} catch {
statusText = "打开生成会话失败:\(error.localizedDescription)"
}
}
}
@discardableResult
func reduceGeneration(
_ action: GenerationSessionAction
) -> GenerationSessionEffects {
var next = generationSession
let effects = next.reduce(action)
if generationSession != next {
generationSession = next
}
if effects.contains(.stopMessageStream) {
generationMessagesTask?.cancel()
}
if effects.contains(.startMessageStream), let jobID = generationSession.jobID {
startGenerationMessageStream(jobID: jobID)
}
return effects
}
private func restoredGenerationState(
status: GenerationJobHistoryStatus?,
messages: [GenerationMessage]
) -> GenerationSessionState {
if let status {
return switch status {
case .pending: .starting
case .running: .running
case .waitingForUser: .waitingForInput
case .completed: .succeeded
case .failed: .failed
case .canceled: .cancelled
}
}
if GenerationConversation.needsUserInput(messages) {
return .waitingForInput
}
if GenerationConversation.succeeded(messages) {
return .succeeded
}
if GenerationConversation.cancelled(messages) {
return .cancelled
}
if GenerationConversation.failed(messages) {
return .failed
}
return .idle
}
func updateBehavior(_ next: BehaviorSettings) {
let patch = BehaviorSettingsPatch(from: behavior, to: next)
guard !patch.isEmpty else { return }
applyBehaviorProjection(next)
behaviorMutationSequence &+= 1
enqueueBehaviorPatch(
patch,
mutationSequence: behaviorMutationSequence
)
}
func waitForBehaviorPersistence() async {
_ = await behaviorMutationTask?.value
}
private func enqueueBehaviorPatch(
_ patch: BehaviorSettingsPatch,
mutationSequence: UInt64
) {
let predecessor = behaviorMutationTask
pendingBehaviorMutationCount += 1
behaviorMutationTask = Task { [weak self] in
_ = await predecessor?.value
guard let self else { return }
await persistBehaviorPatch(
patch,
mutationSequence: mutationSequence
)
pendingBehaviorMutationCount = max(
0,
pendingBehaviorMutationCount - 1
)
}
}
private func persistBehaviorPatch(
_ patch: BehaviorSettingsPatch,
mutationSequence: UInt64
) async {
for attempt in 0..<2 {
do {
let data = try JSONEncoder().encode(patch)
let changes = try JSONSerialization.jsonObject(with: data)
let result = try await requestPetCore(
method: "behavior.patch",
params: [
"expected_revision": behaviorRevision,
"changes": changes
]
)
let resultData = try JSONSerialization.data(withJSONObject: result)
let updated = try JSONDecoder().decode(
VersionedBehaviorSettings.self,
from: resultData
)
behaviorRevision = updated.revision
authoritativeBehavior = updated.behavior
if mutationSequence == behaviorMutationSequence {
applyBehaviorProjection(updated.behavior)
}
statusText = "设置已保存"
return
} catch let error as PetCoreClientError
where attempt == 0
&& error.rpcMessage?.contains("behavior revision conflict") == true {
do {
try await refreshSnapshot()
} catch {
statusText = "设置冲突且刷新失败:\(error.localizedDescription)"
if mutationSequence == behaviorMutationSequence {
applyBehaviorProjection(authoritativeBehavior)
}
return
}
} catch {
statusText = "设置保存失败:\(error.localizedDescription)"
do {
try await refreshSnapshot()
} catch {}
if mutationSequence == behaviorMutationSequence {
applyBehaviorProjection(authoritativeBehavior)
}
return
}
}
}
private func applyBehaviorProjection(_ next: BehaviorSettings) {
let appearanceChanged = behavior.appearanceTheme != next.appearanceTheme
let languageChanged = behavior.interfaceLanguage != next.interfaceLanguage
let groupSessionsByAgentChanged = behavior.groupSessionsByAgent != next.groupSessionsByAgent
let sessionGroupDisplayChanged = behavior.sessionGroupDisplay != next.sessionGroupDisplay
// A different text tier changes measured bubble heights, so the panel
// has to be resized rather than left holding stale geometry.
let bubbleFontScaleChanged = behavior.bubbleFontScale != next.bubbleFontScale
behavior = next
if appearanceChanged {
applyCurrentAppearance()
}
if languageChanged {
applyCurrentLanguage()
}
if groupSessionsByAgentChanged || sessionGroupDisplayChanged {
overlayAgentGroupExpansionOverrides.removeAll()
overlayStandaloneStackExpansionOverride = nil
overlayStandaloneStackDisclosureDirection = .expanding
}
if groupSessionsByAgentChanged || sessionGroupDisplayChanged || bubbleFontScaleChanged {
overlayController.updateLayout()
}
syncOverlayVisibilityForBehavior()
}
private func syncOverlayVisibilityForBehavior() {
let nextVisibility = OverlayVisibility(
petVisible: behavior.enabled,
statusBubbleVisible: behavior.enabled
&& behavior.statusBubble
&& (!activeAgentSessions.isEmpty || (!behavior.autoHide && activeAgentState == nil))
)
let visibilityChanged = overlayVisibility != nextVisibility
if visibilityChanged {
overlayVisibility = nextVisibility
}
let petVisibilityChanged = overlayVisible != nextVisibility.petVisible
if petVisibilityChanged {
overlayVisible = nextVisibility.petVisible
overlayController.setVisible(nextVisibility.petVisible)
} else if visibilityChanged {
overlayController.updateLayout()
}
}
private func applyCurrentAppearance() {
applicationAppearanceApplier(behavior.appearanceTheme)
overlayController.updateAppearance(behavior.appearanceTheme)
}
private func applyCurrentLanguage() {
applicationLanguageApplier(behavior.interfaceLanguage)
}
private func applyCurrentPresentation() {
applyCurrentLanguage()
applyCurrentAppearance()
}
func setSource(_ source: AgentSource, enabled: Bool) {
var next = behavior
next.sources[source] = enabled
updateBehavior(next)
}
func setEvent(_ event: AgentEventKind, enabled: Bool) {
var next = behavior
next.events[event] = enabled
updateBehavior(next)
}
func setAttentionPreset(_ preset: AttentionPreset) {
guard preset != .custom else { return }
updateBehavior(behavior.applyingAttentionPreset(preset))
}
func activatePet(_ pet: PetSummary) {
Task {
_ = await activatePetAndWait(pet)
}
}
/// Shared activation path for the Library, Maker result, and onboarding.
/// Callers that must sequence another durable mutation can await the real
/// PetCore activation result instead of inferring success from local state.
@discardableResult
func activatePetAndWait(_ pet: PetSummary) async -> Bool {
if pets.first(where: { $0.id == pet.id })?.active == true || pet.active {
return true
}
guard !petOperationIDs.contains(pet.id) else { return false }
petOperationIDs.insert(pet.id)
statusText = "正在启用 \(pet.name)"
defer { petOperationIDs.remove(pet.id) }
return await finishPetActivation(
pet,
activate: {
_ = try await self.requestPetCore(
method: "pet.activate",
params: ["id": pet.id]
)
},
refreshSnapshot: { try await self.refreshSnapshot() },
recoverSnapshot: { await self.refresh() }
)
}
@discardableResult
func finishPetActivation(
_ pet: PetSummary,
activate: @MainActor () async throws -> Void,
refreshSnapshot: @MainActor () async throws -> Void,
recoverSnapshot: @MainActor () async -> Void
) async -> Bool {
do {
try await activate()
} catch {
statusText = "启用失败:\(error.localizedDescription)"
await recoverSnapshot()
return false
}
// A successful pet.activate response means PetCore has committed the
// exclusive active-pet transaction. Reflect that committed result
// before requesting the broader snapshot so a transient refresh
// failure cannot put the Enable button back and invite another click.
applyCommittedPetActivation(pet.id)
do {
try await refreshSnapshot()
statusText = "已启用 \(pet.name)"
} catch {
statusText = "已启用 \(pet.name),但状态刷新失败:\(error.localizedDescription)"
await recoverSnapshot()
}
return true
}
private func applyCommittedPetActivation(_ petID: String) {
guard pets.contains(where: { $0.id == petID }) else { return }
let nextPets = pets.map { pet in
var nextPet = pet
nextPet.active = pet.id == petID
return nextPet
}
guard nextPets != pets else { return }
pets = nextPets
}
func dismissOnboardingForCurrentLaunch() {
onboardingDismissedForCurrentLaunch = true
onboardingOperationFailure = nil
}
func retryOnboardingService() {
onboardingOperationFailure = nil
Task { [weak self] in
_ = await self?.recoverServiceConnection()
}
}
func restoreIncludedCompanions() {
Task {
_ = await restoreIncludedCompanionsAndWait()
}
}
/// Re-runs the signed inventory ensure operation without replacing an
/// existing same-ID pet, then requires an authoritative snapshot proving
/// both stable companion identities are selectable.
@discardableResult
func restoreIncludedCompanionsAndWait() async -> Bool {
guard includedCompanionRestoreState != .restoring,
onboardingAvailability == .ready
else {
if onboardingAvailability != .ready {
includedCompanionRestoreState = .failed
}
return false
}
includedCompanionRestoreState = .restoring
let seeded = await performBundledPetSeed()
guard seeded, await refresh() else {
includedCompanionRestoreState = .failed
return false
}
let restoredIDs = Set(onboardingCompanionCandidates.map(\.id))
guard restoredIDs == Set(PetSummary.includedCompanionIDs) else {
diagnostics.log(
.error,
category: "library",
event: "included_companion_restore_incomplete",
metadata: [
"restored_count": .integer(Int64(restoredIDs.count))
]
)
includedCompanionRestoreState = .failed
return false
}
includedCompanionRestoreState = .restored
statusText = "已恢复 App 随附桌宠"
return true
}
@discardableResult
func confirmOnboardingPet(_ candidate: PetSummary) async -> Bool {
guard !onboardingMutationInFlight,
onboardingAvailability == .ready,
let current = onboarding,
current.progress.stage == .choosePet,
let pet = onboardingCompanionCandidates.first(where: {
$0.id == candidate.id
}),
petAssetWarningIndex[pet.id] == nil
else {
if onboardingAvailability != .ready {
onboardingOperationFailure = .serviceUnavailable
}
return false
}
onboardingMutationInFlight = true
onboardingOperationFailure = nil
defer { onboardingMutationInFlight = false }
guard await activatePetAndWait(pet) else {
onboardingOperationFailure = .petActivation
return false
}
return await persistOnboardingTransition(
from: current,
to: .connectAgents
)
}
func petAssetRepairState(for petID: String) -> PetAssetRepairState {
petAssetRepairStates[petID] ?? .idle
}
func repairPetAssets(_ pet: PetSummary) {
Task {
_ = await repairPetAssetsAndWait(pet)
}
}
/// Forces package validation and atomic cover/frame re-extraction even
/// when PetCore has cached an unchanged invalid fingerprint.
@discardableResult
func repairPetAssetsAndWait(_ candidate: PetSummary) async -> Bool {
guard onboardingAvailability == .ready,
petAssetRepairState(for: candidate.id) != .repairing,
!petOperationIDs.contains(candidate.id),
let pet = pets.first(where: { $0.id == candidate.id })
else {
petAssetRepairStates[candidate.id] = .failed
return false
}
petAssetRepairStates[pet.id] = .repairing
petOperationIDs.insert(pet.id)
statusText = "正在重新校验并提取 \(pet.name) 的资源"
defer {
petOperationIDs.remove(pet.id)
}
do {
let value = try await requestPetCore(
method: "pet.assets.repair",
params: ["id": pet.id],
timeout: .seconds(120)
)
guard JSONSerialization.isValidJSONObject(value) else {
throw PetCoreClientError.invalidResponse
}
let outcome = try JSONDecoder().decode(
PetAssetRepairOutcome.self,
from: JSONSerialization.data(withJSONObject: value)
)
guard outcome.pet.id == pet.id,
outcome.warning?.petId == nil
|| outcome.warning?.petId == pet.id
else {
throw PetCoreClientError.invalidResponse
}
try await refreshSnapshot()
let repaired = outcome.warning == nil
&& petAssetWarningIndex[pet.id] == nil
petAssetRepairStates[pet.id] = repaired ? .repaired : .failed
statusText = repaired
? "已恢复 \(pet.name) 的预览资源"
: "\(pet.name) 的资源仍需处理"
return repaired
} catch {
petAssetRepairStates[pet.id] = .failed
statusText = "资源恢复失败:\(error.localizedDescription)"
diagnostics.logFailure(
error,
category: "library",
event: "pet_asset_repair_failed",
metadata: ["pet_id": .string(pet.id)]
)
_ = await refresh()
return false
}
}
@discardableResult
func advanceOnboarding(to nextStage: OnboardingStage) async -> Bool {
guard !onboardingMutationInFlight,
onboardingAvailability == .ready,
let current = onboarding,
current.progress.stage.canAdvance(to: nextStage)
else {
if onboardingAvailability != .ready {
onboardingOperationFailure = .serviceUnavailable
}
return false
}
onboardingMutationInFlight = true
onboardingOperationFailure = nil
defer { onboardingMutationInFlight = false }
return await persistOnboardingTransition(from: current, to: nextStage)
}
private func persistOnboardingTransition(
from current: VersionedOnboardingProgress,
to nextStage: OnboardingStage
) async -> Bool {
let nextProgress = OnboardingProgress(stage: nextStage)
do {
let progressData = try JSONEncoder().encode(nextProgress)
let progressObject = try JSONSerialization.jsonObject(with: progressData)
let result = try await requestPetCore(
method: "onboarding.update",
params: [
"expected_revision": current.revision,
"progress": progressObject,
]
)
let resultData = try JSONSerialization.data(withJSONObject: result)
let updated = try JSONDecoder().decode(
VersionedOnboardingProgress.self,
from: resultData
)
guard updated.progress == nextProgress,
let previousRevision = UInt64(current.revision),
let updatedRevision = UInt64(updated.revision),
updatedRevision > previousRevision
else {
onboardingOperationFailure = .requestRejected
return false
}
if nextStage == .completed {
// Completion atomically enables the pet in PetCore. Publish
// the terminal scene only with the authoritative behavior
// snapshot so the onboarding cannot disappear while the
// desktop pet still looks disabled.
do {
try await refreshSnapshot()
return onboarding?.progress.stage == .completed
} catch {
onboardingOperationFailure = .serviceUnavailable
return false
}
}
onboarding = updated
return true
} catch let error as PetCoreClientError
where error.rpcMessage?.contains("onboarding revision conflict") == true {
do {
try await refreshSnapshot()
if onboarding?.progress.stage == nextStage {
return true
}
} catch {
onboardingOperationFailure = .serviceUnavailable
return false
}
onboardingOperationFailure = .revisionConflict
return false
} catch {
onboardingOperationFailure = onboardingAvailability == .ready
? .requestRejected
: .serviceUnavailable
return false
}
}
func deletePet(_ pet: PetSummary) {
petOperationIDs.insert(pet.id)
statusText = "正在删除 \(pet.name)"
Task {
defer { petOperationIDs.remove(pet.id) }
do {
let result = try await requestPetCore(method: "pet.delete", params: ["id": pet.id])
try await refreshSnapshot()
let deletedAssets = (result as? [String: Any])?["deleted_assets"] as? Bool ?? true
statusText = deletedAssets
? "已删除 \(pet.name)"
: "已删除 \(pet.name),部分本地资源待下次清理"
} catch {
statusText = "删除失败:\(error.localizedDescription)"
await refresh()
}
}
}
func importPetpacks() {
let panel = NSOpenPanel()
panel.title = APCLocalization.text(.libraryImportTitle)
panel.prompt = APCLocalization.text(.libraryImportAction)
panel.message = APCLocalization.text(.libraryImportMessage)
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = true
panel.resolvesAliases = true
panel.canCreateDirectories = false
panel.treatsFilePackagesAsDirectories = false
panel.allowedContentTypes = [PetpackImportPolicy.contentType]
guard panel.runModal() == .OK else { return }
importPetpacks(urls: panel.urls)
}
func importPetpacks(urls: [URL]) {
guard petpackImportTask == nil else { return }
let urls = urls.filter { PetpackImportPolicy.acceptsFileName($0.lastPathComponent) }
guard !urls.isEmpty else {
let notice = setPetLibraryImportFailure(
importedCount: 0,
failures: [.invalidSelection]
)
statusText = notice.message.replacingOccurrences(of: "\n", with: " ")
return
}
petLibraryNotice = nil
isImportingPetpack = true
petpackImportProgress = PetLibraryImportProgress(
phase: .importing,
totalCount: urls.count,
completedCount: 0,
importedCount: 0,
currentFileName: urls.first?.lastPathComponent
)
statusText = urls.count == 1 ? "正在导入本 App .petpack" : "正在导入 \(urls.count) 个本 App .petpack"
petpackImportTask = Task {
defer {
petpackImportProgress = nil
isImportingPetpack = false
petpackImportTask = nil
}
var importedCount = 0
var failures: [PetLibraryImportFailure] = []
for (fileIndex, url) in urls.enumerated() {
petpackImportProgress = PetLibraryImportProgress(
phase: .importing,
totalCount: urls.count,
completedCount: fileIndex,
importedCount: importedCount,
currentFileName: url.lastPathComponent
)
do {
_ = try await requestPetCore(
method: "petpack.import",
params: ["path": url.standardizedFileURL.path]
)
importedCount += 1
} catch {
diagnostics.logFailure(
error,
category: "library",
event: "petpack_import_failed",
metadata: ["file_index": .integer(Int64(fileIndex))]
)
failures.append(.requestFailure(at: url, error: error))
}
petpackImportProgress = PetLibraryImportProgress(
phase: .importing,
totalCount: urls.count,
completedCount: fileIndex + 1,
importedCount: importedCount,
currentFileName: fileIndex + 1 < urls.count
? urls[fileIndex + 1].lastPathComponent
: nil
)
}
if importedCount > 0 {
petpackImportProgress = PetLibraryImportProgress(
phase: .refreshingLibrary,
totalCount: urls.count,
completedCount: urls.count,
importedCount: importedCount,
currentFileName: nil
)
do {
try await refreshSnapshot()
selection = .library
} catch {
await refresh()
}
}
if failures.isEmpty {
petLibraryNotice = .importSuccess(importedCount: importedCount)
statusText = importedCount == 1 ? "已导入本 App .petpack" : "已导入 \(importedCount) 个本 App .petpack"
} else if importedCount > 0 {
let notice = setPetLibraryImportFailure(
importedCount: importedCount,
failures: failures
)
statusText = notice.message.replacingOccurrences(of: "\n", with: " ")
} else {
let notice = setPetLibraryImportFailure(importedCount: 0, failures: failures)
statusText = notice.message.replacingOccurrences(of: "\n", with: " ")
}
}
}
func waitForPetpackImport() async {
_ = await petpackImportTask?.value
}
func dismissPetLibraryNotice() {
petLibraryNotice = nil
}
@discardableResult
func setPetLibraryImportFailure(
importedCount: Int,
failures: [PetLibraryImportFailure]
) -> PetLibraryNotice {
let notice = PetLibraryNotice.importFailure(
importedCount: importedCount,
failures: failures
)
petLibraryNotice = notice
return notice
}
func exportPet(_ pet: PetSummary) {
let panel = NSSavePanel()
panel.title = APCLocalization.text(.libraryExportAction)
panel.prompt = APCLocalization.text(.libraryExportAction)
panel.nameFieldStringValue = "\(safeExportName(pet.name)).petpack"
panel.canCreateDirectories = true
panel.allowedContentTypes = [PetpackImportPolicy.contentType]
guard panel.runModal() == .OK, let destinationURL = panel.url else { return }
petOperationIDs.insert(pet.id)
statusText = "正在校验并导出 \(pet.name)"
Task {
defer { petOperationIDs.remove(pet.id) }
do {
_ = try await requestPetCore(
method: "petpack.export",
params: [
"id": pet.id,
"path": destinationURL.standardizedFileURL.path
]
)
statusText = "已导出 \(destinationURL.lastPathComponent)"
} catch {
statusText = "导出失败:\(error.localizedDescription)"
}
}
}
func prepareDiagnosticsExport() {
guard diagnosticsExportState.primaryAction == .prepare else { return }
let environment = AppDiagnosticEnvironment.capture(store: self)
diagnosticsExportState = .exporting
statusText = "正在打包诊断日志"
diagnostics.log(.notice, category: "diagnostics", event: "diagnostics_export_started")
Task { @MainActor [weak self] in
guard let self else { return }
await self.performDiagnosticsArchivePreparation(environment: environment)
}
}
func savePreparedDiagnosticsArchive() {
guard diagnosticsExportState.primaryAction == .save,
let archive = diagnosticsExportState.preparedArchive
else { return }
let panel = NSSavePanel()
panel.title = APCLocalization.text(.diagnosticsPackageTitle)
panel.prompt = APCLocalization.text(.diagnosticsLogDownload)
panel.message = APCLocalization.text(.diagnosticsPrivacy)
panel.nameFieldStringValue = archive.suggestedFileName
panel.canCreateDirectories = true
panel.isExtensionHidden = false
panel.allowedContentTypes = [.zip]
guard panel.runModal() == .OK, let destinationURL = panel.url else {
diagnostics.log(
.debug,
category: "diagnostics",
event: "diagnostics_archive_save_cancelled"
)
return
}
guard destinationURL.standardizedFileURL != archive.stagedURL.standardizedFileURL else {
let message = "日志保存位置无效,请选择其他位置"
diagnosticsExportState = .saveFailed(archive, message)
statusText = message
return
}
diagnosticsExportState = .saving(archive)
statusText = "正在保存诊断日志"
diagnostics.log(
.notice,
category: "diagnostics",
event: "diagnostics_archive_save_started"
)
Task { @MainActor [weak self] in
guard let self else { return }
await self.performDiagnosticsArchiveSave(archive, to: destinationURL)
}
}
static func defaultDiagnosticsArchiveName(date: Date = Date()) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "yyyyMMdd-HHmmss"
return "AgentPetCompanion-Diagnostics-\(formatter.string(from: date)).zip"
}
static func diagnosticsExportParameters(
environment: AppDiagnosticEnvironment
) throws -> [String: Any] {
["app_environment": try environment.jsonObject()]
}
static func validatedDiagnosticArchiveURL(
from result: Any,
homeURL: URL
) throws -> URL {
let decoded = try AppDiagnosticRPCExportResult.decode(result)
guard (1 ... 128).contains(decoded.fileCount),
decoded.archiveBytes > 0,
!decoded.fileName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else {
throw AppDiagnosticArchiveError.invalidArchive
}
return try AppDiagnosticArchiveSecurity.validateTemporaryArchive(
URL(fileURLWithPath: decoded.path),
homeURL: homeURL,
expectedFileName: decoded.fileName,
expectedBytes: decoded.archiveBytes
)
}
private func performDiagnosticsArchivePreparation(
environment: AppDiagnosticEnvironment
) async {
let homeURL = diagnostics.homeURL ?? AppDiagnosticPaths.defaultHomeURL()
let stagedArchive: URL
do {
let result = try await requestPetCore(
method: "diagnostics.export",
params: Self.diagnosticsExportParameters(environment: environment)
)
stagedArchive = try Self.validatedDiagnosticArchiveURL(
from: result,
homeURL: homeURL
)
diagnostics.log(
.info,
category: "diagnostics",
event: "diagnostics_archive_received"
)
} catch {
diagnostics.logFailure(
error,
category: "diagnostics",
event: "diagnostics_rpc_export_unavailable",
throttleKey: "diagnostics_rpc_export_unavailable",
minimumInterval: 5
)
do {
stagedArchive = try await AppDiagnosticOfflineExporter.makeArchive(
environment: environment,
homeURL: homeURL
)
diagnostics.log(
.notice,
category: "diagnostics",
event: "diagnostics_offline_archive_created"
)
} catch {
diagnostics.logFailure(
error,
category: "diagnostics",
event: "diagnostics_offline_export_failed"
)
let message = "日志导出失败,请稍后重试"
diagnosticsExportState = .failed(message)
statusText = message
return
}
}
let archive = PreparedDiagnosticsArchive(
stagedURL: stagedArchive,
suggestedFileName: Self.defaultDiagnosticsArchiveName()
)
diagnosticsExportState = .ready(archive)
statusText = "诊断日志包已就绪"
diagnostics.log(
.notice,
category: "diagnostics",
event: "diagnostics_archive_ready"
)
}
private func performDiagnosticsArchiveSave(
_ archive: PreparedDiagnosticsArchive,
to destinationURL: URL
) async {
do {
try await AppDiagnosticArchiveSecurity.install(archive.stagedURL, at: destinationURL)
try? FileManager.default.removeItem(at: archive.stagedURL)
let message = "已导出 \(destinationURL.lastPathComponent)"
diagnosticsExportState = .succeeded(message)
statusText = message
diagnostics.log(
.notice,
category: "diagnostics",
event: "diagnostics_export_succeeded"
)
} catch {
diagnostics.logFailure(
error,
category: "diagnostics",
event: "diagnostics_archive_install_failed"
)
let message = "日志导出失败,请检查目标位置后重试"
diagnosticsExportState = .saveFailed(archive, message)
statusText = message
}
}
func repairConnection(_ source: AgentSource) {
launchConnectionOperation(.init(kind: .repair, sources: [source]))
}
func refreshPortableMakerSkillStatus() async {
guard !portableMakerSkillOperation.isBusy else { return }
portableMakerSkillOperation = .checking
portableMakerSkillFailure = nil
defer { portableMakerSkillOperation = .idle }
do {
portableMakerSkillStatus = try await requestPortableMakerSkill(
method: "portable_skill.status"
)
} catch {
portableMakerSkillFailure = .load
}
}
func installPortableMakerSkill() async {
guard !portableMakerSkillOperation.isBusy else { return }
portableMakerSkillOperation = .installing
portableMakerSkillFailure = nil
defer { portableMakerSkillOperation = .idle }
do {
portableMakerSkillStatus = try await requestPortableMakerSkill(
method: "portable_skill.install"
)
} catch {
portableMakerSkillFailure = .install
}
}
func uninstallPortableMakerSkill() async {
guard !portableMakerSkillOperation.isBusy else { return }
portableMakerSkillOperation = .uninstalling
portableMakerSkillFailure = nil
defer { portableMakerSkillOperation = .idle }
do {
portableMakerSkillStatus = try await requestPortableMakerSkill(
method: "portable_skill.uninstall"
)
} catch {
portableMakerSkillFailure = .uninstall
}
}
private func requestPortableMakerSkill(
method: String
) async throws -> PortableMakerSkillStatus {
let result = try await requestPetCore(
method: method,
timeout: .seconds(180)
)
let data = try JSONSerialization.data(withJSONObject: result)
let status = try JSONDecoder().decode(PortableMakerSkillStatus.self, from: data)
guard status.schemaVersion == "apc.portable-skill-status.v1",
status.name == "agent-pet-maker",
status.targetDisplayPath == "~/agent/skills/agent-pet-maker"
else {
throw PetCoreClientError.invalidResponse
}
return status
}
func repairConnections(_ sources: [AgentSource]) {
launchConnectionOperation(.init(kind: .repair, sources: sources))
}
func uninstallConnection(_ source: AgentSource) {
launchConnectionOperation(.init(kind: .uninstall, sources: [source]))
}
func uninstallConnections(_ sources: [AgentSource]) {
launchConnectionOperation(.init(kind: .uninstall, sources: sources))
}
func checkConnection(_ source: AgentSource) {
launchConnectionOperation(.init(kind: .check, sources: [source]))
}
func checkConnections(_ sources: [AgentSource]) {
launchConnectionOperation(.init(kind: .check, sources: sources))
}
func checkAllConnections() {
launchConnectionOperation(.init(kind: .check, sources: AgentSource.allCases))
}
/// Requests one full four-Agent runtime check for this App session.
///
/// The Connections page may appear before the authoritative startup
/// snapshot or release connector convergence has finished. Keep the UI
/// responsive, publish the startup light projection immediately, and
/// begin the heavier host probes only after all four sources are loaded
/// and the serialized connection-operation gate is available.
func requestAutomaticConnectionCheckOnFirstPresentation() {
guard !automaticConnectionCheckRequested else { return }
automaticConnectionCheckRequested = true
automaticConnectionCheckTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
guard self != nil else { return }
if self?.hasLoadedAllConnectionSources == true,
self?.canStartConnectionOperation == true {
let alreadyCurrent = self?.hasCurrentRuntimeConnectionStatusForEverySource
== true
self?.automaticConnectionCheckTask = nil
if !alreadyCurrent {
self?.checkAllConnections()
}
return
}
try? await Task.sleep(for: .milliseconds(200))
}
self?.automaticConnectionCheckTask = nil
}
}
private var hasLoadedAllConnectionSources: Bool {
AgentSource.allCases.allSatisfy { source in
connections.contains(where: { $0.source == source })
}
}
private var hasCurrentRuntimeConnectionStatusForEverySource: Bool {
AgentSource.allCases.allSatisfy { source in
connections.first(where: { $0.source == source })?.checkMode == .runtime
}
}
static func connectionOperationParameters(
source: AgentSource? = nil
) -> [String: String] {
var params: [String: String] = [:]
if let source {
params["source"] = source.rawValue
}
return params
}
func sendConnectionTestEvent(_ source: AgentSource) {
launchConnectionOperation(.init(kind: .test, sources: [source]))
}
func retryConnectionOperation() {
guard let failure = connectionOperationState.failedOperation else { return }
launchConnectionOperation(failure.operation)
}
func dismissConnectionOperationNotice() {
guard !connectionOperationState.isRunning else { return }
connectionOperationState = .idle
}
private func launchConnectionOperation(_ operation: AgentConnectionOperation) {
guard productConvergenceTask == nil else { return }
guard let permit = connectionOperationGate.begin(operation) else { return }
connectionOperationState = .running(operation)
statusText = connectionOperationStartedStatus(operation)
Task { @MainActor [weak self] in
guard let self else { return }
do {
let completion = try await self.performConnectionOperation(operation)
self.connectionOperationGate.finish(permit)
self.connectionOperationState = .succeeded(operation)
self.statusText = completion
} catch {
self.connectionOperationGate.finish(permit)
let reason = Self.connectionOperationFailureReason(for: error)
self.diagnostics.log(
.error,
category: "connections",
event: "connection_operation_failed",
metadata: [
"operation": .string(operation.kind.rawValue),
"reason": .string(reason.rawValue),
"source_count": .integer(Int64(operation.sources.count))
]
)
self.connectionOperationState = .failed(.init(
operation: operation,
reason: reason
))
self.statusText = self.connectionOperationFailurePrefix(operation)
if operation.kind == .repair || operation.kind == .uninstall {
_ = await self.refresh()
}
}
}
}
private func performConnectionOperation(
_ operation: AgentConnectionOperation
) async throws -> String {
switch operation.kind {
case .check:
return try await performConnectionCheck(operation.sources)
case .test:
guard let source = operation.sources.first else {
throw AgentConnectionOperationExecutionError(.invalidRequest)
}
let result = try await requestPetCore(
method: "connections.test",
params: ["source": source.rawValue],
timeout: .seconds(3)
)
guard (result as? [String: Any])?["ok"] as? Bool == true else {
throw AgentConnectionOperationExecutionError(.rejected)
}
// The diagnostic event is intentionally excluded from ordinary
// snapshots and connection evidence. Waiting for a full refresh
// here adds no state and can turn a lightweight test into a
// service-recovery operation that makes the UI appear stuck.
return "\(source.title) 本地连接测试通过"
case .repair:
return try await performConnectionRepair(operation.sources)
case .uninstall:
return try await performConnectionUninstall(operation.sources)
}
}
private func performConnectionCheck(_ sources: [AgentSource]) async throws -> String {
if sources == AgentSource.allCases {
let result = try await requestPetCore(
method: "connections.check",
params: Self.connectionOperationParameters()
)
let data = try JSONSerialization.data(withJSONObject: result)
connections = try JSONDecoder().decode([AgentConnectionStatus].self, from: data)
sortConnections()
reconcileProductConvergenceConnectorAttention(
afterChecking: Set(sources)
)
return "连接检查完成"
}
for source in sources {
let result = try await requestPetCore(
method: "connections.check",
params: Self.connectionOperationParameters(source: source)
)
try updateConnectionStatus(from: result)
}
reconcileProductConvergenceConnectorAttention(
afterChecking: Set(sources)
)
return sources.count == 1
? "\(sources[0].title) 检查完成"
: "\(sources.count) 个 Agent 连接检查完成"
}
private func performConnectionRepair(_ sources: [AgentSource]) async throws -> String {
var repaired: [String] = []
var pending: [String] = []
var failed: [String] = []
for source in sources {
do {
let result = try await requestPetCore(
method: "connections.repair",
params: Self.connectionOperationParameters(source: source)
)
let status = try updateConnectionStatus(from: result)
if unresolvedConnectionItemCount(status) == 0 {
repaired.append(source.shortTitle)
} else {
pending.append(source.shortTitle)
}
} catch {
failed.append(source.shortTitle)
}
}
sortConnections()
if !failed.isEmpty {
throw AgentConnectionOperationExecutionError(.partialFailure)
}
return pending.isEmpty
? "连接修复完成:\(repaired.joined(separator: "、"))"
: "修复已执行,仍需处理:\(pending.joined(separator: "、"))"
}
private func performConnectionUninstall(_ sources: [AgentSource]) async throws -> String {
var uninstalled: [String] = []
var pending: [String] = []
var failed: [String] = []
for source in sources {
do {
let result = try await requestPetCore(
method: "connections.uninstall",
params: ["source": source.rawValue]
)
let status = try updateConnectionStatus(from: result)
if status.hasInstalledConnectorArtifacts {
pending.append(source.shortTitle)
} else {
uninstalled.append(source.shortTitle)
}
} catch {
failed.append(source.shortTitle)
}
}
sortConnections()
if !failed.isEmpty {
throw AgentConnectionOperationExecutionError(.partialFailure)
}
return pending.isEmpty
? "连接卸载完成:\(uninstalled.joined(separator: "、"))"
: "卸载已执行,仍需处理:\(pending.joined(separator: "、"))"
}
private func connectionOperationStartedStatus(
_ operation: AgentConnectionOperation
) -> String {
let names = operation.sources.map(\.shortTitle).joined(separator: "、")
return switch operation.kind {
case .check: "正在检查 \(names)"
case .test: "正在测试 \(names) 的 PetCore 通道"
case .repair: "正在修复 \(names)"
case .uninstall: "正在卸载 \(names)"
}
}
private func connectionOperationFailurePrefix(
_ operation: AgentConnectionOperation
) -> String {
switch operation.kind {
case .check: "连接检查失败"
case .test: "通道自检失败"
case .repair: "连接修复失败"
case .uninstall: "连接卸载失败"
}
}
static func connectionOperationFailureReason(
for error: Error
) -> AgentConnectionOperationFailureReason {
if let error = error as? AgentConnectionOperationExecutionError {
return error.reason
}
if let error = error as? PetCoreClientError {
return switch error {
case .socketPathTooLong, .connectFailed, .writeFailed:
.transportUnavailable
case .invalidResponse:
.invalidResponse
case .rpcError, .rpcErrorResponse:
.rejected
}
}
if error is PetCoreTransportError {
return .transportUnavailable
}
return .unknown
}
func toggleOverlay() {
var next = behavior
next.enabled.toggle()
diagnostics.log(
.info,
category: "overlay",
event: "overlay_enabled_changed",
metadata: ["enabled": .bool(next.enabled)]
)
updateBehavior(next)
}
func updateOverlayPlacement(frame: CGRect, visibleFrame: CGRect?) {
recordOverlayPanelFrame(frame, visibleFrame: visibleFrame)
ensureOverlayPetPosition(in: overlayScreenVisibleFrame)
}
func recordOverlayPanelFrame(_ frame: CGRect, visibleFrame: CGRect?) {
let nextVisibleFrame = visibleFrame ?? NSScreen.main?.visibleFrame ?? .zero
if !rect(overlayScreenFrame, nearlyEquals: frame) {
overlayScreenFrame = frame
}
if !rect(overlayScreenVisibleFrame, nearlyEquals: nextVisibleFrame) {
overlayScreenVisibleFrame = nextVisibleFrame
}
}
func moveOverlayPet(to proposedCenter: CGPoint, visibleFrame: CGRect?, commit: Bool = true) {
overlayPetDragPresentationCenter = nil
let targetScreen = screen(containing: proposedCenter)
?? screen(matchingVisibleFrame: visibleFrame)
?? screen(containing: overlayPetScreenCenter)
?? NSScreen.main
let targetVisibleFrame = targetScreen?.visibleFrame ?? visibleFrame ?? overlayScreenVisibleFrame
guard !targetVisibleFrame.isEmpty else {
overlayPetScreenCenter = proposedCenter
overlayPetPositionInitialized = true
return
}
let movementFrame = OverlayGeometry.petMovementFrame(
screenFrame: targetScreen?.frame ?? targetVisibleFrame,
visibleFrame: targetVisibleFrame
)
if !rect(
overlayScreenVisibleFrame,
nearlyEquals: targetVisibleFrame
) {
overlayScreenVisibleFrame = targetVisibleFrame
}
overlayPetScreenCenter = OverlayGeometry.clampedPetScreenCenter(
proposedCenter,
displayWidthPt: overlayDisplayWidthPt,
visibleFrame: movementFrame,
clickMenuEnabled: behavior.clickMenu,
petVisualEnvelope: overlayPetVisualEnvelope
)
overlayPetPositionInitialized = true
if commit {
diagnostics.log(
.info,
category: "overlay",
event: "overlay_position_committed",
metadata: ["committed": .bool(true)]
)
overlayController.updateLayout()
commitCurrentOverlayPlacement(
interactionID: UUID()
)
} else {
overlayController.updateLayoutDuringInteraction()
}
}
func beginOverlayPetDrag(interactionID: UUID) {
guard overlayDragInteractionID == nil else { return }
overlayLostMouseUpFallbackTask?.cancel()
overlayLostMouseUpFallbackTask = nil
overlayDragInteractionID = interactionID
if overlayPetDragInProgress != true {
overlayPetDragInProgress = true
overlayController.updateLayoutDuringInteraction()
overlayController.refreshPointerPassthrough()
}
}
/// Applies the direct-manipulation presentation position without changing
/// the persistent placement contract. The drag view supplies a hard-clamped
/// absolute anchor and coalesces high-frequency samples to display ticks.
func presentOverlayPetDrag(
at presentationCenter: CGPoint,
visibleFrame: CGRect?,
interactionID: UUID
) {
guard overlayDragInteractionID == interactionID else { return }
if overlayLostMouseUpFallbackTask != nil {
OverlayInteractionTelemetry.shared.fallback(
.fallbackSuppressed,
interactionID: interactionID
)
}
overlayLostMouseUpFallbackTask?.cancel()
overlayLostMouseUpFallbackTask = nil
if let visibleFrame,
!visibleFrame.isEmpty,
!rect(overlayScreenVisibleFrame, nearlyEquals: visibleFrame)
{
overlayScreenVisibleFrame = visibleFrame
}
overlayPetDragPresentationCenter = presentationCenter
overlayPetPositionInitialized = true
overlayController.presentPetDrag(
at: presentationCenter,
visibleFrame: visibleFrame ?? overlayScreenVisibleFrame
)
}
func commitOverlayPetDrag(
at presentationCenter: CGPoint,
visibleFrame: CGRect?,
interactionID: UUID
) {
guard overlayDragInteractionID == interactionID else { return }
let targetScreen = screen(matchingVisibleFrame: visibleFrame)
?? screen(containing: presentationCenter)
?? screen(containing: overlayPetScreenCenter)
?? NSScreen.main
let targetVisibleFrame = targetScreen?.visibleFrame
?? visibleFrame
?? overlayScreenVisibleFrame
guard !targetVisibleFrame.isEmpty else {
overlayPetScreenCenter = presentationCenter
overlayPetDragPresentationCenter = nil
overlayDragInteractionID = nil
overlayPetDragInProgress = false
commitCurrentOverlayPlacement(
interactionID: interactionID
)
return
}
let movementFrame = OverlayGeometry.petMovementFrame(
screenFrame: targetScreen?.frame ?? targetVisibleFrame,
visibleFrame: targetVisibleFrame
)
let targetDisplayWidthPt = pendingDisplayWidthPt
?? overlayDisplayWidthPt
var targetCenter = CGPoint(
x: OverlayPlacementCanonicalization.cgFloatCoordinate(
presentationCenter.x
),
y: OverlayPlacementCanonicalization.cgFloatCoordinate(
presentationCenter.y
)
)
if targetDisplayWidthPt != overlayDisplayWidthPt {
targetCenter = OverlayGeometry.bottomAnchoredCenter(
from: targetCenter,
currentDisplayWidthPt: overlayDisplayWidthPt,
proposedDisplayWidthPt: targetDisplayWidthPt
)
targetCenter = OverlayPetDragGeometry.clampedCenter(
targetCenter,
displayWidthPt: targetDisplayWidthPt,
visibleFrame: movementFrame,
clickMenuEnabled: behavior.clickMenu,
petVisualEnvelope: overlayPetVisualEnvelope
)
}
overlayPetScreenCenter = targetCenter
overlayDisplayWidthPt = targetDisplayWidthPt
pendingDisplayWidthPt = nil
overlayPetDragPresentationCenter = nil
overlayDragInteractionID = nil
overlayPetDragInProgress = false
overlayScreenVisibleFrame = targetVisibleFrame
overlayPetPositionInitialized = true
overlayController.updateDisplayWidth(targetDisplayWidthPt)
diagnostics.log(
.info,
category: "overlay",
event: "overlay_position_committed",
metadata: ["committed": .bool(true)]
)
commitCurrentOverlayPlacement(
interactionID: interactionID
)
}
func endOverlayPetDrag(interactionID: UUID?) {
if let interactionID,
overlayDragInteractionID != nil,
overlayDragInteractionID != interactionID {
return
}
if let interactionID, overlayLostMouseUpFallbackTask != nil {
OverlayInteractionTelemetry.shared.fallback(
.fallbackSuppressed,
interactionID: interactionID
)
}
overlayLostMouseUpFallbackTask?.cancel()
overlayLostMouseUpFallbackTask = nil
overlayDragInteractionID = nil
overlayController.endPetDragInteraction(interactionID)
if overlayPetDragInProgress {
overlayPetDragInProgress = false
overlayController.updateLayoutDuringInteraction()
overlayController.refreshPointerPassthrough()
}
}
func ensureOverlayPetPosition(in visibleFrame: CGRect) {
guard !visibleFrame.isEmpty else { return }
guard overlayPetDragPresentationCenter == nil else { return }
if overlayPetPositionInitialized {
let targetScreen = screen(matchingVisibleFrame: visibleFrame)
?? screen(containing: overlayPetScreenCenter)
let movementFrame = OverlayGeometry.petMovementFrame(
screenFrame: targetScreen?.frame ?? visibleFrame,
visibleFrame: targetScreen?.visibleFrame ?? visibleFrame
)
overlayPetScreenCenter = OverlayGeometry.clampedPetScreenCenter(
overlayPetScreenCenter,
displayWidthPt: overlayDisplayWidthPt,
visibleFrame: movementFrame,
clickMenuEnabled: behavior.clickMenu,
petVisualEnvelope: overlayPetVisualEnvelope
)
} else {
overlayPetScreenCenter = OverlayGeometry.defaultPetScreenCenter(
in: visibleFrame,
displayWidthPt: overlayDisplayWidthPt
)
overlayPetPositionInitialized = true
}
}
func previewOverlayDisplayWidthPt(_ proposed: CGFloat) {
let target = OverlayGeometry.clampedDisplayWidthPt(proposed)
pendingDisplayWidthPt = target
scheduleOverlayDisplayWidthCommit()
guard !overlayPetDragInProgress else { return }
let targetScreen = screen(containing: overlayPetScreenCenter)
?? NSScreen.main
let cadence = OverlayDisplayRefreshCadence.resolved(for: targetScreen)
overlayPlacementPreviewDriver.submit(
target,
targetDisplayID: cadence.displayID,
screen: targetScreen,
fallbackCadence: cadence
) { [weak self] target in
guard let self, !self.overlayPetDragInProgress else { return }
let center = self.previewCenter(forDisplayWidthPt: target)
self.overlayController.previewDisplayWidth(
target,
petScreenCenter: center
)
}
}
func commitOverlayDisplayWidthPt(_ proposed: CGFloat? = nil) {
if let proposed {
pendingDisplayWidthPt = OverlayGeometry.clampedDisplayWidthPt(
proposed
)
}
overlayDisplayWidthCommitTask?.cancel()
overlayDisplayWidthCommitTask = nil
overlayDisplayWidthCommitDeadline = nil
overlayPlacementPreviewDriver.cancelPending()
guard !overlayPetDragInProgress,
let target = pendingDisplayWidthPt else {
return
}
pendingDisplayWidthPt = nil
applyCommittedDisplayWidthPt(
target,
interactionID: UUID()
)
}
func resetOverlayDisplayWidthPt() {
previewOverlayDisplayWidthPt(
OverlayGeometry.defaultDisplayWidthPt
)
commitOverlayDisplayWidthPt(
OverlayGeometry.defaultDisplayWidthPt
)
}
private func scheduleOverlayDisplayWidthCommit() {
overlayDisplayWidthCommitDeadline =
ProcessInfo.processInfo.systemUptime + 0.150
guard overlayDisplayWidthCommitTask == nil else { return }
overlayDisplayWidthCommitTask = Task { @MainActor [weak self] in
while let self, !Task.isCancelled {
guard let deadline = self.overlayDisplayWidthCommitDeadline else {
self.overlayDisplayWidthCommitTask = nil
return
}
let delay = max(
0,
deadline - ProcessInfo.processInfo.systemUptime
)
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled else { return }
guard let currentDeadline = self.overlayDisplayWidthCommitDeadline else {
self.overlayDisplayWidthCommitTask = nil
return
}
guard currentDeadline
<= ProcessInfo.processInfo.systemUptime + 0.000_001 else {
continue
}
self.overlayDisplayWidthCommitTask = nil
self.overlayDisplayWidthCommitDeadline = nil
self.commitOverlayDisplayWidthPt()
return
}
}
}
private func previewCenter(forDisplayWidthPt displayWidthPt: CGFloat) -> CGPoint {
let proposed = OverlayGeometry.bottomAnchoredCenter(
from: overlayPetScreenCenter,
currentDisplayWidthPt: overlayDisplayWidthPt,
proposedDisplayWidthPt: displayWidthPt
)
let targetScreen = screen(containing: overlayPetScreenCenter)
?? NSScreen.main
let visibleFrame = targetScreen?.visibleFrame
?? overlayScreenVisibleFrame
guard !visibleFrame.isEmpty else { return proposed }
let movementFrame = OverlayGeometry.petMovementFrame(
screenFrame: targetScreen?.frame ?? visibleFrame,
visibleFrame: visibleFrame
)
return OverlayPetDragGeometry.clampedCenter(
proposed,
displayWidthPt: displayWidthPt,
visibleFrame: movementFrame,
clickMenuEnabled: behavior.clickMenu,
petVisualEnvelope: overlayPetVisualEnvelope
)
}
private func applyCommittedDisplayWidthPt(
_ proposed: CGFloat,
interactionID: UUID
) {
let target = OverlayGeometry.clampedDisplayWidthPt(proposed)
let targetCenter = previewCenter(forDisplayWidthPt: target)
let widthChanged = target != overlayDisplayWidthPt
let centerChanged = hypot(
targetCenter.x - overlayPetScreenCenter.x,
targetCenter.y - overlayPetScreenCenter.y
) > 0.01
overlayDisplayWidthPt = target
overlayPetScreenCenter = targetCenter
overlayPetPositionInitialized = true
overlayController.updateDisplayWidth(target)
guard widthChanged || centerChanged else { return }
diagnostics.log(
.info,
category: "overlay",
event: "overlay_display_width_committed",
metadata: ["display_width_pt": .double(Double(target))]
)
commitCurrentOverlayPlacement(
interactionID: interactionID
)
}
func updateOverlayLayout() {
overlayController.updateLayout()
}
func updateOverlayBubbleAnchorDirection(_ direction: OverlayBubbleAnchorDirection) {
guard overlayBubbleAnchorDirection != direction else { return }
overlayBubbleAnchorDirection = direction
}
func updateOverlayPetVisualEnvelope(
_ envelope: OverlayPetVisualEnvelope?,
petID: String,
semanticOwnerEntryID: String
) {
guard activePet?.id == petID else { return }
guard OverlayPetAnimationIdentity.stateEntryID(for: presentedActiveAgentState)
== semanticOwnerEntryID
else {
return
}
guard overlayPetVisualEnvelope != envelope else { return }
overlayPetVisualEnvelope = envelope
let visibleFrame = screen(containing: overlayPetScreenCenter)?.visibleFrame
?? overlayScreenVisibleFrame
ensureOverlayPetPosition(in: visibleFrame)
overlayController.updateLayoutDuringInteraction()
}
func updateOverlayPetFrameHitTest(
_ hitTest: OverlayPetFrameHitTest?,
petID: String,
semanticOwnerEntryID: String
) {
guard activePet?.id == petID else { return }
guard OverlayPetAnimationIdentity.stateEntryID(for: presentedActiveAgentState)
== semanticOwnerEntryID else {
return
}
if let projection = overlayPetFrameHitTestProjection,
projection.petID == petID,
projection.semanticOwnerEntryID == semanticOwnerEntryID,
projection.hitTest == hitTest {
return
}
overlayPetFrameHitTestProjection = OverlayPetFrameHitTestProjection(
hitTest: hitTest,
petID: petID,
semanticOwnerEntryID: semanticOwnerEntryID
)
// A frame can change under a stationary pointer. Re-evaluate the panel
// immediately so a newly transparent pixel never retains the window's
// mouse ownership until the next physical pointer event.
overlayController.refreshPointerPassthrough()
}
func toggleOverlayBubble() {
let wasDismissed = overlayBubbleDismissed
if overlayBubbleDismissed {
overlayBubbleDismissed = false
} else if !overlayAvailableBubbleContents.isEmpty {
overlayBubbleDismissed = true
}
if overlayBubbleDismissed != wasDismissed {
diagnostics.log(
.info,
category: "overlay",
event: "overlay_bubble_toggled",
metadata: ["collapsed": .bool(overlayBubbleDismissed)]
)
}
overlayController.updateLayout(
animateBubble: overlayBubbleDismissed != wasDismissed
)
}
/// Idempotently reveal the bubble without turning an already-visible
/// bubble into a collapsed one. Keyboard focus uses this path so focus
/// acquisition never hides the content it is about to enter.
func revealOverlayBubble() {
guard overlayBubbleDismissed, !overlayAvailableBubbleContents.isEmpty else {
overlayController.updateLayout()
return
}
overlayBubbleDismissed = false
diagnostics.log(
.info,
category: "overlay",
event: "overlay_bubble_revealed",
metadata: ["collapsed": .bool(false)]
)
overlayController.updateLayout(animateBubble: true)
}
func focusOverlayBubbleForKeyboardNavigation() {
guard canFocusOverlayBubbleForKeyboardNavigation else { return }
revealOverlayBubble()
overlayKeyboardFocusHandler(overlayController, .bubbleSessions)
}
func toggleOverlayAgentGroup(_ source: AgentSource) {
guard behavior.groupSessionsByAgent else { return }
overlayAgentGroupExpansionOverrides[source] = !overlayAgentGroupIsExpanded(source)
overlayController.updateLayout(animateBubble: true)
}
func toggleOverlayStandaloneStack() {
guard !behavior.groupSessionsByAgent,
overlayAvailableBubbleContents.reduce(0, {
$0 + $1.representedSessionCount
}) > 1
else { return }
let expands = !overlayStandaloneStackIsExpanded
overlayStandaloneStackDisclosureDirection = expands ? .expanding : .collapsing
overlayStandaloneStackExpansionOverride = expands
overlayController.updateLayout(animateBubble: true)
}
var overlayBubbleDisclosureAction: OverlayBubbleDisclosureAction? {
OverlayBubbleDisclosureAction.resolve(
groupSessionsByAgent: behavior.groupSessionsByAgent,
sessionCount: overlayBubbleSessionCount,
bubbleDismissed: overlayBubbleDismissed,
standaloneStackExpanded: overlayStandaloneStackIsExpanded,
standaloneStackDirection: overlayStandaloneStackDisclosureDirection
)
}
/// Moves the pet-side disclosure control by exactly one visual level.
/// A flat multi-session tray therefore travels through the folded card in
/// both directions: expanded -> folded -> hidden and hidden -> folded ->
/// expanded. Pet-body clicks continue to use `toggleOverlayBubble()` and
/// retain their direct visibility toggle.
func stepOverlayBubbleDisclosure() {
guard let action = overlayBubbleDisclosureAction else {
overlayController.updateLayout()
return
}
let wasDismissed = overlayBubbleDismissed
switch action {
case .revealBubble:
overlayBubbleDismissed = false
case .revealCollapsedStandaloneStack:
overlayStandaloneStackDisclosureDirection = .expanding
overlayStandaloneStackExpansionOverride = false
overlayBubbleDismissed = false
case .expandStandaloneStack:
overlayStandaloneStackDisclosureDirection = .expanding
overlayStandaloneStackExpansionOverride = true
case .collapseStandaloneStack:
overlayStandaloneStackDisclosureDirection = .collapsing
overlayStandaloneStackExpansionOverride = false
case .dismissBubble:
overlayBubbleDismissed = true
}
if overlayBubbleDismissed != wasDismissed {
diagnostics.log(
.info,
category: "overlay",
event: "overlay_bubble_toggled",
metadata: ["collapsed": .bool(overlayBubbleDismissed)]
)
}
overlayController.updateLayout(animateBubble: true)
}
private func reconcileOverlayStandaloneSessionOrder(
with states: [ActiveAgentState]
) {
struct Entry {
let id: String
let activationID: String
}
let entries = states.map { state in
let id = OverlaySessionContent.stableID(
source: state.source,
sessionID: state.sessionID ?? state.event.sessionID,
anonymousSessionAlias: state.anonymousSessionAlias,
fallbackEventID: state.event.id
)
// sessionActivatedAt is an epoch boundary, unlike the event ID or
// event timestamp, which can change on every thinking/tool update.
return Entry(
id: id,
activationID: state.sessionActivatedAt.map { "activation:\($0)" }
?? "legacy"
)
}
let promotions = entries.filter { entry in
overlayStandaloneSessionActivationIDs[entry.id] != entry.activationID
|| !overlayStandaloneSessionOrder.contains(entry.id)
}
let promotedIDs = Set(promotions.map(\.id))
if !promotedIDs.isEmpty {
overlayStandaloneSessionOrder.removeAll { promotedIDs.contains($0) }
// PetCore gives us attention/latest-first. Keep that same reading
// direction in the App: the folded foreground card is also the
// first card when the tray expands. A true activation epoch moves
// once to the front; ordinary event churn keeps the existing slot.
for entry in promotions.reversed() {
overlayStandaloneSessionOrder.insert(entry.id, at: 0)
overlayStandaloneSessionActivationIDs[entry.id] = entry.activationID
}
}
// A missing projected row may be a bounded/transient omission, not a
// closed session. Retain a small history so it can recover its slot.
let maximumRememberedSessions = 64
if overlayStandaloneSessionOrder.count > maximumRememberedSessions {
let removed = overlayStandaloneSessionOrder.dropFirst(maximumRememberedSessions)
overlayStandaloneSessionOrder = Array(
overlayStandaloneSessionOrder.prefix(maximumRememberedSessions)
)
for id in removed {
overlayStandaloneSessionActivationIDs[id] = nil
}
}
}
func dismissOverlayBubble(eventID: String) {
overlayDismissedBubbleEventIDs.insert(eventID)
overlayController.updateLayout(animateBubble: true)
}
func dismissOverlayBubble(eventIDs: [String]) {
overlayDismissedBubbleEventIDs.formUnion(eventIDs)
overlayController.updateLayout(animateBubble: true)
}
func dismissAllOverlayBubbles() {
overlayBubbleDismissed = true
overlayController.updateLayout(animateBubble: true)
}
func setOverlayPointerNearPet(_ value: Bool) {
if overlayPointerNearPet != value {
overlayPointerNearPet = value
overlayController.refreshPointerDrivenControlVisibility()
}
}
func refreshOverlayPointerState() {
overlayController.refreshPointerPassthrough()
}
func observeOverlayPrimaryButton(isDown: Bool) {
guard !isDown,
let interactionID = overlayDragInteractionID else {
if isDown {
overlayLostMouseUpFallbackTask?.cancel()
overlayLostMouseUpFallbackTask = nil
}
return
}
guard overlayLostMouseUpFallbackTask == nil else { return }
OverlayInteractionTelemetry.shared.fallback(
.fallbackArmed,
interactionID: interactionID
)
overlayLostMouseUpFallbackTask = Task { @MainActor [weak self] in
// Give the normal DragView mouseUp finalizer one main-runloop
// opportunity before treating this as lost pointer capture.
await Task.yield()
guard let self, !Task.isCancelled else { return }
self.overlayLostMouseUpFallbackTask = nil
guard self.overlayDragInteractionID == interactionID else { return }
OverlayInteractionTelemetry.shared.fallback(
.fallbackFired,
interactionID: interactionID
)
self.cancelOverlayPointerInteractions()
}
}
func cancelOverlayPointerInteractions() {
guard overlayPetDragInProgress else { return }
overlayLostMouseUpFallbackTask?.cancel()
overlayLostMouseUpFallbackTask = nil
let interactionID = overlayDragInteractionID
if let interactionID,
let interruptedDragCenter = overlayPetDragPresentationCenter {
commitOverlayPetDrag(
at: interruptedDragCenter,
visibleFrame: overlayScreenVisibleFrame,
interactionID: interactionID
)
} else {
endOverlayPetDrag(interactionID: interactionID)
if let interactionID {
OverlayInteractionTelemetry.shared.finish(
interactionID: interactionID,
result: .suppressed
)
}
}
}
private func rect(_ lhs: CGRect, nearlyEquals rhs: CGRect, tolerance: CGFloat = 0.5) -> Bool {
abs(lhs.origin.x - rhs.origin.x) <= tolerance
&& abs(lhs.origin.y - rhs.origin.y) <= tolerance
&& abs(lhs.size.width - rhs.size.width) <= tolerance
&& abs(lhs.size.height - rhs.size.height) <= tolerance
}
func chooseReferenceImages() {
guard !generationSession.isActive else {
statusText = "活动任务的参考图已冻结"
return
}
let panel = NSOpenPanel()
panel.title = APCLocalization.text(.studioReferencesPanelTitle)
panel.prompt = APCLocalization.text(.commonChoose)
panel.message = APCLocalization.text(.studioReferencesPanelMessage)
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.allowsMultipleSelection = true
panel.allowedContentTypes = [.png, .jpeg, .webP]
guard panel.runModal() == .OK else { return }
addReferenceImageURLs(panel.urls)
}
func addReferenceImageURLs(_ urls: [URL]) {
guard !generationSession.isActive else {
statusText = "活动任务的参考图已冻结"
return
}
recordMakerUserMutation()
let admission = MakerReferenceImagePolicy.admit(
existingPaths: referenceImages,
urls: urls
)
referenceImages.append(contentsOf: admission.acceptedPaths)
let reselectionFillCount = min(
referenceReselectionCount,
admission.acceptedPaths.count
)
if reselectionFillCount > 0 {
reselectedReferenceImagePaths.formUnion(
admission.acceptedPaths.prefix(reselectionFillCount)
)
referenceReselectionCount -= reselectionFillCount
}
refreshReferenceImageIssue(fallback: admission.issue)
if urls.isEmpty || admission.acceptedPaths.isEmpty && admission.issue != nil {
statusText = "请选择图片文件"
} else if admission.acceptedPaths.isEmpty {
statusText = "参考图已在列表中"
} else {
statusText = "已添加 \(admission.acceptedPaths.count) 张参考图"
}
}
func removeReferenceImage(_ path: String) {
guard !generationSession.isActive else {
statusText = "活动任务的参考图已冻结"
return
}
recordMakerUserMutation()
let removedReselection = reselectedReferenceImagePaths.remove(path) != nil
referenceImages.removeAll { $0 == path }
if removedReselection {
referenceReselectionCount = min(
MakerReferenceImagePolicy.maximumCount,
referenceReselectionCount + 1
)
}
refreshReferenceImageIssue()
statusText = "已移除参考图"
}
private static func safeMakerReferenceImagePath(_ url: URL) -> String? {
MakerReferenceImagePolicy.validatedPath(for: url)
}
private func safeExportName(_ name: String) -> String {
let illegalCharacters = CharacterSet(charactersIn: "/\\:?%*|\"<>")
let cleaned = name
.components(separatedBy: illegalCharacters)
.joined(separator: "-")
.trimmingCharacters(in: .whitespacesAndNewlines)
return cleaned.isEmpty ? "AgentPet" : cleaned
}
private func applyOverlayPlacement(
_ placement: OverlayPlacement,
remoteRevision: UInt64,
remoteIntent: OverlayPlacementRemoteIntent?
) {
guard let normalized = normalizedOverlayPlacement(placement) else {
return
}
if !overlayPlacementAuthority.bootstrapCompleted {
let journal = loadOverlayPlacementJournalOnce()
if let journal,
let recovered = normalizedOverlayPlacement(journal.placement) {
if remoteIntent != nil,
journal.baseRemoteRevision < remoteRevision {
// A newer explicit PetCore intent wins over work recovered
// from an older App process. Its consume commit atomically
// replaces the stale journal entry below.
} else if remoteIntent == nil,
OverlayPlacementCanonicalization.areEquivalent(
placement,
journal.placement
) {
_ = overlayPlacementAuthority.bootstrap(
normalized,
remoteRevision: remoteRevision
)
applyPresentedOverlayPlacement(normalized)
removeOverlayPlacementJournalIfMatching(journal)
if !OverlayPlacementCanonicalization.areEquivalent(
normalized,
placement
) {
commitCurrentOverlayPlacement(interactionID: UUID())
}
return
} else {
_ = overlayPlacementAuthority.bootstrap(
normalized,
remoteRevision: remoteRevision
)
applyPresentedOverlayPlacement(recovered)
let replay = overlayPlacementAuthority.commitLocal(
recovered,
interactionID: UUID()
)
enqueueOverlayPlacementCommit(replay)
return
}
}
_ = overlayPlacementAuthority.bootstrap(
normalized,
remoteRevision: remoteRevision
)
applyPresentedOverlayPlacement(normalized)
if !OverlayPlacementCanonicalization.areEquivalent(
normalized,
placement
) || remoteIntent != nil {
commitCurrentOverlayPlacement(interactionID: UUID())
}
return
}
guard overlayPlacementAuthority.allowsRemote(
normalized,
remoteRevision: remoteRevision,
intent: remoteIntent
) else {
return
}
_ = overlayPlacementAuthority.applyRemote(
normalized,
remoteRevision: remoteRevision,
intent: remoteIntent
)
applyPresentedOverlayPlacement(normalized)
if remoteIntent != nil {
commitCurrentOverlayPlacement(interactionID: UUID())
}
}
private func normalizedOverlayPlacement(
_ placement: OverlayPlacement
) -> OverlayPlacement? {
let persistedCenter = CGPoint(x: placement.x, y: placement.y)
let screen = screen(matchingDisplayID: placement.displayId)
?? screen(containing: persistedCenter)
?? NSScreen.main
?? NSScreen.screens.first
let visibleFrame = screen?.visibleFrame
?? (overlayScreenVisibleFrame.isEmpty ? .zero : overlayScreenVisibleFrame)
guard !visibleFrame.isEmpty else { return nil }
let targetDisplayWidthPt =
OverlayGeometry.resolvedInitialDisplayWidthPt(
persistedDisplayWidthPt: CGFloat(placement.displayWidthPt),
hasPersistedPosition: persistedCenter != .zero
)
let normalizedCenter: CGPoint
if persistedCenter == .zero {
normalizedCenter = OverlayGeometry.defaultPetScreenCenter(
in: visibleFrame,
displayWidthPt: targetDisplayWidthPt
)
} else {
let movementFrame = OverlayGeometry.petMovementFrame(
screenFrame: screen?.frame ?? visibleFrame,
visibleFrame: visibleFrame
)
normalizedCenter = OverlayGeometry.clampedPetScreenCenter(
persistedCenter,
displayWidthPt: targetDisplayWidthPt,
visibleFrame: movementFrame,
clickMenuEnabled: behavior.clickMenu,
petVisualEnvelope: overlayPetVisualEnvelope
)
}
return OverlayPlacement(
x: Double(normalizedCenter.x),
y: Double(normalizedCenter.y),
displayWidthPt: Double(targetDisplayWidthPt),
displayId: currentDisplayID(for: normalizedCenter)
)
}
private func applyPresentedOverlayPlacement(
_ placement: OverlayPlacement
) {
overlayDisplayWidthPt = CGFloat(placement.displayWidthPt)
overlayPetScreenCenter = CGPoint(x: placement.x, y: placement.y)
overlayPetDragPresentationCenter = nil
overlayPetPositionInitialized = true
if let screen = screen(matchingDisplayID: placement.displayId)
?? screen(containing: overlayPetScreenCenter) {
overlayScreenVisibleFrame = screen.visibleFrame
}
overlayController.updateDisplayWidth(overlayDisplayWidthPt)
}
private func commitCurrentOverlayPlacement(interactionID: UUID) {
let placement = currentOverlayPlacement()
if let pending = overlayPlacementAuthority.pending,
OverlayPlacementCanonicalization.areEquivalent(
pending.placement,
placement
) {
OverlayInteractionTelemetry.shared.finish(
interactionID: interactionID,
result: .success
)
return
}
let commit = overlayPlacementAuthority.commitLocal(
placement,
interactionID: interactionID
)
enqueueOverlayPlacementCommit(commit)
}
private func enqueueOverlayPlacementCommit(
_ commit: OverlayPlacementCommit
) {
OverlayInteractionTelemetry.shared.commitQueued(
interactionID: commit.interactionID
)
saveOverlayPlacementJournal(commit)
startOverlayPlacementSaveWorkerIfNeeded(reason: .newLocalCommit)
}
private func startOverlayPlacementSaveWorkerIfNeeded(
reason: OverlayPlacementSaveResumeReason
) {
guard overlayPlacementSaveTask == nil,
let pending = overlayPlacementAuthority.pending else { return }
if overlayPlacementExhaustedGeneration == pending.localRevision {
guard reason.mayResumeExhaustedGeneration else { return }
overlayPlacementExhaustedGeneration = nil
} else if reason == .newLocalCommit {
overlayPlacementExhaustedGeneration = nil
}
overlayPlacementSaveTask = Task { @MainActor [weak self] in
guard let self else { return }
workerLoop: while !Task.isCancelled,
let commit = self.overlayPlacementAuthority.pending {
let outcome = await self.saveOverlayPlacement(commit)
switch outcome {
case .converged, .superseded:
continue workerLoop
case let .exhausted(generation):
if self.overlayPlacementAuthority.pending?.localRevision
== generation {
self.overlayPlacementExhaustedGeneration =
generation
}
break workerLoop
case .cancelled:
break workerLoop
}
}
self.overlayPlacementSaveTask = nil
}
}
private func resumePendingOverlayPlacementSaveIfNeeded(
reason: OverlayPlacementSaveResumeReason
) {
guard overlayPlacementSaveTask == nil,
overlayPlacementAuthority.pending != nil else { return }
startOverlayPlacementSaveWorkerIfNeeded(reason: reason)
}
func retryPendingOverlayPlacementSave() {
resumePendingOverlayPlacementSaveIfNeeded(reason: .explicitRetry)
}
private func saveOverlayPlacement(
_ initialCommit: OverlayPlacementCommit
) async -> OverlayPlacementSaveRunOutcome {
let retryDelays: [Duration] = [
.zero,
.milliseconds(250),
.milliseconds(500),
.seconds(1),
.seconds(2),
]
let telemetryInteractionID = initialCommit.interactionID
let saveStartedAt = ProcessInfo.processInfo.systemUptime
func superseded() -> OverlayPlacementSaveRunOutcome {
OverlayInteractionTelemetry.shared.finish(
interactionID: telemetryInteractionID,
result: .stale
)
return .superseded
}
func cancelled() -> OverlayPlacementSaveRunOutcome {
OverlayInteractionTelemetry.shared.finish(
interactionID: telemetryInteractionID,
result: .suppressed
)
return .cancelled
}
var commit = initialCommit
for attempt in retryDelays.indices {
guard !Task.isCancelled else { return cancelled() }
guard overlayPlacementAuthority.pending?.localRevision
== commit.localRevision else { return superseded() }
let delay = retryDelays[attempt]
if delay != .zero {
do {
try await overlayPlacementRetrySleeper(delay)
} catch {
return cancelled()
}
}
guard !Task.isCancelled,
overlayPlacementAuthority.pending?.localRevision
== commit.localRevision else { return superseded() }
let attemptStartedAt = ProcessInfo.processInfo.systemUptime
do {
let data = try JSONEncoder().encode(commit.placement)
guard var object = try JSONSerialization.jsonObject(
with: data
) as? [String: Any] else {
throw PetCoreClientError.invalidResponse
}
object["expected_revision"] = String(commit.baseRemoteRevision)
OverlayInteractionTelemetry.shared.requestAttempt(
interactionID: telemetryInteractionID,
attempt: attempt + 1
)
let result = try await requestPetCore(
method: "overlay.placement.update",
params: object
)
let response = try OverlayPlacementUpdateResponse(result: result)
guard overlayPlacementAuthority.pending?.localRevision
== commit.localRevision else { return superseded() }
guard response.ok else {
guard response.conflict else {
throw PetCoreClientError.invalidResponse
}
OverlayInteractionTelemetry.shared.requestResult(
interactionID: telemetryInteractionID,
attempt: attempt + 1,
result: .conflict
)
if response.intent != nil {
guard let normalized = normalizedOverlayPlacement(
response.placement
),
let consume = overlayPlacementAuthority
.supersedeWithExplicitRemote(
normalized,
remoteRevision: response.placementRevision,
interactionID: UUID()
) else {
return superseded()
}
applyPresentedOverlayPlacement(normalized)
saveOverlayPlacementJournal(consume)
commit = consume
continue
}
let reconciliation = overlayPlacementAuthority
.reconcileOrdinaryConflict(
for: commit,
actualPlacement: response.placement,
remoteRevision: response.placementRevision
)
switch reconciliation {
case .acknowledged:
removeOverlayPlacementJournalIfMatching(
journalEntry(for: commit)
)
OverlayInteractionTelemetry.shared.finish(
interactionID: telemetryInteractionID,
result: .success
)
return .converged
case let .retry(rebased):
saveOverlayPlacementJournal(rebased)
commit = rebased
continue
case .stale:
removeOverlayPlacementJournalIfMatching(
journalEntry(for: commit)
)
return superseded()
}
}
guard response.intent == nil,
OverlayPlacementCanonicalization.areEquivalent(
response.placement,
commit.placement
) else {
throw PetCoreClientError.invalidResponse
}
OverlayInteractionTelemetry.shared.requestResult(
interactionID: telemetryInteractionID,
attempt: attempt + 1,
result: .success
)
let reconciliation = overlayPlacementAuthority.acknowledge(
commit,
remoteRevision: response.placementRevision
)
switch reconciliation {
case .acknowledged:
removeOverlayPlacementJournalIfMatching(
journalEntry(for: commit)
)
OverlayInteractionTelemetry.shared.finish(
interactionID: telemetryInteractionID,
result: .success
)
return .converged
case let .retry(rebased):
removeOverlayPlacementJournalIfMatching(
journalEntry(for: commit)
)
saveOverlayPlacementJournal(rebased)
commit = rebased
case .stale:
removeOverlayPlacementJournalIfMatching(
journalEntry(for: commit)
)
return superseded()
}
} catch {
OverlayInteractionTelemetry.shared.requestResult(
interactionID: telemetryInteractionID,
attempt: attempt + 1,
result: .transportFailure
)
overlayPlacementAuthority.failCommit(commit)
diagnostics.log(
.warning,
category: "overlay",
event: "placement_save_failed",
metadata: [
"attempt": .integer(Int64(attempt + 1)),
"duration_bucket": .string(
OverlayInteractionDurationBucket(
milliseconds: (
ProcessInfo.processInfo.systemUptime
- attemptStartedAt
) * 1_000
).rawValue
),
"generation_category": .string("current"),
"result_kind": .string("transport_or_protocol_failure"),
]
)
if attempt >= retryDelays.count - 1 {
statusText = "桌宠位置保存失败"
}
}
}
if Task.isCancelled { return cancelled() }
OverlayInteractionTelemetry.shared.finish(
interactionID: telemetryInteractionID,
result: .exhausted
)
diagnostics.log(
.warning,
category: "overlay",
event: "placement_save_exhausted",
metadata: [
"attempt": .integer(Int64(retryDelays.count)),
"duration_bucket": .string(
OverlayInteractionDurationBucket(
milliseconds: (
ProcessInfo.processInfo.systemUptime
- saveStartedAt
) * 1_000
).rawValue
),
"generation_category": .string("current"),
"result_kind": .string("exhausted"),
]
)
return .exhausted(generation: commit.localRevision)
}
private func loadOverlayPlacementJournalOnce()
-> OverlayPlacementJournalEntry? {
guard !overlayPlacementJournalDidLoad else { return nil }
overlayPlacementJournalDidLoad = true
do {
return try overlayPlacementJournalStore.load()
} catch {
logOverlayPlacementJournalFailure("load", error: error)
return nil
}
}
private func saveOverlayPlacementJournal(
_ commit: OverlayPlacementCommit
) {
do {
try overlayPlacementJournalStore.save(journalEntry(for: commit))
} catch {
logOverlayPlacementJournalFailure("save", error: error)
}
}
private func removeOverlayPlacementJournalIfMatching(
_ entry: OverlayPlacementJournalEntry
) {
do {
try overlayPlacementJournalStore.removeIfMatching(entry)
} catch {
logOverlayPlacementJournalFailure("remove", error: error)
}
}
private func journalEntry(
for commit: OverlayPlacementCommit
) -> OverlayPlacementJournalEntry {
OverlayPlacementJournalEntry(
interactionID: commit.interactionID,
localRevision: commit.localRevision,
placement: commit.placement,
baseRemoteRevision: commit.baseRemoteRevision
)
}
private func logOverlayPlacementJournalFailure(
_ operation: String,
error: Error
) {
diagnostics.log(
.warning,
category: "overlay",
event: "placement_journal_failed",
metadata: [
"operation": .string(operation),
"error": .string(String(describing: error)),
]
)
}
@discardableResult
private func updateConnectionStatus(from result: Any) throws -> AgentConnectionStatus {
let data = try JSONSerialization.data(withJSONObject: result)
let status = try JSONDecoder().decode(AgentConnectionStatus.self, from: data)
connections.removeAll { $0.source == status.source }
connections.append(status)
sortConnections()
return status
}
private func unresolvedConnectionItemCount(_ status: AgentConnectionStatus) -> Int {
status.blockingItems.count
}
private func reconcileProductConvergenceConnectorAttention(
afterChecking checkedSources: Set<AgentSource>
) {
guard productConvergenceTask == nil,
case let .needsAttention(.connectors(attentionIssues)) =
appUpdateConvergenceState
else { return }
let resolvedSources: Set<AgentSource> = Set(connections.lazy.compactMap {
status -> AgentSource? in
guard checkedSources.contains(status.source),
ProductConvergenceConnectionRecoveryPolicy.resolvesAttention(status)
else { return nil }
return status.source
})
guard !resolvedSources.isEmpty else { return }
let remainingIssues = attentionIssues.filter {
!resolvedSources.contains($0.source)
}
let fullyResolved = remainingIssues.isEmpty
appUpdateConvergenceState = fullyResolved
? .idle
: .needsAttention(.connectors(remainingIssues))
diagnostics.log(
.notice,
category: "update",
event: "product_convergence_attention_reconciled_by_runtime_check",
metadata: [
"resolved_sources": .string(
resolvedSources
.sorted { $0.rawValue < $1.rawValue }
.map { $0.rawValue }
.joined(separator: ",")
),
"remaining_sources": .string(
remainingIssues.map { $0.source.rawValue }.joined(separator: ",")
),
]
)
if fullyResolved {
scheduleProductConvergence(force: true)
}
}
private func sortConnections() {
connections.sort {
let lhs = AgentSource.allCases.firstIndex(of: $0.source) ?? 0
let rhs = AgentSource.allCases.firstIndex(of: $1.source) ?? 0
return lhs < rhs
}
}
func applyAuthoritativeConnectionSnapshot(_ snapshotConnections: [AgentConnectionStatus]) {
let sorted = snapshotConnections.sorted {
let lhs = AgentSource.allCases.firstIndex(of: $0.source) ?? 0
let rhs = AgentSource.allCases.firstIndex(of: $1.source) ?? 0
return lhs < rhs
}
if connections != sorted {
connections = sorted
}
}
private func currentOverlayPlacement() -> OverlayPlacement {
OverlayPlacement(
x: Double(overlayPetScreenCenter.x),
y: Double(overlayPetScreenCenter.y),
displayWidthPt: Double(overlayDisplayWidthPt),
displayId: currentDisplayID(for: overlayPetScreenCenter)
)
}
private func currentDisplayID(for point: CGPoint) -> String {
let screen = screen(containing: point) ?? NSScreen.main
let number = screen?.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber
return number?.stringValue ?? "main"
}
private func screen(containing point: CGPoint) -> NSScreen? {
NSScreen.screens.first { $0.frame.contains(point) }
}
private func screen(matchingDisplayID displayID: String) -> NSScreen? {
if displayID == "main" {
return NSScreen.main
}
return NSScreen.screens.first { screen in
let number = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber
return number?.stringValue == displayID
}
}
private func screen(matchingVisibleFrame visibleFrame: CGRect?) -> NSScreen? {
guard let visibleFrame, !visibleFrame.isEmpty else { return nil }
return NSScreen.screens.first { screen in
screen.visibleFrame == visibleFrame || screen.visibleFrame.intersects(visibleFrame)
}
}
private func requestPetCore(
method: String,
params: Any = [:],
timeout: Duration? = nil
) async throws -> Any {
let protectsHandoff = Self.isProtectedMutationRPC(method)
if protectsHandoff {
inFlightProtectedMutationCount += 1
}
defer {
if protectsHandoff {
inFlightProtectedMutationCount = max(
0,
inFlightProtectedMutationCount - 1
)
}
}
if let petCoreRequestOverride {
return try await petCoreRequestOverride(method, params, timeout)
}
let startedAt = Date()
do {
let paramsData = try JSONSerialization.data(withJSONObject: params)
let responseData = try await client.requestData(
method: method,
paramsJSONData: paramsData,
timeout: timeout
)
let result = try PetCoreClient.decodeResult(from: responseData)
if !Self.isPollingRPC(method) {
diagnostics.log(
Self.isUserMutationRPC(method) ? .info : .debug,
category: "rpc",
event: "rpc_succeeded",
metadata: [
"method": .string(method),
"duration_ms": .integer(Self.elapsedMilliseconds(since: startedAt))
]
)
}
return result
} catch {
diagnostics.logFailure(
error,
category: "rpc",
event: "rpc_failed",
metadata: [
"method": .string(method),
"duration_ms": .integer(Self.elapsedMilliseconds(since: startedAt))
],
throttleKey: "rpc_failed.\(method)",
minimumInterval: Self.isPollingRPC(method) ? 30 : 5
)
throw error
}
}
private static func elapsedMilliseconds(since date: Date) -> Int64 {
Int64(max(0, Date().timeIntervalSince(date) * 1_000).rounded())
}
private static func isUserMutationRPC(_ method: String) -> Bool {
if method == "generation.history.list" || method == "generation.history.detail" {
return false
}
return method.hasPrefix("generation.")
|| method.hasPrefix("pet.")
|| method.hasPrefix("petpack.")
|| method.hasPrefix("connections.")
|| method == "portable_skill.install"
|| method == "portable_skill.uninstall"
|| method == "behavior.patch"
|| method == "onboarding.update"
|| method == "overlay.placement.update"
|| method == "agent.session.acknowledge"
|| method == "diagnostics.export"
|| method == "product.convergence.update"
}
private static func isProtectedMutationRPC(_ method: String) -> Bool {
switch method {
case "behavior.patch",
"onboarding.update",
"overlay.placement.update",
"agent.session.acknowledge",
"pet.activate",
"pet.delete",
"pet.assets.repair",
"petpack.import",
"petpack.seed_bundled",
"generation.start",
"generation.retry",
"generation.resume",
"generation.edit",
"generation.reply",
"generation.cancel",
"generation.history.delete",
"connections.repair",
"connections.uninstall",
"connections.refresh_installed",
"portable_skill.install",
"portable_skill.uninstall",
"product.convergence.update":
true
default:
false
}
}
private static func isPollingRPC(_ method: String) -> Bool {
method == "state.wait" || method == "generation.messages.wait"
}
}
private struct OverlayPlacementUpdateResponse {
let ok: Bool
let conflict: Bool
let placement: OverlayPlacement
let placementRevision: UInt64
let intent: OverlayPlacementRemoteIntent?
init(result: Any) throws {
guard let object = result as? [String: Any],
let ok = object["ok"] as? Bool,
let encodedRevision = object["overlay_placement_revision"] as? String,
let placementRevision = OverlayPlacementRevisionCodec.parse(
encodedRevision
),
let placementObject = object["overlay_placement"] as? [String: Any],
object.keys.contains("overlay_placement_intent") else {
throw PetCoreClientError.invalidResponse
}
let placementData = try JSONSerialization.data(
withJSONObject: placementObject
)
placement = try JSONDecoder().decode(
OverlayPlacement.self,
from: placementData
)
self.placementRevision = placementRevision
self.ok = ok
conflict = object["conflict"] as? Bool ?? false
if object["overlay_placement_intent"] is NSNull {
intent = nil
} else if let encodedIntent = object["overlay_placement_intent"] as? String,
let decodedIntent = OverlayPlacementRemoteIntent(
rawValue: encodedIntent
) {
intent = decodedIntent
} else {
throw PetCoreClientError.invalidResponse
}
if ok {
guard !conflict,
object["revision"] is String else {
throw PetCoreClientError.invalidResponse
}
} else {
guard conflict,
object["revision"] == nil else {
throw PetCoreClientError.invalidResponse
}
}
}
}
private struct StateSnapshot: Codable {
var revision: String?
var changed: Bool?
var behavior: BehaviorSettings
var behaviorRevision: String?
var onboarding: VersionedOnboardingProgress?
var overlayPlacement: OverlayPlacement?
var overlayPlacementRevision: String
var overlayPlacementIntent: OverlayPlacementRemoteIntent?
var pets: [PetSummary]
var petAssetWarnings: [PetAssetWarning]?
var activeGeneration: ActiveGenerationSnapshot?
var activeAgentState: ActiveAgentState?
var activeAgentSessions: [ActiveAgentState]?
var activeAgentSessionsOmittedCount: Int?
var overlayVisibility: OverlayVisibility?
var events: [AgentEvent]
var recentEvents: [AgentEvent]?
var connections: [AgentConnectionStatus]
enum CodingKeys: String, CodingKey {
case revision
case changed
case behavior
case behaviorRevision = "behavior_revision"
case onboarding
case overlayPlacement = "overlay_placement"
case overlayPlacementRevision = "overlay_placement_revision"
case overlayPlacementIntent = "overlay_placement_intent"
case pets
case petAssetWarnings = "pet_asset_warnings"
case activeGeneration = "active_generation"
case activeAgentState = "active_agent_state"
case activeAgentSessions = "active_agent_sessions"
case activeAgentSessionsOmittedCount = "active_agent_sessions_omitted_count"
case overlayVisibility = "overlay_visibility"
case events
case recentEvents = "recent_events"
case connections
}
}
private struct GenerationMessagesSnapshot: Codable {
var revision: String?
var changed: Bool?
var heartbeatAt: String?
var messages: [GenerationMessage]
var resultPetID: String?
var revisionID: String?
var validationSummary: GenerationValidationSummary?
var resultMetadata: GenerationResultMetadata {
GenerationResultMetadata(
resultPetID: resultPetID,
revisionID: revisionID,
validationSummary: validationSummary
)
}
enum CodingKeys: String, CodingKey {
case revision
case changed
case heartbeatAt = "heartbeat_at"
case messages
case resultPetID = "result_pet_id"
case revisionID = "revision_id"
case validationSummary = "validation_summary"
}
}