release: prepare v0.3.0

This commit is contained in:
lilei committed 2026-08-09 17:18:27 +08:00
1 parent 9253de52f8
commit b8fd24c238
47 files changed
+2608 -387

No files matched your search

+11
View File
@@ -10,6 +10,8 @@ Use the `Added`, `Changed`, `Fixed`, `Deprecated`, `Removed`, and `Security` cat
## [Unreleased]
## [0.3.0] - 2026-08-09
### Added / 新增
- AI Pet Maker is now a persistent split-view session workspace: the single unfinished task stays pinned above terminal history, while the right side switches in place between an untimed draft and a ChatGPT-style, paged, user-visible timeline. Native Codex input requests, waiting or recoverable continuation, backend-authoritative duration, wake/restart refresh, and actionable macOS notifications are first-class App flows; ChatGPT conversation rendering is no longer a product dependency. / AI 宠物制作现改为持久化左右分栏会话工作台:唯一未终结任务固定置顶,右侧在不计时草稿与类似 ChatGPT、可分页的用户可见时间线之间原地切换。Codex 原生输入请求、等待或可恢复继续、后端权威时长、唤醒/重启刷新及可操作的 macOS 通知都成为 App 内正式流程,不再依赖 ChatGPT 对会话内容的呈现。
@@ -26,6 +28,7 @@ Use the `Added`, `Changed`, `Fixed`, `Deprecated`, `Removed`, and `Security` cat
### Changed / 变更
- Local validation now has a change-scoped pre-push gate and an explicit source-fingerprinted resume mode for full-gate diagnosis. Cheap localization parity fails before compiler-heavy suites, environment-mutating generation lifecycle tests run in isolated child processes instead of one poisonable global lock, and one content-verified interaction proof is reused across offline overlay and App assembly in the same validation run; uncached `test_all.sh` remains the CI/Release authority. / 本地验证现新增变更范围预推送门禁,以及用于完整门禁排错、按源码指纹恢复的显式模式。本地化一致性会在重编译测试前快速失败;会修改环境变量的制作生命周期测试改在隔离子进程中运行,不再依赖可能 poison 的全局锁;同一次验证中,经过内容核验的交互证明会由离线悬浮层与 App 组装复用;CI/Release 仍以无缓存的 `test_all.sh` 为权威入口。
- AI Pet Maker history details now open directly into the production conversation without repeating its brief as a separate title and summary. Session status, progress, elapsed time, and available actions now share one adaptive horizontal bar below the conversation; destructive deletion remains directly visible, while supplementary actions collapse into a More menu only when space is constrained. / AI 宠物制作历史详情现在会直接展示制作会话,不再将同一份需求重复渲染为独立标题和摘要;会话状态、进度、耗时与可用操作统一收拢到会话下方的自适应横向操作栏,删除操作始终直接可见,仅在空间不足时将补充操作折叠到“更多”菜单。
- Agent Connections now surfaces the automatic startup light check as **Basic check complete** or a specific actionable problem instead of collapsing every light result into **Not checked**. The first visit to the page in each App session requests one serialized full runtime check after the authoritative four-Agent snapshot and any release connector convergence are ready; fresh runtime results are reused, and **Check All** remains available for explicit retries. / Agent 连接现在会将启动时自动完成的轻量检查显示为“基础检查完成”或具体可处理问题,不再把所有轻量结果统一折叠成“未检查”。每次 App 会话首次进入该页面时,会等待四个 Agent 的权威快照及发行版连接器收敛就绪后,串行触发一次完整运行检测;已有的新鲜运行结果会直接复用,“全部检查”仍可用于显式重试。
- Removed the nonstructural separator lines between the Control Center titlebar and page content, between Pet Configuration's subpage switch and form, and around AI Pet Maker's header and split workspace. Existing spacing, pane sizing, scrolling, selection, and split dragging remain unchanged. / 移除控制中心标题栏与页面内容之间、“宠物配置”子页面切换与表单之间,以及“AI 宠物制作”页头与分栏工作区周围的非结构性分割线;现有间距、面板尺寸、滚动、选择与分栏拖拽行为保持不变。
@@ -86,6 +89,14 @@ Use the `Added`, `Changed`, `Fixed`, `Deprecated`, `Removed`, and `Security` cat
### Fixed / 修复
- Desktop pets that finish a `burst_then_idle` action now publish the settled idle frame's real visible bounds and Alpha click mask while retaining ownership under the originating semantic Agent event. Session bubbles therefore remain attached to the visible pet instead of overlapping its head, and transparent-pixel hit testing follows the frame actually on screen; late geometry from a previous semantic state is still rejected. / 桌宠完成 `burst_then_idle` 动作后,现在会在继续归属于原始 Agent 语义事件的同时,发布 settled idle 帧真实的可见包围盒与 Alpha 点击遮罩。会话气泡会继续贴合屏幕上实际可见的宠物,不再覆盖宠物头部,透明像素命中也会跟随真正显示的帧;来自上一语义状态的迟到几何回调仍会被拒绝。
- Submitting a new or edited pet now switches immediately from the draft to the exact created Maker session even when the history list has not caught up yet. Active, waiting, recoverable, and cancellation-cleanup sessions no longer offer a ChatGPT jump that the desktop client would reject as in use elsewhere; completed, canceled, and terminal-failed sessions retain the action when their exact released thread is available. Session switching and post-delete selection also clear stale reply text and synchronize both detail and paged messages. / 提交新建或修改宠物后,即使历史列表尚未同步,也会立即从草稿切换到服务端返回的精确制作会话。运行中、等待输入、可恢复及取消清理中的会话不再提供会被 ChatGPT 以“正在其他位置使用”拒绝的跳转;已完成、已取消及终态失败会话在精确已释放线程可用时保留该入口。切换会话和删除后选中相邻会话时,也会清理陈旧回复草稿并同步详情与分页消息。
- Technical-information disclosures now make their entire header row clickable and keyboard-focusable instead of relying on the small chevron target, with matching hover and VoiceOver expanded/collapsed feedback everywhere the shared component appears. / 技术信息折叠区现在会让整排标题都可点击并可通过键盘聚焦,不再依赖狭小的箭头命中区;所有复用该组件的页面也会统一提供悬停反馈及 VoiceOver 展开/收起状态。
- Every AI Pet Maker session state now uses the same opaque status-panel structure below the shared metrics bar: pending, running, waiting for input, cancellation cleanup, completed, recoverable or terminal failure, and canceled. Only the semantic color, icon, copy, and optional inline reply/continue control change; result previews remain clipped to a fixed tile and timeline messages never show through underneath. / AI 宠物制作的待开始、运行中、等待回复、取消清理、完成、可恢复或终结失败及已取消状态,现在都在共用指标栏下使用同一套不透明状态面板;仅语义色、图标、说明及可选的内嵌回复/继续控件发生变化。结果预览仍固定裁切在独立区域内,会话消息也不会从下方透出。
- Pet Library's AI modify, export, history, and delete controls now retain their own accessibility identities instead of inheriting one shared action-bar identifier. Modal edit/history presentation waits for the originating accessibility press to finish, and its layout containers preserve their descendant controls, so VoiceOver and accessibility-driven activation can address, open, and operate the intended flow reliably. / 宠物库的 AI 修改、导出、历史与删除控件现在会保留各自的无障碍身份,不再继承同一个操作栏标识;编辑/历史模态页会等待发起它的无障碍按压完成,其布局容器也会保留后代控件,因此 VoiceOver 与基于无障碍的激活可可靠定位、打开并操作目标流程。
- A deferred App update no longer creates a deadlock by disabling the only action that can finish a recoverable AI Pet Maker job. Starting another pet remains blocked while convergence waits, but the preserved job can now be continued and its button accurately reflects whether resume is available. / 延后的 App 更新不再因禁用唯一能完成可恢复 AI 宠物制作任务的操作而形成死锁;收敛等待期间仍会阻止另起宠物,但现在可以继续保留任务,且“继续”按钮会准确反映恢复是否可用。
- A recoverable AI Pet Maker failure no longer blocks the App/PetCore runtime handoff needed to install a fix and continue the preserved job. Current PetCore marks failed resumable work as replacement-safe, while the App safely rechecks older runtimes through their authoritative snapshot and decodes the restored failed-session projection instead of looping startup recovery. / 可恢复的 AI 宠物制作失败不再阻塞安装修复并继续保留任务所必需的 App/PetCore 运行时交接;当前 PetCore 会将可续做的失败任务标记为可安全替换,App 也会通过旧运行时的权威快照安全复核,并正确解码恢复后的失败会话投影,不再反复进入启动恢复。
- AI Pet Maker now recognizes Agent Pet Maker's private workspace layout, bound Motion QA/review evidence, and actual image-generator producer identity when completing strict full-source jobs. A fully validated pet no longer loops through no-progress continuation turns or fails before import merely because its source and evidence use the Maker helper's current paths and metadata contract. / AI 宠物制作现在会在严格 full-source 任务收尾时识别 Agent Pet Maker 的私有工作区布局、已绑定的 Motion QA/review 证据及真实图像生成器身份;已完整通过校验的宠物不会再仅因源码、证据采用 Maker helper 的当前路径与元数据契约而反复进入无进展续接或在导入前失败。
- AI Pet Maker now keeps the selected session header and reply/continue area visible while only its message timeline scrolls. Legacy failures without a safe recovery checkpoint now say why they cannot continue in place and direct users to the preserved ChatGPT conversation or brief-copy path; recoverable failures continue to resume the same job, thread, and workspace. / AI 宠物制作现在会固定显示所选会话的顶部信息与回复/继续区域,仅让消息时间线独立滚动。对于没有安全恢复检查点的旧失败记录,界面会明确说明无法原地继续的原因,并引导用户查看保留的 ChatGPT 会话或复制 brief;可恢复失败任务仍会在同一 job、线程与工作区内继续。
- The Control Center sidebar now reaches the native window edges as one system-material surface, and its live pet/message preview is a pinned bottom pane instead of a list safe-area inset, so resizing or switching pages cannot make the preview disappear. / 控制中心侧栏现在以同一块系统材质直接延伸至原生窗口边缘;宠物与消息实时预览也改为固定的底部区域,不再依赖列表安全区插入,因此调整窗口或切换页面都不会让预览消失。
- Codex sessions stopped by the user now leave the running state on the next App Server refresh. PetCore distinguishes their persisted `interrupted` completion boundary from externally running turns that another App Server instance reports as `interrupted`, so the desktop bubble updates promptly without prematurely ending long tasks. Claude Code, Pi, and OpenCode terminal mappings remain covered by the same cross-Agent lifecycle regression suite. / 用户手动停止 Codex 会话后,现在会在下一次 App Server 刷新时退出运行状态。PetCore 会用持久化的 `interrupted` 完成边界,将其与另一个 App Server 实例同样报告为 `interrupted` 的外部运行中任务区分开,因此桌宠消息气泡能及时更新,同时不会提前终止长任务;Claude Code、Pi 与 OpenCode 的终止映射继续由同一套跨 Agent 生命周期回归测试覆盖。
Generated
+3 -3
View File
@@ -772,7 +772,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petcore"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"hex",
"image",
@@ -792,7 +792,7 @@ dependencies = [
[[package]]
name = "petcore-cli"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"petcore",
"petcore-types",
@@ -803,7 +803,7 @@ dependencies = [
[[package]]
name = "petcore-types"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"serde",
"serde_json",
@@ -83,7 +83,7 @@ struct AppStoreBootstrapHooks {
private struct OverlayPetFrameHitTestProjection {
var hitTest: OverlayPetFrameHitTest?
var petID: String
var stateEntryID: String
var semanticOwnerEntryID: String
}
enum PetCoreRuntimePhase: Equatable {
@@ -697,7 +697,7 @@ final class AppStore: ObservableObject {
guard let projection = overlayPetFrameHitTestProjection,
activePet?.id == projection.petID,
OverlayPetAnimationIdentity.stateEntryID(for: presentedActiveAgentState)
== projection.stateEntryID else {
== projection.semanticOwnerEntryID else {
return nil
}
return projection.hitTest
@@ -709,7 +709,7 @@ final class AppStore: ObservableObject {
guard activePet?.id == projection.petID,
OverlayPetAnimationIdentity.stateEntryID(
for: presentedActiveAgentState
) == projection.stateEntryID else {
) == projection.semanticOwnerEntryID else {
return .stale
}
return projection.hitTest == nil ? .missing : .valid
@@ -808,6 +808,10 @@ final class AppStore: ObservableObject {
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
@@ -1273,6 +1277,21 @@ final class AppStore: ObservableObject {
}
}
/// 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
&& (
@@ -1304,7 +1323,7 @@ final class AppStore: ObservableObject {
var canResumeGeneration: Bool {
generationSession.canResume
&& canStartNewGenerationWork
&& canResumeExistingGenerationWork
&& petStudioCodexAvailability.permitsGeneration
&& referenceReselectionCount == 0
}
@@ -1316,7 +1335,7 @@ final class AppStore: ObservableObject {
jobID == selectedGenerationHistoryJobID,
detail.capabilities?.canResume == true
else { return false }
return canStartNewGenerationWork
return canResumeExistingGenerationWork
&& petStudioCodexAvailability.permitsGeneration
}
@@ -3260,9 +3279,7 @@ final class AppStore: ObservableObject {
jobID: jobID,
baselineRevisionID: acceptedBaselineRevisionID
))
makerDraftIsActive = false
await refreshGenerationHistory()
await selectGenerationHistoryJobAndWait(jobID)
await selectAcceptedGenerationHistoryJobAndWait(jobID)
statusText = "正在修改 \(pet.name)"
} catch {
let failure = GenerationMessage(
@@ -3542,9 +3559,7 @@ final class AppStore: ObservableObject {
jobID: jobID,
baselineRevisionID: dict["baseline_revision_id"] as? String
))
makerDraftIsActive = false
await refreshGenerationHistory()
await selectGenerationHistoryJobAndWait(jobID)
await selectAcceptedGenerationHistoryJobAndWait(jobID)
} catch {
let failure = GenerationMessage(
role: "assistant",
@@ -3805,8 +3820,14 @@ final class AppStore: ObservableObject {
generationHistorySnapshot = snapshot
if let selectedGenerationHistoryJobID,
!snapshot.jobs.contains(where: { $0.jobID == selectedGenerationHistoryJobID }) {
self.selectedGenerationHistoryJobID = nil
generationHistoryDetail = nil
if generationHistorySelectionIntentJobID != selectedGenerationHistoryJobID {
clearGenerationHistorySelection()
}
} else if let generationHistorySelectionIntentJobID,
snapshot.jobs.contains(where: {
$0.jobID == generationHistorySelectionIntentJobID
}) {
self.generationHistorySelectionIntentJobID = nil
}
} catch {
guard sequence == generationHistoryListSequence else { return }
@@ -3828,11 +3849,8 @@ final class AppStore: ObservableObject {
guard generationHistorySnapshot.jobs.contains(where: { $0.jobID == jobID }) else {
return
}
selectedGenerationHistoryJobID = jobID
makerDraftIsActive = false
generationHistoryDetail = nil
generationHistoryMessages = []
generationHistoryMessagesHasMore = false
generationHistorySelectionIntentJobID = nil
activateGenerationHistorySelection(jobID)
Task { [weak self] in
guard let self else { return }
async let detail: Void = loadGenerationHistoryDetail(jobID: jobID)
@@ -3845,23 +3863,50 @@ final class AppStore: ObservableObject {
guard generationHistorySnapshot.jobs.contains(where: { $0.jobID == jobID }) else {
return
}
selectedGenerationHistoryJobID = jobID
makerDraftIsActive = false
generationHistoryDetail = nil
generationHistoryMessages = []
generationHistoryMessagesHasMore = false
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 {
@@ -3970,7 +4015,20 @@ final class AppStore: ObservableObject {
func prepareMakerWorkspace() async {
await refreshGenerationHistory()
if let selectedGenerationHistoryJobID,
generationHistorySnapshot.jobs.contains(where: { $0.jobID == 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
}
@@ -4186,11 +4244,7 @@ final class AppStore: ObservableObject {
generationHistorySnapshot = localSnapshot
generationHistoryHasLoaded = true
if deletedSelection {
selectedGenerationHistoryJobID = nil
generationHistoryDetail = nil
generationHistoryMessages = []
generationHistoryMessagesHasMore = false
generationHistoryDetailIsLoading = false
clearGenerationHistorySelection()
}
await refreshGenerationHistory()
@@ -4203,9 +4257,7 @@ final class AppStore: ObservableObject {
)
if remainingJobs.indices.contains(preferredIndex) {
let nextJobID = remainingJobs[preferredIndex].jobID
selectedGenerationHistoryJobID = nextJobID
generationHistoryDetail = nil
await loadGenerationHistoryDetail(jobID: nextJobID)
await selectGenerationHistoryJobAndWait(nextJobID)
}
}
@@ -4280,7 +4332,8 @@ final class AppStore: ObservableObject {
}
func openGenerationHistorySession() {
guard let session = generationHistoryDetail?.session,
guard generationHistoryDetail?.capabilities?.canOpenSession == true,
let session = generationHistoryDetail?.session,
session.availability == .available,
session.canOpen,
let url = AgentSessionDeepLink.url(
@@ -6084,11 +6137,11 @@ final class AppStore: ObservableObject {
func updateOverlayPetVisualEnvelope(
_ envelope: OverlayPetVisualEnvelope?,
petID: String,
stateEntryID: String
semanticOwnerEntryID: String
) {
guard activePet?.id == petID else { return }
guard OverlayPetAnimationIdentity.stateEntryID(for: presentedActiveAgentState)
== stateEntryID
== semanticOwnerEntryID
else {
return
}
@@ -6103,23 +6156,23 @@ final class AppStore: ObservableObject {
func updateOverlayPetFrameHitTest(
_ hitTest: OverlayPetFrameHitTest?,
petID: String,
stateEntryID: String
semanticOwnerEntryID: String
) {
guard activePet?.id == petID else { return }
guard OverlayPetAnimationIdentity.stateEntryID(for: presentedActiveAgentState)
== stateEntryID else {
== semanticOwnerEntryID else {
return
}
if let projection = overlayPetFrameHitTestProjection,
projection.petID == petID,
projection.stateEntryID == stateEntryID,
projection.semanticOwnerEntryID == semanticOwnerEntryID,
projection.hitTest == hitTest {
return
}
overlayPetFrameHitTestProjection = OverlayPetFrameHitTestProjection(
hitTest: hitTest,
petID: petID,
stateEntryID: stateEntryID
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
@@ -434,6 +434,15 @@ enum PetCoreRuntimeReplacementSafetyPolicy {
expectedReplacementSafe = false
case "waiting_for_user":
expectedReplacementSafe = !connectionOperation
case "failed":
// Older PetCore builds projected recoverable failures as
// active and incorrectly reported replacement_safe=false.
// Recheck their authoritative snapshot instead of deadlocking
// the update that is needed to resume the durable job.
if !replacementSafe, !connectionOperation {
return .snapshotRequired
}
expectedReplacementSafe = !connectionOperation
default:
return .unknown
}
@@ -463,10 +472,11 @@ enum PetCoreRuntimeReplacementSafetyPolicy {
switch status {
case "pending", "running":
generationProtected = true
case "waiting_for_user":
// Waiting input is durable PetCore state. Replacing the runtime
// lets the new App restore the prompt; deferring here would
// deadlock because no UI is connected to answer it.
case "waiting_for_user", "failed":
// Waiting input and recoverable failure are durable PetCore
// states. Replacing the runtime lets the new App restore the
// prompt or continuation; deferring here would deadlock
// because no active worker can make either state progress.
generationProtected = false
default:
return .unknown
@@ -108,18 +108,18 @@ struct OverlayRootView: View {
onInteractionCompleted: { entryID in
interactionPresentation.complete(entryID: entryID)
},
onVisualEnvelopeChanged: { [weak store] envelope, petID, stateEntryID in
onVisualEnvelopeChanged: { [weak store] envelope, petID, semanticOwnerEntryID in
store?.updateOverlayPetVisualEnvelope(
envelope,
petID: petID,
stateEntryID: stateEntryID
semanticOwnerEntryID: semanticOwnerEntryID
)
},
onFrameHitTestChanged: { [weak store] hitTest, petID, stateEntryID in
onFrameHitTestChanged: { [weak store] hitTest, petID, semanticOwnerEntryID in
store?.updateOverlayPetFrameHitTest(
hitTest,
petID: petID,
stateEntryID: stateEntryID
semanticOwnerEntryID: semanticOwnerEntryID
)
},
onPrimaryClick: {
@@ -720,6 +720,29 @@ private typealias PetPlaybackCompletionHandler = @MainActor (
PetPlaybackMode
) -> Void
/// Separates the renderer's playback identity from the semantic event that
/// owns overlay geometry. A burst-then-idle handoff must restart rendering as
/// `settled-idle`, while its visible bounds and Alpha mask remain projections
/// of the original Agent event.
struct OverlayPetFrameProjectionIdentity: Equatable, Sendable {
var semanticOwnerEntryID: String
var renderEntryID: String
static func resolve(
semanticEntryID: String,
interactionEntryID: String? = nil,
presentsSettledIdle: Bool
) -> Self {
Self(
semanticOwnerEntryID: semanticEntryID,
renderEntryID: interactionEntryID
?? (presentsSettledIdle
? "\(semanticEntryID):settled-idle"
: semanticEntryID)
)
}
}
private struct PetInteractionLayer: View {
var pet: PetSummary?
var state: AgentEventKind?
@@ -1472,9 +1495,12 @@ private struct FloatingPetSprite: View {
?? (presentsSettledIdle ? "idle" : semanticStateName)
}
private var presentedEntryID: String {
interaction?.entryID
?? (presentsSettledIdle ? "\(stateEntryID):settled-idle" : stateEntryID)
private var frameProjectionIdentity: OverlayPetFrameProjectionIdentity {
.resolve(
semanticEntryID: stateEntryID,
interactionEntryID: interaction?.entryID,
presentsSettledIdle: presentsSettledIdle
)
}
var body: some View {
@@ -1483,7 +1509,8 @@ private struct FloatingPetSprite: View {
PetFrameLayerView(
pet: pet,
stateName: presentedStateName,
stateEntryID: presentedEntryID,
renderEntryID: frameProjectionIdentity.renderEntryID,
semanticOwnerEntryID: frameProjectionIdentity.semanticOwnerEntryID,
active: active,
reduceMotion: reduceMotion,
onPlaybackCompleted: playbackCompleted,
@@ -1548,7 +1575,8 @@ private struct FloatingPetSprite: View {
private struct PetFrameLayerView: NSViewRepresentable {
var pet: PetSummary
var stateName: String
var stateEntryID: String
var renderEntryID: String
var semanticOwnerEntryID: String
var active: Bool
var reduceMotion: Bool
var onPlaybackCompleted: PetPlaybackCompletionHandler = { _, _ in }
@@ -1565,14 +1593,14 @@ private struct PetFrameLayerView: NSViewRepresentable {
func updateNSView(_ view: MTKView, context: Context) {
let petID = pet.id
let currentStateEntryID = stateEntryID
let currentSemanticOwnerEntryID = semanticOwnerEntryID
let onVisualEnvelopeChanged = onVisualEnvelopeChanged
let onFrameHitTestChanged = onFrameHitTestChanged
context.coordinator.configure(
view: view,
pet: pet,
stateName: stateName,
stateEntryID: stateEntryID,
stateEntryID: renderEntryID,
active: active,
reduceMotion: reduceMotion,
onPlaybackCompleted: onPlaybackCompleted,
@@ -1580,14 +1608,14 @@ private struct PetFrameLayerView: NSViewRepresentable {
onVisualEnvelopeChanged?(
envelope,
petID,
currentStateEntryID
currentSemanticOwnerEntryID
)
},
onFrameHitTestChanged: { hitTest in
onFrameHitTestChanged?(
hitTest,
petID,
currentStateEntryID
currentSemanticOwnerEntryID
)
}
)
@@ -369,12 +369,8 @@ private struct MakerConversationContent: View {
cancel: { cancelConfirmationPresented = true },
delete: { deleteConfirmationPresented = true }
)
if detail.resultPetID != nil || detail.validationSummary != nil {
Divider()
MakerResultSummary(detail: detail)
}
Divider()
MakerSessionComposer(detail: detail)
MakerSessionStatusPanel(detail: detail)
}
.fixedSize(horizontal: false, vertical: true)
}
@@ -586,66 +582,229 @@ private struct MakerConversationActions: View {
}
}
private struct MakerResultSummary: View {
private struct MakerSessionStatusPanel: View {
@EnvironmentObject private var store: AppStore
let detail: GenerationStudioHistoryDetail
private let previewSize = CGSize(width: 72, height: 78)
private var capabilities: GenerationSessionCapabilities {
detail.capabilities ?? .init()
}
private var pet: PetSummary? {
store.selectedGenerationHistoryResultPet
}
private var summaryKind: MakerSessionSummaryKind {
MakerSessionPolicy.summaryKind(
status: detail.status,
recoverable: detail.recoverable,
cancellationPending: detail.cancellationPending
)
}
var body: some View {
HStack(spacing: 14) {
if let pet {
PetCoverImage(
pet: pet,
assetWarning: store.petAssetWarningIndex[pet.id],
fallbackScale: 0.7
)
.frame(width: 84, height: 91)
.background(
Color(nsColor: .controlBackgroundColor),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
} else {
Image(systemName: "checkmark.seal.fill")
.font(.title)
.foregroundStyle(.green)
.frame(width: 84, height: 84)
}
HStack(alignment: .top, spacing: 14) {
summaryVisual
VStack(alignment: .leading, spacing: 6) {
Text(pet?.name ?? APCLocalization.text(.studioSuccessGeneric))
Text(summaryTitle)
.font(.headline)
.textSelection(.enabled)
if let validation = detail.validationSummary {
Label(
APCLocalization.format(
.studioSuccessValidationFormat,
validation.stateCount,
validation.frameCount,
validation.warningCount
),
systemImage: validation.ok ? "checkmark.seal" : "exclamationmark.triangle"
)
.font(.callout)
.foregroundStyle(validation.ok ? Color.secondary : Color.orange)
if summaryKind == .completed {
completedContent
} else {
Text(summaryDetail)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if detail.capabilities?.canOpenResult == true {
Button {
Task { _ = await store.showSelectedGenerationHistoryResultPetInLibrary() }
} label: {
Label(APCLocalization.text(.studioHistoryViewPet), systemImage: "pawprint")
}
.buttonStyle(.borderless)
if capabilities.canReply || capabilities.canResume {
inlineComposer
}
}
Spacer()
.frame(maxWidth: .infinity, alignment: .topLeading)
.layoutPriority(1)
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(Color.green.opacity(0.06))
.accessibilityIdentifier("maker.session-result-summary")
.background {
Color(nsColor: .controlBackgroundColor)
.overlay(toneColor.opacity(0.07))
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("maker.session-status-panel")
}
@ViewBuilder
private var summaryVisual: some View {
if summaryKind == .completed, let pet {
PetCoverImage(
pet: pet,
assetWarning: store.petAssetWarningIndex[pet.id],
fallbackScale: 0.7
)
.frame(width: previewSize.width, height: previewSize.height)
.background(Color(nsColor: .textBackgroundColor), in: previewShape)
.clipShape(previewShape)
.overlay { previewShape.stroke(Color.secondary.opacity(0.16), lineWidth: 1) }
} else {
Image(systemName: summarySystemImage)
.font(.title2)
.foregroundStyle(toneColor)
.frame(width: previewSize.width, height: previewSize.height)
.background(toneColor.opacity(0.10), in: previewShape)
.overlay { previewShape.stroke(toneColor.opacity(0.24), lineWidth: 1) }
}
}
@ViewBuilder
private var completedContent: some View {
if pet != nil {
Text(APCLocalization.text(.studioSuccessGeneric))
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if let validation = detail.validationSummary {
Label(
APCLocalization.format(
.studioSuccessValidationFormat,
validation.stateCount,
validation.frameCount,
validation.warningCount
),
systemImage: validation.ok ? "checkmark.seal" : "exclamationmark.triangle"
)
.font(.callout)
.foregroundStyle(validation.ok ? Color.secondary : Color.orange)
}
if detail.capabilities?.canOpenResult == true {
Button {
Task { _ = await store.showSelectedGenerationHistoryResultPetInLibrary() }
} label: {
Label(APCLocalization.text(.studioHistoryViewPet), systemImage: "pawprint")
}
.buttonStyle(.borderless)
}
}
private var inlineComposer: some View {
HStack(alignment: .bottom, spacing: 8) {
TextField(
APCLocalization.text(
capabilities.canReply
? .studioWorkspaceReplyPlaceholder
: .studioWorkspaceResumePlaceholder
),
text: $store.generationReplyText,
axis: .vertical
)
.lineLimit(1 ... 5)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("maker.session-composer")
Button {
if capabilities.canReply {
store.sendSelectedGenerationHistoryReply()
} else {
store.resumeSelectedGenerationHistory()
}
} label: {
Label(
APCLocalization.text(
capabilities.canReply
? .studioWorkspaceSend
: .studioWorkspaceContinue
),
systemImage: capabilities.canReply ? "arrow.up" : "arrow.clockwise"
)
}
.apcClearGlassButtonStyle(prominent: true)
.accessibilityIdentifier(
capabilities.canReply
? "maker.session.send"
: "maker.session.continue"
)
.disabled(
capabilities.canReply
? store.generationReplyText
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty
: !store.canResumeSelectedGenerationHistory
)
}
.padding(.top, 2)
}
private var summaryTitle: String {
if summaryKind == .completed, let pet {
return pet.name
}
if let status = detail.status {
return MakerSessionPolicy.statusTitle(status, detail: detail)
}
return APCLocalization.text(.studioHistoryLoading)
}
private var summaryDetail: String {
let key: APCLocalizationKey = switch summaryKind {
case .pending, .running:
.studioWorkspaceRunningHint
case .waitingForUser:
.studioWorkspaceReplyHint
case .completed:
.studioSuccessGeneric
case .recoverableFailure:
.studioWorkspaceResumeHint
case .failed:
.studioWorkspaceNonResumableFailureHint
case .canceled:
.studioWorkspaceCanceledHint
case .cancellationPending:
.studioWorkspaceCancelCleanup
case .unknown:
.studioHistoryLoading
}
return APCLocalization.text(key)
}
private var summarySystemImage: String {
switch summaryKind {
case .pending: "clock.fill"
case .running: "sparkles"
case .waitingForUser: "questionmark.bubble.fill"
case .completed: "checkmark.seal.fill"
case .recoverableFailure: "arrow.clockwise.circle.fill"
case .failed: "exclamationmark.triangle.fill"
case .canceled: "xmark.circle.fill"
case .cancellationPending: "hourglass"
case .unknown: "questionmark.circle.fill"
}
}
private var toneColor: Color {
switch summaryKind {
case .pending, .running, .waitingForUser:
APCDesign.accent
case .completed:
APCDesign.success
case .recoverableFailure, .cancellationPending:
APCDesign.warning
case .failed:
APCDesign.destructive
case .canceled, .unknown:
APCDesign.textSecondary
}
}
private var previewShape: RoundedRectangle {
RoundedRectangle(cornerRadius: 12, style: .continuous)
}
}
@@ -820,107 +979,39 @@ private struct MakerMessageRow: View {
}
}
private struct MakerSessionComposer: View {
@EnvironmentObject private var store: AppStore
let detail: GenerationStudioHistoryDetail
private var capabilities: GenerationSessionCapabilities {
detail.capabilities ?? .init()
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if capabilities.canReply || capabilities.canResume {
Text(
APCLocalization.text(
capabilities.canReply
? .studioWorkspaceReplyHint
: .studioWorkspaceResumeHint
)
)
.font(.caption)
.foregroundStyle(.secondary)
HStack(alignment: .bottom, spacing: 8) {
TextField(
APCLocalization.text(
capabilities.canReply
? .studioWorkspaceReplyPlaceholder
: .studioWorkspaceResumePlaceholder
),
text: $store.generationReplyText,
axis: .vertical
)
.lineLimit(1 ... 5)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("maker.session-composer")
Button {
if capabilities.canReply {
store.sendSelectedGenerationHistoryReply()
} else {
store.resumeSelectedGenerationHistory()
}
} label: {
Label(
APCLocalization.text(
capabilities.canReply
? .studioWorkspaceSend
: .studioWorkspaceContinue
),
systemImage: capabilities.canReply ? "arrow.up" : "arrow.clockwise"
)
}
.apcClearGlassButtonStyle(prominent: true)
.accessibilityIdentifier(
capabilities.canReply
? "maker.session.send"
: "maker.session.continue"
)
.disabled(
capabilities.canReply
&& store.generationReplyText
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty
)
}
} else if detail.cancellationPending == true {
Label(
APCLocalization.text(.studioWorkspaceCancelCleanup),
systemImage: "hourglass"
)
.foregroundStyle(.secondary)
} else if detail.status == .running || detail.status == .pending {
Label(
APCLocalization.text(.studioWorkspaceRunningHint),
systemImage: "sparkles"
)
.foregroundStyle(.secondary)
} else if detail.status == .canceled {
Label(
APCLocalization.text(.studioWorkspaceCanceledHint),
systemImage: "xmark.circle"
)
.foregroundStyle(.secondary)
} else if detail.status == .failed {
Label(
APCLocalization.text(.studioWorkspaceNonResumableFailureHint),
systemImage: "exclamationmark.triangle"
)
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.accessibilityIdentifier("maker.session.resume-unavailable")
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(.bar)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("maker.session-composer-region")
}
enum MakerSessionSummaryKind: Equatable {
case pending
case running
case waitingForUser
case completed
case recoverableFailure
case failed
case canceled
case cancellationPending
case unknown
}
enum MakerSessionPolicy {
static func summaryKind(
status: GenerationJobHistoryStatus?,
recoverable: Bool?,
cancellationPending: Bool?
) -> MakerSessionSummaryKind {
if cancellationPending == true {
return .cancellationPending
}
return switch status {
case .pending: .pending
case .running: .running
case .waitingForUser: .waitingForUser
case .completed: .completed
case .failed where recoverable == true: .recoverableFailure
case .failed: .failed
case .canceled: .canceled
case nil: .unknown
}
}
static func isUnfinished(_ job: GenerationStudioHistoryRecord) -> Bool {
if job.cancellationPending == true { return true }
if let capabilities = job.capabilities {
@@ -648,12 +648,22 @@ struct PetLibraryView: View {
private func requestEdit(_ pet: PetSummary) {
guard PetLibraryCapabilities(pet: pet).canModify else { return }
pendingPetSheet = PetLibrarySheetRequest(pet: pet, mode: .edit)
presentPetSheet(PetLibrarySheetRequest(pet: pet, mode: .edit))
}
private func requestHistory(_ pet: PetSummary) {
guard PetLibraryCapabilities(pet: pet).canModify else { return }
pendingPetSheet = PetLibrarySheetRequest(pet: pet, mode: .history)
presentPetSheet(PetLibrarySheetRequest(pet: pet, mode: .history))
}
private func presentPetSheet(_ request: PetLibrarySheetRequest) {
// Let the originating AXPress finish before the button's window gains
// a modal sheet. Synchronous presentation can make AppKit report a
// failed accessibility action even though SwiftUI accepted the press.
Task { @MainActor in
await Task.yield()
pendingPetSheet = request
}
}
}
@@ -890,12 +900,14 @@ private struct PetHistorySheet: View {
detailColumn
.frame(minWidth: 330)
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("pet-library.history.layout.wide")
VStack(alignment: .leading, spacing: 16) {
baselineColumn
detailColumn
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("pet-library.history.layout.compact")
}
.frame(maxWidth: .infinity, alignment: .topLeading)
@@ -954,6 +966,7 @@ private struct PetHistorySheet: View {
idealHeight: 600,
maxHeight: 700
)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("pet-library.history.sheet")
.task(id: pet.id) {
await loadHistory()
@@ -994,6 +1007,7 @@ private struct PetHistorySheet: View {
}
.padding(.top, 4)
}
.accessibilityElement(children: .contain)
.accessibilityIdentifier("pet-library.edit.baseline")
GroupBox(APCLocalization.text(.libraryHistoryRevisionsTitle)) {
@@ -1731,7 +1745,7 @@ private struct PetLibraryHero: View {
.accessibilityIdentifier("pet-library.hero.more")
}
}
.accessibilityIdentifier("pet-library.hero.secondary-actions")
.accessibilityElement(children: .contain)
}
private func workspaceActionButton(
@@ -1818,7 +1832,7 @@ private struct PetLibraryHero: View {
Spacer(minLength: 0)
}
.apcClearGlassButtonStyle()
.accessibilityIdentifier("pet-library.hero.secondary-actions")
.accessibilityElement(children: .contain)
}
}
@@ -1058,6 +1058,8 @@ struct AdvancedDetailsDisclosure<Content: View>: View {
let summary: String?
@Binding var isExpanded: Bool
@ViewBuilder let content: Content
@State private var isHeaderHovered = false
@FocusState private var isHeaderFocused: Bool
init(
identity: ProductComponentIdentity,
@@ -1107,6 +1109,24 @@ struct AdvancedDetailsDisclosure<Content: View>: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.background {
RoundedRectangle(
cornerRadius: SharedProductComponentLayout.cornerRadius - 4,
style: .continuous
)
.fill(
Color.primary.opacity(
isHeaderHovered || isHeaderFocused ? 0.05 : 0
)
)
.allowsHitTesting(false)
}
.focused($isHeaderFocused)
.onHover { isHeaderHovered = $0 }
.animation(.easeOut(duration: 0.12), value: isHeaderHovered)
.animation(.easeOut(duration: 0.12), value: isHeaderFocused)
.accessibilityLabel(title)
.accessibilityValue(APCLocalization.text(
isExpanded ? .commonExpanded : .commonCollapsed
@@ -2448,6 +2448,7 @@ public enum ActiveGenerationStatus: String, Codable, Hashable, Sendable {
case pending
case running
case waitingForUser = "waiting_for_user"
case failed
}
public struct ActiveGenerationSnapshot: Codable, Equatable, Sendable {
@@ -125,6 +125,12 @@ public struct GenerationSessionRestore: Equatable, Sendable {
case .pending: .starting
case .running: .running
case .waitingForUser: .waitingForInput
case .failed:
if snapshot.recoverable {
snapshot.failureCode == "owner_interrupted" ? .paused : .recoverableFailed
} else {
.failed
}
}
}
jobID = snapshot.jobID
@@ -245,6 +245,32 @@ struct GenerationSessionStateTests {
#expect(restore.baselineRevisionID == "rev_11111111111111111111111111111111")
}
@Test("daemon active_generation decodes a recoverable failed session")
func daemonRecoverableFailureSnapshotDecodes() throws {
let data = Data(#"""
{
"job_id":"job_recoverable",
"status":"failed",
"form":{"description":"Preserved pet","style":"半写实","quality":"standard","reference_images":[]},
"heartbeat_at":"2026-08-09T03:54:42Z",
"recoverable":true,
"failure_code":"generation_failed",
"pause_reason":"checkpoint paused",
"message_revision":"156",
"messages":[]
}
"""#.utf8)
let snapshot = try JSONDecoder().decode(ActiveGenerationSnapshot.self, from: data)
let restore = GenerationSessionRestore(snapshot: snapshot)
#expect(snapshot.status == .failed)
#expect(restore.state == .recoverableFailed)
#expect(restore.recoverable)
#expect(restore.pauseReason == "checkpoint paused")
#expect(restore.messageRevision == "156")
}
@Test("an edit session preserves its target across start, failure, and retry setup")
func editSessionPreservesTargetIdentity() {
var session = GenerationSession()
@@ -1967,6 +1967,111 @@ struct AppStoreOverlaySnapshotTests {
}
}
@MainActor
@Test
func settledIdleUpdatesGeometryUnderItsSemanticOwnerAcrossStateChanges() throws {
let store = makeStore()
let pet = makeActivePet()
let thinking = makeState(
source: .codex,
session: "projection-session",
event: .thinking,
activatedSecond: 1
)
let thinkingEntryID = OverlayPetAnimationIdentity.stateEntryID(for: thinking)
let thinkingSettledIdle = OverlayPetFrameProjectionIdentity.resolve(
semanticEntryID: thinkingEntryID,
presentsSettledIdle: true
)
var thinkingSnapshot = try placementSnapshot(
revision: 501,
placement: OverlayPlacement()
)
thinkingSnapshot["pets"] = try jsonArray([pet])
thinkingSnapshot["active_agent_state"] = try jsonObject(thinking)
thinkingSnapshot["active_agent_sessions"] = try jsonArray([thinking])
try store.applyStateSnapshot(thinkingSnapshot)
let thinkingEnvelope = OverlayPetVisualEnvelope(
canvasSize: CGSize(width: 384, height: 416),
visibleBounds: CGRect(x: 70, y: 38, width: 240, height: 340)
)
let thinkingHitTest = try frameHitTest(alpha: 255)
store.updateOverlayPetVisualEnvelope(
thinkingEnvelope,
petID: pet.id,
semanticOwnerEntryID: thinkingSettledIdle.semanticOwnerEntryID
)
store.updateOverlayPetFrameHitTest(
thinkingHitTest,
petID: pet.id,
semanticOwnerEntryID: thinkingSettledIdle.semanticOwnerEntryID
)
#expect(store.overlayPetVisualEnvelope == thinkingEnvelope)
#expect(store.overlayPetFrameHitTest == thinkingHitTest)
#expect(store.overlayPetPointerMaskState == .valid)
let done = makeState(
source: .codex,
session: "projection-session",
event: .done,
activatedSecond: 2
)
let doneEntryID = OverlayPetAnimationIdentity.stateEntryID(for: done)
let doneSettledIdle = OverlayPetFrameProjectionIdentity.resolve(
semanticEntryID: doneEntryID,
presentsSettledIdle: true
)
var doneSnapshot = try placementSnapshot(
revision: 502,
placement: OverlayPlacement()
)
doneSnapshot["pets"] = try jsonArray([pet])
doneSnapshot["active_agent_state"] = try jsonObject(done)
doneSnapshot["active_agent_sessions"] = try jsonArray([done])
try store.applyStateSnapshot(doneSnapshot)
#expect(store.overlayPetPointerMaskState == .stale)
let lateThinkingEnvelope = OverlayPetVisualEnvelope(
canvasSize: CGSize(width: 384, height: 416),
visibleBounds: CGRect(x: 20, y: 15, width: 330, height: 390)
)
store.updateOverlayPetVisualEnvelope(
lateThinkingEnvelope,
petID: pet.id,
semanticOwnerEntryID: thinkingSettledIdle.semanticOwnerEntryID
)
store.updateOverlayPetFrameHitTest(
try frameHitTest(alpha: 96),
petID: pet.id,
semanticOwnerEntryID: thinkingSettledIdle.semanticOwnerEntryID
)
#expect(store.overlayPetVisualEnvelope == thinkingEnvelope)
#expect(store.overlayPetPointerMaskState == .stale)
let doneEnvelope = OverlayPetVisualEnvelope(
canvasSize: CGSize(width: 384, height: 416),
visibleBounds: CGRect(x: 92, y: 64, width: 198, height: 286)
)
let doneHitTest = try frameHitTest(alpha: 192)
store.updateOverlayPetVisualEnvelope(
doneEnvelope,
petID: pet.id,
semanticOwnerEntryID: doneSettledIdle.semanticOwnerEntryID
)
store.updateOverlayPetFrameHitTest(
doneHitTest,
petID: pet.id,
semanticOwnerEntryID: doneSettledIdle.semanticOwnerEntryID
)
#expect(store.overlayPetVisualEnvelope == doneEnvelope)
#expect(store.overlayPetFrameHitTest == doneHitTest)
#expect(store.overlayPetPointerMaskState == .valid)
}
@MainActor
@Test
func placementRevisionMustBePresentCanonicalDecimalString() throws {
@@ -2076,6 +2181,32 @@ struct AppStoreOverlaySnapshotTests {
)
}
private func makeActivePet() -> PetSummary {
PetSummary(
id: "pet_projection_owner",
name: "Projection Owner",
style: "test",
quality: .standard,
renderSize: RenderSize(width: 384, height: 416),
petpackPath: "/tmp/pet_projection_owner.petpack",
coverPath: "/tmp/pet_projection_owner.webp",
active: true,
createdAt: "2026-07-22T00:00:00Z"
)
}
private func frameHitTest(alpha: UInt8) throws -> OverlayPetFrameHitTest {
let mask = try #require(OverlayPetAlphaMask(
pixelWidth: 2,
pixelHeight: 2,
alphaValuesTopToBottom: [alpha, alpha, alpha, alpha]
))
return OverlayPetFrameHitTest(
canvasSize: CGSize(width: 2, height: 2),
alphaMask: mask
)
}
private func jsonObject<T: Encodable>(_ value: T) throws -> [String: Any] {
let data = try JSONEncoder().encode(value)
return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
@@ -14,11 +14,11 @@ struct MakerExperiencePresentationTests {
#expect(workspaceSource.contains("HSplitView"))
#expect(workspaceSource.contains("MakerDraftContent()"))
#expect(workspaceSource.contains("MakerConversationContent()"))
#expect(workspaceSource.contains("MakerResultSummary(detail: detail)"))
#expect(workspaceSource.contains("MakerSessionComposer(detail: detail)"))
#expect(workspaceSource.contains("MakerSessionStatusPanel(detail: detail)"))
#expect(!workspaceSource.contains("MakerSessionComposer(detail: detail)"))
#expect(workspaceSource.contains("maker.draft.submit"))
#expect(workspaceSource.contains("maker.draft.discard"))
#expect(workspaceSource.contains("maker.session-result-summary"))
#expect(workspaceSource.contains("maker.session-status-panel"))
#expect(workspaceSource.contains("copySelectedGenerationHistoryBriefToNewDraft"))
#expect(!workspaceSource.contains("GenerationHistorySheet("))
#expect(!studioSource.contains("maker.layout.two-stage"))
@@ -64,6 +64,47 @@ struct MakerHistoryActionsTests {
#expect(store.generationSession.startedAt == nil)
}
@MainActor
@Test
func acceptedGenerationSelectsItsSessionBeforeHistoryListCatchesUp() async {
let jobID = "job_just_created"
let store = makeStore { method, _, _ in
switch method {
case "generation.start":
return ["ok": true, "job_id": jobID]
case "generation.history.list":
// Reproduce a stale list projection arriving immediately
// after generation.start committed the new job.
return Self.historyList([])
case "generation.history.detail":
return Self.historyDetail(jobID: jobID, status: "running")
case "generation.messages.list":
return [
"ok": true,
"job_id": jobID,
"messages": [],
"has_more": false,
"next_before_sequence": NSNull(),
"revision": "0",
]
default:
throw PetCoreClientError.invalidResponse
}
}
store.beginMakerDraft()
store.updateGenerationDescription("A tiny blue robot bear")
store.startGeneration()
for _ in 0..<200 where store.generationHistoryDetail?.jobID != jobID {
await Task.yield()
}
#expect(store.generationSession.jobID == jobID)
#expect(store.selectedGenerationHistoryJobID == jobID)
#expect(store.generationHistoryDetail?.jobID == jobID)
#expect(!store.makerDraftIsActive)
}
@MainActor
@Test
func draftIsPreservedAcrossRefreshAndCanBeDiscardedBackToHistory() async {
@@ -344,6 +385,7 @@ struct MakerHistoryActionsTests {
@Test
func deletingSelectedHistoryRefreshesAndSelectsTheAdjacentRecord() async {
var listCalls = 0
var messageListCalls: [String] = []
var deletedJobID: String?
let store = makeStore { method, params, _ in
switch method {
@@ -366,6 +408,24 @@ struct MakerHistoryActionsTests {
status: jobID == "job_b" ? "canceled" : "completed",
resultPetID: jobID == "job_a" ? "pet_retained" : nil
)
case "generation.messages.list":
let jobID = (params as? [String: Any])?["job_id"] as? String ?? "missing"
messageListCalls.append(jobID)
return [
"ok": true,
"job_id": jobID,
"messages": [[
"id": "message_\(jobID)",
"sequence": 1,
"role": "assistant",
"content": jobID,
"progress": 1.0,
"created_at": "2026-08-07T00:00:00Z",
]],
"has_more": false,
"next_before_sequence": NSNull(),
"revision": "1",
]
case "generation.history.delete":
deletedJobID = (params as? [String: Any])?["job_id"] as? String
return [
@@ -394,6 +454,8 @@ struct MakerHistoryActionsTests {
#expect(store.generationHistorySnapshot.jobs.map(\.jobID) == ["job_b"])
#expect(store.selectedGenerationHistoryJobID == "job_b")
#expect(store.generationHistoryDetail?.jobID == "job_b")
#expect(messageListCalls == ["job_a", "job_b"])
#expect(store.generationHistoryMessages.map(\.content) == ["job_b"])
#expect(store.generationHistoryDeleteInFlightJobID == nil)
#expect(store.generationHistoryMutationError == nil)
#expect(store.pets.map(\.id) == ["pet_retained"])
@@ -15,7 +15,7 @@ struct MakerSessionWorkspaceTests {
status: "waiting_for_user",
recoverable: false,
cancellationPending: false,
capabilities: #"{"can_reply":true,"can_resume":false,"can_cancel":true,"can_open_result":false,"can_open_session":true,"can_delete":false}"#
capabilities: #"{"can_reply":true,"can_resume":false,"can_cancel":true,"can_open_result":false,"can_open_session":false,"can_delete":false}"#
)
)
let recoverable = try decoder.decode(
@@ -25,7 +25,7 @@ struct MakerSessionWorkspaceTests {
status: "failed",
recoverable: true,
cancellationPending: false,
capabilities: #"{"can_reply":false,"can_resume":true,"can_cancel":true,"can_open_result":false,"can_open_session":true,"can_delete":false}"#
capabilities: #"{"can_reply":false,"can_resume":true,"can_cancel":true,"can_open_result":false,"can_open_session":false,"can_delete":false}"#
)
)
let cancelCleanup = try decoder.decode(
@@ -57,6 +57,55 @@ struct MakerSessionWorkspaceTests {
#expect(canceled.capabilities?.canOpenSession == false)
}
@Test
func everySessionStateMapsToOneUnifiedSummaryKind() {
#expect(MakerSessionPolicy.summaryKind(
status: .pending,
recoverable: false,
cancellationPending: false
) == .pending)
#expect(MakerSessionPolicy.summaryKind(
status: .running,
recoverable: false,
cancellationPending: false
) == .running)
#expect(MakerSessionPolicy.summaryKind(
status: .waitingForUser,
recoverable: false,
cancellationPending: false
) == .waitingForUser)
#expect(MakerSessionPolicy.summaryKind(
status: .completed,
recoverable: false,
cancellationPending: false
) == .completed)
#expect(MakerSessionPolicy.summaryKind(
status: .failed,
recoverable: true,
cancellationPending: false
) == .recoverableFailure)
#expect(MakerSessionPolicy.summaryKind(
status: .failed,
recoverable: false,
cancellationPending: false
) == .failed)
#expect(MakerSessionPolicy.summaryKind(
status: .canceled,
recoverable: false,
cancellationPending: false
) == .canceled)
#expect(MakerSessionPolicy.summaryKind(
status: .running,
recoverable: false,
cancellationPending: true
) == .cancellationPending)
#expect(MakerSessionPolicy.summaryKind(
status: nil,
recoverable: nil,
cancellationPending: nil
) == .unknown)
}
@Test
func backendEndTimeFreezesAConversationLongerThanSixHours() throws {
let now = try #require(ISO8601DateFormatter().date(from: "2026-08-09T20:00:00Z"))
@@ -108,7 +157,12 @@ struct MakerSessionWorkspaceTests {
#expect(text.contains("maker.draft.discard"))
#expect(text.contains("loadOlderGenerationHistoryMessages"))
#expect(text.contains("hasUnseenMessages"))
#expect(text.contains("maker.session-result-summary"))
#expect(text.contains("MakerSessionStatusPanel(detail: detail)"))
#expect(text.contains("maker.session-status-panel"))
#expect(text.contains("MakerSessionSummaryKind"))
#expect(text.contains("Color(nsColor: .controlBackgroundColor)"))
#expect(text.contains(".clipShape(previewShape)"))
#expect(text.contains("private var inlineComposer: some View"))
#expect(text.contains("copySelectedGenerationHistoryBriefToNewDraft"))
#expect(!text.contains("safeAreaInset(edge: .top"))
#expect(text.contains("safeAreaInset(edge: .bottom"))
@@ -119,9 +173,8 @@ struct MakerSessionWorkspaceTests {
#expect(text.contains("MakerConversationActions("))
#expect(text.contains(".menuStyle(.borderlessButton)"))
#expect(text.contains("maker.session-timeline-scroll"))
#expect(text.contains("maker.session-composer-region"))
#expect(text.contains("maker.session-composer"))
#expect(text.contains("maker.session.continue"))
#expect(text.contains("maker.session.resume-unavailable"))
#expect(text.contains("studioWorkspaceNonResumableFailureHint"))
#expect(!text.contains("GenerationHistorySheet("))
}
@@ -1186,6 +1186,20 @@ struct PetCoreProcessManagerTests {
"connection_operation_active": false,
"runtime_replacement_safe": true
]) == .safe)
#expect(PetCoreRuntimeReplacementSafetyPolicy.assess(preflightValue: [
"safe": false,
"active_generation": true,
"active_generation_status": "failed",
"connection_operation_active": false,
"runtime_replacement_safe": true
]) == .safe)
#expect(PetCoreRuntimeReplacementSafetyPolicy.assess(preflightValue: [
"safe": false,
"active_generation": true,
"active_generation_status": "failed",
"connection_operation_active": false,
"runtime_replacement_safe": false
]) == .snapshotRequired)
#expect(PetCoreRuntimeReplacementSafetyPolicy.assess(preflightValue: [
"safe": false,
"active_generation": true,
@@ -1213,6 +1227,10 @@ struct PetCoreProcessManagerTests {
"active_generation": ["job_id": "job-1", "status": "waiting_for_user"],
"connection_operation_active": false
]) == .safe)
#expect(PetCoreRuntimeReplacementSafetyPolicy.assess(snapshotValue: [
"active_generation": ["job_id": "job-1", "status": "failed"],
"connection_operation_active": false
]) == .safe)
#expect(PetCoreRuntimeReplacementSafetyPolicy.assess(snapshotValue: [
"active_generation": NSNull(),
"connection_operation_active": true
@@ -6,6 +6,39 @@ import Testing
@Suite
struct PetFramePipelineTests {
@Test
func settledIdleKeepsSemanticProjectionOwnershipAcrossStateChanges() {
let toolEntryID = "tool:codex:session-a:activation-1"
let toolPlayback = OverlayPetFrameProjectionIdentity.resolve(
semanticEntryID: toolEntryID,
presentsSettledIdle: false
)
let toolSettledIdle = OverlayPetFrameProjectionIdentity.resolve(
semanticEntryID: toolEntryID,
presentsSettledIdle: true
)
let doneEntryID = "done:codex:session-a:activation-1"
let doneSettledIdle = OverlayPetFrameProjectionIdentity.resolve(
semanticEntryID: doneEntryID,
presentsSettledIdle: true
)
#expect(toolPlayback.renderEntryID == toolEntryID)
#expect(toolSettledIdle.renderEntryID == "\(toolEntryID):settled-idle")
#expect(toolPlayback.semanticOwnerEntryID == toolEntryID)
#expect(toolSettledIdle.semanticOwnerEntryID == toolEntryID)
#expect(doneSettledIdle.semanticOwnerEntryID == doneEntryID)
#expect(doneSettledIdle.semanticOwnerEntryID != toolSettledIdle.semanticOwnerEntryID)
let localInteraction = OverlayPetFrameProjectionIdentity.resolve(
semanticEntryID: doneEntryID,
interactionEntryID: "acknowledge:local-1",
presentsSettledIdle: true
)
#expect(localInteraction.renderEntryID == "acknowledge:local-1")
#expect(localInteraction.semanticOwnerEntryID == doneEntryID)
}
@MainActor
@Test
func rendererContentReplayLeavesTheRepresentableUpdateCycle() async {
@@ -202,6 +202,7 @@ struct PetLibraryHistoryTests {
#expect(source.contains("ViewThatFits(in: .horizontal)"))
#expect(source.contains("pet-library.history.layout.wide"))
#expect(source.contains("pet-library.history.layout.compact"))
#expect(source.contains(".accessibilityElement(children: .contain)"))
#expect(source.contains("minWidth: 520"))
#expect(!source.contains(".frame(width: 780)"))
#expect(source.contains("PetLibraryHistoryPresentation.localizedTimestamp(job.updatedAt)"))
@@ -748,6 +748,11 @@ struct PetLibraryTests {
#expect(source.contains("pet-library.hero.history"))
#expect(source.contains("pet-library.hero.export"))
#expect(source.contains("pet-library.hero.more"))
#expect(!source.contains(
".accessibilityIdentifier(\"pet-library.hero.secondary-actions\")"
))
#expect(source.contains("await Task.yield()"))
#expect(source.contains("presentPetSheet(PetLibrarySheetRequest("))
#expect(source.contains("pet-library.hero.action-picker"))
#expect(source.contains(
"@State private var selectedPreviewAction = PetLibraryPreviewActionPolicy.defaultAction"
@@ -302,6 +302,34 @@ struct SharedProductComponentsTests {
#expect(source.contains("ProductSecondaryActionButton("))
}
@Test
func advancedDetailsDisclosureMakesTheWholeHeaderInteractive() throws {
let source = try String(
contentsOf: sharedComponentsURL,
encoding: .utf8
)
let start = try #require(
source.range(of: "struct AdvancedDetailsDisclosure<Content: View>: View")
)
let end = try #require(
source.range(of: "struct EmptyStateAction<Action: Hashable>: View")
)
let disclosureSource = String(
source[start.lowerBound ..< end.lowerBound]
)
#expect(disclosureSource.contains("Button {"))
#expect(disclosureSource.contains("isExpanded.toggle()"))
#expect(disclosureSource.contains(
".buttonStyle(.plain)\n"
+ " .frame(maxWidth: .infinity, alignment: .leading)\n"
+ " .contentShape(Rectangle())"
))
#expect(disclosureSource.contains(".focused($isHeaderFocused)"))
#expect(disclosureSource.contains(".onHover { isHeaderHovered = $0 }"))
#expect(disclosureSource.contains(".accessibilityLabel(title)"))
}
@MainActor
@Test
func productionSessionRowBuildsExactHostAndUnavailableAccessibilityStates() {
@@ -2308,6 +2308,14 @@ struct UIModelTests {
"can_open": true,
"routable_session_id": threadID,
],
"capabilities": [
"can_reply": false,
"can_resume": false,
"can_cancel": false,
"can_open_result": false,
"can_open_session": true,
"can_delete": true,
],
]
default:
throw PetCoreClientError.invalidResponse
@@ -2333,6 +2341,60 @@ struct UIModelTests {
string: "codex://threads/\(threadID)"
))))
var activeRouteOpened = false
let activeStore = makeStore(
petCoreRequestOverride: { method, _, _ in
switch method {
case "generation.history.list":
return historyList
case "generation.history.detail":
return [
"ok": true,
"found": true,
"job_id": "job_history",
"status": "running",
"operation": "create",
"description": "A small fox",
"style": "半写实",
"quality": "standard",
"reference_count": 0,
"progress_messages": [],
"message_count": 0,
"messages_truncated": false,
"session": [
"availability": "available",
"can_open": true,
"routable_session_id": threadID,
],
"capabilities": [
"can_reply": false,
"can_resume": false,
"can_cancel": true,
"can_open_result": false,
"can_open_session": false,
"can_delete": false,
],
]
default:
throw PetCoreClientError.invalidResponse
}
},
agentSessionRouteOpener: { _ in
activeRouteOpened = true
return .openedExactSession
}
)
await activeStore.refreshGenerationHistory()
activeStore.selectGenerationHistoryJob("job_history")
await activeStore.loadGenerationHistoryDetail(jobID: "job_history")
activeStore.openGenerationHistorySession()
await Task.yield()
#expect(!activeRouteOpened)
#expect(activeStore.statusText == APCLocalization.text(
.studioHistorySessionUnavailable
))
var archivedRouteOpened = false
let archivedStore = makeStore(
petCoreRequestOverride: { method, _, _ in
@@ -2429,7 +2491,7 @@ struct UIModelTests {
"can_resume": true,
"can_cancel": true,
"can_open_result": false,
"can_open_session": true,
"can_open_session": false,
"can_delete": false,
],
]
@@ -2472,6 +2534,11 @@ struct UIModelTests {
await store.loadGenerationHistoryDetail(jobID: jobID)
#expect(store.canResumeSelectedGenerationHistory)
store.presentDeferredAppUpdateHandoff()
#expect(store.appUpdateConvergenceState == .waitingForActiveWork)
#expect(!store.canStartNewGenerationWork)
#expect(store.canResumeSelectedGenerationHistory)
store.resumeSelectedGenerationHistory()
for _ in 0..<100 where store.generationSession.jobID != jobID
|| store.generationSession.state != GenerationSessionState.running {
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "petcore-cli"
version = "0.2.1"
version = "0.3.0"
edition.workspace = true
license.workspace = true
+19 -2
View File
@@ -964,10 +964,27 @@ fn summarize_generation_artifacts(
let skill_helper = source_metadata
.and_then(|metadata| metadata.get("skill_helper"))
.and_then(Value::as_str);
let runner = source_metadata
.and_then(|metadata| metadata.get("runner"))
.and_then(Value::as_str);
let visual_source = source_metadata
.and_then(|metadata| metadata.get("visual_source"))
.and_then(Value::as_str);
let preview_only = source_metadata
.and_then(|metadata| metadata.get("preview_only"))
.and_then(Value::as_bool);
let materialized_by_petcore = materializer == Some("petcore-internal-skill-materializer");
let deterministic_preview = is_deterministic_preview(generator, provenance);
let real_skill_source = generator == Some("codex-app-server-skill")
let trusted_producer_identity = generator == Some("codex-app-server-skill")
|| (skill_helper == Some("agent-pet-maker")
&& runner.is_some_and(|value| !value.trim().is_empty()));
let real_skill_source = trusted_producer_identity
&& provenance == Some("skill-full-source")
&& matches!(
visual_source,
Some("image-generation" | "user-reference-derived")
)
&& preview_only == Some(false)
&& materializer.is_none()
&& !deterministic_preview;
let fallback_used = deterministic_preview
@@ -1057,7 +1074,7 @@ fn generation_mode(
Some("skill-full-source"),
Some("petcore-internal-skill-materializer"),
) => "petcore_internal_skill_materialized",
(Some("codex-app-server-skill"), Some("skill-full-source"), None) => "skill_full_source",
(_, Some("skill-full-source"), None) => "skill_full_source",
(Some("codex-app-server-brief-petpack-v3"), Some("codex_app_server_brief"), _) => {
"codex_brief_materialized"
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "petcore-types"
version = "0.2.1"
version = "0.3.0"
edition.workspace = true
license.workspace = true
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "petcore"
version = "0.2.1"
version = "0.3.0"
edition.workspace = true
license.workspace = true
+159 -2
View File
@@ -1021,6 +1021,86 @@ pub fn archive_pet_studio_thread(thread_id: &str, expected_job_dir: &Path) -> Re
))
}
/// Re-exposes an exact Studio thread after cancellation has stopped its owned
/// worker and completed the archive handoff. This does not resume a turn; it
/// only makes the now-inactive transcript routable in ChatGPT. A thread that
/// is already active in the exact job workspace is treated as released.
pub fn unarchive_pet_studio_thread(thread_id: &str, expected_job_dir: &Path) -> Result<()> {
if thread_id.trim() != thread_id
|| thread_id.is_empty()
|| thread_id.len() > 256
|| thread_id.chars().any(char::is_control)
|| !expected_job_dir.is_absolute()
{
return Err(PetCoreError::InvalidRequest(
"Pet Studio thread release requires an exact thread id and absolute job workspace"
.to_string(),
));
}
let (command, _) = codex_app_server_command()
.ok_or_else(|| PetCoreError::Validation("Codex App Server is not available".to_string()))?;
let mut session = StdioSession::spawn(&command)?;
session.send(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"clientInfo": {
"name": "AgentPetCompanion",
"version": env!("CARGO_PKG_VERSION")
},
"capabilities": {}
}
}))?;
let initialize = session.read_response(1, "initialize", PET_STUDIO_INITIALIZE_TIMEOUT)?;
if initialize.get("error").is_some() {
return Err(response_error(
"initialize",
"initialize",
1,
&initialize,
&session,
));
}
session.send(&json!({
"jsonrpc": "2.0",
"method": "initialized",
"params": {}
}))?;
session.send(&json!({
"jsonrpc": "2.0",
"id": 2,
"method": "thread/unarchive",
"params": { "threadId": thread_id }
}))?;
let unarchive = session.read_response(2, "thread/unarchive", THREAD_ARCHIVE_TIMEOUT)?;
if unarchive.get("error").is_none() {
session.terminate();
return Ok(());
}
let source_kinds = ["cli", "vscode", "appServer", "unknown"];
let active = list_exact_pet_studio_thread(
&mut session,
3,
thread_id,
&expected_job_dir.display().to_string(),
false,
&source_kinds,
)?;
session.terminate();
if active.is_some() {
return Ok(());
}
Err(response_error(
"thread_unarchive",
"thread/unarchive",
2,
&unarchive,
&session,
))
}
fn list_exact_pet_studio_thread(
session: &mut StdioSession,
request_id: i64,
@@ -3605,8 +3685,47 @@ fn interrupt_turn_and_wait_for_completion(
}
}
pub(crate) fn pet_studio_source_candidates(job_dir: &Path) -> [PathBuf; 2] {
[
job_dir.join("petpack-source"),
job_dir.join("workspace/petpack-source"),
]
}
pub(crate) fn pet_studio_motion_evidence_candidates(job_dir: &Path) -> [(PathBuf, PathBuf); 4] {
[
(
job_dir.join("motion-qa/report.json"),
job_dir.join("motion-review.json"),
),
(
job_dir.join(".agent-pet-maker/motion-qa/report.json"),
job_dir.join(".agent-pet-maker/motion-review.json"),
),
(
job_dir.join("workspace/motion-qa/report.json"),
job_dir.join("workspace/motion-review.json"),
),
(
job_dir.join("workspace/.agent-pet-maker/motion-qa/report.json"),
job_dir.join("workspace/.agent-pet-maker/motion-review.json"),
),
]
}
fn external_source_ready_for_checkpoint(job_dir: &Path) -> bool {
let source_dir = job_dir.join("petpack-source");
if !pet_studio_source_candidates(job_dir)
.into_iter()
.any(|candidate| checkpoint_source_is_complete(&candidate))
{
return false;
}
pet_studio_motion_evidence_candidates(job_dir)
.into_iter()
.any(|(report, review)| report.is_file() && review.is_file())
}
fn checkpoint_source_is_complete(source_dir: &Path) -> bool {
let validation = read_checkpoint_json(&source_dir.join("build/validation.json"));
if validation
.as_ref()
@@ -3659,7 +3778,7 @@ fn external_source_ready_for_checkpoint(job_dir: &Path) -> bool {
return false;
}
}
job_dir.join("motion-qa/report.json").is_file() && job_dir.join("motion-review.json").is_file()
true
}
fn read_checkpoint_json(path: &Path) -> Option<Value> {
@@ -5506,6 +5625,44 @@ mod timing_normalization_tests {
Some("{\"timing_changed\":true,\"states\":[]}")
);
}
#[test]
fn final_checkpoint_accepts_agent_pet_maker_workspace_evidence() {
for source_relative in ["petpack-source", "workspace/petpack-source"] {
let temporary = tempfile::tempdir().unwrap();
let job_dir = temporary.path();
let source_dir = job_dir.join(source_relative);
let manifest = PetManifest::new(
"pet_checkpointtest".to_string(),
"Checkpoint Test".to_string(),
"test".to_string(),
QualityLevel::Standard,
"2026-08-09T00:00:00Z".to_string(),
);
fs::create_dir_all(source_dir.join("build")).unwrap();
fs::write(
source_dir.join("manifest.json"),
serde_json::to_vec_pretty(&manifest).unwrap(),
)
.unwrap();
fs::write(source_dir.join("build/validation.json"), br#"{"ok":true}"#).unwrap();
for state in &manifest.states {
let state_dir = source_dir.join(&state.frames_dir);
fs::create_dir_all(&state_dir).unwrap();
for frame_index in 0..state.frame_durations_ms.len() {
fs::write(state_dir.join(format!("{frame_index:04}.png")), []).unwrap();
}
}
assert!(!external_source_ready_for_checkpoint(job_dir));
let evidence_dir = job_dir.join("workspace/.agent-pet-maker");
fs::create_dir_all(evidence_dir.join("motion-qa")).unwrap();
fs::write(evidence_dir.join("motion-qa/report.json"), b"{}").unwrap();
fs::write(evidence_dir.join("motion-review.json"), b"{}").unwrap();
assert!(external_source_ready_for_checkpoint(job_dir));
}
}
}
fn default_codex_app_server_command() -> Option<(String, &'static str)> {
+198 -37
View File
@@ -963,16 +963,17 @@ pub fn studio_history_detail(
.count();
let latest_codex_excerpt = (!codex_content.is_empty())
.then(|| bounded_utf8_suffix(&codex_content, MAX_STUDIO_DETAIL_CODEX_BYTES).to_string());
let session = match (
job.status,
job.cancel_requested_at.as_ref(),
job.session_id.as_deref(),
) {
(GenerationJobStatus::Canceled, _, _) | (_, Some(_), _) => {
studio_session_navigation(GenerationStudioSessionAvailability::Archived)
let cancellation_pending =
job.cancel_requested_at.is_some() && job.thread_archived_at.is_none();
let terminal = generation_job_is_terminal(&job);
let session = match (cancellation_pending, terminal, job.session_id.as_deref()) {
(true, _, _) | (false, false, _) => {
studio_session_navigation(GenerationStudioSessionAvailability::Unavailable)
}
(_, _, None) => studio_session_navigation(GenerationStudioSessionAvailability::NotCreated),
(_, _, Some(thread_id)) => {
(false, true, None) => {
studio_session_navigation(GenerationStudioSessionAvailability::NotCreated)
}
(false, true, Some(thread_id)) => {
app_server::inspect_pet_studio_thread(thread_id, &job.job_dir, &form).unwrap_or_else(
|_| studio_session_navigation(GenerationStudioSessionAvailability::Unavailable),
)
@@ -982,8 +983,6 @@ pub fn studio_history_detail(
.last()
.map(|message| message.progress)
.unwrap_or(0.0);
let cancellation_pending =
job.cancel_requested_at.is_some() && job.thread_archived_at.is_none();
let capabilities = generation_job_capabilities(&job, session.can_open);
Ok(GenerationStudioHistoryDetail {
ok: true,
@@ -1042,9 +1041,11 @@ pub fn generation_job_capabilities(
can_cancel: !cancellation_pending && unfinished,
can_open_result: job.status == GenerationJobStatus::Completed
&& job.result_pet_id.is_some(),
can_open_session: !cancellation_pending
&& job.status != GenerationJobStatus::Canceled
&& exact_session_available,
// A routable App Server thread is not sufficient while PetCore still
// owns unfinished work: ChatGPT correctly refuses to open a thread
// whose active turn is in use elsewhere. Only released terminal jobs
// expose the external transcript route.
can_open_session: !cancellation_pending && terminal && exact_session_available,
can_delete: !cancellation_pending && terminal,
}
}
@@ -1682,11 +1683,11 @@ pub fn recover_interrupted_jobs_for_instance(
}
if wait_for_generation_worker_stop(&job.id, GENERATION_WORKER_STOP_TIMEOUT) {
database.confirm_generation_execution_stopped(&job.id)?;
if archive_canceled_generation_thread(database, &job.id).is_ok() {
if release_canceled_generation_thread(database, &job.id).is_ok() {
let _lifecycle = lifecycle_lock();
mark_canceled_locked(paths, database, &job.id)?;
} else {
schedule_canceled_generation_archive_retry(
schedule_canceled_generation_release_retry(
paths.clone(),
database.clone(),
&job.id,
@@ -2111,11 +2112,11 @@ pub fn cancel_generation(
)));
}
database.confirm_generation_execution_stopped(job_id)?;
if let Err(error) = archive_canceled_generation_thread(database, job_id) {
schedule_canceled_generation_archive_retry(paths.clone(), database.clone(), job_id);
if let Err(error) = release_canceled_generation_thread(database, job_id) {
schedule_canceled_generation_release_retry(paths.clone(), database.clone(), job_id);
// Execution is already stopped and the cancellation fence is
// irreversible. Return the frozen task projection while a bounded
// exact-thread archive cleanup continues in the background.
// exact-thread release cleanup continues in the background.
let _ = error;
return read_messages(paths, job_id);
}
@@ -2124,7 +2125,7 @@ pub fn cancel_generation(
read_messages(paths, job_id)
}
fn archive_canceled_generation_thread(database: &Database, job_id: &str) -> Result<()> {
fn release_canceled_generation_thread(database: &Database, job_id: &str) -> Result<()> {
let job = database.generation_job(job_id)?.ok_or_else(|| {
PetCoreError::InvalidRequest(format!("generation job not found: {job_id}"))
})?;
@@ -2133,15 +2134,32 @@ fn archive_canceled_generation_thread(database: &Database, job_id: &str) -> Resu
}
if let Some(thread_id) = job.session_id.as_deref() {
app_server::archive_pet_studio_thread(thread_id, &job.job_dir)?;
if app_server::unarchive_pet_studio_thread(thread_id, &job.job_dir).is_err() {
schedule_canceled_generation_unarchive_retry(
thread_id.to_string(),
job.job_dir.clone(),
);
}
}
database.confirm_generation_thread_archived(job_id)
}
fn schedule_canceled_generation_archive_retry(paths: AppPaths, database: Database, job_id: &str) {
fn schedule_canceled_generation_unarchive_retry(thread_id: String, job_dir: PathBuf) {
thread::spawn(move || {
for _ in 0..3 {
if app_server::unarchive_pet_studio_thread(&thread_id, &job_dir).is_ok() {
return;
}
thread::sleep(Duration::from_millis(250));
}
});
}
fn schedule_canceled_generation_release_retry(paths: AppPaths, database: Database, job_id: &str) {
let job_id = job_id.to_string();
thread::spawn(move || {
for _ in 0..3 {
if archive_canceled_generation_thread(&database, &job_id).is_ok() {
if release_canceled_generation_thread(&database, &job_id).is_ok() {
let _lifecycle = lifecycle_lock();
let _ = mark_canceled_locked(&paths, &database, &job_id);
return;
@@ -4171,7 +4189,8 @@ fn try_import_skill_petpack_source(
database: &Database,
job_id: &str,
) -> Result<SkillPetpackImport> {
let source_dir = paths.jobs_dir.join(job_id).join("petpack-source");
let job_dir = paths.jobs_dir.join(job_id);
let source_dir = preferred_skill_source_dir(&job_dir);
if !source_dir.join("manifest.json").is_file() {
return Ok(SkillPetpackImport::Missing);
}
@@ -4231,6 +4250,11 @@ fn validate_skill_source_identity(source_dir: &Path) -> Result<()> {
.get("provenance")
.and_then(Value::as_str)
.unwrap_or("");
let skill_helper = metadata
.get("skill_helper")
.and_then(Value::as_str)
.unwrap_or("");
let runner = metadata.get("runner").and_then(Value::as_str).unwrap_or("");
if metadata
.get("materialized_by")
.and_then(Value::as_str)
@@ -4241,9 +4265,14 @@ fn validate_skill_source_identity(source_dir: &Path) -> Result<()> {
.to_string(),
));
}
if generator != "codex-app-server-skill" || provenance != "skill-full-source" {
let legacy_studio_identity = generator == "codex-app-server-skill";
let maker_identity = skill_helper == "agent-pet-maker" && !runner.trim().is_empty();
if generator.trim().is_empty()
|| provenance != "skill-full-source"
|| !(legacy_studio_identity || maker_identity)
{
return Err(PetCoreError::Validation(format!(
"Skill petpack-source must declare generator=codex-app-server-skill and provenance=skill-full-source, got generator={generator:?}, provenance={provenance:?}"
"Skill petpack-source must declare its actual generator and runner, provenance=skill-full-source, and skill_helper=agent-pet-maker; got generator={generator:?}, runner={runner:?}, provenance={provenance:?}, skill_helper={skill_helper:?}"
)));
}
if external_skill_source_required() {
@@ -4280,23 +4309,65 @@ fn validate_skill_source_identity(source_dir: &Path) -> Result<()> {
));
}
}
let job_dir = source_dir.parent().ok_or_else(|| {
let source_workspace = source_dir.parent().ok_or_else(|| {
PetCoreError::Validation("petpack-source has no generation workspace".to_string())
})?;
let baseline_dir = job_dir.join("base-petpack-source");
verify_visual_production(
source_dir,
&job_dir.join("motion-qa/report.json"),
&job_dir.join("motion-review.json"),
baseline_dir
.join("manifest.json")
.is_file()
.then_some(baseline_dir.as_path()),
)?;
let job_dir = source_workspace
.parent()
.filter(|_| {
source_workspace.file_name().and_then(|name| name.to_str()) == Some("workspace")
})
.unwrap_or(source_workspace);
let baseline_dir = [
source_workspace.join("base-petpack-source"),
job_dir.join("base-petpack-source"),
job_dir.join("workspace/base-petpack-source"),
]
.into_iter()
.find(|candidate| candidate.join("manifest.json").is_file());
let mut evidence_errors = Vec::new();
for (report, review) in app_server::pet_studio_motion_evidence_candidates(job_dir) {
if !report.is_file() || !review.is_file() {
continue;
}
match verify_visual_production(source_dir, &report, &review, baseline_dir.as_deref()) {
Ok(_) => return Ok(()),
Err(error) => evidence_errors.push(error.to_string()),
}
}
return Err(PetCoreError::Validation(if evidence_errors.is_empty() {
"visual production requires current motion QA and review evidence".to_string()
} else {
format!(
"visual production evidence did not match the current source: {}",
evidence_errors.join("; ")
)
}));
}
Ok(())
}
fn preferred_skill_source_dir(job_dir: &Path) -> PathBuf {
let candidates = app_server::pet_studio_source_candidates(job_dir);
candidates
.iter()
.find(|candidate| {
candidate.join("manifest.json").is_file()
&& fs::read(candidate.join("build/validation.json"))
.ok()
.and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
.and_then(|value| value.get("ok").and_then(Value::as_bool))
== Some(true)
})
.or_else(|| {
candidates
.iter()
.find(|candidate| candidate.join("manifest.json").is_file())
})
.cloned()
.unwrap_or_else(|| job_dir.join("petpack-source"))
}
fn safe_motion_evidence_file(path: &Path, label: &str, maximum_bytes: u64) -> Result<PathBuf> {
let metadata = fs::symlink_metadata(path).map_err(|_| {
PetCoreError::Validation(format!(
@@ -5285,7 +5356,8 @@ fn ensure_skill_full_source_metadata(
form: &GenerationForm,
_app_server_session: &Value,
) -> Result<()> {
let source_dir = paths.jobs_dir.join(job_id).join("petpack-source");
let job_dir = paths.jobs_dir.join(job_id);
let source_dir = preferred_skill_source_dir(&job_dir);
if !source_dir.join("manifest.json").is_file() {
return Ok(());
}
@@ -6161,6 +6233,95 @@ mod tests {
}
}
fn capability_job(
status: GenerationJobStatus,
recoverable: bool,
cancellation_pending: bool,
) -> GenerationJobRecord {
let canceled = status == GenerationJobStatus::Canceled;
GenerationJobRecord {
id: "job_capabilities".to_string(),
status,
form_json: "{}".to_string(),
session_id: Some("019f5b0f-88ff-7413-8953-29de4ed0951c".to_string()),
job_dir: PathBuf::from("/tmp/job_capabilities"),
result_pet_id: None,
retry_of_job_id: None,
owner_instance_id: None,
heartbeat_at: "2026-08-09T00:00:00Z".to_string(),
started_at: "2026-08-09T00:00:00Z".to_string(),
ended_at: None,
cancel_requested_at: (cancellation_pending || canceled)
.then(|| "2026-08-09T00:01:00Z".to_string()),
execution_stopped_at: canceled.then(|| "2026-08-09T00:01:01Z".to_string()),
thread_archived_at: (canceled && !cancellation_pending)
.then(|| "2026-08-09T00:01:02Z".to_string()),
recoverable,
failure_code: None,
pause_reason: None,
active_turn_id: None,
last_checkpoint_at: None,
visible_title: "Capability test".to_string(),
created_at: "2026-08-09T00:00:00Z".to_string(),
updated_at: "2026-08-09T00:00:00Z".to_string(),
}
}
#[test]
fn chatgpt_route_is_exposed_only_for_released_terminal_jobs() {
for (status, recoverable) in [
(GenerationJobStatus::Pending, false),
(GenerationJobStatus::Running, false),
(GenerationJobStatus::WaitingForUser, false),
(GenerationJobStatus::Failed, true),
] {
let job = capability_job(status, recoverable, false);
assert!(!generation_job_capabilities(&job, true).can_open_session);
}
for status in [
GenerationJobStatus::Completed,
GenerationJobStatus::Failed,
GenerationJobStatus::Canceled,
] {
let job = capability_job(status, false, false);
assert!(generation_job_capabilities(&job, true).can_open_session);
assert!(!generation_job_capabilities(&job, false).can_open_session);
}
let pending_cancel = capability_job(GenerationJobStatus::Running, false, true);
assert!(!generation_job_capabilities(&pending_cancel, true).can_open_session);
}
#[test]
fn unfinished_history_detail_never_projects_a_routable_provider_session() {
let temp = tempfile::tempdir().unwrap();
let paths = AppPaths::new(temp.path().join("home"));
paths.ensure().unwrap();
let database = Database::new(paths.db_path.clone());
database.init().unwrap();
let job_id = "job_active_detail";
let job_dir = paths.jobs_dir.join(job_id);
fs::create_dir_all(&job_dir).unwrap();
database
.create_generation_job(job_id, &timing_form(), &job_dir)
.unwrap();
database
.update_generation_job_session(job_id, "019f5b0f-88ff-7413-8953-29de4ed0951c")
.unwrap();
database
.update_generation_job(job_id, GenerationJobStatus::Running, None)
.unwrap();
let detail = studio_history_detail(&paths, &database, job_id).unwrap();
assert_eq!(
detail.session.availability,
GenerationStudioSessionAvailability::Unavailable
);
assert!(!detail.session.can_open);
assert!(!detail.capabilities.can_open_session);
}
#[test]
fn studio_history_keeps_unresolved_jobs_without_exposing_reference_paths() {
let temp = tempfile::tempdir().unwrap();
+24 -4
View File
@@ -2592,10 +2592,12 @@ fn handle_request_inner(state: &CoreState, request: RpcRequest) -> Result<Value>
active_generation.as_ref().map(|job| enum_name(job.status));
let connection_operation_active =
state.connection_operation_active.load(Ordering::Acquire);
let runtime_replacement_safe = active_generation
.as_ref()
.is_none_or(|job| job.status == GenerationJobStatus::WaitingForUser)
&& !connection_operation_active;
let runtime_replacement_safe = active_generation.as_ref().is_none_or(|job| {
matches!(
job.status,
GenerationJobStatus::WaitingForUser | GenerationJobStatus::Failed
)
}) && !connection_operation_active;
Ok(json!({
"safe": active_generation.is_none() && !connection_operation_active,
"active_generation": active_generation.is_some(),
@@ -4705,6 +4707,24 @@ done
"runtime_replacement_safe": true,
})
);
state
.database
.mark_generation_recoverable_failure(
"preflight-active-generation",
"checkpoint_paused",
"recoverable checkpoint",
)
.unwrap();
assert_eq!(
preflight(),
json!({
"safe": false,
"active_generation": true,
"active_generation_status": "failed",
"connection_operation_active": false,
"runtime_replacement_safe": true,
})
);
}
#[test]
+39 -2
View File
@@ -2,8 +2,8 @@ use petcore::app_server::{
archive_pet_studio_thread, inspect_pet_studio_thread, probe_codex_app_server,
read_codex_recent_thread_activities, read_codex_recent_thread_activities_cached,
read_codex_thread_display, run_pet_studio_session,
run_pet_studio_session_with_updates_and_cancel, CodexRecentThreadActivityCache,
PetStudioSessionUpdateKind,
run_pet_studio_session_with_updates_and_cancel, unarchive_pet_studio_thread,
CodexRecentThreadActivityCache, PetStudioSessionUpdateKind,
};
use petcore::paths::AppPaths;
use petcore::rpc::{handle_request, CoreState, RpcRequest};
@@ -164,6 +164,43 @@ done
archive_pet_studio_thread("019f5b0f-88ff-7413-8953-29de4ed0951e", &job_dir).unwrap();
}
#[test]
fn pet_studio_unarchive_releases_the_exact_stopped_thread() {
let _lock = ENV_LOCK.lock().unwrap();
let temp = tempfile::tempdir().unwrap();
let script = temp.path().join("unarchive-studio-thread.sh");
let job_dir = temp.path().join("generation-jobs/job_released");
std::fs::create_dir_all(&job_dir).unwrap();
let thread_id = "019f5b0f-88ff-7413-8953-29de4ed0951f";
std::fs::write(
&script,
format!(
r#"#!/bin/sh
while IFS= read -r request; do
case "$request" in
*\"method\":\"initialize\"*)
printf '%s\n' '{{"jsonrpc":"2.0","id":1,"result":{{"serverInfo":{{"name":"unarchive-test"}}}}}}'
;;
*\"method\":\"thread/unarchive\"*)
case "$request" in
*\"threadId\":\"{thread_id}\"*)
printf '%s\n' '{{"jsonrpc":"2.0","id":2,"result":{{}}}}'
;;
*) exit 41 ;;
esac
;;
esac
done
"#,
),
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let _command = EnvGuard::set("CODEX_APP_SERVER_CMD", script.as_os_str());
unarchive_pet_studio_thread(thread_id, &job_dir).unwrap();
}
#[test]
fn recent_thread_activity_uses_state_db_and_bounded_display_fields() {
let _lock = ENV_LOCK.lock().unwrap();
+53 -41
View File
@@ -7,10 +7,21 @@ use rusqlite::{params, Connection};
use serde_json::{json, Value};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use time::{format_description::well_known::Rfc3339, OffsetDateTime};
// Keep ordinary fixtures inside the retention window regardless of the host
// clock. Tests that exercise pruning provide their own explicit timestamps.
const RECEIVED_AT: &str = "2099-08-09T00:00:00Z";
fn received_at() -> &'static str {
// Ordinary fixtures stay inside the retention window without embedding a
// calendar deadline. Pruning tests provide their own explicit timestamps.
static RECEIVED_AT: OnceLock<String> = OnceLock::new();
RECEIVED_AT
.get_or_init(|| {
OffsetDateTime::now_utc()
.format(&Rfc3339)
.expect("format current fixture timestamp")
})
.as_str()
}
const FORBIDDEN_VALUES: &[&str] = &[
"FORBIDDEN_PROMPT_VALUE_7f16",
"FORBIDDEN_COMMAND_VALUE_b8c2",
@@ -79,7 +90,7 @@ fn external_display_and_metadata_aliases_never_reach_visible_records() {
"diagnostic": false
}
}),
RECEIVED_AT,
received_at(),
)
.unwrap();
@@ -117,7 +128,7 @@ fn pi_input_is_a_first_class_allowlisted_lifecycle_event() {
"diagnostic": false
}
}),
RECEIVED_AT,
received_at(),
)
.unwrap();
@@ -157,7 +168,7 @@ fn activity_ingress_never_recurses_through_credential_containers() {
"diagnostic": false
}
}),
RECEIVED_AT,
received_at(),
)
.unwrap();
assert!(event.payload_json["activity_content"].is_null());
@@ -178,7 +189,7 @@ fn activity_ingress_never_recurses_through_credential_containers() {
"diagnostic": false
}
}),
RECEIVED_AT,
received_at(),
)
.unwrap();
assert_eq!(
@@ -209,7 +220,7 @@ fn session_display_fields_enforce_exact_utf8_byte_boundaries() {
"diagnostic": false
}
}),
RECEIVED_AT,
received_at(),
)
.unwrap();
assert_eq!(
@@ -251,7 +262,7 @@ fn session_display_fields_enforce_exact_utf8_byte_boundaries() {
"event_type": "start",
"payload": payload
}),
RECEIVED_AT,
received_at(),
)
.unwrap_err();
let error = error.to_string();
@@ -291,7 +302,7 @@ fn opencode_v2_activity_events_are_first_class_allowlisted_lifecycle_names() {
"diagnostic": false
}
}),
RECEIVED_AT,
received_at(),
)
.unwrap();
@@ -314,7 +325,7 @@ fn opencode_prompt_admitted_outcome_survives_the_envelope_vocabularies() {
"diagnostic": false
}
}),
RECEIVED_AT,
received_at(),
)
.unwrap();
@@ -339,9 +350,9 @@ fn payload_json_alias_is_normalized_through_the_same_closed_vocabularies() {
"outcome": "api_failure",
"diagnostic": false
},
"created_at": RECEIVED_AT
"created_at": received_at()
}),
RECEIVED_AT,
received_at(),
)
.unwrap();
@@ -372,7 +383,7 @@ fn diagnostic_events_always_normalize_to_non_activity_metadata() {
"event_type": "tool",
"payload": payload
}),
RECEIVED_AT,
received_at(),
)
.unwrap();
@@ -397,7 +408,7 @@ fn session_navigation_accepts_only_allowlisted_warp_focus_urls() {
"session_open_url": "warppreview://session/A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"
}
}),
RECEIVED_AT,
received_at(),
)
.unwrap();
assert_eq!(event.payload_json["session_open"], true);
@@ -425,7 +436,7 @@ fn session_navigation_accepts_only_allowlisted_warp_focus_urls() {
"session_open_url": invalid_url
}
}),
RECEIVED_AT,
received_at(),
)
.unwrap_err();
assert!(
@@ -444,7 +455,7 @@ fn strict_ingest_rejects_unknown_top_level_and_envelope_fields() {
"event_type": "start",
"raw_prompt": "must not be ignored"
}),
RECEIVED_AT,
received_at(),
)
.unwrap_err();
assert!(top_level.to_string().contains("field is not supported"));
@@ -461,7 +472,7 @@ fn strict_ingest_rejects_unknown_top_level_and_envelope_fields() {
"raw_command": "must not be ignored"
}
}),
RECEIVED_AT,
received_at(),
)
.unwrap_err();
assert!(nested
@@ -478,7 +489,7 @@ fn strict_ingest_rejects_ambiguous_payload_aliases_and_source_mismatch() {
"payload": {},
"payload_json": {}
}),
RECEIVED_AT,
received_at(),
)
.unwrap_err();
assert!(ambiguous.to_string().contains("only one of payload"));
@@ -489,7 +500,7 @@ fn strict_ingest_rejects_ambiguous_payload_aliases_and_source_mismatch() {
"source": "codex",
"event_type": "start"
}),
RECEIVED_AT,
received_at(),
)
.unwrap_err();
assert!(mismatch.to_string().contains("source does not match"));
@@ -505,7 +516,7 @@ fn security_fixture() -> Value {
fn raw_hook_payload_cannot_cross_the_strict_ingest_boundary() {
let encoded = serde_json::to_string(&security_fixture()).unwrap();
let error =
NormalizedAgentEvent::from_external(AgentSource::Codex, security_fixture(), RECEIVED_AT)
NormalizedAgentEvent::from_external(AgentSource::Codex, security_fixture(), received_at())
.unwrap_err();
assert!(error.to_string().contains("payload field is not supported"));
@@ -521,7 +532,7 @@ fn external_title_and_detail_are_discarded_even_when_multibyte() {
value["title"] = Value::String("宠".repeat(100));
value["detail"] = Value::String("🙂".repeat(200));
let event = NormalizedAgentEvent::from_external(AgentSource::Pi, value, RECEIVED_AT).unwrap();
let event = NormalizedAgentEvent::from_external(AgentSource::Pi, value, received_at()).unwrap();
assert_eq!(event.title, AgentEventType::Tool.zh_label());
assert_eq!(event.detail, None);
@@ -533,10 +544,10 @@ fn missing_external_id_is_stable_for_the_same_canonical_input() {
value.as_object_mut().unwrap().remove("id");
let first =
NormalizedAgentEvent::from_external(AgentSource::ClaudeCode, value.clone(), RECEIVED_AT)
NormalizedAgentEvent::from_external(AgentSource::ClaudeCode, value.clone(), received_at())
.unwrap();
let second =
NormalizedAgentEvent::from_external(AgentSource::ClaudeCode, value, RECEIVED_AT).unwrap();
NormalizedAgentEvent::from_external(AgentSource::ClaudeCode, value, received_at()).unwrap();
assert_eq!(first.id, second.id);
assert!(first.id.starts_with("evt_external_"));
@@ -548,9 +559,10 @@ fn same_external_id_from_two_sources_is_not_deduplicated() {
let database = Database::new(temp.path().join("events.sqlite"));
database.init().unwrap();
let value = external_event("shared-id", Some("session"));
let codex = NormalizedAgentEvent::from_external(AgentSource::Codex, value.clone(), RECEIVED_AT)
.unwrap();
let pi = NormalizedAgentEvent::from_external(AgentSource::Pi, value, RECEIVED_AT).unwrap();
let codex =
NormalizedAgentEvent::from_external(AgentSource::Codex, value.clone(), received_at())
.unwrap();
let pi = NormalizedAgentEvent::from_external(AgentSource::Pi, value, received_at()).unwrap();
assert_eq!(
database.insert_event(&codex).unwrap(),
@@ -571,13 +583,13 @@ fn same_id_in_two_sessions_is_not_deduplicated() {
let first = NormalizedAgentEvent::from_external(
AgentSource::Codex,
external_event("shared-id", Some("session-a")),
RECEIVED_AT,
received_at(),
)
.unwrap();
let second = NormalizedAgentEvent::from_external(
AgentSource::Codex,
external_event("shared-id", Some("session-b")),
RECEIVED_AT,
received_at(),
)
.unwrap();
@@ -600,13 +612,13 @@ fn null_and_empty_sessions_share_a_stable_deduplication_namespace() {
let missing = NormalizedAgentEvent::from_external(
AgentSource::Opencode,
external_event("same-no-session", None),
RECEIVED_AT,
received_at(),
)
.unwrap();
let empty = NormalizedAgentEvent::from_external(
AgentSource::Opencode,
external_event("same-no-session", Some(" ")),
RECEIVED_AT,
received_at(),
)
.unwrap();
@@ -640,7 +652,7 @@ fn internal_codex_suggestion_session_is_deleted_and_future_events_are_suppressed
"session_active": false,
"diagnostic": false
}),
created_at: RECEIVED_AT.to_string(),
created_at: received_at().to_string(),
};
assert_eq!(
database.insert_event(&session_start).unwrap(),
@@ -662,7 +674,7 @@ fn internal_codex_suggestion_session_is_deleted_and_future_events_are_suppressed
"message_content": "# Overview\n\nGenerate 0 to 3 hyperpersonalized suggestions for what this user can do with Codex in this Projectless task\n\nGet an understanding of the user's intent",
"diagnostic": false
}),
created_at: RECEIVED_AT.to_string(),
created_at: received_at().to_string(),
};
assert_eq!(
database.insert_event(&internal_prompt).unwrap(),
@@ -674,7 +686,7 @@ fn internal_codex_suggestion_session_is_deleted_and_future_events_are_suppressed
"internal-later-tool",
AgentSource::Codex,
Some(session_id),
RECEIVED_AT,
received_at(),
);
assert_eq!(
database.insert_event(&later_tool).unwrap(),
@@ -702,7 +714,7 @@ fn child_session_marker_deletes_existing_projection_and_blocks_future_events() {
"child-before-lineage",
AgentSource::Opencode,
Some(session_id),
RECEIVED_AT,
received_at(),
);
assert_eq!(
database.insert_event(&ordinary).unwrap(),
@@ -722,7 +734,7 @@ fn child_session_marker_deletes_existing_projection_and_blocks_future_events() {
"session_active": false,
"diagnostic": false
}),
created_at: RECEIVED_AT.to_string(),
created_at: received_at().to_string(),
};
assert_eq!(
database.insert_event(&marker).unwrap(),
@@ -734,7 +746,7 @@ fn child_session_marker_deletes_existing_projection_and_blocks_future_events() {
"child-after-lineage",
AgentSource::Opencode,
Some(session_id),
RECEIVED_AT,
received_at(),
);
assert_eq!(
database.insert_event(&later).unwrap(),
@@ -765,7 +777,7 @@ fn title_only_app_server_activity_removes_an_existing_projected_session() {
"message_content": "Done",
"diagnostic": false
}),
created_at: RECEIVED_AT.to_string(),
created_at: received_at().to_string(),
};
assert!(database
.upsert_codex_activity_event(&ordinary_activity)
@@ -827,7 +839,7 @@ fn schema_upgrade_scrubs_historical_internal_codex_suggestion_sessions() {
"diagnostic": false
}))
.unwrap(),
RECEIVED_AT
received_at()
],
)
.unwrap();
@@ -862,7 +874,7 @@ fn recent_events_clamps_limit() {
&format!("event-{index:03}"),
AgentSource::Codex,
Some("session"),
RECEIVED_AT,
received_at(),
);
assert_eq!(
database.insert_event(&event).unwrap(),
@@ -1067,7 +1079,7 @@ fn legacy_payload_migration_removes_plaintext_and_rebuilds_rows() {
params![
"legacy-event",
r#"{"prompt":"FORBIDDEN_LEGACY_PLAINTEXT_63bb","command":"cat secret"}"#,
RECEIVED_AT,
received_at(),
],
)
.unwrap();
+111 -15
View File
@@ -10,10 +10,51 @@ use std::ffi::OsString;
use std::io::Write;
use std::os::unix::prelude::PermissionsExt;
use std::path::Path;
use std::sync::Mutex;
use std::process::Command;
use std::time::{Duration, Instant};
static ENV_LOCK: Mutex<()> = Mutex::new(());
const ISOLATED_ENV_TEST: &str = "APC_GENERATION_LIFECYCLE_ISOLATED_TEST";
const ISOLATED_ENV_KEYS: &[&str] = &[
"CODEX_APP_SERVER_CMD",
"APC_FAKE_APP_SERVER_WAIT_FILE",
"APC_FAKE_APP_SERVER_ARCHIVE_FILE",
"APC_FAKE_APP_SERVER_ARCHIVE_FAIL_ONCE_FILE",
"APC_FAKE_APP_SERVER_JOB_CWD",
];
/// Runs environment-mutating scenarios in separate processes. Rust's test
/// harness can execute the parents concurrently, while every child receives
/// its own process environment and exact single-test filter.
fn run_in_isolated_process(test_name: &str) -> bool {
if let Some(active_test) = std::env::var_os(ISOLATED_ENV_TEST) {
assert_eq!(
active_test, test_name,
"isolated test process selected the wrong test"
);
return false;
}
let mut command = Command::new(std::env::current_exe().expect("current test executable"));
command
.arg("--exact")
.arg(test_name)
.arg("--nocapture")
.arg("--test-threads=1")
.env(ISOLATED_ENV_TEST, test_name);
for key in ISOLATED_ENV_KEYS {
command.env_remove(key);
}
let output = command
.output()
.expect("launch isolated generation lifecycle test");
assert!(
output.status.success(),
"isolated generation lifecycle test {test_name} failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
true
}
struct EnvVarGuard {
key: &'static str,
@@ -64,8 +105,18 @@ while IFS= read -r request; do
fi
printf '%s\n' '{{"jsonrpc":"2.0","id":2,"result":{{}}}}'
;;
*thread/unarchive*)
if [ -n "${{APC_FAKE_APP_SERVER_UNARCHIVE_FILE:-}}" ]; then
printf '%s' "$request" > "$APC_FAKE_APP_SERVER_UNARCHIVE_FILE"
fi
printf '%s\n' '{{"jsonrpc":"2.0","id":2,"result":{{}}}}'
;;
*thread/list*)
printf '%s\n' '{{"jsonrpc":"2.0","id":3,"result":{{"data":[{{"id":"{thread_id}","cwd":"'"${{APC_FAKE_APP_SERVER_JOB_CWD:-/tmp}}"'","ephemeral":false,"archived":false}}]}}}}'
case "$request" in
*\"id\":2*) response_id=2 ;;
*) response_id=3 ;;
esac
printf '%s\n' '{{"jsonrpc":"2.0","id":'"$response_id"',"result":{{"data":[{{"id":"{thread_id}","cwd":"'"${{APC_FAKE_APP_SERVER_JOB_CWD:-/tmp}}"'","ephemeral":false,"archived":false}}]}}}}'
;;
*thread/start*)
printf '%s\n' '{{"jsonrpc":"2.0","id":2,"result":{{"thread":{{"id":"{thread_id}","sessionId":"{thread_id}","ephemeral":false,"status":{{"type":"idle"}},"cwd":"/tmp","turns":[]}},"model":"fake-model","modelProvider":"fake","cwd":"/tmp","approvalPolicy":"never","sandbox":{{"type":"workspaceWrite"}}}}}}'
@@ -126,7 +177,11 @@ fn generation_history_for_unknown_pet_has_a_stable_empty_shape() {
#[test]
fn pet_edit_instruction_uses_the_shared_scalar_limit_before_job_creation() {
let _env_lock = ENV_LOCK.lock().unwrap();
if run_in_isolated_process(
"pet_edit_instruction_uses_the_shared_scalar_limit_before_job_creation",
) {
return;
}
let temp = tempfile::tempdir().unwrap();
let fake_app_server = temp.path().join("fake_app_server.sh");
write_fake_app_server_script(&fake_app_server, "thread_edit_scalar_boundary");
@@ -191,7 +246,11 @@ fn pet_edit_instruction_uses_the_shared_scalar_limit_before_job_creation() {
#[test]
fn historical_edit_receipt_returns_the_selected_baseline_timing_instead_of_the_current_head() {
let _env_lock = ENV_LOCK.lock().unwrap();
if run_in_isolated_process(
"historical_edit_receipt_returns_the_selected_baseline_timing_instead_of_the_current_head",
) {
return;
}
let temp = tempfile::tempdir().unwrap();
let _app_server = EnvVarGuard::set("CODEX_APP_SERVER_CMD", "/usr/bin/false");
let paths = AppPaths::new(temp.path().join("home"));
@@ -322,7 +381,9 @@ fn historical_edit_receipt_returns_the_selected_baseline_timing_instead_of_the_c
#[test]
fn unowned_pet_edit_retry_pins_original_submitted_baseline() {
let _env_lock = ENV_LOCK.lock().unwrap();
if run_in_isolated_process("unowned_pet_edit_retry_pins_original_submitted_baseline") {
return;
}
let temp = tempfile::tempdir().unwrap();
let _app_server = EnvVarGuard::set("CODEX_APP_SERVER_CMD", "/usr/bin/false");
let paths = AppPaths::new(temp.path().join("home"));
@@ -554,7 +615,7 @@ fn wait_for_terminal_message(state: &CoreState, job_id: &str, kind: &str) {
// runner than it does on a developer machine. Keep this as a bounded wait,
// but leave enough headroom that scheduler load is not reported as a
// lifecycle failure.
let deadline = Instant::now() + Duration::from_secs(120);
let deadline = Instant::now() + Duration::from_secs(180);
loop {
let messages = generation_messages(state, job_id);
if messages
@@ -582,21 +643,35 @@ fn terminal_count(messages: &[Value], kind: &str) -> usize {
#[test]
fn generation_lifecycle_cancel_is_idempotent_and_thread_cannot_complete() {
let _env_lock = ENV_LOCK.lock().unwrap();
if run_in_isolated_process(
"generation_lifecycle_cancel_is_idempotent_and_thread_cannot_complete",
) {
return;
}
let temp = tempfile::tempdir().unwrap();
let fake_app_server = temp.path().join("fake_app_server.sh");
let wait_file = temp.path().join("allow-complete");
let archive_file = temp.path().join("archive-request");
write_fake_app_server_script(&fake_app_server, "thread_lifecycle_cancel");
let unarchive_file = temp.path().join("unarchive-request");
let thread_id = "019f5b0f-88ff-7413-8953-29de4ed0951c";
write_fake_app_server_script(&fake_app_server, thread_id);
let _app_server = EnvVarGuard::set("CODEX_APP_SERVER_CMD", fake_app_server.as_os_str());
let _wait_file = EnvVarGuard::set("APC_FAKE_APP_SERVER_WAIT_FILE", wait_file.as_os_str());
let _archive_file =
EnvVarGuard::set("APC_FAKE_APP_SERVER_ARCHIVE_FILE", archive_file.as_os_str());
let _unarchive_file = EnvVarGuard::set(
"APC_FAKE_APP_SERVER_UNARCHIVE_FILE",
unarchive_file.as_os_str(),
);
let paths = AppPaths::new(temp.path().to_path_buf());
let state = CoreState::new(paths);
state.ensure_ready().unwrap();
let job_id = start_generation(&state, "取消中的生命周期桌宠。");
let _job_cwd = EnvVarGuard::set(
"APC_FAKE_APP_SERVER_JOB_CWD",
state.paths.jobs_dir.join(&job_id).as_os_str(),
);
wait_for_message(&state, &job_id, "Pet Studio brief turn 已启动");
let progress = generation_messages(&state, &job_id)
.into_iter()
@@ -632,10 +707,20 @@ fn generation_lifecycle_cancel_is_idempotent_and_thread_cannot_complete() {
archive_request.contains("thread/archive"),
"{archive_request}"
);
assert!(archive_request.contains(thread_id), "{archive_request}");
let unarchive_request = std::fs::read_to_string(&unarchive_file).unwrap();
assert!(
archive_request.contains("thread_lifecycle_cancel"),
"{archive_request}"
unarchive_request.contains("thread/unarchive"),
"{unarchive_request}"
);
assert!(unarchive_request.contains(thread_id), "{unarchive_request}");
let detail = handle_request(
&state,
request("generation.history.detail", json!({ "job_id": job_id })),
)
.unwrap();
assert_eq!(detail["session"]["availability"], "available");
assert_eq!(detail["capabilities"]["can_open_session"], true);
std::fs::write(&wait_file, "ok").unwrap();
std::thread::sleep(Duration::from_millis(900));
@@ -649,7 +734,10 @@ fn generation_lifecycle_cancel_is_idempotent_and_thread_cannot_complete() {
#[test]
fn generation_cancel_retries_a_temporary_exact_thread_archive_failure() {
let _env_lock = ENV_LOCK.lock().unwrap();
if run_in_isolated_process("generation_cancel_retries_a_temporary_exact_thread_archive_failure")
{
return;
}
let temp = tempfile::tempdir().unwrap();
let fake_app_server = temp.path().join("fake_app_server.sh");
let wait_file = temp.path().join("allow-complete");
@@ -701,7 +789,11 @@ fn generation_cancel_retries_a_temporary_exact_thread_archive_failure() {
#[test]
fn generation_lifecycle_completed_job_rejects_reply_and_keeps_committed_pet() {
let _env_lock = ENV_LOCK.lock().unwrap();
if run_in_isolated_process(
"generation_lifecycle_completed_job_rejects_reply_and_keeps_committed_pet",
) {
return;
}
let temp = tempfile::tempdir().unwrap();
let fake_app_server = temp.path().join("fake_app_server.sh");
let wait_file = temp.path().join("allow-complete");
@@ -773,7 +865,9 @@ fn generation_lifecycle_completed_job_rejects_reply_and_keeps_committed_pet() {
#[test]
fn imported_pet_can_start_codex_edit_as_same_id_revision() {
let _env_lock = ENV_LOCK.lock().unwrap();
if run_in_isolated_process("imported_pet_can_start_codex_edit_as_same_id_revision") {
return;
}
let temp = tempfile::tempdir().unwrap();
let fake_app_server = temp.path().join("fake_app_server.sh");
write_fake_app_server_script(&fake_app_server, "thread_imported_pet_edit");
@@ -1005,7 +1099,9 @@ fn imported_pet_can_start_codex_edit_as_same_id_revision() {
#[test]
fn pet_edit_rejects_commit_when_base_revision_changes() {
let _env_lock = ENV_LOCK.lock().unwrap();
if run_in_isolated_process("pet_edit_rejects_commit_when_base_revision_changes") {
return;
}
let temp = tempfile::tempdir().unwrap();
let fake_app_server = temp.path().join("fake_app_server.sh");
let wait_file = temp.path().join("allow-edit-complete");
+2 -2
View File
@@ -80,7 +80,7 @@ Runtime assets are derived from the immutable package. Cached asset fingerprints
`GenerationForm` contains description, style, `low` or `standard` quality, and bounded copied PNG, JPG/JPEG, or WebP references. Extension, decoded format, byte count, pixel count, ownership, and no-follow snapshot identity are validated before use. Authored frame timing is package output, not an App form setting. Portable imported packages may be `high`; the Codex-backed in-App Studio rejects `high` before generation.
Jobs retain the compatible `pending`, `running`, `waiting_for_user`, `failed`, `completed`, and `canceled` storage enum. `recoverable`, `failure_code`, `pause_reason`, and cancellation timestamps project the finer paused, recoverable-failed, and cancellation-cleanup UI states. `started_at` is fixed at job creation. `ended_at` is set only for completion, terminal failure, or the user's `cancel_requested_at`; therefore waiting, sleep gaps, and recoverable failures continue to accrue duration while cancellation freezes it immediately. `execution_stopped_at` and `thread_archived_at` prove the two cancellation cleanup boundaries. `active_turn_id`, `last_checkpoint_at`, and `visible_title` support exact control, recovery, and stable list presentation.
Jobs retain the compatible `pending`, `running`, `waiting_for_user`, `failed`, `completed`, and `canceled` storage enum. `recoverable`, `failure_code`, `pause_reason`, and cancellation timestamps project the finer paused, recoverable-failed, and cancellation-cleanup UI states. `started_at` is fixed at job creation. `ended_at` is set only for completion, terminal failure, or the user's `cancel_requested_at`; therefore waiting, sleep gaps, and recoverable failures continue to accrue duration while cancellation freezes it immediately. `execution_stopped_at` proves worker ownership ended, while `thread_archived_at` proves the exact cancellation archive boundary completed. PetCore may subsequently keep or restore that stopped thread as unarchived for terminal transcript viewing; route availability is always revalidated live rather than inferred from the timestamp. `active_turn_id`, `last_checkpoint_at`, and `visible_title` support exact control, recovery, and stable list presentation.
A partial unique index admits only one unfinished job: pending, running, waiting, recoverable failed, or any row with cancellation requested but thread archival incomplete. Every create, edit, and new-job retry also checks the same rule inside its transaction and returns `generation_active_conflict` with the occupying job ID/status. SQLite is the message authority and retains every job, including terminal jobs without a result pet, until explicit terminal deletion. `generation.history.list` puts the unfinished row first, then terminal rows newest-first, and returns bounded metadata plus PetCore-authoritative capabilities without reference paths, workspaces, messages, or raw provider identities.
@@ -88,7 +88,7 @@ A partial unique index admits only one unfinished job: pending, running, waiting
`generation_messages` stores a strict per-job sequence of user text, visible Agent text, phase/activity/checkpoint summaries, structured `input_request` payloads, recoverable or terminal errors, results, and cancellation. Input payloads contain only bounded request/question/option fields. Periodic liveness coalesces into `generation_jobs.heartbeat_at` and the wait revision instead of growing the table. `generation.messages.list` pages backward by sequence while returning each page in chronological order. `generation_action_requests` binds reply/resume request IDs to action and content digest so transport retries cannot start duplicate turns.
Only waiting accepts `generation.reply`; only a recoverable failure accepts `generation.resume`. Both persist the user text first and continue the same job/workspace and available thread. A completed task is closed and changes use a new edit task. A canceled task has an irreversible write fence and no reply, resume, retry, or session-open capability. Terminal failed tasks may create a distinct retry job linked by `retry_of_job_id`.
Only waiting accepts `generation.reply`; only a recoverable failure accepts `generation.resume`. Both persist the user text first and continue the same job/workspace and available thread. A completed task is closed and changes use a new edit task. A canceled task has an irreversible write fence and no reply, resume, or retry capability. Pending, running, waiting, recoverable-failed, and cancellation-cleanup jobs never expose ChatGPT navigation; completed, canceled, and terminal-failed jobs may expose it only when App Server confirms the exact unarchived thread in the exact job workspace. Terminal failed tasks may create a distinct retry job linked by `retry_of_job_id`.
The App may render this job-scoped conversation directly, but heartbeat/activity records are separate from Agent conversation bubbles and the entire Maker stream is excluded from ordinary Agent session projection and diagnostics; the thread/job identity is persisted before the first Studio turn so concurrent Codex hooks are suppressed, and startup removes legacy Studio bubble rows. Tool payloads, hidden reasoning, credentials, and arbitrary App Server objects are not copied into the Maker projection. Private job directories may contain source rows, copied references, deterministic guides, transparent masters, runtime frames, QA previews/reports, App Server state, and an optional validated edit baseline. These artifacts never enter the package or library projection unless the closed V3 builder explicitly includes them.
+3 -3
View File
@@ -78,13 +78,13 @@ Persisted events are `start`, `thinking`, `plan`, `tool`, `waiting`, `done`, and
### Pet creation, import, and activation
AI Pet Maker is a permanent split workspace: the left column lists every retained creation or edit task and the right column shows either an in-memory draft or that task's user-visible timeline. Submitting a draft replaces it in place with a PetCore job. The database admits exactly one unfinished task across pending, running, waiting for input, recoverable failure, and cancellation cleanup; the App disables its new-task action under the same contract. The unfinished task is listed first and terminal tasks follow newest-first.
AI Pet Maker is a permanent split workspace: the left column lists every retained creation or edit task and the right column shows either an in-memory draft or that task's user-visible timeline. Submitting a draft replaces it in place with the exact PetCore job ID returned by the create/edit/retry RPC; that authoritative selection does not wait for the asynchronously refreshed history list to catch up. The database admits exactly one unfinished task across pending, running, waiting for input, recoverable failure, and cancellation cleanup; the App disables its new-task action under the same contract. The unfinished task is listed first and terminal tasks follow newest-first.
PetCore, not ChatGPT, owns the product session. It persists bounded user messages, Agent replies, business-level phases, checkpoint summaries, native App Server `requestUserInput` questions, recoverable errors, validation evidence, and results. The page supports upward message pagination and only follows new messages when the user is already near the bottom. Hidden reasoning, raw tool arguments, complete commands, and complete tool results are never copied into this timeline. A verified live Codex deep link can be offered as an auxiliary action, but ChatGPT sidebar visibility, rendering, or availability is not a creation, input, or recovery dependency.
PetCore, not ChatGPT, owns the product session. It persists bounded user messages, Agent replies, business-level phases, checkpoint summaries, native App Server `requestUserInput` questions, recoverable errors, validation evidence, and results. The page supports upward message pagination and only follows new messages when the user is already near the bottom. Hidden reasoning, raw tool arguments, complete commands, and complete tool results are never copied into this timeline. A verified live Codex deep link is an auxiliary terminal-task action only: pending, running, waiting, recoverable-failed, and cancellation-cleanup tasks never expose it, even if App Server can still list their thread. ChatGPT sidebar visibility, rendering, or availability is not a creation, input, or recovery dependency.
External-source work continues across checkpoint turns while its bounded workspace fingerprint advances; three consecutive no-progress turns become a recoverable failure and a separate twelve-hour continuous-run bound becomes a recoverable pause. Waiting releases its worker while retaining the task, request, workspace, and thread identity. After sleep or restart, waiting remains waiting; a running task whose owner disappeared becomes recoverable and resumes the same thread when available, otherwise it starts a replacement thread in the same job and workspace. Accepted assets and QA evidence remain in place.
Task duration is derived only from PetCore's `started_at` and `ended_at`. Waiting, sleep gaps, and recoverable failures continue to accrue time. A cancel request freezes `ended_at` immediately, fences every later message and artifact commit, interrupts the exact turn, synchronously stops the owned worker/App Server process group, and archives the exact thread before the task becomes `canceled`; an archive failure remains a non-recoverable cancellation-cleanup state with bounded retries. Canceled tasks expose no reply, resume, retry, or session-open capability.
Task duration is derived only from PetCore's `started_at` and `ended_at`. Waiting, sleep gaps, and recoverable failures continue to accrue time. A cancel request freezes `ended_at` immediately, fences every later message and artifact commit, interrupts the exact turn, synchronously stops the owned worker/App Server process group, and completes an exact archive handoff before the task becomes `canceled`; an archive failure remains a non-recoverable cancellation-cleanup state with bounded retries. PetCore then re-releases the stopped thread for external viewing when App Server supports it, with bounded best-effort retries that never hold cancellation open. Canceled tasks expose no reply, resume, or retry capability, but an available released thread may offer the same verified terminal-only ChatGPT transcript action as completed and terminal-failed tasks.
The App accepts bounded, content-verified PNG, JPG/JPEG, and WebP references. User messages and bounded visible Codex `agentMessage` text are written to the private per-job message stream so the creation page can refresh and scroll the live conversation; tool payloads, hidden reasoning, credentials, and arbitrary server objects do not enter that stream. App creation supports `low` and `standard`; portable workflows may produce `high` when the untouched source pixels satisfy the V3 contract. Generated output is untrusted until the shared package and production validators pass.
+3 -3
View File
@@ -44,7 +44,7 @@ The App reads a consistent `state.snapshot`, then waits with `state.wait(after_r
`OverlayPlacementAuthority` is the only owner of presented pet position. A drag derives every frame from one captured absolute-screen anchor, moves the pet plus attached bubble/menu composition, and commits one final hard-clamped placement. Persistence uses a latest-generation journal with bounded retry; stale snapshots, late acknowledgements, superseded responses, and failed saves cannot move the presented pet. Explicit external reset/reposition intent is revisioned and remains pending until the App acknowledges it.
Overlay pointer ownership is resolved before AppKit chooses the target window by a main-run-loop, listen-only CoreGraphics mouse event tap. The current frame's Alpha mask owns only opaque pet pixels; the bubble owns only its rendered rounded cards, and all surrounding transparent panel area remains available to lower applications. Missing or stale pet masks deliberately fall back to the geometric pet region so launch and frame transitions never disable dragging. The same policy is reapplied in AppKit hit testing as a defensive boundary. A conditional exact-position timer is used only if macOS cannot create the listen-only tap; no enlarged proximity region may take ownership on behalf of pet or bubble content.
Overlay pointer ownership is resolved before AppKit chooses the target window by a main-run-loop, listen-only CoreGraphics mouse event tap. The current frame's Alpha mask owns only opaque pet pixels; the bubble owns only its rendered rounded cards, and all surrounding transparent panel area remains available to lower applications. A finite playback substate has its own render identity but not a second semantic owner: when `burst_then_idle` presents its settled idle frame, that frame publishes its real visible envelope and Alpha mask under the originating Agent event's entry ID. The App therefore accepts the current settled geometry while continuing to reject callbacks owned by an earlier semantic state. Missing or stale pet masks deliberately fall back to the geometric pet region so launch and frame transitions never disable dragging. The same policy is reapplied in AppKit hit testing as a defensive boundary. A conditional exact-position timer is used only if macOS cannot create the listen-only tap; no enlarged proximity region may take ownership on behalf of pet or bubble content.
Display width is stored separately and changed only through Pet Configuration. Bubble and menu geometry stays App-local and does not cross RPC. Interaction telemetry is aggregate-only and excludes coordinates, display/session identifiers, paths, payloads, and user text.
@@ -82,7 +82,7 @@ Method families are:
| Convergence | receipt and read-only replacement preflight |
| Support | renderer budget, App Server probe, diagnostics export |
[rpc.rs](../../crates/petcore/src/rpc.rs) and the Swift client own the exact method and parameter contract. The Maker page uses the bounded App Server probe before enabling creation. `generation.history.list` returns only task-row metadata, including title, progress, lifecycle timestamps, recovery/cancellation facts, and PetCore-authoritative `capabilities`. `generation.history.detail` returns the selected task and an exact live session-navigation check. `generation.messages.list(job_id, before_sequence, limit)` pages older visible messages; `generation.messages.wait` streams revision-based changes without duplicating rows. `generation.history.delete` is an irreversible terminal-only mutation with a typed receipt: it retains a completed task's published pet, advances `state_revision`, repairs direct retry links without changing child ordering, and removes the exact owned private workspace through descriptor-bound no-follow traversal.
[rpc.rs](../../crates/petcore/src/rpc.rs) and the Swift client own the exact method and parameter contract. The Maker page uses the bounded App Server probe before enabling creation. `generation.history.list` returns only task-row metadata, including title, progress, lifecycle timestamps, recovery/cancellation facts, and PetCore-authoritative `capabilities`. `generation.history.detail` returns the selected task and performs an exact live session-navigation check only for released terminal work; unfinished tasks fail closed even when their thread remains listed. `generation.messages.list(job_id, before_sequence, limit)` pages older visible messages; `generation.messages.wait` streams revision-based changes without duplicating rows. `generation.history.delete` is an irreversible terminal-only mutation with a typed receipt: it retains a completed task's published pet, advances `state_revision`, repairs direct retry links without changing child ordering, and removes the exact owned private workspace through descriptor-bound no-follow traversal.
Persistent Studio threads receive a bounded user-facing name through `thread/name/set`. Studio turns use App Server `plan` collaboration mode so the native `request_user_input` tool is available; the Studio developer instruction asks for one concise question with two or three options, while the bounded decoder remains tolerant of the broader protocol envelope. The App consumes `item/tool/requestUserInput`, stores at most three bounded questions and eight options per question, interrupts and releases the current turn/process, and moves the job to `waiting_for_user`. `generation.reply` accepts only that state, persists the user message and request ID before starting a continuation turn, and uses `thread/resume` when the stored thread is available. `generation.resume` similarly accepts an optional instruction only for interrupted or recoverable failures. Repeated request IDs are idempotent and a reused ID with different content fails closed. Automatic ChatGPT-sidebar discovery and ChatGPT's presentation of App Server events are not protocol or recovery guarantees.
@@ -98,7 +98,7 @@ the relevant projection immediately.
During a job, `generation.messages.wait` carries ordered typed phase/activity records, continuation markers, the current durable heartbeat timestamp, structured input requests, and bounded visible Codex message chunks; it does not transport tool input/output or hidden reasoning. Periodic App Server liveness signals advance the job heartbeat and wait revision without appending unbounded timeline rows. Waiting jobs are excluded from interrupted-owner recovery. A stale running owner becomes `failed + recoverable` with `owner_interrupted`, retains no `ended_at`, and continues to occupy the single unfinished-task slot.
`generation.cancel` first atomically records irreversible `cancel_requested_at`, freezes duration, and installs a database/write-path fence. It then signals the registered job worker, sends `turn/interrupt` for the exact active thread/turn, waits briefly for the corresponding terminal turn event, terminates only that session's private App Server process group on timeout, and waits for worker stop acknowledgement. PetCore writes `execution_stopped_at`, executes `thread/archive`, writes `thread_archived_at`, and only then commits the `canceled` terminal message/status. If archive temporarily fails, the task remains fenced in cancellation cleanup and receives bounded retries; the RPC never exposes recovery or session-open authority. `thread/unsubscribe` is not a cancellation primitive. Long-running App Server turns distinguish retryable transport errors from terminal protocol failure. External full-source checkpointing is progress-aware rather than turn-count-based: durable workspace change resets the consecutive-stall counter, three unchanged continuation turns pause with a resumable cause, and twelve hours bounds one uninterrupted continuation run. The shared production verifier remains the final package trust boundary.
`generation.cancel` first atomically records irreversible `cancel_requested_at`, freezes duration, and installs a database/write-path fence. It then signals the registered job worker, sends `turn/interrupt` for the exact active thread/turn, waits briefly for the corresponding terminal turn event, terminates only that session's private App Server process group on timeout, and waits for worker stop acknowledgement. PetCore writes `execution_stopped_at`, executes an exact `thread/archive`, attempts `thread/unarchive` to release the stopped transcript for external viewing, writes `thread_archived_at`, and only then commits the `canceled` terminal message/status. An archive failure keeps the task fenced in cancellation cleanup and receives bounded retries; an unarchive failure does not hold cancellation open and instead receives separate bounded best-effort retries. Cancellation cleanup never exposes recovery or session-open authority. Once terminal, canceled, completed, and non-recoverable failed jobs may expose a route only after an exact unarchived workspace match. `thread/unsubscribe` is not a cancellation primitive. Long-running App Server turns distinguish retryable transport errors from terminal protocol failure. External full-source checkpointing is progress-aware rather than turn-count-based: durable workspace change resets the consecutive-stall counter, three unchanged continuation turns pause with a resumable cause, and twelve hours bounds one uninterrupted continuation run. The shared production verifier remains the final package trust boundary.
### Capability-token loopback
+16
View File
@@ -25,6 +25,22 @@ APC_VALIDATE_REAL_APP_SERVER=0 \
The default gate uses isolated homes and must not launch the GUI, mutate user LaunchAgents, invoke real Agents, or read credentials. Run a focused component check first when diagnosing a failure. / 默认门禁使用隔离 home,不启动 GUI、不修改用户 LaunchAgent、不调用真实 Agent,也不读取凭据;排错时先运行最小相关检查。
For an ordinary local commit, start with the change-scoped pre-push gate. It always checks the diff and source syntax, then selects localization parity, Rust packages, Swift tests, connector smoke, or pet-production checks from the changed paths. `--plan-only` shows the selection without running it, and `--full` moves to the complete local gate. / 普通本地提交应先运行变更范围预推送门禁。它始终检查 diff 与源码语法,再根据变更路径选择本地化一致性、Rust package、Swift 测试、连接器 smoke 或宠物制作检查;`--plan-only` 仅显示计划,`--full` 转入完整本地门禁。
```bash
./script/validate_pre_push.sh --plan-only
./script/validate_pre_push.sh
```
During local full-gate diagnosis, `test_all.sh --resume` stores successful step checkpoints under this worktree's Git directory, and every executed step reports its wall-clock duration. Every checkpoint includes a source-scope fingerprint, command, workload settings, and local toolchain identity; a relevant source or toolchain change reruns that step, while unrelated successful steps can be reused. Interaction evidence is content-bound to `interaction-contract-files.txt`; the same proven suites may be rebound to the current validation build and reused by offline overlay and App assembly only after the source digest is rechecked. App assembly itself always reruns because it produces the final bundle. Clear local checkpoints explicitly with `test_all.sh --clear-cache`. / 本地完整门禁排错时,`test_all.sh --resume` 会把成功步骤的检查点写入当前 worktree 的 Git 目录,每个实际执行的步骤也会报告墙钟耗时。每个检查点都绑定源码作用域指纹、命令、负载设置与本机工具链;相关源码或工具链变化会重跑该步骤,无关的已通过步骤可以复用。交互证据按 `interaction-contract-files.txt` 内容绑定;只有重新核对源码摘要后,已证明的同组测试才能绑定到当前验证 build,并供离线悬浮层与 App 组装复用。最终 App 组装始终重跑,因为它负责产出最终 bundle。可用 `test_all.sh --clear-cache` 显式清除本地检查点。
```bash
./script/test_all.sh --resume
./script/test_all.sh --clear-cache
```
GitHub Release and other authoritative CI evidence must invoke plain `./script/test_all.sh`; they never consume local checkpoints. The String Catalog/`.strings` parity check intentionally runs before expensive Rust and Swift suites so translation drift fails fast. / GitHub Release 与其他权威 CI 证据必须调用无参数的 `./script/test_all.sh`,绝不消费本地检查点。String Catalog 与 `.strings` 的一致性检查会刻意前置到昂贵的 Rust、Swift 测试之前,使翻译漂移尽早失败。
## Environment-dependent gates / 环境门禁
- `APC_VALIDATE_HOST_UI=1` permits repository validators that affect a packaged App runtime. It does not prescribe the UI inspection tool; the executing Agent selects a suitable method. Computer Use is recommended when available and useful.
+24 -4
View File
@@ -10,6 +10,7 @@ CONFIGURATION="debug"
UNIVERSAL=0
TARGET_ARCH=""
OUTPUT_PATH=""
INTERACTION_ATTESTATION_SOURCE=""
SOURCE_VERSION="$(
awk -F'"' '/^version = / {print $2; exit}' "$ROOT_DIR/crates/petcore/Cargo.toml"
)"
@@ -24,7 +25,7 @@ CARGO_RUSTC=""
usage() {
cat <<'EOF'
usage: build_app_bundle.sh [--configuration debug|release] [--arch arm64|x86_64] [--universal] [--archive] [--unsigned] [--output PATH]
usage: build_app_bundle.sh [--configuration debug|release] [--arch arm64|x86_64] [--universal] [--archive] [--unsigned] [--output PATH] [--interaction-attestation ABSOLUTE_PATH]
Builds an ad-hoc signed development app bundle by default. --archive also
creates and verifies a `-develop.zip` for informal handoff. --unsigned disables
@@ -68,6 +69,11 @@ while (($# > 0)); do
OUTPUT_PATH="$2"
shift 2
;;
--interaction-attestation)
(($# >= 2)) || { usage >&2; exit 2; }
INTERACTION_ATTESTATION_SOURCE="$2"
shift 2
;;
-h|--help)
usage
exit 0
@@ -84,6 +90,10 @@ if [[ "$CREATE_DEVELOP_ARCHIVE" == "1" && "$SIGN_DEVELOPMENT" != "1" ]]; then
echo '--archive cannot be combined with --unsigned' >&2
exit 2
fi
if [[ -n "$INTERACTION_ATTESTATION_SOURCE" && "$INTERACTION_ATTESTATION_SOURCE" != /* ]]; then
echo 'interaction attestation input must be an absolute path' >&2
exit 2
fi
if [[ "$UNIVERSAL" == "1" && -n "$TARGET_ARCH" ]]; then
echo '--universal cannot be combined with --arch' >&2
exit 2
@@ -425,9 +435,19 @@ mkdir -p "$APP_MACOS" "$APP_RESOURCES/bin" "$APP_RESOURCES/skills"
echo "missing app icon source: $APP_ICON_SOURCE" >&2
exit 1
}
"$ROOT_DIR/script/validate_overlay_interaction.sh" \
--attestation-out "$INTERACTION_ATTESTATION" \
--build-id "$BUILD_ID"
if [[ -n "$INTERACTION_ATTESTATION_SOURCE" ]]; then
"$ROOT_DIR/script/validate_interaction_attestation.py" \
"$INTERACTION_ATTESTATION_SOURCE" \
--expected-build-id "$BUILD_ID"
cp "$INTERACTION_ATTESTATION_SOURCE" "$INTERACTION_ATTESTATION"
"$ROOT_DIR/script/validate_interaction_attestation.py" \
"$INTERACTION_ATTESTATION" \
--expected-build-id "$BUILD_ID"
else
"$ROOT_DIR/script/validate_overlay_interaction.sh" \
--attestation-out "$INTERACTION_ATTESTATION" \
--build-id "$BUILD_ID"
fi
if [[ "$UNIVERSAL" == "1" ]]; then
build_universal
+23 -10
View File
@@ -4,10 +4,11 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
usage() {
echo 'usage: prepare_interaction_attestation.sh --output ABSOLUTE_PATH'
echo 'usage: prepare_interaction_attestation.sh --output ABSOLUTE_PATH [--proof-in ABSOLUTE_PATH]'
}
OUTPUT=""
PROOF_IN=""
while (($# > 0)); do
case "$1" in
--output)
@@ -15,6 +16,11 @@ while (($# > 0)); do
OUTPUT="$2"
shift 2
;;
--proof-in)
(($# >= 2)) || { usage >&2; exit 2; }
PROOF_IN="$2"
shift 2
;;
-h|--help)
usage
exit 0
@@ -30,6 +36,10 @@ done
echo 'interaction attestation output must be an absolute path' >&2
exit 2
}
if [[ -n "$PROOF_IN" && "$PROOF_IN" != /* ]]; then
echo 'interaction proof input must be an absolute path' >&2
exit 2
fi
SOURCE_VERSION="$(
awk -F'"' '/^version = / {print $2; exit}' "$ROOT_DIR/crates/petcore/Cargo.toml"
@@ -61,17 +71,20 @@ print(value.get("build_id", ""), value.get("interaction_contract_digest", ""))
exit 1
}
"$ROOT_DIR/script/validate_overlay_interaction.sh" \
--attestation-out "$OUTPUT" \
INTERACTION_ARGS=(
--attestation-out "$OUTPUT"
--build-id "$BUILD_ID"
)
if [[ -n "$PROOF_IN" ]]; then
INTERACTION_ARGS+=(--proof-in "$PROOF_IN")
fi
"$ROOT_DIR/script/validate_overlay_interaction.sh" "${INTERACTION_ARGS[@]}"
ATTESTATION_DIGEST="$(python3 -B - "$OUTPUT" <<'PY'
import json
import pathlib
import sys
print(json.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"))["interaction_contract_digest"])
PY
ATTESTATION_DIGEST="$(
"$ROOT_DIR/script/validate_interaction_attestation.py" \
"$OUTPUT" \
--expected-build-id "$BUILD_ID" \
--print-digest
)"
[[ "$ATTESTATION_DIGEST" == "$ACTUAL_DIGEST" ]] || {
echo 'generated interaction attestation digest does not match compiled PetCore' >&2
+174 -19
View File
@@ -2,8 +2,89 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RESUME_VALIDATION="${APC_TEST_ALL_RESUME:-0}"
CLEAR_VALIDATION_CACHE=0
usage() {
cat <<'EOF'
usage: test_all.sh [--resume] [--clear-cache]
--resume Reuse successful local steps only when their scoped source
fingerprint and toolchain context are unchanged.
--clear-cache Remove this worktree's local validation checkpoints and exit.
The default invocation never consumes checkpoints and remains the CI/Release
proof path.
EOF
}
while (($# > 0)); do
case "$1" in
--resume)
RESUME_VALIDATION=1
shift
;;
--clear-cache)
CLEAR_VALIDATION_CACHE=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
usage >&2
exit 2
;;
esac
done
case "$RESUME_VALIDATION" in
0|false|FALSE|no|NO) RESUME_VALIDATION=0 ;;
1|true|TRUE|yes|YES) RESUME_VALIDATION=1 ;;
*) echo 'APC_TEST_ALL_RESUME must be 0 or 1' >&2; exit 2 ;;
esac
VALIDATION_CACHE_DIR=""
if [[ "$RESUME_VALIDATION" == "1" || "$CLEAR_VALIDATION_CACHE" == "1" ]]; then
validation_git_path="$(git -C "$ROOT_DIR" rev-parse --git-path apc-validation-cache-v1)"
if [[ "$validation_git_path" == /* ]]; then
VALIDATION_CACHE_DIR="$validation_git_path"
else
VALIDATION_CACHE_DIR="$ROOT_DIR/$validation_git_path"
fi
fi
if [[ "$CLEAR_VALIDATION_CACHE" == "1" ]]; then
[[ -n "$VALIDATION_CACHE_DIR" && "$VALIDATION_CACHE_DIR" != "/" ]] || {
echo 'refusing to clear an invalid validation cache path' >&2
exit 1
}
rm -rf -- "$VALIDATION_CACHE_DIR"
echo "Cleared local validation checkpoints: $VALIDATION_CACHE_DIR"
exit 0
fi
TEST_ALL_TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/apc-test-all.XXXXXX")"
trap 'rm -rf "$TEST_ALL_TEMP_DIR"' EXIT
CACHE_CONTEXT=""
if [[ "$RESUME_VALIDATION" == "1" ]]; then
mkdir -p "$VALIDATION_CACHE_DIR/steps" "$VALIDATION_CACHE_DIR/artifacts"
find "$VALIDATION_CACHE_DIR/steps" -type f -name '*.ok' -mtime +14 -delete
CACHE_CONTEXT="$(
{
uname -a
rustc -Vv
swift --version
python3 --version 2>&1
node --version 2>/dev/null || true
python3 -c 'import PIL; print(PIL.__version__)' 2>/dev/null || true
env | LC_ALL=C sort | awk -F= '
/^APC_/ && $1 != "APC_TEST_ALL_RESUME" && $1 != "APC_BUILD_ID" && $1 != "APC_INTERACTION_ATTESTATION_PATH" { print }
'
shasum -a 256 "$ROOT_DIR/script/test_all.sh" "$ROOT_DIR/script/validation_fingerprint.py"
} | shasum -a 256 | awk '{print $1}'
)"
fi
log_legend() {
cat <<'EOF'
@@ -22,8 +103,54 @@ run_step() {
local profile="$1"
local label="$2"
shift 2
local started_at=$SECONDS
printf '\n== [%s] %s ==\n' "$profile" "$label"
"$@"
if "$@"; then
printf 'Completed in %ss: %s\n' "$((SECONDS - started_at))" "$label"
return 0
else
local status=$?
printf 'Failed after %ss: %s\n' "$((SECONDS - started_at))" "$label" >&2
return "$status"
fi
}
run_cached_step() {
local profile="$1"
local step_id="$2"
local scope="$3"
local label="$4"
shift 4
if [[ "$RESUME_VALIDATION" != "1" ]]; then
run_step "$profile" "$label" "$@"
return
fi
local command_key command_source_digest fingerprint marker_dir marker temporary_marker
printf -v command_key '%q ' "$@"
command_source_digest=""
if [[ "$1" == "$ROOT_DIR/"* && -f "$1" ]]; then
command_source_digest="$(shasum -a 256 "$1" | awk '{print $1}')"
fi
fingerprint="$(
"$ROOT_DIR/script/validation_fingerprint.py" \
--root "$ROOT_DIR" \
--scope "$scope" \
--extra "$CACHE_CONTEXT|$command_source_digest|$step_id|$command_key|${APC_EVENT_STORM_COUNT:-180}"
)"
marker_dir="$VALIDATION_CACHE_DIR/steps/$step_id"
marker="$marker_dir/$fingerprint.ok"
if [[ -f "$marker" ]]; then
printf '\n== [%s] %s ==\n' "$profile" "$label"
printf 'Reused local checkpoint for unchanged %s inputs (%s)\n' "$scope" "${fingerprint:0:12}"
return
fi
run_step "$profile" "$label" "$@"
mkdir -p "$marker_dir"
temporary_marker="$marker_dir/.${fingerprint}.$$"
printf '%s\n' "$fingerprint" >"$temporary_marker"
mv "$temporary_marker" "$marker"
}
log_skip() {
@@ -107,26 +234,54 @@ real_agent_connectors_skip_reason() {
log_legend
run_step "fast/core" "default test isolation and owned-process safety" "$ROOT_DIR/script/validate_test_isolation.sh"
run_step "fast/core" "macOS UI-host and PetCore lifecycle contract" "$ROOT_DIR/script/validate_app_lifecycle_contract.sh"
run_step "fast/core" "JSON Schema positive/negative fixtures" "$ROOT_DIR/script/validate_schema_fixtures.sh"
run_step "fast/core" "published petpack producer-profile schemas" "$ROOT_DIR/script/validate_petpack_spec_schemas.sh"
run_cached_step "fast/core" "test-isolation" "scripts" "default test isolation and owned-process safety" "$ROOT_DIR/script/validate_test_isolation.sh"
run_cached_step "fast/core" "app-lifecycle-contract" "swift" "macOS UI-host and PetCore lifecycle contract" "$ROOT_DIR/script/validate_app_lifecycle_contract.sh"
run_cached_step "fast/core" "schema-fixtures" "schemas" "JSON Schema positive/negative fixtures" "$ROOT_DIR/script/validate_schema_fixtures.sh"
run_cached_step "fast/core" "petpack-schemas" "schemas" "published petpack producer-profile schemas" "$ROOT_DIR/script/validate_petpack_spec_schemas.sh"
run_cached_step "fast/core" "localization-parity" "localization" "String Catalog and shipped localization parity" "$ROOT_DIR/script/validate_localizations.py"
if [[ -z "${APC_BUILD_ID:-}" ]]; then
export APC_BUILD_ID="validation.$(date -u +%Y%m%d%H%M%S).$$"
fi
export APC_INTERACTION_ATTESTATION_PATH="$TEST_ALL_TEMP_DIR/interaction-attestation.json"
INTERACTION_PROOF_IN=""
if [[ "$RESUME_VALIDATION" == "1" ]]; then
interaction_fingerprint="$(
"$ROOT_DIR/script/validation_fingerprint.py" --root "$ROOT_DIR" --scope interaction
)"
cached_interaction_proof="$VALIDATION_CACHE_DIR/artifacts/interaction-$interaction_fingerprint-$CACHE_CONTEXT.json"
if [[ -f "$cached_interaction_proof" ]] \
&& "$ROOT_DIR/script/validate_interaction_attestation.py" "$cached_interaction_proof" >/dev/null; then
INTERACTION_PROOF_IN="$cached_interaction_proof"
fi
fi
INTERACTION_PREPARE_ARGS=(--output "$APC_INTERACTION_ATTESTATION_PATH")
if [[ -n "$INTERACTION_PROOF_IN" ]]; then
INTERACTION_PREPARE_ARGS+=(--proof-in "$INTERACTION_PROOF_IN")
fi
run_step "fast/core" "native Swift Phase A/T-B4 interaction attestation bound to this PetCore build" \
"$ROOT_DIR/script/prepare_interaction_attestation.sh" \
--output "$APC_INTERACTION_ATTESTATION_PATH"
run_step "simulated integration" "portable pet maker helper, create/modify, and isolated daemon roundtrip" "$ROOT_DIR/script/validate_portable_pet_maker.sh"
run_step "fast/core" "pet-making Skill structure and shared contracts" "$ROOT_DIR/script/validate_pet_skills.sh"
run_step "fast/core" "shell, Python, JSON and release-script syntax/safety" "$ROOT_DIR/script/validate_build_scripts_safety.sh" --static-only
run_step "fast/core" "Rust formatting" cargo fmt --all --manifest-path "$ROOT_DIR/Cargo.toml" -- --check
run_step "fast/core" "strict Rust linting" cargo clippy --manifest-path "$ROOT_DIR/Cargo.toml" --workspace --all-targets --all-features --locked -- -D warnings
run_step "fast/core" "Rust workspace unit and integration tests" cargo test --manifest-path "$ROOT_DIR/Cargo.toml" --workspace --locked
run_step "fast/core" "complete Swift unit and UI-model test suites" "$ROOT_DIR/script/validate_swift_tests.sh"
run_step "simulated integration" "generated connector hook/plugin runtime smoke; not real third-party agent acceptance" "$ROOT_DIR/script/validate_connectors_runtime.sh"
run_step "perf/nightly" "bounded event storm stress at APC_EVENT_STORM_COUNT=${APC_EVENT_STORM_COUNT:-180}" "$ROOT_DIR/script/validate_event_storm.sh"
run_step "fast/core" "offline overlay geometry, scheduler, accessibility, frame-pipeline and pointer contracts" "$ROOT_DIR/script/validate_overlay_offline.sh"
run_step "fast/core" "security boundary checks with fake sentinel secrets" "$ROOT_DIR/script/validate_security_boundaries.sh"
run_step "simulated integration" "development app bundle assembly without launch" "$ROOT_DIR/script/build_app_bundle.sh" --configuration debug
"$ROOT_DIR/script/prepare_interaction_attestation.sh" "${INTERACTION_PREPARE_ARGS[@]}"
if [[ "$RESUME_VALIDATION" == "1" && -z "$INTERACTION_PROOF_IN" ]]; then
cp "$APC_INTERACTION_ATTESTATION_PATH" "$cached_interaction_proof"
fi
run_cached_step "simulated integration" "portable-pet-maker" "producer" "portable pet maker helper, create/modify, and isolated daemon roundtrip" "$ROOT_DIR/script/validate_portable_pet_maker.sh"
run_cached_step "fast/core" "pet-skills" "producer" "pet-making Skill structure and shared contracts" "$ROOT_DIR/script/validate_pet_skills.sh"
run_cached_step "fast/core" "build-script-safety" "scripts" "shell, Python, JSON and release-script syntax/safety" "$ROOT_DIR/script/validate_build_scripts_safety.sh" --static-only
run_cached_step "fast/core" "rust-format" "rust" "Rust formatting" cargo fmt --all --manifest-path "$ROOT_DIR/Cargo.toml" -- --check
run_cached_step "fast/core" "rust-clippy" "rust" "strict Rust linting" cargo clippy --manifest-path "$ROOT_DIR/Cargo.toml" --workspace --all-targets --all-features --locked -- -D warnings
run_cached_step "fast/core" "rust-tests" "rust" "Rust workspace unit and integration tests" cargo test --manifest-path "$ROOT_DIR/Cargo.toml" --workspace --locked
run_cached_step "fast/core" "swift-tests" "swift" "complete Swift unit and UI-model test suites" "$ROOT_DIR/script/validate_swift_tests.sh"
run_cached_step "simulated integration" "connector-runtime" "connectors" "generated connector hook/plugin runtime smoke; not real third-party agent acceptance" "$ROOT_DIR/script/validate_connectors_runtime.sh"
run_cached_step "perf/nightly" "event-storm" "rust" "bounded event storm stress at APC_EVENT_STORM_COUNT=${APC_EVENT_STORM_COUNT:-180}" "$ROOT_DIR/script/validate_event_storm.sh"
run_step "fast/core" "offline overlay geometry, scheduler, accessibility, frame-pipeline and pointer contracts" \
"$ROOT_DIR/script/validate_overlay_offline.sh" \
--interaction-attestation "$APC_INTERACTION_ATTESTATION_PATH"
run_cached_step "fast/core" "security-boundaries" "security" "security boundary checks with fake sentinel secrets" "$ROOT_DIR/script/validate_security_boundaries.sh"
run_step "simulated integration" "development app bundle assembly without launch" \
"$ROOT_DIR/script/build_app_bundle.sh" \
--configuration debug \
--interaction-attestation "$APC_INTERACTION_ATTESTATION_PATH"
if host_ui_skip_reason_value="$(host_ui_skip_reason)"; then
log_skip "macos runtime" "real app bundle, overlay layout, display-width persistence, renderer telemetry, and app recovery" "$host_ui_skip_reason_value"
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import hashlib
import pathlib
import subprocess
import tempfile
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[2]
LOCALIZATION_VALIDATOR = ROOT / "script/validate_localizations.py"
FINGERPRINT = ROOT / "script/validation_fingerprint.py"
INTERACTION_VALIDATOR = ROOT / "script/validate_interaction_attestation.py"
OVERLAY_INTERACTION = ROOT / "script/validate_overlay_interaction.sh"
INTERACTION_SUITES = [
"OverlayPlacementAuthorityTests",
"AppStoreOverlaySnapshotTests",
"OverlayGeometryTests",
"OverlayDisplayWidthTests",
"OverlayInteractionTelemetryTests",
]
def write_localizations(root: pathlib.Path, *, chinese_value: str = "宠物库") -> None:
resources = root / "apps/macos/Sources/AgentPetCompanion/Resources"
(resources / "en.lproj").mkdir(parents=True)
(resources / "zh-Hans.lproj").mkdir(parents=True)
(resources / "en.lproj/Localizable.strings").write_text(
'/* navigation */\n"nav.library" = "Pet Library";\n', encoding="utf-8"
)
(resources / "zh-Hans.lproj/Localizable.strings").write_text(
f'"nav.library" = "{chinese_value}";\n', encoding="utf-8"
)
catalog = {
"sourceLanguage": "en",
"strings": {
"nav.library": {
"localizations": {
"en": {"stringUnit": {"state": "translated", "value": "Pet Library"}},
"zh-Hans": {"stringUnit": {"state": "translated", "value": "宠物库"}},
}
}
},
"version": "1.0",
}
(resources / "Localizable.xcstrings").write_text(
json.dumps(catalog, ensure_ascii=False), encoding="utf-8"
)
class LocalizationValidationTests(unittest.TestCase):
def test_matching_catalog_and_strings_pass(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
write_localizations(root)
result = subprocess.run(
[str(LOCALIZATION_VALIDATOR), "--root", str(root)],
text=True,
capture_output=True,
)
self.assertEqual(result.returncode, 0, result.stderr)
def test_value_drift_fails_with_the_key(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
write_localizations(root, chinese_value="错误翻译")
result = subprocess.run(
[str(LOCALIZATION_VALIDATOR), "--root", str(root)],
text=True,
capture_output=True,
)
self.assertNotEqual(result.returncode, 0)
self.assertIn("nav.library", result.stderr)
class ValidationFingerprintTests(unittest.TestCase):
def test_scope_changes_only_when_its_inputs_change(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
subprocess.run(["git", "init", "-q"], cwd=root, check=True)
(root / "crates/example").mkdir(parents=True)
(root / "docs").mkdir()
(root / "script").mkdir()
(root / "crates/example/lib.rs").write_text("pub fn value() -> u8 { 1 }\n", encoding="utf-8")
(root / "Cargo.toml").write_text("[workspace]\nmembers = []\n", encoding="utf-8")
(root / "Cargo.lock").write_text("", encoding="utf-8")
(root / "docs/note.md").write_text("one\n", encoding="utf-8")
(root / "script/interaction-contract-files.txt").write_text(
"crates/example/lib.rs\n", encoding="utf-8"
)
subprocess.run(["git", "add", "."], cwd=root, check=True)
def digest() -> str:
return subprocess.check_output(
[str(FINGERPRINT), "--root", str(root), "--scope", "rust"],
text=True,
).strip()
original = digest()
(root / "docs/note.md").write_text("two\n", encoding="utf-8")
self.assertEqual(digest(), original)
(root / "crates/example/lib.rs").write_text("pub fn value() -> u8 { 2 }\n", encoding="utf-8")
self.assertNotEqual(digest(), original)
class InteractionProofTests(unittest.TestCase):
def test_current_proof_can_be_rebound_without_repeating_swift_tests(self) -> None:
entries = [
line
for line in (ROOT / "script/interaction-contract-files.txt")
.read_text(encoding="utf-8")
.splitlines()
if line
]
digest = hashlib.sha256()
for entry in entries:
digest.update(entry.encode("utf-8"))
digest.update(b"\0")
digest.update((ROOT / entry).read_bytes())
digest.update(b"\0")
with tempfile.TemporaryDirectory() as directory:
temporary = pathlib.Path(directory)
proof = temporary / "proof.json"
rebound = temporary / "rebound.json"
proof.write_text(
json.dumps(
{
"schema_version": "apc.overlay-interaction-attestation.v1",
"build_id": "old-build",
"interaction_contract_digest": digest.hexdigest(),
"passed_suites": INTERACTION_SUITES,
"ok": True,
}
)
+ "\n",
encoding="utf-8",
)
subprocess.run([str(INTERACTION_VALIDATOR), str(proof)], check=True)
proof_link = temporary / "proof-link.json"
proof_link.symlink_to(proof)
linked = subprocess.run(
[str(INTERACTION_VALIDATOR), str(proof_link)],
text=True,
capture_output=True,
)
self.assertNotEqual(linked.returncode, 0)
self.assertIn("non-symlink", linked.stderr)
subprocess.run(
[
str(OVERLAY_INTERACTION),
"--proof-in",
str(proof),
"--attestation-out",
str(rebound),
"--build-id",
"new-build",
],
check=True,
text=True,
capture_output=True,
)
payload = json.loads(rebound.read_text(encoding="utf-8"))
self.assertEqual(payload["build_id"], "new-build")
self.assertEqual(payload["interaction_contract_digest"], digest.hexdigest())
if __name__ == "__main__":
unittest.main()
+32
View File
@@ -33,6 +33,8 @@ RELEASE_SCRIPTS=(
WORKFLOW="$ROOT_DIR/.github/workflows/release.yml"
TEST_ALL="$ROOT_DIR/script/test_all.sh"
OVERLAY_INTERACTION_VALIDATOR="$ROOT_DIR/script/validate_overlay_interaction.sh"
PRE_PUSH_VALIDATOR="$ROOT_DIR/script/validate_pre_push.sh"
LOCALIZATION_VALIDATOR="$ROOT_DIR/script/validate_localizations.py"
for obsolete_path in \
"$ROOT_DIR/config/distribution/AgentPetCompanion.entitlements" \
@@ -91,6 +93,27 @@ rg -Fq 'interaction-contract-files.txt' "$OVERLAY_INTERACTION_VALIDATOR"
rg -Fq 'petpack verify-production-interaction' \
"$ROOT_DIR/script/validate_app_bundle.sh"
rg -Fq 'interaction-attestation.json' "$ROOT_DIR/script/build_app_bundle.sh"
rg -Fq -- '--interaction-attestation "$APC_INTERACTION_ATTESTATION_PATH"' "$TEST_ALL"
rg -Fq 'validate_interaction_attestation.py' "$ROOT_DIR/script/build_app_bundle.sh"
rg -Fq 'validate_interaction_attestation.py' "$ROOT_DIR/script/validate_overlay_offline.sh"
rg -Fq 'validate_localizations.py' "$TEST_ALL"
rg -Fq 'validation_fingerprint.py' "$TEST_ALL"
rg -Fq -- '--resume' "$TEST_ALL"
rg -Fq 'test_all.sh" --resume' "$PRE_PUSH_VALIDATOR"
if rg -Fq 'test_all.sh --resume' "$WORKFLOW"; then
echo 'GitHub Release must not consume local validation checkpoints' >&2
exit 1
fi
python3 - "$TEST_ALL" <<'PY'
import pathlib
import sys
source = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")
localization = source.index('"localization-parity"')
rust_tests = source.index('"rust-tests"')
if localization >= rust_tests:
raise SystemExit("localization parity must run before expensive Rust tests")
PY
rg -Fq 'Run host-safe source, Phase A/T-B4 interaction, and integration gates' \
"$WORKFLOW"
@@ -291,6 +314,15 @@ PY
"$ROOT_DIR/script/validate_codex_plugin_version.py" --help >/dev/null
"$ROOT_DIR/script/verify_release_candidate_digests.sh" --help >/dev/null
"$ROOT_DIR/script/validate_github_release_artifacts.sh" --help >/dev/null
"$ROOT_DIR/script/validate_pre_push.sh" --help >/dev/null
"$ROOT_DIR/script/test_all.sh" --help >/dev/null
"$ROOT_DIR/script/validate_overlay_offline.sh" --help >/dev/null
"$ROOT_DIR/script/validate_localizations.py" --help >/dev/null
"$ROOT_DIR/script/validation_fingerprint.py" --help >/dev/null
"$ROOT_DIR/script/validate_interaction_attestation.py" --help >/dev/null
PYTHONDONTWRITEBYTECODE=1 \
python3 "$ROOT_DIR/script/tests/test_validation_tooling.py"
if [[ "$STATIC_ONLY" == "0" ]]; then
PYTHONDONTWRITEBYTECODE=1 \
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Validate a native interaction attestation against the current source contract."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import pathlib
import stat
import sys
EXPECTED_SUITES = [
"OverlayPlacementAuthorityTests",
"AppStoreOverlaySnapshotTests",
"OverlayGeometryTests",
"OverlayDisplayWidthTests",
"OverlayInteractionTelemetryTests",
]
def contract_digest(root: pathlib.Path) -> str:
list_path = root / "script/interaction-contract-files.txt"
entries = [line for line in list_path.read_text(encoding="utf-8").splitlines() if line]
if not entries or entries != sorted(set(entries)):
raise ValueError("interaction contract file list must be sorted, unique, and non-empty")
digest = hashlib.sha256()
for entry in entries:
relative = pathlib.PurePosixPath(entry)
if relative.is_absolute() or not relative.parts or any(part in {"", ".", ".."} for part in relative.parts):
raise ValueError(f"unsafe interaction contract path: {entry}")
path = root.joinpath(*relative.parts)
metadata = path.lstat()
if not stat.S_ISREG(metadata.st_mode) or path.is_symlink():
raise ValueError(f"interaction contract entry is not a regular file: {entry}")
digest.update(entry.encode("utf-8"))
digest.update(b"\0")
digest.update(path.read_bytes())
digest.update(b"\0")
return digest.hexdigest()
def validate(root: pathlib.Path, attestation_path: pathlib.Path, expected_build_id: str | None) -> None:
try:
descriptor = os.open(attestation_path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW)
except OSError as error:
raise ValueError("interaction attestation must be a regular, non-symlink file") from error
try:
metadata = os.fstat(descriptor)
if not stat.S_ISREG(metadata.st_mode):
raise ValueError("interaction attestation must be a regular, non-symlink file")
with os.fdopen(descriptor, encoding="utf-8") as file:
descriptor = -1
attestation = json.load(file)
finally:
if descriptor >= 0:
os.close(descriptor)
if attestation.get("schema_version") != "apc.overlay-interaction-attestation.v1":
raise ValueError("interaction attestation schema_version is invalid")
if attestation.get("ok") is not True:
raise ValueError("interaction attestation is not successful")
if attestation.get("passed_suites") != EXPECTED_SUITES:
raise ValueError("interaction attestation suite inventory is incomplete or reordered")
if expected_build_id is not None and attestation.get("build_id") != expected_build_id:
raise ValueError("interaction attestation build_id does not match the requested App build")
if attestation.get("interaction_contract_digest") != contract_digest(root):
raise ValueError("interaction attestation does not match the current source contract")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("attestation", type=pathlib.Path)
parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path(__file__).resolve().parent.parent)
parser.add_argument("--expected-build-id")
parser.add_argument("--print-digest", action="store_true")
args = parser.parse_args()
try:
attestation_path = pathlib.Path(os.path.abspath(args.attestation))
validate(args.root.resolve(), attestation_path, args.expected_build_id)
if args.print_digest:
print(contract_digest(args.root.resolve()))
except (OSError, ValueError, json.JSONDecodeError) as error:
print(f"invalid interaction attestation: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Validate exact parity between the String Catalog and shipped .strings files."""
from __future__ import annotations
import argparse
import json
import pathlib
import re
import sys
TOKEN = re.compile(
r"(?P<space>\s+)"
r"|(?P<block>/\*.*?\*/)"
r"|(?P<line>//[^\n]*(?:\n|$))"
r'|(?P<entry>"(?:\\.|[^"\\])*"\s*=\s*"(?:\\.|[^"\\])*"\s*;)',
re.DOTALL,
)
ENTRY = re.compile(
r'^(?P<key>"(?:\\.|[^"\\])*")\s*=\s*'
r'(?P<value>"(?:\\.|[^"\\])*")\s*;$',
re.DOTALL,
)
def decode_apple_string(token: str) -> str:
# Apple .strings permits uppercase \Uhhhh escapes; JSON uses \uhhhh.
normalized = re.sub(r"\\U([0-9A-Fa-f]{4})", r"\\u\1", token)
try:
value = json.loads(normalized)
except json.JSONDecodeError as error:
raise ValueError(f"invalid quoted string {token!r}: {error.msg}") from error
if not isinstance(value, str):
raise ValueError(f"expected quoted string, received {token!r}")
return value
def parse_strings(path: pathlib.Path) -> dict[str, str]:
source = path.read_text(encoding="utf-8")
entries: dict[str, str] = {}
position = 0
while position < len(source):
match = TOKEN.match(source, position)
if match is None:
line = source.count("\n", 0, position) + 1
excerpt = source[position : position + 80].splitlines()[0]
raise ValueError(f"{path}:{line}: unsupported .strings syntax near {excerpt!r}")
position = match.end()
entry = match.group("entry")
if entry is None:
continue
parsed = ENTRY.match(entry)
if parsed is None: # Defensive: TOKEN and ENTRY intentionally mirror one another.
raise ValueError(f"{path}: could not parse localization entry {entry!r}")
key = decode_apple_string(parsed.group("key"))
value = decode_apple_string(parsed.group("value"))
if key in entries:
raise ValueError(f"{path}: duplicate localization key {key!r}")
entries[key] = value
return entries
def catalog_values(path: pathlib.Path, locale: str) -> dict[str, str]:
with path.open(encoding="utf-8") as file:
catalog = json.load(file)
if catalog.get("sourceLanguage") != "en":
raise ValueError(f"{path}: sourceLanguage must be 'en'")
strings = catalog.get("strings")
if not isinstance(strings, dict):
raise ValueError(f"{path}: strings must be an object")
values: dict[str, str] = {}
for key, entry in strings.items():
try:
value = entry["localizations"][locale]["stringUnit"]["value"]
except (KeyError, TypeError) as error:
raise ValueError(f"{path}: {key!r} is missing the {locale!r} stringUnit value") from error
if not isinstance(value, str):
raise ValueError(f"{path}: {key!r} has a non-string {locale!r} value")
values[key] = value
return values
def compare(label: str, expected: dict[str, str], actual: dict[str, str]) -> list[str]:
errors: list[str] = []
missing = sorted(expected.keys() - actual.keys())
extra = sorted(actual.keys() - expected.keys())
if missing:
errors.append(f"{label}: missing keys: {', '.join(missing)}")
if extra:
errors.append(f"{label}: unexpected keys: {', '.join(extra)}")
for key in sorted(expected.keys() & actual.keys()):
if expected[key] != actual[key]:
errors.append(
f"{label}: {key!r} differs: .strings={expected[key]!r}, catalog={actual[key]!r}"
)
return errors
def validate(root: pathlib.Path) -> list[str]:
resources = root / "apps/macos/Sources/AgentPetCompanion/Resources"
catalog_path = resources / "Localizable.xcstrings"
english_path = resources / "en.lproj/Localizable.strings"
chinese_path = resources / "zh-Hans.lproj/Localizable.strings"
english = parse_strings(english_path)
chinese = parse_strings(chinese_path)
catalog_english = catalog_values(catalog_path, "en")
catalog_chinese = catalog_values(catalog_path, "zh-Hans")
errors = compare("English catalog parity", english, catalog_english)
errors.extend(compare("Simplified Chinese catalog parity", chinese, catalog_chinese))
if english.keys() != chinese.keys():
missing_chinese = sorted(english.keys() - chinese.keys())
missing_english = sorted(chinese.keys() - english.keys())
if missing_chinese:
errors.append(f"Simplified Chinese .strings missing keys: {', '.join(missing_chinese)}")
if missing_english:
errors.append(f"English .strings missing keys: {', '.join(missing_english)}")
return errors
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--root",
type=pathlib.Path,
default=pathlib.Path(__file__).resolve().parent.parent,
help="repository root (defaults to this script's parent repository)",
)
args = parser.parse_args()
try:
errors = validate(args.root.resolve())
except (OSError, ValueError, json.JSONDecodeError) as error:
print(error, file=sys.stderr)
return 1
if errors:
print("Localization source parity failed:", file=sys.stderr)
for error in errors:
print(f"- {error}", file=sys.stderr)
return 1
print("Localization String Catalog and .strings parity ok")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+32 -13
View File
@@ -5,9 +5,10 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
MACOS_DIR="$ROOT_DIR/apps/macos"
ATTESTATION_OUT=""
BUILD_ID=""
PROOF_IN=""
usage() {
echo 'usage: validate_overlay_interaction.sh [--attestation-out ABSOLUTE_PATH --build-id BUILD_ID]'
echo 'usage: validate_overlay_interaction.sh [--attestation-out ABSOLUTE_PATH --build-id BUILD_ID] [--proof-in ABSOLUTE_PATH]'
}
while (($# > 0)); do
@@ -22,6 +23,11 @@ while (($# > 0)); do
BUILD_ID="$2"
shift 2
;;
--proof-in)
(($# >= 2)) || { usage >&2; exit 2; }
PROOF_IN="$2"
shift 2
;;
-h|--help)
usage
exit 0
@@ -44,22 +50,35 @@ if [[ -n "$ATTESTATION_OUT" || -n "$BUILD_ID" ]]; then
}
mkdir -p "$(dirname "$ATTESTATION_OUT")"
fi
if [[ -n "$PROOF_IN" ]]; then
[[ "$PROOF_IN" == /* ]] || {
echo 'interaction proof input must be an absolute path' >&2
exit 2
}
[[ -n "$ATTESTATION_OUT" && -n "$BUILD_ID" ]] || {
echo 'interaction proof reuse requires --attestation-out and --build-id' >&2
exit 2
}
"$ROOT_DIR/script/validate_interaction_attestation.py" "$PROOF_IN"
fi
# Real pointer, Space, focus-loss, and multi-display acceptance remains a
# separate live-UI step. This script locks the same interaction invariants with
# deterministic tests that are safe to run unattended.
for suite in \
OverlayPlacementAuthorityTests \
AppStoreOverlaySnapshotTests \
OverlayGeometryTests \
OverlayDisplayWidthTests \
OverlayInteractionTelemetryTests; do
swift test \
--package-path "$MACOS_DIR" \
--filter "$suite" \
-Xswiftc -strict-concurrency=complete \
-Xswiftc -warnings-as-errors
done
if [[ -z "$PROOF_IN" ]]; then
for suite in \
OverlayPlacementAuthorityTests \
AppStoreOverlaySnapshotTests \
OverlayGeometryTests \
OverlayDisplayWidthTests \
OverlayInteractionTelemetryTests; do
swift test \
--package-path "$MACOS_DIR" \
--filter "$suite" \
-Xswiftc -strict-concurrency=complete \
-Xswiftc -warnings-as-errors
done
fi
if [[ -n "$ATTESTATION_OUT" ]]; then
python3 -B - \
+46 -8
View File
@@ -5,6 +5,40 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
MACOS_DIR="$ROOT_DIR/apps/macos"
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/apc-overlay-offline.XXXXXX")"
trap 'rm -rf "$TMP_DIR"' EXIT
INTERACTION_ATTESTATION=""
usage() {
echo 'usage: validate_overlay_offline.sh [--interaction-attestation ABSOLUTE_PATH]'
}
while (($# > 0)); do
case "$1" in
--interaction-attestation)
(($# >= 2)) || { usage >&2; exit 2; }
INTERACTION_ATTESTATION="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
usage >&2
exit 2
;;
esac
done
if [[ -n "$INTERACTION_ATTESTATION" ]]; then
[[ "$INTERACTION_ATTESTATION" == /* ]] || {
echo 'interaction attestation input must be an absolute path' >&2
exit 2
}
"$ROOT_DIR/script/validate_interaction_attestation.py" "$INTERACTION_ATTESTATION"
VERIFIED_INTERACTION_ATTESTATION="$TMP_DIR/interaction-attestation.json"
cp "$INTERACTION_ATTESTATION" "$VERIFIED_INTERACTION_ATTESTATION"
"$ROOT_DIR/script/validate_interaction_attestation.py" "$VERIFIED_INTERACTION_ATTESTATION"
fi
swift build \
--package-path "$MACOS_DIR" \
@@ -18,14 +52,18 @@ APC_DISABLE_LAUNCH_AGENT=1 \
APC_DISABLE_CODEX_APP_SERVER_AUTO=1 \
"$BIN_DIR/AgentPetCompanion" --run-ui-validation
for suite in \
OverlayPlacementAuthorityTests \
AppStoreOverlaySnapshotTests \
OverlayGeometryTests \
OverlayDisplayWidthTests \
OverlayInteractionTelemetryTests \
FrameTimelineTests \
PetFramePipelineTests; do
SUITES=(FrameTimelineTests PetFramePipelineTests)
if [[ -z "$INTERACTION_ATTESTATION" ]]; then
SUITES=(
OverlayPlacementAuthorityTests
AppStoreOverlaySnapshotTests
OverlayGeometryTests
OverlayDisplayWidthTests
OverlayInteractionTelemetryTests
"${SUITES[@]}"
)
fi
for suite in "${SUITES[@]}"; do
swift test \
--package-path "$MACOS_DIR" \
--filter "$suite" \
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BASE_REF=""
PLAN_ONLY=0
FULL=0
usage() {
cat <<'EOF'
usage: validate_pre_push.sh [--base REF] [--plan-only] [--full]
Runs source-safe, change-scoped checks for an ordinary commit. --full delegates
to test_all.sh --resume; CI and Release continue to use uncached test_all.sh.
EOF
}
while (($# > 0)); do
case "$1" in
--base)
(($# >= 2)) || { usage >&2; exit 2; }
BASE_REF="$2"
shift 2
;;
--plan-only)
PLAN_ONLY=1
shift
;;
--full)
FULL=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
usage >&2
exit 2
;;
esac
done
if [[ "$FULL" == "1" ]]; then
if [[ "$PLAN_ONLY" == "1" ]]; then
echo './script/test_all.sh --resume'
exit 0
fi
exec "$ROOT_DIR/script/test_all.sh" --resume
fi
if [[ -z "$BASE_REF" ]]; then
if upstream="$(git -C "$ROOT_DIR" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null)"; then
BASE_REF="$(git -C "$ROOT_DIR" merge-base HEAD "$upstream")"
else
BASE_REF="HEAD"
fi
fi
git -C "$ROOT_DIR" rev-parse --verify "$BASE_REF^{commit}" >/dev/null
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/apc-pre-push.XXXXXX")"
trap 'rm -rf "$TMP_DIR"' EXIT
CHANGED_PATHS="$TMP_DIR/changed-paths"
UNTRACKED_PATHS="$TMP_DIR/untracked-paths"
git -C "$ROOT_DIR" diff --name-only -z "$BASE_REF" -- >"$CHANGED_PATHS"
git -C "$ROOT_DIR" ls-files --others --exclude-standard -z >"$UNTRACKED_PATHS"
cat "$UNTRACKED_PATHS" >>"$CHANGED_PATHS"
eval "$(python3 -B - "$CHANGED_PATHS" <<'PY'
import pathlib
import shlex
import sys
raw = pathlib.Path(sys.argv[1]).read_bytes()
paths = sorted({item.decode("utf-8", errors="surrogateescape") for item in raw.split(b"\0") if item})
def matches(prefixes):
return any(path == prefix or path.startswith(prefix.rstrip("/") + "/") for path in paths for prefix in prefixes)
root_rust = any(path in {"Cargo.toml", "Cargo.lock", "rust-toolchain.toml"} for path in paths)
petcore_types = matches(("crates/petcore-types",))
petcore = matches(("crates/petcore",))
petcore_cli = matches(("crates/petcore-cli",))
schema_inputs = matches(("schemas", "fixtures"))
swift = matches(("apps/macos",))
localization = matches(("apps/macos/Sources/AgentPetCompanion/Resources",))
scripts = matches(("script", ".github"))
producer = (
matches(("skills/agent-pet-maker", "skills/agent-pet-studio"))
or any(path.startswith(("schemas/petpack", "fixtures/petpack")) for path in paths)
)
connectors = matches(("plugins", "docs/integrations/agent-connectors.md"))
if root_rust or petcore_types or schema_inputs:
rust_mode = "workspace"
elif petcore:
rust_mode = "petcore"
elif petcore_cli:
rust_mode = "cli"
else:
rust_mode = "none"
values = {
"APC_CHANGED_COUNT": str(len(paths)),
"APC_CHANGED_SWIFT": "1" if swift else "0",
"APC_CHANGED_LOCALIZATION": "1" if localization else "0",
"APC_CHANGED_SCRIPTS": "1" if scripts else "0",
"APC_CHANGED_PRODUCER": "1" if producer else "0",
"APC_CHANGED_CONNECTORS": "1" if connectors else "0",
"APC_RUST_MODE": rust_mode,
}
for key, value in values.items():
print(f"{key}={shlex.quote(value)}")
PY
)"
PLAN=("git diff --check against $BASE_REF" "source syntax and local Markdown links")
if [[ "$APC_CHANGED_LOCALIZATION" == "1" ]]; then
PLAN+=("String Catalog/.strings parity")
fi
if [[ "$APC_CHANGED_SCRIPTS" == "1" ]]; then
PLAN+=("static build and release-script safety")
fi
if [[ "$APC_CHANGED_PRODUCER" == "1" ]]; then
PLAN+=("pet Skill contracts and portable maker roundtrip")
fi
if [[ "$APC_CHANGED_CONNECTORS" == "1" ]]; then
PLAN+=("generated connector runtime smoke")
fi
case "$APC_RUST_MODE" in
workspace) PLAN+=("Rust fmt plus workspace Clippy/tests") ;;
petcore) PLAN+=("Rust fmt plus petcore/petcore-cli Clippy/tests") ;;
cli) PLAN+=("Rust fmt plus petcore-cli Clippy/tests") ;;
esac
if [[ "$APC_CHANGED_SWIFT" == "1" ]]; then
PLAN+=("complete Swift unit and UI-model tests")
fi
echo "Pre-push plan for $APC_CHANGED_COUNT changed path(s):"
printf ' - %s\n' "${PLAN[@]}"
if [[ "$PLAN_ONLY" == "1" ]]; then
exit 0
fi
run() {
local label="$1"
shift
printf '\n== %s ==\n' "$label"
"$@"
}
run "Whitespace and conflict-marker check" git -C "$ROOT_DIR" diff --check "$BASE_REF" --
while IFS= read -r -d '' untracked_path; do
untracked_check=""
untracked_status=0
if untracked_check="$(
git -C "$ROOT_DIR" diff --no-index --check -- /dev/null "$ROOT_DIR/$untracked_path" 2>&1
)"; then
untracked_status=0
else
untracked_status=$?
fi
if ((untracked_status > 1)) || [[ -n "$untracked_check" ]]; then
printf '%s\n' "$untracked_check" >&2
echo "untracked file failed whitespace validation: $untracked_path" >&2
exit 1
fi
done <"$UNTRACKED_PATHS"
run "Source syntax" "$ROOT_DIR/script/validate_source_syntax.sh"
if [[ "$APC_CHANGED_LOCALIZATION" == "1" ]]; then
run "Localization parity" "$ROOT_DIR/script/validate_localizations.py"
fi
if [[ "$APC_CHANGED_SCRIPTS" == "1" ]]; then
run "Static build-script safety" "$ROOT_DIR/script/validate_build_scripts_safety.sh" --static-only
fi
if [[ "$APC_CHANGED_PRODUCER" == "1" ]]; then
run "Pet Skill contracts" "$ROOT_DIR/script/validate_pet_skills.sh"
run "Portable pet maker" "$ROOT_DIR/script/validate_portable_pet_maker.sh"
fi
if [[ "$APC_CHANGED_CONNECTORS" == "1" ]]; then
run "Connector runtime" "$ROOT_DIR/script/validate_connectors_runtime.sh"
fi
RUST_PACKAGE_ARGS=()
case "$APC_RUST_MODE" in
workspace) RUST_PACKAGE_ARGS=(--workspace) ;;
petcore) RUST_PACKAGE_ARGS=(-p petcore -p petcore-cli) ;;
cli) RUST_PACKAGE_ARGS=(-p petcore-cli) ;;
esac
if ((${#RUST_PACKAGE_ARGS[@]} > 0)); then
run "Rust formatting" cargo fmt --all --manifest-path "$ROOT_DIR/Cargo.toml" -- --check
run "Scoped Rust linting" cargo clippy \
--manifest-path "$ROOT_DIR/Cargo.toml" \
"${RUST_PACKAGE_ARGS[@]}" \
--all-targets --all-features --locked -- -D warnings
run "Scoped Rust tests" cargo test \
--manifest-path "$ROOT_DIR/Cargo.toml" \
"${RUST_PACKAGE_ARGS[@]}" \
--locked
fi
if [[ "$APC_CHANGED_SWIFT" == "1" ]]; then
run "Swift tests" "$ROOT_DIR/script/validate_swift_tests.sh"
fi
echo 'Change-scoped pre-push validation passed. Use --full for the complete local gate.'
+52 -2
View File
@@ -143,18 +143,34 @@ cp "$ROOT_DIR/script/test_all.sh" "$TEST_ROOT/script/test_all.sh"
cp "$ROOT_DIR/script/validation_helpers.sh" "$TEST_ROOT/script/validation_helpers.sh"
for script_name in \
validate_test_isolation.sh validate_app_lifecycle_contract.sh validate_schema_fixtures.sh \
validate_build_scripts_safety.sh validate_source_syntax.sh validate_swift_tests.sh build_app_bundle.sh \
validate_build_scripts_safety.sh validate_source_syntax.sh validate_localizations.py \
validate_swift_tests.sh build_app_bundle.sh \
validate_connectors_runtime.sh validate_event_storm.sh \
validate_portable_pet_maker.sh validate_pet_skills.sh validate_petpack_spec_schemas.sh \
validate_security_boundaries.sh validate_overlay_offline.sh validate_main_window_ui.sh \
validate_overlay_non_mouse.sh validate_overlay_interaction.sh \
prepare_interaction_attestation.sh \
validate_overlay_display_width_persistence.sh validate_renderer_runtime_budget.sh \
validate_app_recovery.sh; do
validate_app_recovery.sh validate_interaction_attestation.py; do
write_executable "$TEST_ROOT/script/$script_name" \
'#!/usr/bin/env bash' \
'exit 0'
done
write_executable "$TEST_ROOT/script/validation_fingerprint.py" \
'#!/usr/bin/env bash' \
'printf '\''fixture-fingerprint\n'\'''
write_executable "$TEST_ROOT/script/prepare_interaction_attestation.sh" \
'#!/usr/bin/env bash' \
'output=""' \
'while (($# > 0)); do' \
' case "$1" in' \
' --output) output="$2"; shift 2 ;;' \
' --proof-in) shift 2 ;;' \
' *) shift ;;' \
' esac' \
'done' \
'mkdir -p "$(dirname "$output")"' \
'printf '\''{}\n'\'' >"$output"'
write_executable "$TEST_ROOT/script/build_and_run.sh" \
'#!/usr/bin/env bash' \
'printf '\''host-ui\n'\'' >>"$APC_ISOLATION_FORBIDDEN_LOG"' \
@@ -191,6 +207,40 @@ if ! rg -q '^clippy --manifest-path .+/Cargo\.toml --workspace --all-targets --a
record_failure 'default test_all did not invoke the strict Rust linting gate'
fi
# The explicit local resume mode must cache successful source-scoped steps,
# while final overlay/App assembly stays outside the cache. The fixture uses a
# deterministic fingerprint and inert proof validator so no real build runs.
git -C "$TEST_ROOT" init -q
: >"$TEST_ALL_CARGO_LOG"
if ! env \
-u APC_VALIDATE_HOST_UI \
-u APC_VALIDATE_REAL_AGENT_CONNECTORS \
-u APC_VALIDATE_REAL_APP_SERVER \
PATH="$SHIM_DIR:$PATH" \
APC_ISOLATION_FORBIDDEN_LOG="$FORBIDDEN_LOG" \
APC_TEST_ALL_CARGO_LOG="$TEST_ALL_CARGO_LOG" \
"$TEST_ROOT/script/test_all.sh" --resume >/dev/null 2>&1; then
record_failure 'resumable test_all.sh failed on its first isolated run'
fi
first_resume_cargo_count="$(wc -l <"$TEST_ALL_CARGO_LOG" | tr -d ' ')"
if [[ "$first_resume_cargo_count" == "0" ]]; then
record_failure 'resumable test_all.sh did not execute uncached Rust steps'
fi
if ! env \
-u APC_VALIDATE_HOST_UI \
-u APC_VALIDATE_REAL_AGENT_CONNECTORS \
-u APC_VALIDATE_REAL_APP_SERVER \
PATH="$SHIM_DIR:$PATH" \
APC_ISOLATION_FORBIDDEN_LOG="$FORBIDDEN_LOG" \
APC_TEST_ALL_CARGO_LOG="$TEST_ALL_CARGO_LOG" \
"$TEST_ROOT/script/test_all.sh" --resume >/dev/null 2>&1; then
record_failure 'resumable test_all.sh failed on its second isolated run'
fi
second_resume_cargo_count="$(wc -l <"$TEST_ALL_CARGO_LOG" | tr -d ' ')"
if [[ "$second_resume_cargo_count" != "$first_resume_cargo_count" ]]; then
record_failure 'resumable test_all.sh repeated unchanged successful Rust steps'
fi
if rg -n '/tmp/apc-connector-extra\.(out|err)' "$ROOT_DIR/script/validate_connectors_runtime.sh" >/dev/null; then
record_failure 'connector validation still uses shared /tmp output paths'
fi
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Compute source-scoped fingerprints for resumable local validation."""
from __future__ import annotations
import argparse
import hashlib
import os
import pathlib
import stat
import subprocess
import sys
SCOPES: dict[str, tuple[str, ...]] = {
"all": (),
"localization": (
"apps/macos/Sources/AgentPetCompanion/Resources",
"script/validate_localizations.py",
),
"schemas": ("schemas", "fixtures", "script/validate_schema_fixtures.sh", "script/validate_petpack_spec_schemas.sh"),
"scripts": ("script", "skills", "schemas", "fixtures", "plugins", ".github"),
"producer": ("Cargo.toml", "Cargo.lock", "crates", "skills", "schemas", "fixtures", "script"),
"rust": (
"Cargo.toml",
"Cargo.lock",
"rust-toolchain.toml",
"crates",
"schemas",
"fixtures",
"plugins",
"skills",
),
"swift": ("apps/macos",),
"connectors": ("Cargo.toml", "Cargo.lock", "crates", "plugins", "schemas", "fixtures", "script"),
"security": ("Cargo.toml", "Cargo.lock", "crates", "plugins", "schemas", "fixtures", "script"),
"bundle": ("Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "apps/macos", "crates", "plugins", "skills", "schemas", "fixtures", "script"),
}
def git_files(root: pathlib.Path, paths: tuple[str, ...]) -> list[pathlib.Path]:
command = ["git", "ls-files", "-co", "--exclude-standard", "-z"]
if paths:
command.extend(["--", *paths])
result = subprocess.run(command, cwd=root, check=True, capture_output=True)
relative_paths = sorted({item for item in result.stdout.split(b"\0") if item})
return [root / os.fsdecode(item) for item in relative_paths]
def interaction_files(root: pathlib.Path) -> list[pathlib.Path]:
list_path = root / "script/interaction-contract-files.txt"
entries = [line for line in list_path.read_text(encoding="utf-8").splitlines() if line]
if not entries or entries != sorted(set(entries)):
raise ValueError("interaction contract file list must be sorted, unique, and non-empty")
return [list_path, *(root / entry for entry in entries)]
def fingerprint(root: pathlib.Path, scope: str, extra: str) -> str:
files = interaction_files(root) if scope == "interaction" else git_files(root, SCOPES[scope])
if scope == "rust":
files = sorted(set((*files, *interaction_files(root))))
digest = hashlib.sha256()
digest.update(b"apc.validation-fingerprint.v1\0")
digest.update(scope.encode("utf-8"))
digest.update(b"\0")
digest.update(extra.encode("utf-8"))
digest.update(b"\0")
for path in files:
relative = path.relative_to(root).as_posix()
metadata = path.lstat()
digest.update(relative.encode("utf-8"))
digest.update(b"\0")
digest.update(str(stat.S_IMODE(metadata.st_mode)).encode("ascii"))
digest.update(b"\0")
if path.is_symlink():
digest.update(b"link\0")
digest.update(os.readlink(path).encode("utf-8", errors="surrogateescape"))
elif path.is_file():
digest.update(b"file\0")
with path.open("rb") as file:
for chunk in iter(lambda: file.read(1024 * 1024), b""):
digest.update(chunk)
else:
raise ValueError(f"validation input is not a regular file or symlink: {relative}")
digest.update(b"\0")
return digest.hexdigest()
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path.cwd())
parser.add_argument("--scope", choices=sorted((*SCOPES.keys(), "interaction")), required=True)
parser.add_argument("--extra", default="")
args = parser.parse_args()
try:
print(fingerprint(args.root.resolve(), args.scope, args.extra))
except (OSError, ValueError, subprocess.CalledProcessError) as error:
print(f"could not fingerprint validation inputs: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())