mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 00:03:20 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/fix-pty-connection-flake
This commit is contained in:
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Download APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
|
||||
- **Android:** [Download APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+164
-13
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-07-20",
|
||||
"updatedAt": "2026-07-22",
|
||||
"policy": {
|
||||
"maturityLevels": [
|
||||
"experimental",
|
||||
@@ -2552,11 +2552,107 @@
|
||||
],
|
||||
"demotionRule": "Cannot promote while Windows E2E is flaky, silently skipped, or screenshot-only."
|
||||
},
|
||||
{
|
||||
"id": "terminal-performance.cold-restore-replay-budget",
|
||||
"title": "Daemon cold restore keeps replay work and retained payloads bounded",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime",
|
||||
"layer": "main-daemon-unit",
|
||||
"surfaces": [
|
||||
"startup restore",
|
||||
"daemon history replay",
|
||||
"sleep and hibernation restore",
|
||||
"main-process memory"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["daemon", "wsl"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["daemon"],
|
||||
"coverageNotes": "Deterministic main-process tests cover byte-bounded cache eviction and ACK release, one-at-a-time replay admission, a fixed per-turn replay budget within one large output record, UTF-16 boundary preservation, and checkpoint-only restore bypass while another replay is paused. The same HistoryReader path carries WSL context, but live WSL and cross-platform startup-scale runs remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/9971",
|
||||
"https://github.com/stablyai/orca/pull/9990",
|
||||
"https://github.com/stablyai/orca/issues/9441"
|
||||
],
|
||||
"invariant": "Cold restore must reproduce persisted terminal output while admitting at most one scratch-emulator replay, yielding after at most 64 Ki UTF-16 code units or 1,024 replay operations, keeping sticky restore payloads within 16 MiB, and allowing header-only checkpoint restores to bypass the replay queue.",
|
||||
"oracle": "Pause setImmediate during two single-batch restores larger than one replay slice and require exactly one admitted yield at a time, preserved text across a surrogate-pair slice boundary, and a concurrent header-only checkpoint restore to finish without consuming a replay slot. Cache tests require least-recently-used eviction, rejection of one oversized payload, and zero retained cache bytes after renderer ACK.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/cold-restore-payload-cache.test.ts src/main/daemon/history-reader.test.ts src/main/daemon/terminal-history-incremental-restore.test.ts src/main/daemon/hibernation-cold-restore-repro.test.ts src/main/daemon/daemon-pty-adapter.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/daemon/cold-restore-payload-cache.test.ts",
|
||||
"src/main/daemon/history-reader.test.ts",
|
||||
"src/main/daemon/terminal-history-incremental-restore.test.ts",
|
||||
"src/main/daemon/hibernation-cold-restore-repro.test.ts",
|
||||
"src/main/daemon/daemon-pty-adapter.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/daemon/terminal-history-incremental-restore.test.ts",
|
||||
"assertions": [
|
||||
"large single-batch replays yield within the record, preserve a surrogate pair at the slice boundary, and admit only one scratch replay at a time",
|
||||
"a header-only checkpoint restore completes while an unrelated incremental replay is paused"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/daemon/cold-restore-payload-cache.test.ts",
|
||||
"assertions": [
|
||||
"least-recently-used payloads are evicted to the aggregate byte budget",
|
||||
"one payload larger than the entire budget is not retained"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/daemon/daemon-pty-adapter.test.ts",
|
||||
"assertions": [
|
||||
"StrictMode remount receives sticky cold-restore data until renderer ACK clears its retained bytes"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-22",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/cold-restore-payload-cache.test.ts src/main/daemon/history-reader.test.ts src/main/daemon/terminal-history-incremental-restore.test.ts src/main/daemon/hibernation-cold-restore-repro.test.ts src/main/daemon/daemon-pty-adapter.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 5.68,
|
||||
"summary": "Five focused files passed 150 tests, including deterministic single-record replay slicing, one-at-a-time admission, UTF-16 boundary preservation, header-only queue bypass, byte-bounded LRU eviction, and ACK cleanup."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 15,
|
||||
"scope": "focused main-process cold-restore unit contract"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "The focused deterministic slice is new and has no CI or soak history yet."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The prior implementation had no yield inside one large batch and queued header-only restores behind the shared semaphore by inspection; an intentional pre-fix test run was not recorded."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Production admits one emulator replay globally, yields after a deterministic 64 Ki character or 1,024-operation budget even within one record, bypasses the semaphore for the common header-only final-checkpoint path, and caps sticky payloads at 16 MiB. No polling, subprocess, session inventory, or renderer wake loop is added."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Record an intentional-break red run for both the within-record yield and header-only bypass assertions.",
|
||||
"Collect startup event-loop-delay evidence with dozens of near-cap histories on representative macOS, Windows, and Linux hardware.",
|
||||
"Add live WSL restore evidence before claiming WSL coverage."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The log decoder and final headless snapshot serialization remain synchronous inside the one-at-a-time replay slot; the gate bounds replay writes, not every CPU phase.",
|
||||
"No live Electron startup-scale run currently proves first-pane paint order or end-to-end restore latency with dozens of histories.",
|
||||
"SSH, remote-runtime, relay, and mobile do not use this local daemon history reader and are unaffected."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote to protection none if output differs across replay slices, header-only restores consume a replay slot, retained payload bytes exceed the cap, or the focused gate flakes."
|
||||
},
|
||||
{
|
||||
"id": "terminal-performance.no-hot-list-sessions",
|
||||
"title": "Hot terminal interactions do not call global PTY session listing",
|
||||
"maturity": "experimental",
|
||||
"protection": "none",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-performance",
|
||||
"layer": "ipc-count-contract",
|
||||
"surfaces": [
|
||||
@@ -2580,19 +2676,74 @@
|
||||
"wsl",
|
||||
"remote-runtime"
|
||||
],
|
||||
"coveredPlatforms": [],
|
||||
"coveredPlatforms": [
|
||||
"macos"
|
||||
],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Registered gap on main. The targeted-hasPty product hardening and no-hot count assertions exist only on the pending reliability stack. It registers here with its owning split PR.",
|
||||
"coverageNotes": "Platform-neutral unit coverage proves the Resource Manager closed badge performs one readiness seed, coalesces unknown spawn signals, skips known-session reattach signals, and installs no interval. Broader terminal interaction coverage remains on the pending reliability stack.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/7002",
|
||||
"https://github.com/stablyai/orca/pull/6858"
|
||||
"https://github.com/stablyai/orca/pull/6858",
|
||||
"https://github.com/stablyai/orca/issues/9386",
|
||||
"https://github.com/stablyai/orca/pull/9387"
|
||||
],
|
||||
"invariant": "Typing, focus, terminal switch, workspace switch, visibility resume, resize, render, and per-pane liveness paths must not call global pty:listSessions; they must use targeted per-PTY APIs or cached provider-owned state.",
|
||||
"oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and closed Resource Manager status badges avoid pty:listSessions; targeted hasPty/getSize calls are allowed for liveness/resize slices and forbidden for light tab/active-state resume. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session local/daemon/SSH fixtures.",
|
||||
"commands": [],
|
||||
"testFiles": [],
|
||||
"assertionRefs": [],
|
||||
"evidenceRuns": [],
|
||||
"oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and a closed Resource Manager budget of one readiness seed plus one coalesced inventory read only for unknown spawn IDs; known-session reattach signals and steady closed time perform zero reads. Targeted hasPty/getSize calls are allowed for liveness/resize slices and forbidden for light tab/active-state resume. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session local/daemon/SSH fixtures.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx src/renderer/src/components/status-bar/resource-session-inventory.test.ts src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/ipc/pty.test.ts",
|
||||
"src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx",
|
||||
"src/renderer/src/components/status-bar/resource-session-inventory.test.ts",
|
||||
"src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx",
|
||||
"assertions": [
|
||||
"the false-to-true workspace readiness transition performs one daemon inventory seed",
|
||||
"a failed readiness seed surfaces an error and a later inventory refresh recovers",
|
||||
"known-session reattach signals perform zero additional inventory reads",
|
||||
"multiple unknown background spawn signals coalesce to one inventory read",
|
||||
"spawn signals during a slow inventory read never overlap provider-wide scans and cause at most one required follow-up",
|
||||
"unknown sessions that exit before reconciliation cancel their queued inventory read",
|
||||
"unmount during a slow inventory read cannot schedule follow-up work",
|
||||
"exit and out-of-order refresh races cannot resurrect stale sessions"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/pty.test.ts",
|
||||
"assertions": [
|
||||
"global inventory starts local and SSH provider listings concurrently"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/status-bar/resource-session-inventory.test.ts",
|
||||
"assertions": [
|
||||
"daemon inventory construction copies its source and preserves count parity",
|
||||
"single and batch removals preserve unrelated sessions and no-op references"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts",
|
||||
"assertions": [
|
||||
"the closed inventory hook installs no interval",
|
||||
"the badge count comes from cached daemon inventory rather than wake-hint bindings"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-22",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx src/renderer/src/components/status-bar/resource-session-inventory.test.ts src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 4.3,
|
||||
"summary": "4 files and 358 tests passed, covering readiness seed/recovery, zero interval polling, bounded unknown-spawn reconciliation, concurrent provider starts, exit fencing, cleanup, and out-of-order refresh fencing."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 20,
|
||||
"scope": "unit or focused Electron count gate"
|
||||
@@ -2603,7 +2754,7 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Tests assert visibility resume prefers targeted hasPty over listSessions, first input after visibility resume calls targeted hasPty once, resize re-assertion after visibility resume uses getSize/resize without listSessions, light tab switches and visible active-state resume avoid listSessions/hasPty/getSize fanout while still allowing the active PTY scheduler hint, SSH/remote broad listing is skipped, Resource Manager broad session inventory polling is scoped to the open popover rather than its closed badge, and panes close only on authoritative false. Needs broader raw focus/workspace-switch/render/high-session count coverage before promotion."
|
||||
"evidence": "Tests assert visibility resume prefers targeted hasPty over listSessions, first input after visibility resume calls targeted hasPty once, resize re-assertion after visibility resume uses getSize/resize without listSessions, light tab switches and visible active-state resume avoid listSessions/hasPty/getSize fanout while still allowing the active PTY scheduler hint, SSH/remote broad listing is skipped, and the closed Resource Manager performs one readiness seed while known reattach signals and steady time perform no additional reads. Needs broader raw focus/workspace-switch/render/high-session count coverage before promotion."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
@@ -2615,8 +2766,8 @@
|
||||
"Run with enough preserved sessions/providers to make a broad listing observable."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No executable coverage on main yet; the slice lives on the pending fix-terminal-reliability stack.",
|
||||
"Current command covers targeted visibility/first-input liveness, resize re-assertion on visibility resume, light tab/active-state resume, SSH/remote skip behavior, and closed Resource Manager session-poll avoidance, but not every hot interaction listed in the invariant.",
|
||||
"Current commands cover Resource Manager readiness/lifecycle inventory counts; the broader targeted-liveness slice still lives on the pending fix-terminal-reliability stack.",
|
||||
"Current coverage includes the closed Resource Manager's no-interval and known-reattach budgets, but not every hot interaction listed in the invariant.",
|
||||
"No Electron or IPC-level high-session counter gate yet proves raw focus, workspace switch, render, or high-session typing stay at zero global listSessions calls."
|
||||
],
|
||||
"demotionRule": "Cannot promote if the test allows broad listing in any hot interaction path."
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
"../src/main/codex/codex-app-server-session.ts",
|
||||
"../src/main/codex/codex-config-mirror.ts",
|
||||
"../src/main/codex/codex-config-path-reference-rewrite.ts",
|
||||
"../src/main/codex/codex-config-settings-preservation.ts",
|
||||
"../src/main/codex/codex-config-settings-removal.ts",
|
||||
"../src/main/codex/codex-config-settings-upsert.ts",
|
||||
"../src/main/codex/codex-home-paths.ts",
|
||||
"../src/main/codex/codex-host-retry-deadlines.ts",
|
||||
"../src/main/codex/codex-hook-identity.ts",
|
||||
@@ -41,7 +44,10 @@
|
||||
"../src/main/codex/codex-user-hook-trust-rebase.ts",
|
||||
"../src/main/codex/codex-wsl-hook-install-plan.ts",
|
||||
"../src/main/codex/codex-wsl-reconciliation-generations.ts",
|
||||
"../src/main/codex/config-settings-baseline.ts",
|
||||
"../src/main/codex/config-settings-conflict-resolution.ts",
|
||||
"../src/main/codex/config-settings-promotion.ts",
|
||||
"../src/main/codex/config-toml-key-path.ts",
|
||||
"../src/main/codex/config-toml-line-scan.ts",
|
||||
"../src/main/codex/config-toml-trust.ts",
|
||||
"../src/main/codex/hook-service.ts",
|
||||
|
||||
@@ -31,7 +31,10 @@ function sendResult(accepted: boolean): RpcSuccess {
|
||||
return { id: 'send', ok: true, result: { send: { accepted } }, _meta: { runtimeId: 'r' } }
|
||||
}
|
||||
|
||||
function makeClient(responses: RpcResponse[]): Pick<RpcClient, 'sendRequest'> & {
|
||||
function makeClient(responses: (RpcResponse | Promise<RpcResponse>)[]): Pick<
|
||||
RpcClient,
|
||||
'sendRequest'
|
||||
> & {
|
||||
calls: { method: string; params: Record<string, unknown> }[]
|
||||
} {
|
||||
const calls: { method: string; params: Record<string, unknown> }[] = []
|
||||
@@ -52,6 +55,7 @@ type HookArgs = Parameters<typeof useMobileNativeChatImageAttachments>[0]
|
||||
type Hook = ReturnType<typeof useMobileNativeChatImageAttachments>
|
||||
|
||||
const SCOPE_A = 'h\0w\0tab-a'
|
||||
const SCOPE_B = 'h\0w\0tab-b'
|
||||
|
||||
function baseArgs(overrides: Partial<HookArgs> & Pick<HookArgs, 'client'>): HookArgs {
|
||||
return {
|
||||
@@ -403,6 +407,14 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
await act(async () => {
|
||||
await hook!.attachImage('library')
|
||||
})
|
||||
let overlappingAccepted = true
|
||||
await act(async () => {
|
||||
overlappingAccepted = await hook!.sendNativeChat('too soon')
|
||||
})
|
||||
expect(overlappingAccepted).toBe(false)
|
||||
expect(baseSend).not.toHaveBeenCalled()
|
||||
expect(client.calls.filter((call) => call.method === 'terminal.send')).toHaveLength(2)
|
||||
|
||||
await act(async () => {
|
||||
releaseSettle!()
|
||||
await sendPromise
|
||||
@@ -498,6 +510,159 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
expect(baseSend).toHaveBeenCalledWith('hi again')
|
||||
})
|
||||
|
||||
it('retains the stale marker when a rejected healing clear blocks text-only send', async () => {
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
const client = makeClient([
|
||||
methodNotFound('start'),
|
||||
ok('save', '/tmp/a.png'),
|
||||
sendResult(true), // Ctrl+U clear
|
||||
sendResult(true), // image paste accepted
|
||||
sendResult(false), // first healing Ctrl+U rejected
|
||||
sendResult(true) // retry healing Ctrl+U accepted
|
||||
])
|
||||
const baseSend = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true)
|
||||
mount(baseArgs({ client: client as unknown as RpcClient, baseSend }))
|
||||
await act(async () => {
|
||||
await hook!.attachImage('library')
|
||||
})
|
||||
await act(async () => {
|
||||
await hook!.sendNativeChat('hi')
|
||||
})
|
||||
expect(hook!.attachments).toHaveLength(1)
|
||||
|
||||
await act(async () => {
|
||||
hook!.removeAttachment('img-1')
|
||||
})
|
||||
let accepted = true
|
||||
await act(async () => {
|
||||
accepted = await hook!.sendNativeChat('hi again')
|
||||
})
|
||||
expect(accepted).toBe(false)
|
||||
expect(baseSend).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
accepted = await hook!.sendNativeChat('hi again')
|
||||
})
|
||||
expect(accepted).toBe(true)
|
||||
const sendCalls = client.calls.filter((c) => c.method === 'terminal.send')
|
||||
expect(sendCalls).toHaveLength(4)
|
||||
expect(sendCalls[2]?.params).toMatchObject({ text: '\x15', enter: false })
|
||||
expect(sendCalls[3]?.params).toMatchObject({ text: '\x15', enter: false })
|
||||
expect(baseSend).toHaveBeenNthCalledWith(1, 'hi', ['file:///a.jpg'])
|
||||
expect(baseSend).toHaveBeenNthCalledWith(2, 'hi again')
|
||||
})
|
||||
|
||||
it('does not reroute text when the active terminal changes during a healing clear', async () => {
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
let releaseClear: ((response: RpcResponse) => void) | null = null
|
||||
const deferredClear = new Promise<RpcResponse>((resolve) => {
|
||||
releaseClear = resolve
|
||||
})
|
||||
const client = makeClient([
|
||||
methodNotFound('start'),
|
||||
ok('save', '/tmp/a.png'),
|
||||
sendResult(true),
|
||||
sendResult(true),
|
||||
deferredClear
|
||||
])
|
||||
const baseSend = vi.fn().mockResolvedValueOnce(false)
|
||||
const activeHandleRef = { current: 'term-1' }
|
||||
mount(baseArgs({ client: client as unknown as RpcClient, activeHandleRef, baseSend }))
|
||||
await act(async () => {
|
||||
await hook!.attachImage('library')
|
||||
})
|
||||
await act(async () => {
|
||||
await hook!.sendNativeChat('hi')
|
||||
})
|
||||
await act(async () => {
|
||||
hook!.removeAttachment('img-1')
|
||||
})
|
||||
|
||||
let retry: Promise<boolean> | null = null
|
||||
await act(async () => {
|
||||
retry = hook!.sendNativeChat('hi again')
|
||||
await Promise.resolve()
|
||||
})
|
||||
activeHandleRef.current = 'term-2'
|
||||
let accepted = true
|
||||
await act(async () => {
|
||||
releaseClear!(sendResult(true))
|
||||
accepted = await retry!
|
||||
})
|
||||
|
||||
expect(accepted).toBe(false)
|
||||
expect(baseSend).toHaveBeenCalledTimes(1)
|
||||
const sendCalls = client.calls.filter((c) => c.method === 'terminal.send')
|
||||
expect(sendCalls[2]?.params).toMatchObject({ terminal: 'term-1', text: '\x15', enter: false })
|
||||
})
|
||||
|
||||
it('heals rejected image submits independently across terminals', async () => {
|
||||
pick
|
||||
.mockResolvedValueOnce({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
.mockResolvedValueOnce({ base64: 'BBBB', uri: 'file:///b.jpg' })
|
||||
const client = makeClient([
|
||||
methodNotFound('start-a'),
|
||||
ok('save-a', '/tmp/a.png'),
|
||||
sendResult(true),
|
||||
sendResult(true),
|
||||
methodNotFound('start-b'),
|
||||
ok('save-b', '/tmp/b.png'),
|
||||
sendResult(true),
|
||||
sendResult(true),
|
||||
sendResult(true),
|
||||
sendResult(true)
|
||||
])
|
||||
const baseSend = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true)
|
||||
const activeHandleRef = { current: 'term-1' }
|
||||
const args = baseArgs({ client: client as unknown as RpcClient, activeHandleRef, baseSend })
|
||||
mount(args)
|
||||
await act(async () => {
|
||||
await hook!.attachImage('library')
|
||||
})
|
||||
await act(async () => {
|
||||
await hook!.sendNativeChat('first')
|
||||
})
|
||||
|
||||
activeHandleRef.current = 'term-2'
|
||||
update({ ...args, scopeKey: SCOPE_B })
|
||||
await act(async () => {
|
||||
await hook!.attachImage('library')
|
||||
})
|
||||
await act(async () => {
|
||||
await hook!.sendNativeChat('second')
|
||||
})
|
||||
await act(async () => {
|
||||
hook!.removeAttachment('img-2')
|
||||
})
|
||||
|
||||
activeHandleRef.current = 'term-1'
|
||||
update(args)
|
||||
await act(async () => {
|
||||
hook!.removeAttachment('img-1')
|
||||
})
|
||||
await act(async () => {
|
||||
expect(await hook!.sendNativeChat('retry first')).toBe(true)
|
||||
})
|
||||
|
||||
activeHandleRef.current = 'term-2'
|
||||
update({ ...args, scopeKey: SCOPE_B })
|
||||
await act(async () => {
|
||||
expect(await hook!.sendNativeChat('retry second')).toBe(true)
|
||||
})
|
||||
|
||||
const sendCalls = client.calls.filter((c) => c.method === 'terminal.send')
|
||||
expect(sendCalls.slice(4).map((call) => call.params)).toMatchObject([
|
||||
{ terminal: 'term-1', text: '\x15', enter: false },
|
||||
{ terminal: 'term-2', text: '\x15', enter: false }
|
||||
])
|
||||
expect(baseSend).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('reports a disconnected attach failure via the live connection state', async () => {
|
||||
const client = makeClient([])
|
||||
const showToast = vi.fn()
|
||||
|
||||
@@ -71,6 +71,10 @@ function withScopeAttachments(
|
||||
return remaining
|
||||
}
|
||||
|
||||
function markTerminalInputStale(staleInputs: Set<string>, terminal: string): void {
|
||||
staleInputs.add(terminal)
|
||||
}
|
||||
|
||||
const defaultSleep = (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
@@ -99,9 +103,10 @@ export function useMobileNativeChatImageAttachments({
|
||||
// checked 'connected' at entry, so only a ref can see a mid-upload disconnect.
|
||||
const connStateRef = useRef(connState)
|
||||
connStateRef.current = connState
|
||||
// Terminal whose input line may hold a partial paste from a failed send; the
|
||||
// next send TO THAT terminal must lead with Ctrl+U even if it has no images.
|
||||
const staleInputTerminalRef = useRef<string | null>(null)
|
||||
// Terminals whose input may hold a failed paste; each must heal independently.
|
||||
const staleInputTerminalsRef = useRef(new Set<string>())
|
||||
// Serialize clear/paste/submit ownership per terminal while allowing other tabs to send.
|
||||
const sendInFlightTerminalsRef = useRef(new Set<string>())
|
||||
|
||||
const attachments = (scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_ATTACHMENTS
|
||||
|
||||
@@ -192,92 +197,120 @@ export function useMobileNativeChatImageAttachments({
|
||||
|
||||
const sendNativeChat = useCallback(
|
||||
async (text: string): Promise<boolean> => {
|
||||
const scope = scopeKey
|
||||
const pendingImages = (scope ? attachmentsByScope[scope] : undefined) ?? NO_ATTACHMENTS
|
||||
if (pendingImages.length === 0 || !scope) {
|
||||
// Heal a previously failed paste: a text-only send to that terminal would
|
||||
// otherwise glue the stale image paste onto this message. Best-effort —
|
||||
// on failure the marker stays set and the send proceeds as before.
|
||||
const staleTerminal = staleInputTerminalRef.current
|
||||
if (staleTerminal && staleTerminal === activeHandleRef.current && client) {
|
||||
try {
|
||||
await pasteMobileNativeChatImagePaths({
|
||||
client,
|
||||
terminal: staleTerminal,
|
||||
deviceToken: deviceTokenRef.current,
|
||||
imagePaths: []
|
||||
})
|
||||
staleInputTerminalRef.current = null
|
||||
} catch {
|
||||
// Leave marked for the next attempt.
|
||||
}
|
||||
}
|
||||
return baseSend(text)
|
||||
}
|
||||
const handle = activeHandleRef.current
|
||||
if (!client || !handle || !enabled || connState !== 'connected') {
|
||||
onError?.()
|
||||
// Mirror the text path's failure surface (the base send is never reached).
|
||||
showToast('Message not sent (disconnected)', 1500)
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const pasted = await pasteMobileNativeChatImagePaths({
|
||||
client,
|
||||
terminal: handle,
|
||||
deviceToken: deviceTokenRef.current,
|
||||
imagePaths: pendingImages.map((attachment) => attachment.path)
|
||||
})
|
||||
if (!pasted) {
|
||||
// Keep the chips so the user can retry; the failed paste never submitted.
|
||||
staleInputTerminalRef.current = handle
|
||||
onError?.()
|
||||
showToast('Message not sent', 1500)
|
||||
return false
|
||||
}
|
||||
// The paste's leading Ctrl+U cleared any earlier stale input in `handle`.
|
||||
if (staleInputTerminalRef.current === handle) {
|
||||
staleInputTerminalRef.current = null
|
||||
}
|
||||
// Let the TUI absorb the image paste before the text + Enter follow. The
|
||||
// preview URIs ride along to baseSend so the sent bubble shows the photo
|
||||
// immediately (empty text still submits a bare Enter through baseSend).
|
||||
await sleep(MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS)
|
||||
// The paste above targeted `handle`; a tab switch during the settle would
|
||||
// route the text + Enter to a different terminal than the images. Abort —
|
||||
// the chips keep their scope and a retry's Ctrl+U clears the stale paste.
|
||||
if (activeHandleRef.current !== handle) {
|
||||
staleInputTerminalRef.current = handle
|
||||
onError?.()
|
||||
showToast('Message not sent', 1500)
|
||||
return false
|
||||
}
|
||||
const accepted = await baseSend(
|
||||
text,
|
||||
pendingImages.map((attachment) => attachment.previewUri)
|
||||
)
|
||||
if (accepted) {
|
||||
// Drop only what rode along — a chip attached while this send was in
|
||||
// flight keeps waiting for its own send.
|
||||
const sentIds = new Set(pendingImages.map((attachment) => attachment.id))
|
||||
setAttachmentsByScope((prev) =>
|
||||
withScopeAttachments(
|
||||
prev,
|
||||
scope,
|
||||
(prev[scope] ?? []).filter((attachment) => !sentIds.has(attachment.id))
|
||||
)
|
||||
)
|
||||
}
|
||||
return accepted
|
||||
} catch {
|
||||
// A thrown paste/send (network/RPC) keeps the chips and honors the
|
||||
// Promise<boolean> contract instead of rejecting. Retry-safe: the next
|
||||
// attempt's leading Ctrl+U clears whatever fraction of the paste landed.
|
||||
staleInputTerminalRef.current = handle
|
||||
const operationTerminal = activeHandleRef.current
|
||||
if (operationTerminal && sendInFlightTerminalsRef.current.has(operationTerminal)) {
|
||||
onError?.()
|
||||
showToast('Message not sent', 1500)
|
||||
return false
|
||||
}
|
||||
if (operationTerminal) {
|
||||
sendInFlightTerminalsRef.current.add(operationTerminal)
|
||||
}
|
||||
try {
|
||||
const scope = scopeKey
|
||||
const pendingImages = (scope ? attachmentsByScope[scope] : undefined) ?? NO_ATTACHMENTS
|
||||
if (pendingImages.length === 0 || !scope) {
|
||||
// Heal a previously failed paste: a text-only send to that terminal would
|
||||
// otherwise glue the stale image paste onto this message. Best-effort —
|
||||
// on failure the marker stays set and the text must not be submitted.
|
||||
const staleTerminal = activeHandleRef.current
|
||||
if (staleTerminal && staleInputTerminalsRef.current.has(staleTerminal) && client) {
|
||||
let cleared = false
|
||||
try {
|
||||
cleared = await pasteMobileNativeChatImagePaths({
|
||||
client,
|
||||
terminal: staleTerminal,
|
||||
deviceToken: deviceTokenRef.current,
|
||||
imagePaths: []
|
||||
})
|
||||
} catch {
|
||||
// Leave marked for the next attempt.
|
||||
}
|
||||
if (!cleared) {
|
||||
onError?.()
|
||||
showToast('Message not sent', 1500)
|
||||
return false
|
||||
}
|
||||
staleInputTerminalsRef.current.delete(staleTerminal)
|
||||
if (activeHandleRef.current !== staleTerminal) {
|
||||
onError?.()
|
||||
showToast('Message not sent', 1500)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return baseSend(text)
|
||||
}
|
||||
const handle = activeHandleRef.current
|
||||
if (!client || !handle || !enabled || connState !== 'connected') {
|
||||
onError?.()
|
||||
// Mirror the text path's failure surface (the base send is never reached).
|
||||
showToast('Message not sent (disconnected)', 1500)
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const pasted = await pasteMobileNativeChatImagePaths({
|
||||
client,
|
||||
terminal: handle,
|
||||
deviceToken: deviceTokenRef.current,
|
||||
imagePaths: pendingImages.map((attachment) => attachment.path)
|
||||
})
|
||||
if (!pasted) {
|
||||
// Keep the chips so the user can retry; the failed paste never submitted.
|
||||
markTerminalInputStale(staleInputTerminalsRef.current, handle)
|
||||
onError?.()
|
||||
showToast('Message not sent', 1500)
|
||||
return false
|
||||
}
|
||||
// The paste's leading Ctrl+U cleared any earlier stale input in `handle`.
|
||||
staleInputTerminalsRef.current.delete(handle)
|
||||
// Let the TUI absorb the image paste before the text + Enter follow. The
|
||||
// preview URIs ride along to baseSend so the sent bubble shows the photo
|
||||
// immediately (empty text still submits a bare Enter through baseSend).
|
||||
await sleep(MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS)
|
||||
// The paste above targeted `handle`; a tab switch during the settle would
|
||||
// route the text + Enter to a different terminal than the images. Abort —
|
||||
// the chips keep their scope and a retry's Ctrl+U clears the stale paste.
|
||||
if (activeHandleRef.current !== handle) {
|
||||
markTerminalInputStale(staleInputTerminalsRef.current, handle)
|
||||
onError?.()
|
||||
showToast('Message not sent', 1500)
|
||||
return false
|
||||
}
|
||||
const accepted = await baseSend(
|
||||
text,
|
||||
pendingImages.map((attachment) => attachment.previewUri)
|
||||
)
|
||||
if (!accepted) {
|
||||
// A rejected submit leaves the successfully pasted image path on this input line.
|
||||
markTerminalInputStale(staleInputTerminalsRef.current, handle)
|
||||
}
|
||||
if (accepted) {
|
||||
// Drop only what rode along — a chip attached while this send was in
|
||||
// flight keeps waiting for its own send.
|
||||
const sentIds = new Set(pendingImages.map((attachment) => attachment.id))
|
||||
setAttachmentsByScope((prev) =>
|
||||
withScopeAttachments(
|
||||
prev,
|
||||
scope,
|
||||
(prev[scope] ?? []).filter((attachment) => !sentIds.has(attachment.id))
|
||||
)
|
||||
)
|
||||
}
|
||||
return accepted
|
||||
} catch {
|
||||
// A thrown paste/send (network/RPC) keeps the chips and honors the
|
||||
// Promise<boolean> contract instead of rejecting. Retry-safe: the next
|
||||
// attempt's leading Ctrl+U clears whatever fraction of the paste landed.
|
||||
markTerminalInputStale(staleInputTerminalsRef.current, handle)
|
||||
onError?.()
|
||||
showToast('Message not sent', 1500)
|
||||
return false
|
||||
}
|
||||
} finally {
|
||||
if (operationTerminal) {
|
||||
sendInFlightTerminalsRef.current.delete(operationTerminal)
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
activeHandleRef,
|
||||
|
||||
@@ -2586,4 +2586,188 @@ describe('CodexAccountService config sync', () => {
|
||||
expect(state.systemDefault?.email).toBe('real@home.dev')
|
||||
})
|
||||
})
|
||||
|
||||
// Why: quota probes against a cold per-account CODEX_HOME can take 10–25s
|
||||
// (RPC + PTY fallback) and queue behind an in-flight global usage refresh;
|
||||
// account mutations must never block on — or fail because of — that probe.
|
||||
describe('quota refresh decoupling', () => {
|
||||
function createAccountOneSettings(): GlobalSettings {
|
||||
const managedHomePath = createManagedHome(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
'',
|
||||
'{"account":"managed"}\n'
|
||||
)
|
||||
return createSettings({
|
||||
codexManagedAccounts: [
|
||||
{
|
||||
id: 'account-1',
|
||||
email: 'user@example.com',
|
||||
managedHomePath,
|
||||
providerAccountId: null,
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
async function expectResolvesPromptly<T>(promise: Promise<T>, label: string): Promise<T> {
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error(`${label} blocked on the quota refresh`)),
|
||||
2_000
|
||||
)
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
function createLoginSpawnMock() {
|
||||
return vi.fn((_command: string, _args: string[], options: { env: NodeJS.ProcessEnv }) => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: PassThrough
|
||||
stderr: PassThrough
|
||||
kill: () => void
|
||||
}
|
||||
child.stdout = new PassThrough()
|
||||
child.stderr = new PassThrough()
|
||||
child.kill = vi.fn()
|
||||
writeFileSync(
|
||||
join(options.env.CODEX_HOME!, 'auth.json'),
|
||||
createCodexAuthJson('user@example.com', 'provider-account-1', 'refresh-token'),
|
||||
'utf-8'
|
||||
)
|
||||
queueMicrotask(() => child.emit('close', 0))
|
||||
return child
|
||||
})
|
||||
}
|
||||
|
||||
it('resolves selectAccount while the quota refresh never settles', async () => {
|
||||
const store = createStore(createAccountOneSettings())
|
||||
const rateLimits = {
|
||||
refreshForCodexAccountChange: vi.fn(() => new Promise<never>(() => {})),
|
||||
evictInactiveCodexCache: vi.fn()
|
||||
}
|
||||
const runtimeHome = createRuntimeHome()
|
||||
|
||||
const { CodexAccountService } = await import('./service')
|
||||
const service = new CodexAccountService(
|
||||
store as never,
|
||||
rateLimits as never,
|
||||
runtimeHome as never
|
||||
)
|
||||
|
||||
const state = await expectResolvesPromptly(
|
||||
service.selectAccount('account-1'),
|
||||
'selectAccount'
|
||||
)
|
||||
|
||||
expect(state.activeAccountId).toBe('account-1')
|
||||
expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('resolves selectAccount when the quota refresh rejects', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const store = createStore(createAccountOneSettings())
|
||||
const rateLimits = {
|
||||
refreshForCodexAccountChange: vi.fn().mockRejectedValue(new Error('cold probe failed')),
|
||||
evictInactiveCodexCache: vi.fn()
|
||||
}
|
||||
const runtimeHome = createRuntimeHome()
|
||||
|
||||
const { CodexAccountService } = await import('./service')
|
||||
const service = new CodexAccountService(
|
||||
store as never,
|
||||
rateLimits as never,
|
||||
runtimeHome as never
|
||||
)
|
||||
|
||||
const state = await service.selectAccount('account-1')
|
||||
|
||||
expect(state.activeAccountId).toBe('account-1')
|
||||
await vi.waitFor(() => expect(errorSpy).toHaveBeenCalled())
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('resolves addAccount while the post-login quota refresh never settles', async () => {
|
||||
vi.resetModules()
|
||||
writeFileSync(
|
||||
join(testState.fakeHomeDir, '.codex', 'config.toml'),
|
||||
'approval_policy = "never"\n',
|
||||
'utf-8'
|
||||
)
|
||||
const spawnMock = createLoginSpawnMock()
|
||||
vi.doMock('node:child_process', () => ({ execFileSync: vi.fn(), spawn: spawnMock }))
|
||||
vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' }))
|
||||
|
||||
const store = createStore(createSettings())
|
||||
const rateLimits = {
|
||||
refreshForCodexAccountChange: vi.fn(() => new Promise<never>(() => {})),
|
||||
evictInactiveCodexCache: vi.fn()
|
||||
}
|
||||
const runtimeHome = createRuntimeHome()
|
||||
|
||||
const { CodexAccountService } = await import('./service')
|
||||
const service = new CodexAccountService(
|
||||
store as never,
|
||||
rateLimits as never,
|
||||
runtimeHome as never
|
||||
)
|
||||
|
||||
const state = await expectResolvesPromptly(service.addAccount(), 'addAccount')
|
||||
|
||||
expect(state.accounts).toHaveLength(1)
|
||||
expect(state.accounts[0].email).toBe('user@example.com')
|
||||
expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the new account and its managed home when the post-login quota refresh rejects', async () => {
|
||||
vi.resetModules()
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
writeFileSync(
|
||||
join(testState.fakeHomeDir, '.codex', 'config.toml'),
|
||||
'approval_policy = "never"\n',
|
||||
'utf-8'
|
||||
)
|
||||
const spawnMock = createLoginSpawnMock()
|
||||
vi.doMock('node:child_process', () => ({ execFileSync: vi.fn(), spawn: spawnMock }))
|
||||
vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' }))
|
||||
|
||||
const store = createStore(createSettings())
|
||||
const rateLimits = {
|
||||
refreshForCodexAccountChange: vi.fn().mockRejectedValue(new Error('cold probe failed')),
|
||||
evictInactiveCodexCache: vi.fn()
|
||||
}
|
||||
const runtimeHome = createRuntimeHome()
|
||||
|
||||
const { CodexAccountService } = await import('./service')
|
||||
const service = new CodexAccountService(
|
||||
store as never,
|
||||
rateLimits as never,
|
||||
runtimeHome as never
|
||||
)
|
||||
|
||||
const state = await service.addAccount()
|
||||
|
||||
expect(state.accounts).toHaveLength(1)
|
||||
const account = store.getSettings().codexManagedAccounts[0]
|
||||
expect(account.email).toBe('user@example.com')
|
||||
// The durable mutation must survive a failed usage probe — previously the
|
||||
// rejection fell into login cleanup and deleted the just-created home.
|
||||
expect(existsSync(account.managedHomePath)).toBe(true)
|
||||
expect(existsSync(join(account.managedHomePath, 'auth.json'))).toBe(true)
|
||||
await vi.waitFor(() => expect(errorSpy).toHaveBeenCalled())
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -204,6 +204,20 @@ export class CodexAccountService {
|
||||
return this.serializeMutation(() => this.doSelectAccount(accountId, target))
|
||||
}
|
||||
|
||||
// Why: quota probes against a cold per-account CODEX_HOME can take 10–25s
|
||||
// (RPC + PTY fallback) and queue behind an in-flight global usage refresh.
|
||||
// The refresh synchronously flips usage to "fetching" before its first await,
|
||||
// so the switcher updates immediately; the probe itself must never block or
|
||||
// fail the already-durable account mutation.
|
||||
private startQuotaRefreshInBackground(
|
||||
outgoingAccountId: string | null | undefined,
|
||||
target: CodexAccountSelectionTarget | undefined
|
||||
): void {
|
||||
void this.rateLimits.refreshForCodexAccountChange(outgoingAccountId, target).catch((error) => {
|
||||
console.error('[codex-accounts] Quota refresh after account change failed:', error)
|
||||
})
|
||||
}
|
||||
|
||||
private async doAddAccount(target?: CodexAccountAddTarget): Promise<CodexRateLimitAccountsState> {
|
||||
const accountId = randomUUID()
|
||||
const managedHome = this.createManagedHome(accountId, target)
|
||||
@@ -254,7 +268,7 @@ export class CodexAccountService {
|
||||
|
||||
// Why: switching activates the new account, so cache the outgoing account's usage for the switcher.
|
||||
const outgoingAccountId = getSelectedCodexAccountIdForTarget(settings, targetSelection)
|
||||
await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId, targetSelection)
|
||||
this.startQuotaRefreshInBackground(outgoingAccountId, targetSelection)
|
||||
return this.getSnapshot()
|
||||
} catch (error) {
|
||||
this.safeRemoveManagedHome(managedHomePath, accountId)
|
||||
@@ -310,7 +324,7 @@ export class CodexAccountService {
|
||||
this.runtimeHome.syncForCurrentSelection(accountTarget)
|
||||
|
||||
// Why: re-auth can change the underlying Codex identity, so force a fresh read to avoid showing stale quota.
|
||||
await this.rateLimits.refreshForCodexAccountChange(undefined, accountTarget)
|
||||
this.startQuotaRefreshInBackground(undefined, accountTarget)
|
||||
return this.getSnapshot()
|
||||
}
|
||||
|
||||
@@ -339,7 +353,7 @@ export class CodexAccountService {
|
||||
// Why: a removed account can no longer appear in the switcher dropdown,
|
||||
// so purge its cached usage to avoid stale entries.
|
||||
this.rateLimits.evictInactiveCodexCache(accountId)
|
||||
await this.rateLimits.refreshForCodexAccountChange(
|
||||
this.startQuotaRefreshInBackground(
|
||||
getSelectedCodexAccountIdForTarget(settings, getCodexSelectionTargetForAccount(account)) ===
|
||||
accountId
|
||||
? accountId
|
||||
@@ -388,7 +402,7 @@ export class CodexAccountService {
|
||||
this.lifecycle.onHostSystemDefaultSelected?.()
|
||||
}
|
||||
|
||||
await this.rateLimits.refreshForCodexAccountChange(outgoingAccountId, effectiveTarget)
|
||||
this.startQuotaRefreshInBackground(outgoingAccountId, effectiveTarget)
|
||||
return this.getSnapshot()
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@ import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import {
|
||||
promoteCodexRuntimeSettingsToSystem,
|
||||
snapshotCodexRuntimeSettingsBaseline,
|
||||
type CodexSettingsPromotionHomes
|
||||
type CodexSettingsPromotionHomes,
|
||||
type CodexSettingsPromotionPlan
|
||||
} from './config-settings-promotion'
|
||||
import { preserveRuntimeConflictValues } from './codex-config-settings-preservation'
|
||||
import {
|
||||
createTomlLineScanState,
|
||||
getTomlTableHeader,
|
||||
@@ -31,32 +33,37 @@ export function syncSystemConfigIntoManagedCodexHome(
|
||||
// Why: the mirror overwrites runtime settings from ~/.codex, so changes the
|
||||
// user made inside Orca-launched Codex (/model, /approvals) must be written
|
||||
// back to ~/.codex first or this very pass silently reverts them.
|
||||
if (!promoteCodexRuntimeSettingsToSystem(homes)) {
|
||||
const promotionPlan = promoteCodexRuntimeSettingsToSystem(homes)
|
||||
if (!promotionPlan) {
|
||||
// Why: mirroring after a failed write-back would erase the runtime change;
|
||||
// leave both runtime and its old baseline intact so the next launch retries.
|
||||
return
|
||||
}
|
||||
let preservedConflictKeys: ReadonlySet<string>
|
||||
try {
|
||||
syncSystemConfigIntoManagedCodexHomeUnsafe(homes)
|
||||
preservedConflictKeys = syncSystemConfigIntoManagedCodexHomeUnsafe(homes, promotionPlan)
|
||||
} catch (error) {
|
||||
console.warn('[codex-config] Failed to mirror system Codex config:', error)
|
||||
return
|
||||
}
|
||||
// Why: the baseline advances only after a successful mirror; recording an
|
||||
// unpromoted runtime change as Orca-written would strand it forever.
|
||||
snapshotCodexRuntimeSettingsBaseline(homes.runtimeHomePath)
|
||||
snapshotCodexRuntimeSettingsBaseline(
|
||||
homes.runtimeHomePath,
|
||||
new Map([...promotionPlan.conflicts].filter(([key]) => preservedConflictKeys.has(key)))
|
||||
)
|
||||
}
|
||||
|
||||
function syncSystemConfigIntoManagedCodexHomeUnsafe({
|
||||
runtimeHomePath,
|
||||
systemHomePath
|
||||
}: CodexSettingsPromotionHomes): void {
|
||||
function syncSystemConfigIntoManagedCodexHomeUnsafe(
|
||||
{ runtimeHomePath, systemHomePath }: CodexSettingsPromotionHomes,
|
||||
promotionPlan: CodexSettingsPromotionPlan
|
||||
): ReadonlySet<string> {
|
||||
const systemConfigPath = join(systemHomePath, 'config.toml')
|
||||
const runtimeConfigPath = join(runtimeHomePath, 'config.toml')
|
||||
const systemConfigExists = existsSync(systemConfigPath)
|
||||
const runtimeConfigExists = existsSync(runtimeConfigPath)
|
||||
if (!systemConfigExists && !runtimeConfigExists) {
|
||||
return
|
||||
return new Set()
|
||||
}
|
||||
|
||||
const rawSystemConfig = systemConfigExists ? readAgentStateFileSync(systemConfigPath) : ''
|
||||
@@ -66,15 +73,19 @@ function syncSystemConfigIntoManagedCodexHomeUnsafe({
|
||||
runtimeConfigPath,
|
||||
prepareSystemConfigForFreshRuntimeMirror(rawSystemConfig, sourceConfigDir)
|
||||
)
|
||||
return
|
||||
return new Set()
|
||||
}
|
||||
|
||||
const systemConfig = prepareSystemConfigForRuntimeMirror(rawSystemConfig, sourceConfigDir)
|
||||
const runtimeConfig = readAgentStateFileSync(runtimeConfigPath)
|
||||
const mergedConfig = mergeSystemCodexConfigIntoRuntime(runtimeConfig, systemConfig)
|
||||
if (mergedConfig !== runtimeConfig) {
|
||||
writeFileAtomically(runtimeConfigPath, mergedConfig)
|
||||
const preserved = preserveRuntimeConflictValues(
|
||||
mergeSystemCodexConfigIntoRuntime(runtimeConfig, systemConfig),
|
||||
promotionPlan.runtimeValuesToPreserve
|
||||
)
|
||||
if (preserved.content !== runtimeConfig) {
|
||||
writeFileAtomically(runtimeConfigPath, preserved.content)
|
||||
}
|
||||
return preserved.keys
|
||||
}
|
||||
|
||||
export function resolveCodexConfigMirrorSourceDirectory(systemHomePath: string): string {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { removePromotedSettingsFromContent } from './codex-config-settings-removal'
|
||||
import { upsertPromotedSettingsInContent } from './codex-config-settings-upsert'
|
||||
|
||||
export function preserveRuntimeConflictValues(
|
||||
content: string,
|
||||
values: ReadonlyMap<string, string | null>
|
||||
): { content: string; keys: ReadonlySet<string> } {
|
||||
let result = content
|
||||
const keys = new Set<string>()
|
||||
for (const [key, raw] of values) {
|
||||
const previous = result
|
||||
result =
|
||||
raw === null
|
||||
? removePromotedSettingsFromContent(result, new Set([key]))
|
||||
: upsertPromotedSettingsInContent(result, new Map([[key, raw]]))
|
||||
if (result !== previous) {
|
||||
keys.add(key)
|
||||
}
|
||||
}
|
||||
// Why: only schema-new ambiguous keys stay runtime-local; every unrelated setting still mirrors.
|
||||
return { content: result, keys }
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { removePromotedSettingsFromContent } from './codex-config-settings-removal'
|
||||
|
||||
describe('removePromotedSettingsFromContent', () => {
|
||||
it('removes a top-level preamble key without touching nested copies', () => {
|
||||
expect(
|
||||
removePromotedSettingsFromContent(
|
||||
'model = "root"\n\n[profiles.dev]\nmodel = "nested"\n',
|
||||
new Set(['model'])
|
||||
)
|
||||
).toBe('\n[profiles.dev]\nmodel = "nested"\n')
|
||||
})
|
||||
|
||||
it('removes a bare key from the first tui table body', () => {
|
||||
expect(
|
||||
removePromotedSettingsFromContent(
|
||||
'[tui]\ntheme = "dark"\nanimations = true\n\n[tui.notifications]\ntheme = "nested"\n',
|
||||
new Set(['tui.theme'])
|
||||
)
|
||||
).toBe('[tui]\nanimations = true\n\n[tui.notifications]\ntheme = "nested"\n')
|
||||
})
|
||||
|
||||
it('removes dotted and quoted dotted tui keys from the preamble', () => {
|
||||
expect(
|
||||
removePromotedSettingsFromContent(
|
||||
'tui.theme = "dark"\n"tui" . "status_line" = ["model"]\n',
|
||||
new Set(['tui.theme', 'tui.status_line'])
|
||||
)
|
||||
).toBe('')
|
||||
})
|
||||
|
||||
it('does not remove a tui-shaped key inside another table', () => {
|
||||
const content = '[[profiles]]\ntui.theme = "profile-theme"\n'
|
||||
expect(removePromotedSettingsFromContent(content, new Set(['tui.theme']))).toBe(content)
|
||||
})
|
||||
|
||||
it('does not remove a nested tui descendant with the same first segment', () => {
|
||||
const content = '[tui]\ntheme.variant = "dark"\n'
|
||||
expect(removePromotedSettingsFromContent(content, new Set(['tui.theme']))).toBe(content)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
createTomlLineScanState,
|
||||
getTomlTableHeader,
|
||||
isTomlStructuralLine,
|
||||
updateTomlLineScanState
|
||||
} from './config-toml-line-scan'
|
||||
import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path'
|
||||
import { tuiStructuredKey } from './codex-config-settings-upsert'
|
||||
|
||||
export function removePromotedSettingsFromContent(
|
||||
content: string,
|
||||
removals: ReadonlySet<string>
|
||||
): string {
|
||||
if (removals.size === 0) {
|
||||
return content
|
||||
}
|
||||
const lines = content.split('\n')
|
||||
const indexes: number[] = []
|
||||
let state = createTomlLineScanState()
|
||||
let inPreamble = true
|
||||
let tuiTableSeen = false
|
||||
let tuiBodyActive = false
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index] ?? ''
|
||||
if (isTomlStructuralLine(state)) {
|
||||
const header = getTomlTableHeader(line)
|
||||
if (header) {
|
||||
const table = parseTomlTableHeaderPath(header)
|
||||
tuiBodyActive =
|
||||
table !== null &&
|
||||
!table.isArray &&
|
||||
table.segments.length === 1 &&
|
||||
table.segments[0] === 'tui' &&
|
||||
!tuiTableSeen
|
||||
tuiTableSeen ||= tuiBodyActive
|
||||
inPreamble = false
|
||||
state = updateTomlLineScanState(state, line)
|
||||
continue
|
||||
}
|
||||
const parsed = parseTomlKeyPath(line)
|
||||
if (parsed && line[parsed.end] === '=') {
|
||||
const structuredKey = getStructuredKey(parsed.segments, inPreamble, tuiBodyActive)
|
||||
if (structuredKey && removals.has(structuredKey)) {
|
||||
indexes.push(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
state = updateTomlLineScanState(state, line)
|
||||
}
|
||||
|
||||
for (const index of indexes.toReversed()) {
|
||||
lines.splice(index, 1)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function getStructuredKey(
|
||||
segments: string[],
|
||||
inPreamble: boolean,
|
||||
tuiBodyActive: boolean
|
||||
): string | null {
|
||||
if (inPreamble && segments.length === 1) {
|
||||
return segments[0] ?? null
|
||||
}
|
||||
if (inPreamble && segments.length === 2 && segments[0] === 'tui') {
|
||||
return segments[1] ? tuiStructuredKey(segments[1]) : null
|
||||
}
|
||||
if (tuiBodyActive && segments.length === 1) {
|
||||
return segments[0] ? tuiStructuredKey(segments[0]) : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import {
|
||||
createTomlLineScanState,
|
||||
getTomlTableHeader,
|
||||
isTomlStructuralLine,
|
||||
updateTomlLineScanState
|
||||
} from './config-toml-line-scan'
|
||||
import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path'
|
||||
|
||||
const TUI_STRUCTURED_PREFIX = 'tui.'
|
||||
|
||||
// Why: promoted [tui] settings are keyed by structured path (tui.<key>) so their
|
||||
// baseline/update entries can never collide with a top-level key of the same name.
|
||||
export function tuiStructuredKey(key: string): string {
|
||||
return `${TUI_STRUCTURED_PREFIX}${key}`
|
||||
}
|
||||
|
||||
export function isTuiStructuredKey(structuredKey: string): boolean {
|
||||
return structuredKey.startsWith(TUI_STRUCTURED_PREFIX)
|
||||
}
|
||||
|
||||
export function tuiKeyFromStructuredKey(structuredKey: string): string {
|
||||
return structuredKey.slice(TUI_STRUCTURED_PREFIX.length)
|
||||
}
|
||||
|
||||
// Why: promoted updates arrive keyed by structured path; the preamble and [tui]
|
||||
// regions are disjoint, so a mixed batch (e.g. /model + a status-line change)
|
||||
// composes in one rewrite — top-level keys land in the preamble, tui.<key>
|
||||
// entries wherever the [tui] placement rule puts them.
|
||||
export function upsertPromotedSettingsInContent(
|
||||
content: string,
|
||||
updates: Map<string, string>
|
||||
): string {
|
||||
const topLevelUpdates = new Map<string, string>()
|
||||
const tuiUpdates = new Map<string, string>()
|
||||
for (const [key, raw] of updates) {
|
||||
if (isTuiStructuredKey(key)) {
|
||||
tuiUpdates.set(tuiKeyFromStructuredKey(key), raw)
|
||||
} else {
|
||||
topLevelUpdates.set(key, raw)
|
||||
}
|
||||
}
|
||||
let result = content
|
||||
if (topLevelUpdates.size > 0) {
|
||||
result = upsertTopLevelSettingsInContent(result, topLevelUpdates)
|
||||
}
|
||||
if (tuiUpdates.size > 0) {
|
||||
result = upsertTuiSettingsInContent(result, tuiUpdates)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function upsertTopLevelSettingsInContent(
|
||||
content: string,
|
||||
updates: Map<string, string>
|
||||
): string {
|
||||
const lines = content.split('\n')
|
||||
let state = createTomlLineScanState()
|
||||
let preambleEnd = lines.length
|
||||
const keyLineIndexes = new Map<string, number>()
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index] ?? ''
|
||||
if (isTomlStructuralLine(state)) {
|
||||
if (getTomlTableHeader(line)) {
|
||||
preambleEnd = index
|
||||
break
|
||||
}
|
||||
const parsed = parseTomlKeyPath(line)
|
||||
const key = parsed?.segments.length === 1 ? parsed.segments[0] : null
|
||||
if (parsed && line[parsed.end] === '=' && key && updates.has(key)) {
|
||||
keyLineIndexes.set(key, index)
|
||||
}
|
||||
}
|
||||
state = updateTomlLineScanState(state, line)
|
||||
}
|
||||
|
||||
// Why: CRLF configs keep a trailing \r after the split; new lines must use
|
||||
// the file's existing endings or a Windows-owned config becomes mixed-EOL.
|
||||
const usesCrlf = content.includes('\r\n')
|
||||
const insertions: string[] = []
|
||||
for (const [key, raw] of updates) {
|
||||
const existingIndex = keyLineIndexes.get(key)
|
||||
const rendered = `${key} = ${raw}`
|
||||
if (existingIndex !== undefined) {
|
||||
lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered
|
||||
} else {
|
||||
insertions.push(usesCrlf ? `${rendered}\r` : rendered)
|
||||
}
|
||||
}
|
||||
if (insertions.length > 0) {
|
||||
let insertAt = preambleEnd
|
||||
while (insertAt > 0 && (lines[insertAt - 1] ?? '').trim() === '') {
|
||||
insertAt -= 1
|
||||
}
|
||||
if (insertAt === preambleEnd && preambleEnd < lines.length) {
|
||||
insertions.push(usesCrlf ? '\r' : '')
|
||||
}
|
||||
lines.splice(insertAt, 0, ...insertions)
|
||||
}
|
||||
return joinPreservingTrailingNewline(lines, usesCrlf)
|
||||
}
|
||||
|
||||
type TuiPlacementScan = {
|
||||
bareKeyIndexes: Map<string, number>
|
||||
dottedKeyIndexes: Map<string, number>
|
||||
hasBareTuiTable: boolean
|
||||
hasDottedTuiKey: boolean
|
||||
blocksNewTuiTable: boolean
|
||||
blockedAbsentKeys: Set<string>
|
||||
bareBodyInsertIndex: number
|
||||
lastDottedTuiIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Upserts promoted `[tui]` keys (keyed by bare name) into the system config,
|
||||
* placing each per the design's total placement rule: replace an existing key
|
||||
* in place keeping its form; else insert bare into the first `[tui]` body; else
|
||||
* dotted in the preamble beside existing dotted `tui.*` keys; else create one
|
||||
* `[tui]` table at EOF for every key that reaches that branch. Rendering follows
|
||||
* the destination — bare inside a table, dotted in the preamble — so no `tui`
|
||||
* table is ever defined twice.
|
||||
*/
|
||||
export function upsertTuiSettingsInContent(content: string, updates: Map<string, string>): string {
|
||||
const lines = content.split('\n')
|
||||
const scan = scanTuiPlacement(lines, updates)
|
||||
const usesCrlf = content.includes('\r\n')
|
||||
const bareBodyInserts: string[] = []
|
||||
const dottedPreambleInserts: string[] = []
|
||||
const newTableKeys: string[] = []
|
||||
|
||||
for (const [key, raw] of updates) {
|
||||
const dottedIndex = scan.dottedKeyIndexes.get(key)
|
||||
if (dottedIndex !== undefined) {
|
||||
lines[dottedIndex] = withTrailingCr(lines[dottedIndex]!, `${tuiStructuredKey(key)} = ${raw}`)
|
||||
continue
|
||||
}
|
||||
const bareIndex = scan.bareKeyIndexes.get(key)
|
||||
if (bareIndex !== undefined) {
|
||||
lines[bareIndex] = withTrailingCr(lines[bareIndex]!, `${key} = ${raw}`)
|
||||
continue
|
||||
}
|
||||
// Why: adding a scalar beside an existing tui.<key> descendant would turn valid TOML invalid.
|
||||
if (scan.blockedAbsentKeys.has(key)) {
|
||||
continue
|
||||
}
|
||||
if (scan.hasBareTuiTable) {
|
||||
bareBodyInserts.push(`${key} = ${raw}`)
|
||||
} else if (scan.hasDottedTuiKey) {
|
||||
dottedPreambleInserts.push(`${tuiStructuredKey(key)} = ${raw}`)
|
||||
} else if (!scan.blocksNewTuiTable) {
|
||||
// Why: inline/array tui definitions block this branch because adding a
|
||||
// plain [tui] beside either would make the config invalid.
|
||||
newTableKeys.push(`${key} = ${raw}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the config shape routes every absent key to the same branch, so at most
|
||||
// one insert group is non-empty; still apply EOF→body→preamble so a splice
|
||||
// never shifts a lower index a later splice depends on.
|
||||
if (newTableKeys.length > 0) {
|
||||
appendNewTuiTable(lines, newTableKeys, usesCrlf)
|
||||
}
|
||||
if (bareBodyInserts.length > 0) {
|
||||
lines.splice(
|
||||
scan.bareBodyInsertIndex,
|
||||
0,
|
||||
...bareBodyInserts.map((line) => withCrLine(line, usesCrlf))
|
||||
)
|
||||
}
|
||||
if (dottedPreambleInserts.length > 0) {
|
||||
lines.splice(
|
||||
scan.lastDottedTuiIndex + 1,
|
||||
0,
|
||||
...dottedPreambleInserts.map((line) => withCrLine(line, usesCrlf))
|
||||
)
|
||||
}
|
||||
return joinPreservingTrailingNewline(lines, usesCrlf)
|
||||
}
|
||||
|
||||
function scanTuiPlacement(lines: string[], updates: Map<string, string>): TuiPlacementScan {
|
||||
let state = createTomlLineScanState()
|
||||
let inPreamble = true
|
||||
let tuiTableSeen = false
|
||||
let tuiBodyActive = false
|
||||
let tuiBodyHeaderIndex = -1
|
||||
let tuiBodyEndIndex = -1
|
||||
let hasDottedTuiKey = false
|
||||
let blocksNewTuiTable = false
|
||||
let lastDottedTuiIndex = -1
|
||||
const bareKeyIndexes = new Map<string, number>()
|
||||
const dottedKeyIndexes = new Map<string, number>()
|
||||
const blockedAbsentKeys = new Set<string>()
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index] ?? ''
|
||||
if (isTomlStructuralLine(state)) {
|
||||
const header = getTomlTableHeader(line)
|
||||
if (header) {
|
||||
if (tuiBodyActive) {
|
||||
tuiBodyEndIndex = index
|
||||
tuiBodyActive = false
|
||||
}
|
||||
const table = parseTomlTableHeaderPath(header)
|
||||
if (
|
||||
table &&
|
||||
!table.isArray &&
|
||||
table.segments.length === 1 &&
|
||||
table.segments[0] === 'tui' &&
|
||||
!tuiTableSeen
|
||||
) {
|
||||
tuiTableSeen = true
|
||||
tuiBodyActive = true
|
||||
tuiBodyHeaderIndex = index
|
||||
}
|
||||
// Why: a root [[tui]] is already an array, so appending [tui] would
|
||||
// redefine it and make an otherwise valid config unparseable.
|
||||
if (table?.isArray && table.segments.length === 1 && table.segments[0] === 'tui') {
|
||||
blocksNewTuiTable = true
|
||||
}
|
||||
const descendantKey =
|
||||
table?.segments[0] === 'tui' && table.segments.length > 1 ? table.segments[1] : null
|
||||
if (descendantKey && updates.has(descendantKey)) {
|
||||
blockedAbsentKeys.add(descendantKey)
|
||||
}
|
||||
inPreamble = false
|
||||
state = updateTomlLineScanState(state, line)
|
||||
continue
|
||||
}
|
||||
if (inPreamble) {
|
||||
// Why: any dotted `tui.*` key (allowlisted or not) already defines the
|
||||
// implicit tui table, so a new `[tui]` table at EOF would duplicate it.
|
||||
const parsed = parseTomlKeyPath(line)
|
||||
const isAssignment = parsed && line[parsed.end] === '='
|
||||
if (isAssignment && parsed.segments[0] === 'tui' && parsed.segments.length > 1) {
|
||||
hasDottedTuiKey = true
|
||||
lastDottedTuiIndex = index
|
||||
const promotedKey = parsed.segments.length === 2 ? parsed.segments[1] : null
|
||||
if (promotedKey && updates.has(promotedKey)) {
|
||||
dottedKeyIndexes.set(promotedKey, index)
|
||||
}
|
||||
const descendantKey = parsed.segments.length > 2 ? parsed.segments[1] : null
|
||||
if (descendantKey && updates.has(descendantKey)) {
|
||||
blockedAbsentKeys.add(descendantKey)
|
||||
}
|
||||
} else if (isAssignment && parsed.segments.length === 1 && parsed.segments[0] === 'tui') {
|
||||
blocksNewTuiTable = true
|
||||
}
|
||||
} else if (tuiBodyActive) {
|
||||
const parsed = parseTomlKeyPath(line)
|
||||
const key = parsed?.segments.length === 1 ? parsed.segments[0] : null
|
||||
if (parsed && line[parsed.end] === '=' && key && updates.has(key)) {
|
||||
bareKeyIndexes.set(key, index)
|
||||
}
|
||||
const descendantKey = parsed && parsed.segments.length > 1 ? parsed.segments[0] : null
|
||||
if (descendantKey && updates.has(descendantKey)) {
|
||||
blockedAbsentKeys.add(descendantKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
state = updateTomlLineScanState(state, line)
|
||||
}
|
||||
if (tuiBodyActive) {
|
||||
tuiBodyEndIndex = lines.length
|
||||
}
|
||||
|
||||
return {
|
||||
bareKeyIndexes,
|
||||
dottedKeyIndexes,
|
||||
hasBareTuiTable: tuiTableSeen,
|
||||
hasDottedTuiKey,
|
||||
blocksNewTuiTable,
|
||||
blockedAbsentKeys,
|
||||
bareBodyInsertIndex: computeBareBodyInsertIndex(lines, tuiBodyHeaderIndex, tuiBodyEndIndex),
|
||||
lastDottedTuiIndex
|
||||
}
|
||||
}
|
||||
|
||||
// Why: TOML forbids adding bare keys to `[tui]` after a `[tui.*]` subtable opens,
|
||||
// so absent keys land at the body's end — before trailing blanks and before the
|
||||
// next header — which is the only valid spot.
|
||||
function computeBareBodyInsertIndex(
|
||||
lines: string[],
|
||||
headerIndex: number,
|
||||
endIndex: number
|
||||
): number {
|
||||
if (headerIndex === -1) {
|
||||
return -1
|
||||
}
|
||||
let insertAt = endIndex
|
||||
while (insertAt > headerIndex + 1 && (lines[insertAt - 1] ?? '').trim() === '') {
|
||||
insertAt -= 1
|
||||
}
|
||||
return insertAt
|
||||
}
|
||||
|
||||
function appendNewTuiTable(lines: string[], keyRenders: string[], usesCrlf: boolean): void {
|
||||
let appendAt = lines.length
|
||||
while (appendAt > 0 && (lines[appendAt - 1] ?? '').trim() === '') {
|
||||
appendAt -= 1
|
||||
}
|
||||
// Why: separate the new table from prior content with a blank line, unless the
|
||||
// file was empty/blank, where a leading blank would be spurious.
|
||||
const block = appendAt > 0 ? ['', '[tui]', ...keyRenders] : ['[tui]', ...keyRenders]
|
||||
lines.splice(appendAt, 0, ...block.map((line) => withCrLine(line, usesCrlf)))
|
||||
}
|
||||
|
||||
function withTrailingCr(originalLine: string, rendered: string): string {
|
||||
return originalLine.endsWith('\r') ? `${rendered}\r` : rendered
|
||||
}
|
||||
|
||||
function withCrLine(rendered: string, usesCrlf: boolean): string {
|
||||
return usesCrlf ? `${rendered}\r` : rendered
|
||||
}
|
||||
|
||||
// Why: a missing trailing newline is restored in the file's own EOL so a
|
||||
// preamble-only or table-appended rewrite matches the source's newline behavior.
|
||||
function joinPreservingTrailingNewline(lines: string[], usesCrlf: boolean): string {
|
||||
const result = lines.join('\n')
|
||||
if (result.endsWith('\n') || result.length === 0) {
|
||||
return result
|
||||
}
|
||||
return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}`
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import type * as Os from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const { homedirMock } = vi.hoisted(() => ({
|
||||
homedirMock: vi.fn<() => string>()
|
||||
}))
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof Os>()
|
||||
return { ...actual, homedir: homedirMock }
|
||||
})
|
||||
|
||||
import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror'
|
||||
|
||||
let tmpHome: string
|
||||
let userDataDir: string
|
||||
let previousUserDataPath: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = mkdtempSync(join(tmpdir(), 'orca-codex-settings-upgrade-home-'))
|
||||
userDataDir = mkdtempSync(join(tmpdir(), 'orca-codex-settings-upgrade-data-'))
|
||||
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
|
||||
process.env.ORCA_USER_DATA_PATH = userDataDir
|
||||
homedirMock.mockReturnValue(tmpHome)
|
||||
if (homedir() !== tmpHome) {
|
||||
throw new Error('node:os homedir mock is not active; refusing to touch the real ~/.codex')
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpHome, { recursive: true, force: true })
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
if (previousUserDataPath === undefined) {
|
||||
delete process.env.ORCA_USER_DATA_PATH
|
||||
} else {
|
||||
process.env.ORCA_USER_DATA_PATH = previousUserDataPath
|
||||
}
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function systemConfigPath(): string {
|
||||
return join(tmpHome, '.codex', 'config.toml')
|
||||
}
|
||||
|
||||
function runtimeHomePath(): string {
|
||||
return join(userDataDir, 'codex-runtime-home', 'home')
|
||||
}
|
||||
|
||||
function runtimeConfigPath(): string {
|
||||
return join(runtimeHomePath(), 'config.toml')
|
||||
}
|
||||
|
||||
function baselinePath(): string {
|
||||
return join(runtimeHomePath(), '.orca-config-settings-baseline.json')
|
||||
}
|
||||
|
||||
function prepareLegacyState(systemConfig: string, runtimeConfig: string): void {
|
||||
mkdirSync(join(tmpHome, '.codex'), { recursive: true })
|
||||
mkdirSync(runtimeHomePath(), { recursive: true })
|
||||
writeFileSync(systemConfigPath(), systemConfig, 'utf-8')
|
||||
writeFileSync(runtimeConfigPath(), runtimeConfig, 'utf-8')
|
||||
writeFileSync(
|
||||
baselinePath(),
|
||||
`${JSON.stringify({ version: 1, settings: { model: '"gpt-5"' } }, null, 2)}\n`,
|
||||
'utf-8'
|
||||
)
|
||||
}
|
||||
|
||||
function readBaseline(): {
|
||||
version: number
|
||||
settings: Record<string, string | null>
|
||||
conflicts?: Record<string, { runtime: string | null; system: string | null }>
|
||||
} {
|
||||
return JSON.parse(readFileSync(baselinePath(), 'utf-8'))
|
||||
}
|
||||
|
||||
describe('Codex settings baseline schema upgrade', () => {
|
||||
it('upgrades an aligned legacy baseline without creating a conflict', () => {
|
||||
const config = 'model = "gpt-5"\n\n[tui]\ntheme = "dark"\n'
|
||||
prepareLegacyState(config, config)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readBaseline()).toMatchObject({
|
||||
version: 2,
|
||||
settings: { model: '"gpt-5"', 'tui.theme': '"dark"' }
|
||||
})
|
||||
expect(readBaseline().conflicts).toBeUndefined()
|
||||
})
|
||||
|
||||
it('anchors a schema-new conflict while promoting an unrelated known key', () => {
|
||||
prepareLegacyState(
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "system"\n',
|
||||
'model = "o4"\n\n[tui]\ntheme = "runtime"\n'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('model = "o4"')
|
||||
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('theme = "system"')
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('theme = "runtime"')
|
||||
expect(readBaseline().conflicts).toEqual({
|
||||
'tui.theme': { runtime: '"runtime"', system: '"system"' }
|
||||
})
|
||||
})
|
||||
|
||||
it('promotes the runtime side when its anchored value changes', () => {
|
||||
prepareLegacyState(
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "system"\n',
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "runtime"\n'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
writeFileSync(
|
||||
runtimeConfigPath(),
|
||||
readFileSync(runtimeConfigPath(), 'utf-8').replace('theme = "runtime"', 'theme = "chosen"'),
|
||||
'utf-8'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('theme = "chosen"')
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('theme = "chosen"')
|
||||
expect(readBaseline().conflicts).toBeUndefined()
|
||||
expect(readBaseline().settings['tui.theme']).toBe('"chosen"')
|
||||
})
|
||||
|
||||
it('accepts the system side when its anchored value changes', () => {
|
||||
prepareLegacyState(
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "system"\n',
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "runtime"\n'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
writeFileSync(
|
||||
systemConfigPath(),
|
||||
readFileSync(systemConfigPath(), 'utf-8').replace('theme = "system"', 'theme = "outside"'),
|
||||
'utf-8'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('theme = "outside"')
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('theme = "outside"')
|
||||
expect(readBaseline().conflicts).toBeUndefined()
|
||||
})
|
||||
|
||||
it('ignores unrelated config writes while a value pair is anchored', () => {
|
||||
prepareLegacyState(
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "system"\n',
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "runtime"\n'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
writeFileSync(
|
||||
runtimeConfigPath(),
|
||||
`${readFileSync(runtimeConfigPath(), 'utf-8')}\n[projects."/tmp/repo"]\ntrust_level = "trusted"\n`,
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
systemConfigPath(),
|
||||
`${readFileSync(systemConfigPath(), 'utf-8')}\n[features]\nhooks = true\n`,
|
||||
'utf-8'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('theme = "system"')
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('theme = "runtime"')
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('[projects."/tmp/repo"]')
|
||||
expect(readBaseline().conflicts).toEqual({
|
||||
'tui.theme': { runtime: '"runtime"', system: '"system"' }
|
||||
})
|
||||
})
|
||||
|
||||
it('re-anchors two new divergent values until one side changes again', () => {
|
||||
prepareLegacyState(
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "system"\n',
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "runtime"\n'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
writeFileSync(
|
||||
runtimeConfigPath(),
|
||||
readFileSync(runtimeConfigPath(), 'utf-8').replace(
|
||||
'theme = "runtime"',
|
||||
'theme = "runtime-2"'
|
||||
),
|
||||
'utf-8'
|
||||
)
|
||||
writeFileSync(
|
||||
systemConfigPath(),
|
||||
readFileSync(systemConfigPath(), 'utf-8').replace('theme = "system"', 'theme = "system-2"'),
|
||||
'utf-8'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readBaseline().conflicts).toEqual({
|
||||
'tui.theme': { runtime: '"runtime-2"', system: '"system-2"' }
|
||||
})
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('theme = "runtime-2"')
|
||||
})
|
||||
|
||||
it('preserves an absent runtime value until the user chooses one', () => {
|
||||
prepareLegacyState('model = "gpt-5"\n\n[tui]\ntheme = "system"\n', 'model = "gpt-5"\n')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).not.toContain('theme =')
|
||||
expect(readBaseline().conflicts).toEqual({
|
||||
'tui.theme': { runtime: null, system: '"system"' }
|
||||
})
|
||||
|
||||
writeFileSync(runtimeConfigPath(), 'model = "gpt-5"\n\n[tui]\ntheme = "chosen"\n', 'utf-8')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('theme = "chosen"')
|
||||
expect(readBaseline().conflicts).toBeUndefined()
|
||||
})
|
||||
|
||||
it('applies the migration rule to future top-level schema additions', () => {
|
||||
prepareLegacyState(
|
||||
'model = "gpt-5"\napproval_policy = "never"\n',
|
||||
'model = "gpt-5"\napproval_policy = "on-request"\n'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('approval_policy = "never"')
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).toContain('approval_policy = "on-request"')
|
||||
expect(readBaseline().conflicts).toEqual({
|
||||
approval_policy: { runtime: '"on-request"', system: '"never"' }
|
||||
})
|
||||
})
|
||||
|
||||
it('lets an incompatible system TOML shape win instead of stranding a conflict', () => {
|
||||
prepareLegacyState(
|
||||
'model = "gpt-5"\ntui = { animations = false }\n',
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "runtime"\n'
|
||||
)
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readFileSync(systemConfigPath(), 'utf-8')).toContain('tui = { animations = false }')
|
||||
expect(readFileSync(runtimeConfigPath(), 'utf-8')).not.toContain('theme = "runtime"')
|
||||
expect(readBaseline().conflicts).toBeUndefined()
|
||||
expect(readBaseline().settings['tui.theme']).toBeNull()
|
||||
})
|
||||
|
||||
it('does not require filesystem timestamp mutation during migration', () => {
|
||||
prepareLegacyState(
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "system"\n',
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "runtime"\n'
|
||||
)
|
||||
const baselineBefore = readFileSync(baselinePath(), 'utf-8')
|
||||
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(existsSync(baselinePath())).toBe(true)
|
||||
expect(readFileSync(baselinePath(), 'utf-8')).not.toBe(baselineBefore)
|
||||
expect(readBaseline().conflicts?.['tui.theme']).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { existsSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { readAgentStateFileSync, readAgentStateJsonFileSync } from '../agent-state-file-reader'
|
||||
|
||||
const SETTINGS_BASELINE_FILE = '.orca-config-settings-baseline.json'
|
||||
|
||||
export type CodexSettingsConflict = {
|
||||
runtime: string | null
|
||||
system: string | null
|
||||
}
|
||||
|
||||
export type CodexSettingsBaseline = {
|
||||
settings: ReadonlyMap<string, string | null>
|
||||
conflicts: ReadonlyMap<string, CodexSettingsConflict>
|
||||
}
|
||||
|
||||
type StoredSettingsBaseline = {
|
||||
version: 1 | 2
|
||||
settings: Record<string, string | null>
|
||||
conflicts?: Record<string, CodexSettingsConflict>
|
||||
}
|
||||
|
||||
export function readCodexSettingsBaseline(runtimeHomePath: string): CodexSettingsBaseline | null {
|
||||
const baselinePath = getCodexSettingsBaselinePath(runtimeHomePath)
|
||||
if (!existsSync(baselinePath)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = readAgentStateJsonFileSync(baselinePath)
|
||||
if (!isStoredSettingsBaseline(parsed)) {
|
||||
return null
|
||||
}
|
||||
const settings = new Map(
|
||||
Object.entries(parsed.settings).filter((entry): entry is [string, string | null] => {
|
||||
return typeof entry[1] === 'string' || entry[1] === null
|
||||
})
|
||||
)
|
||||
const conflicts = new Map<string, CodexSettingsConflict>()
|
||||
for (const [key, conflict] of Object.entries(parsed.conflicts ?? {})) {
|
||||
if (
|
||||
conflict &&
|
||||
(typeof conflict.runtime === 'string' || conflict.runtime === null) &&
|
||||
(typeof conflict.system === 'string' || conflict.system === null)
|
||||
) {
|
||||
conflicts.set(key, conflict)
|
||||
}
|
||||
}
|
||||
return { settings, conflicts }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCodexSettingsBaseline(
|
||||
runtimeHomePath: string,
|
||||
baseline: CodexSettingsBaseline
|
||||
): void {
|
||||
const file: StoredSettingsBaseline = {
|
||||
version: 2,
|
||||
settings: Object.fromEntries(baseline.settings)
|
||||
}
|
||||
if (baseline.conflicts.size > 0) {
|
||||
file.conflicts = Object.fromEntries(baseline.conflicts)
|
||||
}
|
||||
const baselinePath = getCodexSettingsBaselinePath(runtimeHomePath)
|
||||
const serialized = `${JSON.stringify(file, null, 2)}\n`
|
||||
// Why: launch prep runs repeatedly; byte-identical baselines should not churn disk metadata.
|
||||
if (existsSync(baselinePath) && readAgentStateFileSync(baselinePath) === serialized) {
|
||||
return
|
||||
}
|
||||
writeFileSync(baselinePath, serialized, { encoding: 'utf-8', mode: 0o600 })
|
||||
}
|
||||
|
||||
function getCodexSettingsBaselinePath(runtimeHomePath: string): string {
|
||||
return join(runtimeHomePath, SETTINGS_BASELINE_FILE)
|
||||
}
|
||||
|
||||
function isStoredSettingsBaseline(value: unknown): value is StoredSettingsBaseline {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return false
|
||||
}
|
||||
const candidate = value as Partial<StoredSettingsBaseline>
|
||||
return (
|
||||
(candidate.version === 1 || candidate.version === 2) &&
|
||||
!!candidate.settings &&
|
||||
typeof candidate.settings === 'object' &&
|
||||
!Array.isArray(candidate.settings)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { CodexSettingsConflict } from './config-settings-baseline'
|
||||
|
||||
export type CodexSettingsConflictResolution =
|
||||
| { action: 'aligned' }
|
||||
| { action: 'preserve'; conflict: CodexSettingsConflict }
|
||||
| { action: 'promote-runtime'; raw: string }
|
||||
| { action: 'use-system' }
|
||||
|
||||
export function resolveUntrackedCodexSetting(
|
||||
runtime: string | null,
|
||||
system: string | null,
|
||||
existingConflict?: CodexSettingsConflict
|
||||
): CodexSettingsConflictResolution {
|
||||
if (runtime === system) {
|
||||
return { action: 'aligned' }
|
||||
}
|
||||
if (!existingConflict) {
|
||||
return { action: 'preserve', conflict: { runtime, system } }
|
||||
}
|
||||
|
||||
const runtimeChanged = runtime !== existingConflict.runtime
|
||||
const systemChanged = system !== existingConflict.system
|
||||
if (runtimeChanged && !systemChanged) {
|
||||
// Why: steady-state promotion intentionally does not propagate deletions.
|
||||
return runtime === null ? { action: 'use-system' } : { action: 'promote-runtime', raw: runtime }
|
||||
}
|
||||
if (!runtimeChanged && systemChanged) {
|
||||
return { action: 'use-system' }
|
||||
}
|
||||
if (runtimeChanged && systemChanged) {
|
||||
// Why: two new divergent values remain ambiguous; re-anchor their content without blocking other keys.
|
||||
return { action: 'preserve', conflict: { runtime, system } }
|
||||
}
|
||||
return { action: 'preserve', conflict: existingConflict }
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import type * as CodexFsUtils from '../codex-accounts/fs-utils'
|
||||
|
||||
const { homedirMock, promotionTestState } = vi.hoisted(() => ({
|
||||
homedirMock: vi.fn<() => string>(),
|
||||
promotionTestState: { failAtomicWrite: false }
|
||||
promotionTestState: { failAtomicWrite: false, atomicWritePaths: [] as string[] }
|
||||
}))
|
||||
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
@@ -35,6 +35,7 @@ vi.mock('../codex-accounts/fs-utils', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
writeFileAtomically: (...args: Parameters<typeof actual.writeFileAtomically>) => {
|
||||
promotionTestState.atomicWritePaths.push(args[0])
|
||||
if (promotionTestState.failAtomicWrite) {
|
||||
throw new Error('injected atomic write failure')
|
||||
}
|
||||
@@ -44,7 +45,15 @@ vi.mock('../codex-accounts/fs-utils', async (importOriginal) => {
|
||||
})
|
||||
|
||||
import { syncSystemConfigIntoManagedCodexHome } from './codex-config-mirror'
|
||||
import { upsertTopLevelSettingsInContent } from './config-settings-promotion'
|
||||
import {
|
||||
upsertPromotedSettingsInContent,
|
||||
upsertTopLevelSettingsInContent
|
||||
} from './codex-config-settings-upsert'
|
||||
|
||||
// The exact [tui] block codex 0.144.6 writes via config/batchWrite (all four
|
||||
// promoted keys single-line, theme a string).
|
||||
const CODEX_TUI_BLOCK =
|
||||
'[tui]\nstatus_line = ["model-with-reasoning", "task-progress"]\nstatus_line_use_colors = true\nterminal_title = ["model"]\ntheme = "dark-photon"\n'
|
||||
|
||||
let tmpHome: string
|
||||
let userDataDir: string
|
||||
@@ -57,6 +66,7 @@ beforeEach(() => {
|
||||
process.env.ORCA_USER_DATA_PATH = userDataDir
|
||||
homedirMock.mockReturnValue(tmpHome)
|
||||
promotionTestState.failAtomicWrite = false
|
||||
promotionTestState.atomicWritePaths.length = 0
|
||||
// Why: promotion writes into homedir()/.codex — if the mock ever fails to
|
||||
// intercept, these tests would rewrite the developer's real Codex config.
|
||||
if (homedir() !== tmpHome) {
|
||||
@@ -117,6 +127,13 @@ function simulateCodexSettingWrite(key: string, rawValue: string): void {
|
||||
writeFileSync(runtimeConfigPath(), next, 'utf-8')
|
||||
}
|
||||
|
||||
// Codex reads then rewrites the whole runtime config; simulate that by writing
|
||||
// a known runtime config directly (its EOL is normalized by the mirror anyway).
|
||||
function setRuntimeConfig(content: string): void {
|
||||
mkdirSync(runtimeHomeDir(), { recursive: true })
|
||||
writeFileSync(runtimeConfigPath(), content, 'utf-8')
|
||||
}
|
||||
|
||||
function simulateCodexSettingRemoval(key: string): void {
|
||||
const existing = readFileSync(runtimeConfigPath(), 'utf-8')
|
||||
const linePattern = new RegExp(`^${key}[ \\t]*=.*\\n?`, 'm')
|
||||
@@ -184,7 +201,7 @@ describe('codex settings write-back promotion', () => {
|
||||
simulateCodexSettingWrite('model', '"o4"')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
expect(readSystemConfig()).toBe('model = "gpt-5"\n')
|
||||
expect(JSON.parse(readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 1 })
|
||||
expect(JSON.parse(readFileSync(baselinePath(), 'utf-8'))).toMatchObject({ version: 2 })
|
||||
|
||||
simulateCodexSettingWrite('model', '"o4"')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
@@ -402,6 +419,363 @@ describe('codex settings write-back promotion', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('codex [tui] settings write-back promotion', () => {
|
||||
it('promotes a runtime [tui] block (codex 0.144.6 shape) into ~/.codex and survives the remirror', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
// The user customizes the status line/theme inside Orca-launched Codex.
|
||||
writeFileSync(runtimeConfigPath(), `${readRuntimeConfig()}\n${CODEX_TUI_BLOCK}`, 'utf-8')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe(`model = "gpt-5"\n\n${CODEX_TUI_BLOCK}`)
|
||||
const runtime = readRuntimeConfig()
|
||||
expect(runtime).toContain('status_line = ["model-with-reasoning", "task-progress"]')
|
||||
expect(runtime).toContain('status_line_use_colors = true')
|
||||
expect(runtime).toContain('terminal_title = ["model"]')
|
||||
expect(runtime).toContain('theme = "dark-photon"')
|
||||
|
||||
const settledSystem = readSystemConfig()
|
||||
const settledRuntime = readRuntimeConfig()
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
expect(readSystemConfig()).toBe(settledSystem)
|
||||
expect(readRuntimeConfig()).toBe(settledRuntime)
|
||||
})
|
||||
|
||||
it('replaces a promoted key in an existing [tui] table, leaving non-promoted neighbors untouched', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n\n[tui]\nanimations = true\ntheme = "dark"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "gpt-5"\n\n[tui]\nanimations = true\ntheme = "light"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe(
|
||||
'model = "gpt-5"\n\n[tui]\nanimations = true\ntheme = "light"\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('promotes a changed status_line array value', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n\n[tui]\nstatus_line = ["model"]\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig(
|
||||
'model = "gpt-5"\n\n[tui]\nstatus_line = ["model-with-reasoning", "task-progress"]\n'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe(
|
||||
'model = "gpt-5"\n\n[tui]\nstatus_line = ["model-with-reasoning", "task-progress"]\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('promotes a model change and a status-line change in one pass into their regions', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark-photon"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "o4"\n\n[tui]\ntheme = "dark-photon"\nstatus_line = ["model"]\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe(
|
||||
'model = "o4"\n\n[tui]\ntheme = "dark-photon"\nstatus_line = ["model"]\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('detects and replaces a dotted-form system tui key without creating a [tui] table', () => {
|
||||
writeSystemConfig('model = "gpt-5"\ntui.theme = "dark"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
// toml_edit preserves the dotted form when codex rewrites the value.
|
||||
setRuntimeConfig('model = "gpt-5"\ntui.theme = "light"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe('model = "gpt-5"\ntui.theme = "light"\n')
|
||||
expect(readSystemConfig()).not.toContain('[tui]')
|
||||
})
|
||||
|
||||
it('promotes through a quoted tui table without creating a duplicate table', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n\n["tui"]\ntheme = "dark"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "gpt-5"\n\n["tui"]\ntheme = "light"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe('model = "gpt-5"\n\n["tui"]\ntheme = "light"\n')
|
||||
expect(readSystemConfig()).not.toContain('\n[tui]\n')
|
||||
})
|
||||
|
||||
it('inserts a second dotted tui key beside an existing dotted-only tui config', () => {
|
||||
writeSystemConfig('model = "gpt-5"\ntui.theme = "dark"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "gpt-5"\ntui.theme = "dark"\ntui.status_line = ["model"]\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe(
|
||||
'model = "gpt-5"\ntui.theme = "dark"\ntui.status_line = ["model"]\n'
|
||||
)
|
||||
expect(readSystemConfig()).not.toContain('[tui]')
|
||||
})
|
||||
|
||||
it('inserts dotted beside a non-promoted dotted tui key instead of creating a [tui] table', () => {
|
||||
// Why: any dotted tui.* key already defines the implicit tui table, so a
|
||||
// fresh [tui] table at EOF would be a duplicate-definition parse error.
|
||||
writeSystemConfig('model = "gpt-5"\ntui.pet = "cat"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "gpt-5"\ntui.pet = "cat"\ntui.theme = "dark-photon"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe('model = "gpt-5"\ntui.pet = "cat"\ntui.theme = "dark-photon"\n')
|
||||
expect(readSystemConfig()).not.toContain('[tui]')
|
||||
})
|
||||
|
||||
it('creates a [tui] table at EOF when the only tui presence is a subtable', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n\n[tui.notifications]\nenabled = true\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig(
|
||||
'model = "gpt-5"\n\n[tui.notifications]\nenabled = true\n\n[tui]\nstatus_line = ["model"]\n'
|
||||
)
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe(
|
||||
'model = "gpt-5"\n\n[tui.notifications]\nenabled = true\n\n[tui]\nstatus_line = ["model"]\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('creates exactly one [tui] table for two keys promoted in one pass', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark-photon"\nstatus_line = ["model"]\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const system = readSystemConfig()
|
||||
expect(system.match(/^\[tui\]$/gm)?.length).toBe(1)
|
||||
expect(system).toBe(
|
||||
'model = "gpt-5"\n\n[tui]\nstatus_line = ["model"]\ntheme = "dark-photon"\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('lets an outside ~/.codex [tui] edit win over a conflicting in-Codex tui change', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "in-codex"\n')
|
||||
writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "outside-edit"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe('model = "gpt-5"\n\n[tui]\ntheme = "outside-edit"\n')
|
||||
expect(readRuntimeConfig()).toContain('theme = "outside-edit"')
|
||||
})
|
||||
|
||||
it('does not promote a [tui] key deletion', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "gpt-5"\n\n[tui]\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toContain('theme = "dark"')
|
||||
})
|
||||
|
||||
it('inserts a promoted key into a CRLF system [tui] table preserving CRLF', () => {
|
||||
writeSystemConfig('model = "gpt-5"\r\n\r\n[tui]\r\ntheme = "dark"\r\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark"\nstatus_line = ["model"]\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
const system = readSystemConfig()
|
||||
expect(system).toContain('status_line = ["model"]\r\n')
|
||||
expect(system).toBe(
|
||||
'model = "gpt-5"\r\n\r\n[tui]\r\ntheme = "dark"\r\nstatus_line = ["model"]\r\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('never appends a [tui] table when the system config defines tui inline', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
// In-Codex tui change racing an outside edit that adds an inline tui table:
|
||||
// appending [tui] would make the system config unparseable, so the change
|
||||
// is dropped instead.
|
||||
setRuntimeConfig('model = "gpt-5"\n\n[tui]\ntheme = "dark-photon"\n')
|
||||
writeSystemConfig('model = "gpt-5"\ntui = { animations = false }\n')
|
||||
promotionTestState.atomicWritePaths.length = 0
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe('model = "gpt-5"\ntui = { animations = false }\n')
|
||||
expect(promotionTestState.atomicWritePaths).not.toContain(systemConfigPath())
|
||||
})
|
||||
|
||||
it('ignores an allowlisted key nested under a [tui.*] subtable', () => {
|
||||
writeSystemConfig('model = "gpt-5"\n\n[tui.notifications]\ntheme = "should-not-promote"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
setRuntimeConfig('model = "gpt-5"\n\n[tui.notifications]\ntheme = "changed-in-subtable"\n')
|
||||
syncSystemConfigIntoManagedCodexHome()
|
||||
|
||||
expect(readSystemConfig()).toBe(
|
||||
'model = "gpt-5"\n\n[tui.notifications]\ntheme = "should-not-promote"\n'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('upsertPromotedSettingsInContent', () => {
|
||||
it('replaces a bare key in place inside the [tui] table', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'[tui]\ntheme = "dark"\n',
|
||||
new Map([['tui.theme', '"light"']])
|
||||
)
|
||||
).toBe('[tui]\ntheme = "light"\n')
|
||||
})
|
||||
|
||||
it('inserts a bare key at the end of the [tui] body, before a subtable', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'[tui]\ntheme = "dark"\n\n[tui.notifications]\nenabled = true\n',
|
||||
new Map([['tui.status_line', '["model"]']])
|
||||
)
|
||||
).toBe(
|
||||
'[tui]\ntheme = "dark"\nstatus_line = ["model"]\n\n[tui.notifications]\nenabled = true\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('replaces a dotted preamble tui key in place, keeping the dotted form', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent('tui.theme = "dark"\n', new Map([['tui.theme', '"light"']]))
|
||||
).toBe('tui.theme = "light"\n')
|
||||
})
|
||||
|
||||
it('inserts a dotted tui key beside an existing dotted tui key', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'tui.theme = "dark"\n\n[features]\nx = 1\n',
|
||||
new Map([['tui.status_line', '["model"]']])
|
||||
)
|
||||
).toBe('tui.theme = "dark"\ntui.status_line = ["model"]\n\n[features]\nx = 1\n')
|
||||
})
|
||||
|
||||
it('creates a [tui] table from empty content', () => {
|
||||
expect(upsertPromotedSettingsInContent('', new Map([['tui.theme', '"dark"']]))).toBe(
|
||||
'[tui]\ntheme = "dark"\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('drops an absent key instead of appending [tui] beside an inline tui table', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'tui = { animations = false }\n',
|
||||
new Map([['tui.theme', '"dark"']])
|
||||
)
|
||||
).toBe('tui = { animations = false }\n')
|
||||
})
|
||||
|
||||
it('drops an absent key beside a quoted inline tui table', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'"tui" = { animations = false }\n',
|
||||
new Map([['tui.theme', '"dark"']])
|
||||
)
|
||||
).toBe('"tui" = { animations = false }\n')
|
||||
})
|
||||
|
||||
it('inserts beside a quoted dotted tui key instead of appending a table', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent('"tui" . "pet" = "cat"\n', new Map([['tui.theme', '"dark"']]))
|
||||
).toBe('"tui" . "pet" = "cat"\ntui.theme = "dark"\n')
|
||||
})
|
||||
|
||||
it('creates a [tui] super-table at EOF after a [tui.*] subtable', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'[tui.notifications]\nenabled = true\n',
|
||||
new Map([['tui.theme', '"dark"']])
|
||||
)
|
||||
).toBe('[tui.notifications]\nenabled = true\n\n[tui]\ntheme = "dark"\n')
|
||||
})
|
||||
|
||||
it('drops an absent scalar that would redefine an existing tui key table', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'[tui."theme"]\nvariant = "dark"\n',
|
||||
new Map([['tui.theme', '"light"']])
|
||||
)
|
||||
).toBe('[tui."theme"]\nvariant = "dark"\n')
|
||||
})
|
||||
|
||||
it('drops an absent scalar that would redefine a dotted tui key table', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'tui.theme.variant = "dark"\n',
|
||||
new Map([['tui.theme', '"light"']])
|
||||
)
|
||||
).toBe('tui.theme.variant = "dark"\n')
|
||||
})
|
||||
|
||||
it('does not mistake a dotted tui key inside an array table for a root key', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'[[profiles]]\ntui.theme = "profile-theme"\n',
|
||||
new Map([['tui.theme', '"root-theme"']])
|
||||
)
|
||||
).toBe('[[profiles]]\ntui.theme = "profile-theme"\n\n[tui]\ntheme = "root-theme"\n')
|
||||
})
|
||||
|
||||
it('does not append a table beside a root tui array-of-tables', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'[[tui]]\ntheme = "array-theme"\n',
|
||||
new Map([['tui.theme', '"root-theme"']])
|
||||
)
|
||||
).toBe('[[tui]]\ntheme = "array-theme"\n')
|
||||
})
|
||||
|
||||
it('does not append a table beside a quoted root tui array-of-tables', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'[["tui"]]\ntheme = "array-theme"\n',
|
||||
new Map([['tui.theme', '"root-theme"']])
|
||||
)
|
||||
).toBe('[["tui"]]\ntheme = "array-theme"\n')
|
||||
})
|
||||
|
||||
it('creates one [tui] table for multiple keys reaching the new-table branch', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'',
|
||||
new Map([
|
||||
['tui.status_line', '["model"]'],
|
||||
['tui.theme', '"dark"']
|
||||
])
|
||||
)
|
||||
).toBe('[tui]\nstatus_line = ["model"]\ntheme = "dark"\n')
|
||||
})
|
||||
|
||||
it('routes a mixed top-level + tui batch to its two regions in one rewrite', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'model = "gpt-5"\n\n[tui]\ntheme = "dark"\n',
|
||||
new Map([
|
||||
['model', '"o4"'],
|
||||
['tui.theme', '"light"']
|
||||
])
|
||||
)
|
||||
).toBe('model = "o4"\n\n[tui]\ntheme = "light"\n')
|
||||
})
|
||||
|
||||
it('inserts into a CRLF [tui] table with CRLF endings', () => {
|
||||
expect(
|
||||
upsertPromotedSettingsInContent(
|
||||
'[tui]\r\ntheme = "dark"\r\n',
|
||||
new Map([['tui.status_line', '["model"]']])
|
||||
)
|
||||
).toBe('[tui]\r\ntheme = "dark"\r\nstatus_line = ["model"]\r\n')
|
||||
})
|
||||
})
|
||||
|
||||
describe('upsertTopLevelSettingsInContent', () => {
|
||||
it('writes into empty content', () => {
|
||||
expect(upsertTopLevelSettingsInContent('', new Map([['model', '"x"']]))).toBe('model = "x"\n')
|
||||
@@ -428,6 +802,12 @@ describe('upsertTopLevelSettingsInContent', () => {
|
||||
).toBe('# keep\nmodel = "new"\n\n[t]\nk = 1\n')
|
||||
})
|
||||
|
||||
it('replaces a quoted top-level key instead of adding its bare equivalent', () => {
|
||||
expect(
|
||||
upsertTopLevelSettingsInContent('"model" = "old"\n', new Map([['model', '"new"']]))
|
||||
).toBe('model = "new"\n')
|
||||
})
|
||||
|
||||
it('inserts with CRLF endings into CRLF content', () => {
|
||||
expect(
|
||||
upsertTopLevelSettingsInContent('[features]\r\nhooks = true\r\n', new Map([['model', '"x"']]))
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { readAgentStateFileSync, readAgentStateJsonFileSync } from '../agent-state-file-reader'
|
||||
import { readAgentStateFileSync } from '../agent-state-file-reader'
|
||||
import { writeFileAtomically } from '../codex-accounts/fs-utils'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex-home-paths'
|
||||
@@ -18,6 +18,15 @@ import {
|
||||
isTomlStructuralLine,
|
||||
updateTomlLineScanState
|
||||
} from './config-toml-line-scan'
|
||||
import { parseTomlKeyPath, parseTomlTableHeaderPath } from './config-toml-key-path'
|
||||
import { tuiStructuredKey, upsertPromotedSettingsInContent } from './codex-config-settings-upsert'
|
||||
import {
|
||||
readCodexSettingsBaseline,
|
||||
writeCodexSettingsBaseline,
|
||||
type CodexSettingsBaseline,
|
||||
type CodexSettingsConflict
|
||||
} from './config-settings-baseline'
|
||||
import { resolveUntrackedCodexSetting } from './config-settings-conflict-resolution'
|
||||
|
||||
// Why: the mirror reverts in-Codex config changes each launch; promotion salvages them by diffing the last baseline.
|
||||
|
||||
@@ -29,65 +38,112 @@ export const PROMOTED_CODEX_SETTING_KEYS = [
|
||||
'sandbox_mode'
|
||||
] as const
|
||||
|
||||
// Why: the [tui] keys the Codex TUI's user-facing pickers persist (status line,
|
||||
// terminal title, theme). Like the top-level list, every key here gets written
|
||||
// into the user's real ~/.codex/config.toml on promotion — grow it deliberately.
|
||||
export const PROMOTED_CODEX_TUI_SETTING_KEYS = [
|
||||
'status_line',
|
||||
'status_line_use_colors',
|
||||
'terminal_title',
|
||||
'theme'
|
||||
] as const
|
||||
|
||||
// Why: promotion diffs and upserts operate on structured keys — top-level keys
|
||||
// keep their bare name, [tui] keys are namespaced tui.<key> so their baseline
|
||||
// entries cannot collide with a top-level key of the same name.
|
||||
const PROMOTED_STRUCTURED_KEYS: readonly string[] = [
|
||||
...PROMOTED_CODEX_SETTING_KEYS,
|
||||
...PROMOTED_CODEX_TUI_SETTING_KEYS.map(tuiStructuredKey)
|
||||
]
|
||||
|
||||
function isPromotedTuiKey(key: string): boolean {
|
||||
return (PROMOTED_CODEX_TUI_SETTING_KEYS as readonly string[]).includes(key)
|
||||
}
|
||||
|
||||
// Returns the structured tui key a scanned line's key represents, or null. In
|
||||
// the preamble it recognizes the dotted `tui.<key>` form a user may hand-author;
|
||||
// inside the first `[tui]` table body it recognizes the bare `<key>` form Codex
|
||||
// writes. Both map to the same structured key so either config shape promotes.
|
||||
function matchTuiStructuredKey(
|
||||
keyPath: string[],
|
||||
inPreamble: boolean,
|
||||
tuiBodyActive: boolean
|
||||
): string | null {
|
||||
if (inPreamble) {
|
||||
const tuiKey = keyPath.length === 2 && keyPath[0] === 'tui' ? keyPath[1] : null
|
||||
return tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null
|
||||
}
|
||||
const tuiKey = keyPath.length === 1 ? keyPath[0] : null
|
||||
return tuiBodyActive && tuiKey && isPromotedTuiKey(tuiKey) ? tuiStructuredKey(tuiKey) : null
|
||||
}
|
||||
|
||||
type TopLevelSettingValue = {
|
||||
raw: string
|
||||
// Why: a multiline string/array value can't be replaced line-by-line, so it's excluded from promotion.
|
||||
multiline: boolean
|
||||
}
|
||||
|
||||
type SettingsBaselineFile = {
|
||||
version: 1
|
||||
settings: Record<string, string>
|
||||
}
|
||||
|
||||
function getSettingsBaselinePath(runtimeHomePath: string): string {
|
||||
return join(runtimeHomePath, '.orca-config-settings-baseline.json')
|
||||
}
|
||||
|
||||
function readSettingsBaseline(runtimeHomePath: string): Map<string, string> | null {
|
||||
const baselinePath = getSettingsBaselinePath(runtimeHomePath)
|
||||
if (!existsSync(baselinePath)) {
|
||||
function matchPromotedStructuredKey(
|
||||
line: string,
|
||||
inPreamble: boolean,
|
||||
tuiBodyActive: boolean
|
||||
): { structuredKey: string; raw: string } | null {
|
||||
const parsed = parseTomlKeyPath(line)
|
||||
if (!parsed || line[parsed.end] !== '=') {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = readAgentStateJsonFileSync(baselinePath)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return null
|
||||
}
|
||||
const settings = (parsed as SettingsBaselineFile).settings
|
||||
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
|
||||
return null
|
||||
}
|
||||
const result = new Map<string, string>()
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
if (typeof value === 'string') {
|
||||
result.set(key, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
return null
|
||||
const raw = line.slice(parsed.end + 1).trim()
|
||||
const topLevelKey = parsed.segments.length === 1 ? parsed.segments[0] : null
|
||||
if (
|
||||
inPreamble &&
|
||||
topLevelKey &&
|
||||
(PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(topLevelKey)
|
||||
) {
|
||||
return { structuredKey: topLevelKey, raw }
|
||||
}
|
||||
const tuiKey = matchTuiStructuredKey(parsed.segments, inPreamble, tuiBodyActive)
|
||||
return tuiKey ? { structuredKey: tuiKey, raw } : null
|
||||
}
|
||||
|
||||
// Why: only top-level preamble keys are scanned; rewriting nested [profiles.*] tables isn't worth the risk here.
|
||||
function readTopLevelSettingValues(configPath: string): Map<string, TopLevelSettingValue> {
|
||||
// Why: top-level preamble scalars keep the historical behavior; [tui] keys are
|
||||
// collected from the first bare [tui] table body or the dotted preamble form,
|
||||
// keyed by structured path. Any table header (including [tui.*] subtables) ends
|
||||
// the [tui] body, and [profiles.*]/other tables are still ignored.
|
||||
function readPromotedSettingValues(configPath: string): Map<string, TopLevelSettingValue> {
|
||||
const result = new Map<string, TopLevelSettingValue>()
|
||||
if (!existsSync(configPath)) {
|
||||
return result
|
||||
}
|
||||
const lines = readAgentStateFileSync(configPath).split('\n')
|
||||
let state = createTomlLineScanState()
|
||||
let inPreamble = true
|
||||
let tuiTableSeen = false
|
||||
let tuiBodyActive = false
|
||||
for (const line of lines) {
|
||||
if (isTomlStructuralLine(state)) {
|
||||
if (getTomlTableHeader(line)) {
|
||||
break
|
||||
const header = getTomlTableHeader(line)
|
||||
if (header) {
|
||||
const table = parseTomlTableHeaderPath(header)
|
||||
tuiBodyActive =
|
||||
table !== null &&
|
||||
!table.isArray &&
|
||||
table.segments.length === 1 &&
|
||||
table.segments[0] === 'tui' &&
|
||||
!tuiTableSeen
|
||||
if (tuiBodyActive) {
|
||||
tuiTableSeen = true
|
||||
}
|
||||
inPreamble = false
|
||||
state = updateTomlLineScanState(state, line)
|
||||
continue
|
||||
}
|
||||
const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=[ \t]*(.*?)[ \t\r]*$/.exec(line)
|
||||
const key = match?.[1]
|
||||
if (key && (PROMOTED_CODEX_SETTING_KEYS as readonly string[]).includes(key)) {
|
||||
const matched = matchPromotedStructuredKey(line, inPreamble, tuiBodyActive)
|
||||
if (matched) {
|
||||
const nextState = updateTomlLineScanState(state, line)
|
||||
result.set(key, { raw: match?.[2] ?? '', multiline: !isTomlStructuralLine(nextState) })
|
||||
result.set(matched.structuredKey, {
|
||||
raw: matched.raw,
|
||||
multiline: !isTomlStructuralLine(nextState)
|
||||
})
|
||||
state = nextState
|
||||
continue
|
||||
}
|
||||
@@ -103,28 +159,22 @@ function readTopLevelSettingValues(configPath: string): Map<string, TopLevelSett
|
||||
* Call after a successful mirror only — advancing past an unpromoted change strands it forever.
|
||||
*/
|
||||
export function snapshotCodexRuntimeSettingsBaseline(
|
||||
runtimeHomePath = getOrcaManagedCodexHomePath()
|
||||
runtimeHomePath = getOrcaManagedCodexHomePath(),
|
||||
conflicts: ReadonlyMap<string, CodexSettingsConflict> = new Map()
|
||||
): void {
|
||||
try {
|
||||
const runtimeTomlPath = join(runtimeHomePath, 'config.toml')
|
||||
// Why: record an empty baseline even for a missing runtime config, so Codex's first write still diffs and promotes.
|
||||
const settings: Record<string, string> = {}
|
||||
for (const [key, value] of readTopLevelSettingValues(runtimeTomlPath)) {
|
||||
if (!value.multiline) {
|
||||
settings[key] = value.raw
|
||||
const runtimeValues = readPromotedSettingValues(runtimeTomlPath)
|
||||
const settings = new Map<string, string | null>()
|
||||
for (const key of PROMOTED_STRUCTURED_KEYS) {
|
||||
const value = runtimeValues.get(key)
|
||||
if (!conflicts.has(key) && !value?.multiline) {
|
||||
// Why: explicit nulls distinguish a schema-aware absence from a key added by a later schema.
|
||||
settings.set(key, value?.raw ?? null)
|
||||
}
|
||||
}
|
||||
const file: SettingsBaselineFile = { version: 1, settings }
|
||||
const baselinePath = getSettingsBaselinePath(runtimeHomePath)
|
||||
const serialized = `${JSON.stringify(file, null, 2)}\n`
|
||||
// Why: launch prep runs repeatedly; skip byte-identical rewrites to avoid needless disk writes.
|
||||
if (existsSync(baselinePath) && readAgentStateFileSync(baselinePath) === serialized) {
|
||||
return
|
||||
}
|
||||
writeFileSync(baselinePath, serialized, {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600
|
||||
})
|
||||
writeCodexSettingsBaseline(runtimeHomePath, { settings, conflicts })
|
||||
} catch (error) {
|
||||
console.warn('[codex-settings-promotion] failed to snapshot settings baseline', error)
|
||||
}
|
||||
@@ -135,6 +185,11 @@ export type CodexSettingsPromotionHomes = {
|
||||
systemHomePath: string
|
||||
}
|
||||
|
||||
export type CodexSettingsPromotionPlan = {
|
||||
conflicts: ReadonlyMap<string, CodexSettingsConflict>
|
||||
runtimeValuesToPreserve: ReadonlyMap<string, string | null>
|
||||
}
|
||||
|
||||
function getHostPromotionHomes(): CodexSettingsPromotionHomes {
|
||||
return {
|
||||
runtimeHomePath: getOrcaManagedCodexHomePath(),
|
||||
@@ -147,56 +202,50 @@ function getHostPromotionHomes(): CodexSettingsPromotionHomes {
|
||||
* Runs before the config mirror so promoted values survive it instead of reverting.
|
||||
* WSL callers pass explicit per-distro homes; default is the host runtime home and ~/.codex.
|
||||
*/
|
||||
export function promoteCodexRuntimeSettingsToSystem(homes?: CodexSettingsPromotionHomes): boolean {
|
||||
export function promoteCodexRuntimeSettingsToSystem(
|
||||
homes?: CodexSettingsPromotionHomes
|
||||
): CodexSettingsPromotionPlan | null {
|
||||
try {
|
||||
promoteCodexRuntimeSettingsToSystemUnsafe(homes ?? getHostPromotionHomes())
|
||||
return true
|
||||
return promoteCodexRuntimeSettingsToSystemUnsafe(homes ?? getHostPromotionHomes())
|
||||
} catch (error) {
|
||||
// Why: promotion is best-effort launch prep; a malformed file must not block Codex launch.
|
||||
console.warn('[codex-settings-promotion] failed to promote runtime settings', error)
|
||||
return false
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotionHomes): void {
|
||||
function promoteCodexRuntimeSettingsToSystemUnsafe(
|
||||
homes: CodexSettingsPromotionHomes
|
||||
): CodexSettingsPromotionPlan {
|
||||
const { runtimeHomePath, systemHomePath } = homes
|
||||
const runtimeTomlPath = join(runtimeHomePath, 'config.toml')
|
||||
const systemTomlPath = join(systemHomePath, 'config.toml')
|
||||
if (resolve(runtimeTomlPath) === resolve(systemTomlPath)) {
|
||||
return
|
||||
return emptyPromotionPlan()
|
||||
}
|
||||
if (!existsSync(runtimeTomlPath)) {
|
||||
return
|
||||
return emptyPromotionPlan()
|
||||
}
|
||||
// Why: without a baseline, a stale runtime value looks like a fresh in-Codex change; skip until the mirror writes one.
|
||||
const baseline = readSettingsBaseline(runtimeHomePath)
|
||||
const baseline = readCodexSettingsBaseline(runtimeHomePath)
|
||||
if (!baseline) {
|
||||
return
|
||||
return emptyPromotionPlan()
|
||||
}
|
||||
const runtimeValues = readTopLevelSettingValues(runtimeTomlPath)
|
||||
const systemValues = readTopLevelSettingValues(systemTomlPath)
|
||||
const runtimeValues = readPromotedSettingValues(runtimeTomlPath)
|
||||
const systemValues = readPromotedSettingValues(systemTomlPath)
|
||||
const updates = new Map<string, string>()
|
||||
for (const key of PROMOTED_CODEX_SETTING_KEYS) {
|
||||
const runtime = runtimeValues.get(key)
|
||||
if (!runtime || runtime.multiline) {
|
||||
continue
|
||||
}
|
||||
if (runtime.raw === baseline.get(key)) {
|
||||
// Orca mirrored this value and nothing touched it since — not a change.
|
||||
continue
|
||||
}
|
||||
const system = systemValues.get(key)
|
||||
if (system?.multiline) {
|
||||
continue
|
||||
}
|
||||
// Why: ~/.codex is source of truth — an outside edit since the baseline wins over the in-Codex change.
|
||||
if (system?.raw !== baseline.get(key)) {
|
||||
continue
|
||||
}
|
||||
updates.set(key, runtime.raw)
|
||||
}
|
||||
const conflicts = new Map<string, CodexSettingsConflict>()
|
||||
const runtimeValuesToPreserve = new Map<string, string | null>()
|
||||
collectPromotionChanges({
|
||||
baseline,
|
||||
runtimeValues,
|
||||
systemValues,
|
||||
updates,
|
||||
conflicts,
|
||||
runtimeValuesToPreserve
|
||||
})
|
||||
if (updates.size === 0) {
|
||||
return
|
||||
return { conflicts, runtimeValuesToPreserve }
|
||||
}
|
||||
// Why: a fresh host has no ~/.codex; create it owner-only (holds auth.json) or the atomic write ENOENTs and the mirror wipes it.
|
||||
mkdirSync(systemHomePath, { recursive: true, mode: 0o700 })
|
||||
@@ -205,15 +254,71 @@ function promoteCodexRuntimeSettingsToSystemUnsafe(homes: CodexSettingsPromotion
|
||||
mkdirSync(dirname(writeTarget.path), { recursive: true, mode: 0o700 })
|
||||
const targetExists = existsSync(writeTarget.path)
|
||||
const systemContent = targetExists ? readAgentStateFileSync(writeTarget.path) : ''
|
||||
const nextContent = upsertTopLevelSettingsInContent(systemContent, updates)
|
||||
const nextContent = upsertPromotedSettingsInContent(systemContent, updates)
|
||||
if (nextContent === systemContent) {
|
||||
return { conflicts, runtimeValuesToPreserve }
|
||||
}
|
||||
if (targetExists && parseWslUncPath(writeTarget.path)) {
|
||||
// Why: \\wsl$ 9P symlink metadata is unreliable; write through the existing file to preserve the WSL-side inode.
|
||||
writeFileSync(writeTarget.path, nextContent, 'utf-8')
|
||||
return
|
||||
return { conflicts, runtimeValuesToPreserve }
|
||||
}
|
||||
writeFileAtomically(writeTarget.path, nextContent, {
|
||||
mode: writeTarget.mode
|
||||
})
|
||||
return { conflicts, runtimeValuesToPreserve }
|
||||
}
|
||||
|
||||
type PromotionCollectionContext = {
|
||||
baseline: CodexSettingsBaseline
|
||||
runtimeValues: ReadonlyMap<string, TopLevelSettingValue>
|
||||
systemValues: ReadonlyMap<string, TopLevelSettingValue>
|
||||
updates: Map<string, string>
|
||||
conflicts: Map<string, CodexSettingsConflict>
|
||||
runtimeValuesToPreserve: Map<string, string | null>
|
||||
}
|
||||
|
||||
function collectPromotionChanges(context: PromotionCollectionContext): void {
|
||||
for (const key of PROMOTED_STRUCTURED_KEYS) {
|
||||
const runtimeRaw = getComparableRaw(context.runtimeValues.get(key))
|
||||
const systemRaw = getComparableRaw(context.systemValues.get(key))
|
||||
if (runtimeRaw === undefined || systemRaw === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
const existingConflict = context.baseline.conflicts.get(key)
|
||||
if (existingConflict || !context.baseline.settings.has(key)) {
|
||||
const resolution = resolveUntrackedCodexSetting(runtimeRaw, systemRaw, existingConflict)
|
||||
if (resolution.action === 'promote-runtime') {
|
||||
context.updates.set(key, resolution.raw)
|
||||
} else if (resolution.action === 'preserve') {
|
||||
// Why: a schema-new key has no three-way ancestor; preserve both values until content changes one side.
|
||||
context.conflicts.set(key, resolution.conflict)
|
||||
context.runtimeValuesToPreserve.set(key, runtimeRaw)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (runtimeRaw === null || runtimeRaw === context.baseline.settings.get(key)) {
|
||||
continue
|
||||
}
|
||||
// Why: ~/.codex remains source of truth when both sides changed from a known baseline.
|
||||
if (systemRaw !== context.baseline.settings.get(key)) {
|
||||
continue
|
||||
}
|
||||
context.updates.set(key, runtimeRaw)
|
||||
}
|
||||
}
|
||||
|
||||
function getComparableRaw(value: TopLevelSettingValue | undefined): string | null | undefined {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
return value.multiline ? undefined : value.raw
|
||||
}
|
||||
|
||||
function emptyPromotionPlan(): CodexSettingsPromotionPlan {
|
||||
return { conflicts: new Map(), runtimeValuesToPreserve: new Map() }
|
||||
}
|
||||
|
||||
// Why: follow an existing dotfile-manager symlink and carry its mode forward so an atomic write can't widen a 0600 config.
|
||||
@@ -252,55 +357,3 @@ function resolveDanglingSymlinkTarget(linkPath: string): string {
|
||||
// Why: replacing any link in a cycle would destroy dotfile-manager state; abort instead.
|
||||
throw new Error(`Codex config symlink cycle at ${linkPath}`)
|
||||
}
|
||||
|
||||
export function upsertTopLevelSettingsInContent(
|
||||
content: string,
|
||||
updates: Map<string, string>
|
||||
): string {
|
||||
const lines = content.split('\n')
|
||||
let state = createTomlLineScanState()
|
||||
let preambleEnd = lines.length
|
||||
const keyLineIndexes = new Map<string, number>()
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index] ?? ''
|
||||
if (isTomlStructuralLine(state)) {
|
||||
if (getTomlTableHeader(line)) {
|
||||
preambleEnd = index
|
||||
break
|
||||
}
|
||||
const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=/.exec(line)
|
||||
if (match?.[1] && updates.has(match[1])) {
|
||||
keyLineIndexes.set(match[1], index)
|
||||
}
|
||||
}
|
||||
state = updateTomlLineScanState(state, line)
|
||||
}
|
||||
|
||||
// Why: match the file's existing EOL (CRLF split leaves a trailing \r) so a Windows config doesn't go mixed-EOL.
|
||||
const usesCrlf = content.includes('\r\n')
|
||||
const insertions: string[] = []
|
||||
for (const [key, raw] of updates) {
|
||||
const existingIndex = keyLineIndexes.get(key)
|
||||
const rendered = `${key} = ${raw}`
|
||||
if (existingIndex !== undefined) {
|
||||
lines[existingIndex] = lines[existingIndex]?.endsWith('\r') ? `${rendered}\r` : rendered
|
||||
} else {
|
||||
insertions.push(usesCrlf ? `${rendered}\r` : rendered)
|
||||
}
|
||||
}
|
||||
if (insertions.length > 0) {
|
||||
let insertAt = preambleEnd
|
||||
while (insertAt > 0 && (lines[insertAt - 1] ?? '').trim() === '') {
|
||||
insertAt -= 1
|
||||
}
|
||||
if (insertAt === preambleEnd && preambleEnd < lines.length) {
|
||||
insertions.push(usesCrlf ? '\r' : '')
|
||||
}
|
||||
lines.splice(insertAt, 0, ...insertions)
|
||||
}
|
||||
const result = lines.join('\n')
|
||||
if (result.endsWith('\n') || result.length === 0) {
|
||||
return result
|
||||
}
|
||||
return result.endsWith('\r') ? `${result}\n` : `${result}${usesCrlf ? '\r\n' : '\n'}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { parseTomlSingleLineStringValue } from './config-toml-line-scan'
|
||||
|
||||
export type ParsedTomlKeyPath = {
|
||||
segments: string[]
|
||||
end: number
|
||||
}
|
||||
|
||||
export type ParsedTomlTableHeaderPath = ParsedTomlKeyPath & {
|
||||
isArray: boolean
|
||||
}
|
||||
|
||||
export function parseTomlTableHeaderPath(header: string): ParsedTomlTableHeaderPath | null {
|
||||
const trimmed = header.trim()
|
||||
let source: string
|
||||
let isArray: boolean
|
||||
if (trimmed.startsWith('[[')) {
|
||||
if (!trimmed.endsWith(']]')) {
|
||||
return null
|
||||
}
|
||||
source = trimmed.slice(2, -2)
|
||||
isArray = true
|
||||
} else {
|
||||
if (!trimmed.startsWith('[') || !trimmed.endsWith(']') || trimmed.endsWith(']]')) {
|
||||
return null
|
||||
}
|
||||
source = trimmed.slice(1, -1)
|
||||
isArray = false
|
||||
}
|
||||
const parsed = parseTomlKeyPath(source)
|
||||
if (!parsed || parsed.end !== source.length) {
|
||||
return null
|
||||
}
|
||||
return { ...parsed, isArray }
|
||||
}
|
||||
|
||||
export function parseTomlKeyPath(source: string, offset = 0): ParsedTomlKeyPath | null {
|
||||
const segments: string[] = []
|
||||
let index = skipTomlKeyWhitespace(source, offset)
|
||||
while (index < source.length) {
|
||||
const quoted = parseTomlSingleLineStringValue(source, index)
|
||||
if (quoted) {
|
||||
segments.push(quoted.value)
|
||||
index = quoted.end
|
||||
} else {
|
||||
const bare = /^[A-Za-z0-9_-]+/.exec(source.slice(index))
|
||||
if (!bare) {
|
||||
return null
|
||||
}
|
||||
segments.push(bare[0])
|
||||
index += bare[0].length
|
||||
}
|
||||
index = skipTomlKeyWhitespace(source, index)
|
||||
if (source[index] !== '.') {
|
||||
return { segments, end: index }
|
||||
}
|
||||
index = skipTomlKeyWhitespace(source, index + 1)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function skipTomlKeyWhitespace(source: string, offset: number): number {
|
||||
let index = offset
|
||||
while (source[index] === ' ' || source[index] === '\t') {
|
||||
index += 1
|
||||
}
|
||||
return index
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ColdRestorePayloadCache,
|
||||
getColdRestorePayloadBytes,
|
||||
type ColdRestorePayload
|
||||
} from './cold-restore-payload-cache'
|
||||
|
||||
function payload(scrollback: string): ColdRestorePayload {
|
||||
return { scrollback, cwd: '/tmp', cols: 80, rows: 24 }
|
||||
}
|
||||
|
||||
describe('ColdRestorePayloadCache', () => {
|
||||
it('counts retained strings by UTF-16 code units', () => {
|
||||
expect(
|
||||
getColdRestorePayloadBytes({
|
||||
...payload('😀'),
|
||||
oscLinks: [{ row: 0, startCol: 0, endCol: 1, uri: 'é' }]
|
||||
})
|
||||
).toBe(54)
|
||||
})
|
||||
|
||||
it('evicts least-recently-used payloads to stay under its byte bound', () => {
|
||||
const first = payload('a'.repeat(100))
|
||||
const second = payload('b'.repeat(100))
|
||||
const third = payload('c'.repeat(100))
|
||||
const maxBytes = getColdRestorePayloadBytes(first) * 2
|
||||
const evicted: string[] = []
|
||||
const cache = new ColdRestorePayloadCache(maxBytes, (sessionId) => evicted.push(sessionId))
|
||||
|
||||
cache.set('first', first)
|
||||
cache.set('second', second)
|
||||
expect(cache.get('first')).toBe(first)
|
||||
cache.set('third', third)
|
||||
|
||||
expect(cache.has('first')).toBe(true)
|
||||
expect(cache.has('second')).toBe(false)
|
||||
expect(cache.has('third')).toBe(true)
|
||||
expect(cache.byteSize).toBeLessThanOrEqual(maxBytes)
|
||||
expect(evicted).toEqual(['second'])
|
||||
})
|
||||
|
||||
it('does not retain one payload larger than the entire cache budget', () => {
|
||||
const cache = new ColdRestorePayloadCache(32)
|
||||
|
||||
cache.set('oversized', payload('x'.repeat(100)))
|
||||
|
||||
expect(cache.has('oversized')).toBe(false)
|
||||
expect(cache.byteSize).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
|
||||
|
||||
export type ColdRestorePayload = {
|
||||
scrollback: string
|
||||
cwd: string
|
||||
cols: number
|
||||
rows: number
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
}
|
||||
|
||||
// Why: restore payloads remain sticky only for remount safety; cap their aggregate main-process footprint.
|
||||
export const MAX_COLD_RESTORE_CACHE_BYTES = 16 * 1024 * 1024
|
||||
|
||||
export function getColdRestorePayloadBytes(payload: ColdRestorePayload): number {
|
||||
const oscLinkBytes =
|
||||
payload.oscLinks?.reduce((bytes, link) => bytes + link.uri.length * 2 + 24, 0) ?? 0
|
||||
// Why: code-unit sizing bounds V8 string storage without rescanning or flattening multi-MB ropes.
|
||||
return payload.scrollback.length * 2 + payload.cwd.length * 2 + oscLinkBytes + 16
|
||||
}
|
||||
|
||||
export class ColdRestorePayloadCache {
|
||||
private entries = new Map<string, { payload: ColdRestorePayload; bytes: number }>()
|
||||
private totalBytes = 0
|
||||
|
||||
constructor(
|
||||
private readonly maxBytes = MAX_COLD_RESTORE_CACHE_BYTES,
|
||||
private readonly onEvict?: (sessionId: string) => void
|
||||
) {}
|
||||
|
||||
get byteSize(): number {
|
||||
return this.totalBytes
|
||||
}
|
||||
|
||||
get(sessionId: string): ColdRestorePayload | undefined {
|
||||
const entry = this.entries.get(sessionId)
|
||||
if (!entry) {
|
||||
return undefined
|
||||
}
|
||||
this.entries.delete(sessionId)
|
||||
this.entries.set(sessionId, entry)
|
||||
return entry.payload
|
||||
}
|
||||
|
||||
has(sessionId: string): boolean {
|
||||
return this.entries.has(sessionId)
|
||||
}
|
||||
|
||||
set(sessionId: string, payload: ColdRestorePayload): void {
|
||||
this.delete(sessionId)
|
||||
const bytes = getColdRestorePayloadBytes(payload)
|
||||
this.entries.set(sessionId, { payload, bytes })
|
||||
this.totalBytes += bytes
|
||||
|
||||
while (this.totalBytes > this.maxBytes) {
|
||||
const oldestSessionId = this.entries.keys().next().value
|
||||
if (oldestSessionId === undefined) {
|
||||
break
|
||||
}
|
||||
this.delete(oldestSessionId)
|
||||
this.onEvict?.(oldestSessionId)
|
||||
}
|
||||
}
|
||||
|
||||
delete(sessionId: string): boolean {
|
||||
const entry = this.entries.get(sessionId)
|
||||
if (!entry) {
|
||||
return false
|
||||
}
|
||||
this.entries.delete(sessionId)
|
||||
this.totalBytes -= entry.bytes
|
||||
return true
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries.clear()
|
||||
this.totalBytes = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { HeadlessEmulator } from './headless-emulator'
|
||||
|
||||
const REPLAY_CHARS_PER_TURN = 64 * 1024
|
||||
const REPLAY_OPERATIONS_PER_TURN = 1024
|
||||
|
||||
export class ColdRestoreReplayWriter {
|
||||
private chars = 0
|
||||
private operations = 0
|
||||
|
||||
constructor(private readonly emulator: HeadlessEmulator) {}
|
||||
|
||||
async write(data: string): Promise<boolean> {
|
||||
let offset = 0
|
||||
while (offset < data.length) {
|
||||
const pendingYield = this.takeBudgetYield()
|
||||
if (pendingYield) {
|
||||
await pendingYield
|
||||
}
|
||||
const remainingBudget = REPLAY_CHARS_PER_TURN - this.chars
|
||||
let end = Math.min(data.length, offset + remainingBudget)
|
||||
// Why: xterm must receive UTF-16 surrogate pairs together when a replay slice lands between them.
|
||||
const leftCodeUnit = data.charCodeAt(end - 1)
|
||||
const rightCodeUnit = data.charCodeAt(end)
|
||||
const splitsSurrogatePair =
|
||||
end < data.length &&
|
||||
leftCodeUnit >= 0xd800 &&
|
||||
leftCodeUnit <= 0xdbff &&
|
||||
rightCodeUnit >= 0xdc00 &&
|
||||
rightCodeUnit <= 0xdfff
|
||||
if (splitsSurrogatePair) {
|
||||
end += end === offset + 1 ? 1 : -1
|
||||
}
|
||||
if (!this.emulator.writeSync(data.slice(offset, end))) {
|
||||
return false
|
||||
}
|
||||
this.chars += end - offset
|
||||
this.operations += 1
|
||||
offset = end
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async resize(cols: number, rows: number): Promise<void> {
|
||||
const pendingYield = this.takeBudgetYield()
|
||||
if (pendingYield) {
|
||||
await pendingYield
|
||||
}
|
||||
this.emulator.resize(cols, rows)
|
||||
this.operations += 1
|
||||
}
|
||||
|
||||
async clearScrollback(): Promise<void> {
|
||||
const pendingYield = this.takeBudgetYield()
|
||||
if (pendingYield) {
|
||||
await pendingYield
|
||||
}
|
||||
this.emulator.clearScrollback()
|
||||
this.operations += 1
|
||||
}
|
||||
|
||||
private takeBudgetYield(): Promise<void> | null {
|
||||
if (this.chars < REPLAY_CHARS_PER_TURN && this.operations < REPLAY_OPERATIONS_PER_TURN) {
|
||||
return null
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
setImmediate(() => {
|
||||
this.chars = 0
|
||||
this.operations = 0
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2069,9 +2069,13 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
writeFileSync(join(sessionDir, 'scrollback.bin'), 'cached output')
|
||||
|
||||
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
|
||||
const internals = historyAdapter as unknown as {
|
||||
coldRestoreCache: { byteSize: number }
|
||||
}
|
||||
|
||||
const first = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
|
||||
expect(first.coldRestore).toBeDefined()
|
||||
expect(internals.coldRestoreCache.byteSize).toBeGreaterThan(0)
|
||||
|
||||
// Second call (StrictMode remount) should get cached data
|
||||
const second = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
|
||||
@@ -2080,6 +2084,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
|
||||
// After ack, cold restore should not be returned
|
||||
historyAdapter.ackColdRestore(sessionId)
|
||||
expect(internals.coldRestoreCache.byteSize).toBe(0)
|
||||
const third = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
|
||||
expect(third.coldRestore).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -43,19 +43,11 @@ import { resolveWslSessionContext } from './wsl-session-context'
|
||||
import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd'
|
||||
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
|
||||
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
|
||||
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
|
||||
import { ColdRestorePayloadCache, type ColdRestorePayload } from './cold-restore-payload-cache'
|
||||
import { PtyProcessListAdmission } from '../providers/pty-process-list-admission'
|
||||
|
||||
type ColdRestorePayload = {
|
||||
scrollback: string
|
||||
cwd: string
|
||||
cols: number
|
||||
rows: number
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
}
|
||||
|
||||
type PendingDaemonSpawnOperation = {
|
||||
exitsBySessionId: Map<string, { incarnationId?: string }[]>
|
||||
ignoredExitIncarnationIds: Set<string>
|
||||
@@ -138,8 +130,10 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
// Why: StrictMode/re-render remounts can call createOrAttach for a just-killed session; tombstones stop the daemon resurrecting it (Map evicts oldest-first, per terminal-host.ts).
|
||||
private killedSessionTombstones = new Map<string, number>()
|
||||
// Why: React StrictMode double-mounts; this sticky cache returns the same cold restore data on remount until the renderer acknowledges it.
|
||||
private coldRestoreCache = new Map<string, ColdRestorePayload>()
|
||||
private sleepRestoreSessionIds = new Set<string>()
|
||||
private coldRestoreCache = new ColdRestorePayloadCache(undefined, (sessionId) => {
|
||||
this.sleepRestoreSessionIds.delete(sessionId)
|
||||
})
|
||||
private activeSessionIds = new Set<string>()
|
||||
private sessionIncarnations = new Map<string, string>()
|
||||
private pendingSpawnOperationsBySessionId = new Map<string, Set<PendingDaemonSpawnOperation>>()
|
||||
@@ -258,9 +252,11 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
shellOverride: opts.shellOverride,
|
||||
terminalWindowsWslDistro: opts.terminalWindowsWslDistro
|
||||
})?.distro
|
||||
const detectColdRestore = (options?: { ignoreCleanEnd?: boolean }): ColdRestoreInfo | null => {
|
||||
const detectColdRestore = async (options?: {
|
||||
ignoreCleanEnd?: boolean
|
||||
}): Promise<ColdRestoreInfo | null> => {
|
||||
const restoreInfo =
|
||||
this.historyReader?.detectColdRestore(sessionId, { ...options, wslDistro }) ?? null
|
||||
(await this.historyReader?.detectColdRestore(sessionId, { ...options, wslDistro })) ?? null
|
||||
if (!restoreInfo) {
|
||||
return null
|
||||
}
|
||||
@@ -297,7 +293,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
if ((await this.getAppliedSize(sessionId)) !== null) {
|
||||
restoreSkippedForLiveSession = true
|
||||
} else {
|
||||
restoreInfo = detectColdRestore()
|
||||
restoreInfo = await detectColdRestore()
|
||||
}
|
||||
}
|
||||
let effectiveCwd = restoreInfo?.cwd ?? opts.cwd
|
||||
@@ -407,7 +403,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
// Why: the probe→createOrAttach gap is racy — the session can exit in between, so re-detect to match the unprobed restore path.
|
||||
// Why ignoreCleanEnd: the raced exit event can write endedAt before the reply; nulling the restore here would delete the checkpoint instead of restoring it.
|
||||
if (result.isNew && restoreSkippedForLiveSession) {
|
||||
restoreInfo = detectColdRestore({ ignoreCleanEnd: true })
|
||||
restoreInfo = await detectColdRestore({ ignoreCleanEnd: true })
|
||||
scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null
|
||||
if (restoreInfo && scrollback) {
|
||||
// Why: the aliveness probe raced with session death, so the first
|
||||
@@ -439,7 +435,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
this.initialCwds.set(sessionId, effectiveCwd)
|
||||
}
|
||||
} else if (!result.isNew && result.historySeeded === false) {
|
||||
restoreInfo = detectColdRestore()
|
||||
restoreInfo = await detectColdRestore()
|
||||
scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null
|
||||
}
|
||||
|
||||
@@ -659,7 +655,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
await this.checkpointSessions([id], { final: true, teardown: true })
|
||||
const wslDistro = this.wslDistrosBySessionId.get(id)
|
||||
const detected = this.historyReader?.detectColdRestore(id, { wslDistro }) ?? null
|
||||
const detected = (await this.historyReader?.detectColdRestore(id, { wslDistro })) ?? null
|
||||
const restoreInfo = detected
|
||||
? {
|
||||
...detected,
|
||||
@@ -674,7 +670,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
const coldRestore = restoreInfo ? this.buildColdRestorePayload(restoreInfo) : null
|
||||
if (coldRestore) {
|
||||
this.coldRestoreCache.set(id, coldRestore)
|
||||
this.sleepRestoreSessionIds.add(id)
|
||||
if (this.coldRestoreCache.has(id)) {
|
||||
this.sleepRestoreSessionIds.add(id)
|
||||
}
|
||||
// Why: physical exit must not mark intentional sleep as a clean end; the final checkpoint stays the wake-time recovery authority.
|
||||
this.historyManager?.suspendSession(id)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('agent hibernation cold-restore (alt-screen TUI)', () => {
|
||||
await manager.checkpoint(sessionId, em.getSnapshot())
|
||||
em.dispose()
|
||||
|
||||
const info = reader.detectColdRestore(sessionId)
|
||||
const info = await reader.detectColdRestore(sessionId)
|
||||
expect(info).not.toBeNull()
|
||||
// Adapter uses rehydrateSequences + snapshotAnsi for non-alt-screen → non-empty.
|
||||
expect(info!.modes.alternateScreen).toBe(false)
|
||||
@@ -59,7 +59,7 @@ describe('agent hibernation cold-restore (alt-screen TUI)', () => {
|
||||
await manager.checkpoint(sessionId, em.getSnapshot())
|
||||
em.dispose()
|
||||
|
||||
const info = reader.detectColdRestore(sessionId)
|
||||
const info = await reader.detectColdRestore(sessionId)
|
||||
// Session is eligible (endedAt null) and the agent's snapshot is intact.
|
||||
expect(info).not.toBeNull()
|
||||
expect(info!.modes.alternateScreen).toBe(true)
|
||||
@@ -88,7 +88,7 @@ describe('agent hibernation cold-restore (alt-screen TUI)', () => {
|
||||
await manager.checkpoint(sessionId, em.getSnapshot())
|
||||
em.dispose()
|
||||
|
||||
const info = reader.detectColdRestore(sessionId)
|
||||
const info = await reader.detectColdRestore(sessionId)
|
||||
const adapterScrollback = info!.modes.alternateScreen
|
||||
? info!.scrollbackAnsi || info!.snapshotAnsi || null
|
||||
: info!.rehydrateSequences + info!.snapshotAnsi
|
||||
@@ -118,7 +118,7 @@ describe('agent hibernation cold-restore (alt-screen TUI)', () => {
|
||||
await manager.checkpoint(sessionId, em.getSnapshot())
|
||||
em.dispose()
|
||||
|
||||
const info = reader.detectColdRestore(sessionId)
|
||||
const info = await reader.detectColdRestore(sessionId)
|
||||
expect(info!.modes.alternateScreen).toBe(true)
|
||||
const adapterScrollback = info!.scrollbackAnsi || info!.snapshotAnsi || null
|
||||
expect(adapterScrollback).not.toContain(ALT_SCREEN_ON)
|
||||
|
||||
@@ -73,24 +73,23 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('terminal history restore memory limits', () => {
|
||||
it('falls back to a valid checkpoint when the incremental log is oversized', () => {
|
||||
it('falls back to a valid checkpoint when the incremental log is oversized', async () => {
|
||||
const { basePath, sessionPath } = createSession('oversized-log')
|
||||
writeFileSync(join(sessionPath, 'checkpoint.json'), checkpoint())
|
||||
createSparseFile(join(sessionPath, 'output.log'), TERMINAL_HISTORY_LOG_MAX_BYTES + 1)
|
||||
|
||||
expect(new HistoryReader(basePath).detectColdRestore('oversized-log')?.snapshotAnsi).toBe(
|
||||
'safe checkpoint'
|
||||
)
|
||||
const restore = await new HistoryReader(basePath).detectColdRestore('oversized-log')
|
||||
expect(restore?.snapshotAnsi).toBe('safe checkpoint')
|
||||
})
|
||||
|
||||
it('ignores oversized checkpoint and metadata files before parsing', () => {
|
||||
it('ignores oversized checkpoint and metadata files before parsing', async () => {
|
||||
const checkpointSession = createSession('oversized-checkpoint')
|
||||
createSparseFile(
|
||||
join(checkpointSession.sessionPath, 'checkpoint.json'),
|
||||
TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES + 1
|
||||
)
|
||||
expect(
|
||||
new HistoryReader(checkpointSession.basePath).detectColdRestore('oversized-checkpoint')
|
||||
await new HistoryReader(checkpointSession.basePath).detectColdRestore('oversized-checkpoint')
|
||||
).toBeNull()
|
||||
|
||||
const metadataSession = createSession('oversized-metadata')
|
||||
|
||||
@@ -81,10 +81,10 @@ describe('HistoryReader', () => {
|
||||
})
|
||||
|
||||
describe('detectColdRestore — checkpoint.json', () => {
|
||||
it('returns restore info from checkpoint.json for unclean shutdown', () => {
|
||||
it('returns restore info from checkpoint.json for unclean shutdown', async () => {
|
||||
writeSessionWithCheckpoint(dir, 'sess-1', makeMeta(), makeCheckpoint())
|
||||
|
||||
const info = reader.detectColdRestore('sess-1')
|
||||
const info = await reader.detectColdRestore('sess-1')
|
||||
expect(info).not.toBeNull()
|
||||
expect(info!.cwd).toBe('/home/user/project')
|
||||
expect(info!.cols).toBe(80)
|
||||
@@ -93,7 +93,7 @@ describe('HistoryReader', () => {
|
||||
expect(info!.rehydrateSequences).toBe('')
|
||||
})
|
||||
|
||||
it('restores terminal modes from checkpoint', () => {
|
||||
it('restores terminal modes from checkpoint', async () => {
|
||||
const modes = {
|
||||
bracketedPaste: true,
|
||||
mouseTracking: false,
|
||||
@@ -102,12 +102,12 @@ describe('HistoryReader', () => {
|
||||
}
|
||||
writeSessionWithCheckpoint(dir, 'sess-1', makeMeta(), makeCheckpoint({ modes }))
|
||||
|
||||
const info = reader.detectColdRestore('sess-1')
|
||||
const info = await reader.detectColdRestore('sess-1')
|
||||
expect(info!.modes.bracketedPaste).toBe(true)
|
||||
expect(info!.modes.applicationCursor).toBe(true)
|
||||
})
|
||||
|
||||
it('restores rehydrateSequences from checkpoint', () => {
|
||||
it('restores rehydrateSequences from checkpoint', async () => {
|
||||
writeSessionWithCheckpoint(
|
||||
dir,
|
||||
'sess-1',
|
||||
@@ -115,19 +115,19 @@ describe('HistoryReader', () => {
|
||||
makeCheckpoint({ rehydrateSequences: '\x1b[?2004h' })
|
||||
)
|
||||
|
||||
const info = reader.detectColdRestore('sess-1')
|
||||
const info = await reader.detectColdRestore('sess-1')
|
||||
expect(info!.rehydrateSequences).toBe('\x1b[?2004h')
|
||||
})
|
||||
|
||||
it('restores OSC link ranges from checkpoint', () => {
|
||||
it('restores OSC link ranges from checkpoint', async () => {
|
||||
const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }]
|
||||
writeSessionWithCheckpoint(dir, 'sess-1', makeMeta(), makeCheckpoint({ oscLinks }))
|
||||
|
||||
const info = reader.detectColdRestore('sess-1')
|
||||
const info = await reader.detectColdRestore('sess-1')
|
||||
expect(info!.oscLinks).toEqual(oscLinks)
|
||||
})
|
||||
|
||||
it('returns null for clean shutdown (endedAt is set)', () => {
|
||||
it('returns null for clean shutdown (endedAt is set)', async () => {
|
||||
writeSessionWithCheckpoint(
|
||||
dir,
|
||||
'sess-1',
|
||||
@@ -135,37 +135,37 @@ describe('HistoryReader', () => {
|
||||
makeCheckpoint()
|
||||
)
|
||||
|
||||
expect(reader.detectColdRestore('sess-1')).toBeNull()
|
||||
expect(await reader.detectColdRestore('sess-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for nonexistent session', () => {
|
||||
expect(reader.detectColdRestore('nonexistent')).toBeNull()
|
||||
it('returns null for nonexistent session', async () => {
|
||||
expect(await reader.detectColdRestore('nonexistent')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for corrupt meta.json', () => {
|
||||
it('returns null for corrupt meta.json', async () => {
|
||||
const sessionDir = join(dir, getHistorySessionDirName('corrupt'))
|
||||
mkdirSync(sessionDir, { recursive: true })
|
||||
writeFileSync(join(sessionDir, 'meta.json'), 'not json')
|
||||
writeFileSync(join(sessionDir, 'checkpoint.json'), JSON.stringify(makeCheckpoint()))
|
||||
|
||||
expect(reader.detectColdRestore('corrupt')).toBeNull()
|
||||
expect(await reader.detectColdRestore('corrupt')).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to scrollback.bin when checkpoint.json is corrupt', () => {
|
||||
it('falls back to scrollback.bin when checkpoint.json is corrupt', async () => {
|
||||
const sessionDir = join(dir, getHistorySessionDirName('bad-cp'))
|
||||
mkdirSync(sessionDir, { recursive: true })
|
||||
writeFileSync(join(sessionDir, 'meta.json'), JSON.stringify(makeMeta()))
|
||||
writeFileSync(join(sessionDir, 'checkpoint.json'), 'not json')
|
||||
writeFileSync(join(sessionDir, 'scrollback.bin'), 'fallback data\r\n')
|
||||
|
||||
const info = reader.detectColdRestore('bad-cp')
|
||||
const info = await reader.detectColdRestore('bad-cp')
|
||||
expect(info).not.toBeNull()
|
||||
expect(info!.snapshotAnsi).toBe('fallback data\r\n')
|
||||
expect(info!.rehydrateSequences).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
it('replays incremental hostname OSC-7 with the same WSL context', () => {
|
||||
it('replays incremental hostname OSC-7 with the same WSL context', async () => {
|
||||
writeSessionWithCheckpoint(dir, 'wsl-log', makeMeta(), makeCheckpoint({ generation: 7 }))
|
||||
const sessionDir = join(dir, getHistorySessionDirName('wsl-log'))
|
||||
writeFileSync(
|
||||
@@ -178,16 +178,16 @@ describe('HistoryReader', () => {
|
||||
])
|
||||
)
|
||||
|
||||
const info = reader.detectColdRestore('wsl-log', { wslDistro: 'Ubuntu' })
|
||||
const info = await reader.detectColdRestore('wsl-log', { wslDistro: 'Ubuntu' })
|
||||
|
||||
expect(info?.cwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\user\\project')
|
||||
})
|
||||
|
||||
describe('detectColdRestore — scrollback.bin fallback (backward compatibility)', () => {
|
||||
it('restores from scrollback.bin when checkpoint.json is absent', () => {
|
||||
it('restores from scrollback.bin when checkpoint.json is absent', async () => {
|
||||
writeSessionWithScrollback(dir, 'old-sess', makeMeta(), 'old format data\r\n')
|
||||
|
||||
const info = reader.detectColdRestore('old-sess')
|
||||
const info = await reader.detectColdRestore('old-sess')
|
||||
expect(info).not.toBeNull()
|
||||
expect(info!.snapshotAnsi).toContain('old format data')
|
||||
expect(info!.rehydrateSequences).toBe('')
|
||||
@@ -195,20 +195,20 @@ describe('HistoryReader', () => {
|
||||
expect(info!.modes.alternateScreen).toBe(false)
|
||||
})
|
||||
|
||||
it('returns null when neither checkpoint.json nor scrollback.bin exist', () => {
|
||||
it('returns null when neither checkpoint.json nor scrollback.bin exist', async () => {
|
||||
const sessionDir = join(dir, getHistorySessionDirName('no-data'))
|
||||
mkdirSync(sessionDir, { recursive: true })
|
||||
writeFileSync(join(sessionDir, 'meta.json'), JSON.stringify(makeMeta()))
|
||||
|
||||
expect(reader.detectColdRestore('no-data')).toBeNull()
|
||||
expect(await reader.detectColdRestore('no-data')).toBeNull()
|
||||
})
|
||||
|
||||
it('truncates alt-screen from scrollback.bin fallback', () => {
|
||||
it('truncates alt-screen from scrollback.bin fallback', async () => {
|
||||
const scrollback = ['normal output\r\n', '\x1b[?1049h', 'vim content here'].join('')
|
||||
|
||||
writeSessionWithScrollback(dir, 'tui-sess', makeMeta(), scrollback)
|
||||
|
||||
const info = reader.detectColdRestore('tui-sess')
|
||||
const info = await reader.detectColdRestore('tui-sess')
|
||||
expect(info).not.toBeNull()
|
||||
expect(info!.snapshotAnsi).toContain('normal output')
|
||||
expect(info!.snapshotAnsi).not.toContain('vim content')
|
||||
@@ -216,7 +216,7 @@ describe('HistoryReader', () => {
|
||||
})
|
||||
|
||||
describe('TUI truncation (scrollback.bin fallback path)', () => {
|
||||
it('preserves content when alt-screen is properly closed', () => {
|
||||
it('preserves content when alt-screen is properly closed', async () => {
|
||||
const scrollback = [
|
||||
'before vim\r\n',
|
||||
'\x1b[?1049h',
|
||||
@@ -227,13 +227,13 @@ describe('HistoryReader', () => {
|
||||
|
||||
writeSessionWithScrollback(dir, 'closed-tui', makeMeta(), scrollback)
|
||||
|
||||
const info = reader.detectColdRestore('closed-tui')
|
||||
const info = await reader.detectColdRestore('closed-tui')
|
||||
expect(info).not.toBeNull()
|
||||
expect(info!.snapshotAnsi).toContain('before vim')
|
||||
expect(info!.snapshotAnsi).toContain('after vim')
|
||||
})
|
||||
|
||||
it('handles multiple alt-screen cycles with last one unclosed', () => {
|
||||
it('handles multiple alt-screen cycles with last one unclosed', async () => {
|
||||
const scrollback = [
|
||||
'line1\r\n',
|
||||
'\x1b[?1049h',
|
||||
@@ -246,14 +246,14 @@ describe('HistoryReader', () => {
|
||||
|
||||
writeSessionWithScrollback(dir, 'multi-tui', makeMeta(), scrollback)
|
||||
|
||||
const info = reader.detectColdRestore('multi-tui')
|
||||
const info = await reader.detectColdRestore('multi-tui')
|
||||
expect(info).not.toBeNull()
|
||||
expect(info!.snapshotAnsi).toContain('line1')
|
||||
expect(info!.snapshotAnsi).toContain('line2')
|
||||
expect(info!.snapshotAnsi).not.toContain('vim2-still-running')
|
||||
})
|
||||
|
||||
it('truncates at outermost unmatched alt-screen-on for nested sessions', () => {
|
||||
it('truncates at outermost unmatched alt-screen-on for nested sessions', async () => {
|
||||
const scrollback = [
|
||||
'normal output\r\n',
|
||||
'\x1b[?1049h',
|
||||
@@ -264,17 +264,17 @@ describe('HistoryReader', () => {
|
||||
|
||||
writeSessionWithScrollback(dir, 'nested-tui', makeMeta(), scrollback)
|
||||
|
||||
const info = reader.detectColdRestore('nested-tui')
|
||||
const info = await reader.detectColdRestore('nested-tui')
|
||||
expect(info).not.toBeNull()
|
||||
expect(info!.snapshotAnsi).toContain('normal output')
|
||||
expect(info!.snapshotAnsi).not.toContain('tmux content')
|
||||
expect(info!.snapshotAnsi).not.toContain('vim inside tmux')
|
||||
})
|
||||
|
||||
it('returns full content when no alt-screen sequences', () => {
|
||||
it('returns full content when no alt-screen sequences', async () => {
|
||||
writeSessionWithScrollback(dir, 'plain', makeMeta(), 'just normal shell output\r\n')
|
||||
|
||||
const info = reader.detectColdRestore('plain')
|
||||
const info = await reader.detectColdRestore('plain')
|
||||
expect(info!.snapshotAnsi).toBe('just normal shell output\r\n')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { join } from 'node:path'
|
||||
import { existsSync, opendirSync } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import type { SessionMeta } from './history-manager'
|
||||
import type { TerminalCheckpointFile, TerminalModes } from './types'
|
||||
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
|
||||
import { getHistorySessionDirName } from './history-paths'
|
||||
import { decodeTerminalHistoryLog } from './terminal-history-log'
|
||||
import { decodeTerminalHistoryLog, LOG_HEADER_BYTES } from './terminal-history-log'
|
||||
import { HeadlessEmulator } from './headless-emulator'
|
||||
import { PrioritySemaphore } from './priority-semaphore'
|
||||
import { ColdRestoreReplayWriter } from './cold-restore-replay-writer'
|
||||
import {
|
||||
readTerminalHistoryBuffer,
|
||||
readTerminalHistoryBufferAsync,
|
||||
readTerminalHistoryJson,
|
||||
readTerminalHistoryText
|
||||
readTerminalHistoryJsonAsync
|
||||
} from './terminal-history-file-reader'
|
||||
import { detectColdRestoreFromLegacyScrollback } from './terminal-history-legacy-scrollback-restore'
|
||||
import {
|
||||
TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES,
|
||||
TERMINAL_HISTORY_LEGACY_SCROLLBACK_MAX_BYTES,
|
||||
TERMINAL_HISTORY_LOG_MAX_BYTES,
|
||||
TERMINAL_HISTORY_META_MAX_BYTES
|
||||
} from './terminal-history-file-limits'
|
||||
@@ -33,8 +36,8 @@ export type ColdRestoreInfo = {
|
||||
modes: TerminalModes
|
||||
}
|
||||
|
||||
const ALT_SCREEN_ON = '\x1b[?1049h'
|
||||
const ALT_SCREEN_OFF = '\x1b[?1049l'
|
||||
// Why: parallel pane mounts should interleave with main-process work without multiplying replay slices per turn.
|
||||
const coldRestoreReplaySemaphore = new PrioritySemaphore(1)
|
||||
|
||||
export class HistoryReader {
|
||||
private basePath: string
|
||||
@@ -52,10 +55,10 @@ export class HistoryReader {
|
||||
return meta !== null && meta.endedAt === null
|
||||
}
|
||||
|
||||
detectColdRestore(
|
||||
async detectColdRestore(
|
||||
sessionId: string,
|
||||
opts?: { ignoreCleanEnd?: boolean; wslDistro?: string }
|
||||
): ColdRestoreInfo | null {
|
||||
): Promise<ColdRestoreInfo | null> {
|
||||
const meta = this.readMeta(sessionId)
|
||||
if (!meta) {
|
||||
return null
|
||||
@@ -74,7 +77,7 @@ export class HistoryReader {
|
||||
let checkpoint: TerminalCheckpointFile | null = null
|
||||
if (checkpointExists) {
|
||||
try {
|
||||
checkpoint = readTerminalHistoryJson<TerminalCheckpointFile>(
|
||||
checkpoint = await readTerminalHistoryJsonAsync<TerminalCheckpointFile>(
|
||||
checkpointPath,
|
||||
TERMINAL_HISTORY_CHECKPOINT_MAX_BYTES
|
||||
)
|
||||
@@ -87,7 +90,12 @@ export class HistoryReader {
|
||||
// byte-exact output up to ~5s before the crash (up to the full-snapshot
|
||||
// cooldown, ~45s, for a streaming session mid-deferral), while the
|
||||
// checkpoint can be a full log-cap (~5MB of output) stale.
|
||||
const logRestore = this.restoreFromIncrementalLog(sessionDir, meta, checkpoint, opts?.wslDistro)
|
||||
const logRestore = await this.restoreFromIncrementalLog(
|
||||
sessionDir,
|
||||
meta,
|
||||
checkpoint,
|
||||
opts?.wslDistro
|
||||
)
|
||||
if (logRestore) {
|
||||
return logRestore
|
||||
}
|
||||
@@ -95,7 +103,7 @@ export class HistoryReader {
|
||||
if (!checkpoint) {
|
||||
// Why: backward compatibility with pre-checkpoint sessions, and corrupt
|
||||
// checkpoints — the old scrollback.bin is the best remaining data.
|
||||
return this.detectColdRestoreFromScrollback(sessionId, meta)
|
||||
return await detectColdRestoreFromLegacyScrollback(this.basePath, sessionId, meta)
|
||||
}
|
||||
|
||||
return this.coldRestoreInfoFromSnapshot(checkpoint, checkpoint.cwd, meta)
|
||||
@@ -161,80 +169,90 @@ export class HistoryReader {
|
||||
// emulator the daemon used reproduces the exact terminal state at the last
|
||||
// appended batch — including alt-screen and mode handling — and reuses
|
||||
// getSnapshot()'s normalization instead of string-level reconstruction.
|
||||
private restoreFromIncrementalLog(
|
||||
private async restoreFromIncrementalLog(
|
||||
sessionDir: string,
|
||||
meta: SessionMeta,
|
||||
checkpoint: TerminalCheckpointFile | null,
|
||||
wslDistro?: string
|
||||
): ColdRestoreInfo | null {
|
||||
let logBuffer: Buffer
|
||||
): Promise<ColdRestoreInfo | null> {
|
||||
const logPath = join(sessionDir, 'output.log')
|
||||
try {
|
||||
logBuffer = readTerminalHistoryBuffer(
|
||||
join(sessionDir, 'output.log'),
|
||||
TERMINAL_HISTORY_LOG_MAX_BYTES
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const log = decodeTerminalHistoryLog(logBuffer)
|
||||
if (!log || log.batches.length === 0) {
|
||||
return null
|
||||
}
|
||||
// Generation mismatch means the log does not continue this checkpoint
|
||||
// (e.g. crash between checkpoint rename and log reset, or a pre-log
|
||||
// checkpoint without a generation field). Replaying it would duplicate or
|
||||
// garble content; the checkpoint alone is consistent.
|
||||
if (checkpoint) {
|
||||
if (typeof checkpoint.generation !== 'number' || log.generation !== checkpoint.generation) {
|
||||
// Why: final checkpoints leave a header-only log; they need no scarce replay slot and must not queue sleep teardown behind startup restores.
|
||||
if ((await stat(logPath)).size <= LOG_HEADER_BYTES) {
|
||||
return null
|
||||
}
|
||||
} else if (log.generation !== 0) {
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const emulator = new HeadlessEmulator({
|
||||
cols: checkpoint?.cols ?? meta.cols,
|
||||
rows: checkpoint?.rows ?? meta.rows,
|
||||
wslDistro
|
||||
})
|
||||
const release = await coldRestoreReplaySemaphore.acquire(0)
|
||||
try {
|
||||
let logBuffer: Buffer
|
||||
try {
|
||||
logBuffer = await readTerminalHistoryBufferAsync(logPath, TERMINAL_HISTORY_LOG_MAX_BYTES)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const log = decodeTerminalHistoryLog(logBuffer)
|
||||
if (!log || log.batches.length === 0) {
|
||||
return null
|
||||
}
|
||||
// Generation mismatch means the log does not continue this checkpoint
|
||||
// (e.g. crash between checkpoint rename and log reset, or a pre-log
|
||||
// checkpoint without a generation field). Replaying it would duplicate or
|
||||
// garble content; the checkpoint alone is consistent.
|
||||
if (checkpoint) {
|
||||
if (
|
||||
!emulator.writeSync(
|
||||
(checkpoint.scrollbackAnsi ?? '') +
|
||||
checkpoint.rehydrateSequences +
|
||||
checkpoint.snapshotAnsi
|
||||
)
|
||||
) {
|
||||
if (typeof checkpoint.generation !== 'number' || log.generation !== checkpoint.generation) {
|
||||
return null
|
||||
}
|
||||
emulator.setRestoredOscLinks(checkpoint.oscLinks)
|
||||
} else if (log.generation !== 0) {
|
||||
return null
|
||||
}
|
||||
for (const batch of log.batches) {
|
||||
for (const record of batch.records) {
|
||||
if (record.kind === 'output') {
|
||||
if (!emulator.writeSync(record.data)) {
|
||||
return null
|
||||
|
||||
const emulator = new HeadlessEmulator({
|
||||
cols: checkpoint?.cols ?? meta.cols,
|
||||
rows: checkpoint?.rows ?? meta.rows,
|
||||
wslDistro
|
||||
})
|
||||
const replay = new ColdRestoreReplayWriter(emulator)
|
||||
try {
|
||||
if (checkpoint) {
|
||||
if (
|
||||
!(await replay.write(checkpoint.scrollbackAnsi ?? '')) ||
|
||||
!(await replay.write(checkpoint.rehydrateSequences)) ||
|
||||
!(await replay.write(checkpoint.snapshotAnsi))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
emulator.setRestoredOscLinks(checkpoint.oscLinks)
|
||||
}
|
||||
for (const batch of log.batches) {
|
||||
for (const record of batch.records) {
|
||||
if (record.kind === 'output') {
|
||||
if (!(await replay.write(record.data))) {
|
||||
return null
|
||||
}
|
||||
} else if (record.kind === 'resize') {
|
||||
await replay.resize(record.cols, record.rows)
|
||||
} else {
|
||||
await replay.clearScrollback()
|
||||
}
|
||||
} else if (record.kind === 'resize') {
|
||||
emulator.resize(record.cols, record.rows)
|
||||
} else {
|
||||
emulator.clearScrollback()
|
||||
}
|
||||
}
|
||||
const snapshot = emulator.getSnapshot()
|
||||
return this.coldRestoreInfoFromSnapshot(
|
||||
snapshot,
|
||||
snapshot.cwd ?? checkpoint?.cwd ?? meta.cwd,
|
||||
meta
|
||||
)
|
||||
} catch {
|
||||
// Why: a replay failure must degrade to checkpoint-only restore, never
|
||||
// surface as a failed spawn.
|
||||
return null
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
}
|
||||
const snapshot = emulator.getSnapshot()
|
||||
return this.coldRestoreInfoFromSnapshot(
|
||||
snapshot,
|
||||
snapshot.cwd ?? checkpoint?.cwd ?? meta.cwd,
|
||||
meta
|
||||
)
|
||||
} catch {
|
||||
// Why: a replay failure must degrade to checkpoint-only restore, never
|
||||
// surface as a failed spawn.
|
||||
return null
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,81 +296,4 @@ export class HistoryReader {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Why: handles the upgrade transition where sessions created before the
|
||||
// checkpoint migration still have scrollback.bin but no checkpoint.json.
|
||||
private detectColdRestoreFromScrollback(
|
||||
sessionId: string,
|
||||
meta: SessionMeta
|
||||
): ColdRestoreInfo | null {
|
||||
const scrollbackPath = join(
|
||||
this.basePath,
|
||||
getHistorySessionDirName(sessionId),
|
||||
'scrollback.bin'
|
||||
)
|
||||
if (!existsSync(scrollbackPath)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const scrollback = readTerminalHistoryText(
|
||||
scrollbackPath,
|
||||
TERMINAL_HISTORY_LEGACY_SCROLLBACK_MAX_BYTES
|
||||
)
|
||||
const truncated = this.truncateAltScreen(scrollback)
|
||||
return {
|
||||
snapshotAnsi: truncated,
|
||||
scrollbackAnsi: truncated,
|
||||
rehydrateSequences: '',
|
||||
cwd: meta.cwd,
|
||||
cols: meta.cols,
|
||||
rows: meta.rows,
|
||||
modes: {
|
||||
bracketedPaste: false,
|
||||
mouseTracking: false,
|
||||
applicationCursor: false,
|
||||
alternateScreen: false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Why: raw scrollback from TUI sessions (vim, less, htop) contains
|
||||
// alternate-screen switches that produce garbled output when replayed.
|
||||
// Truncate before the outermost unmatched alt-screen-on so only normal
|
||||
// terminal output is restored.
|
||||
private truncateAltScreen(data: string): string {
|
||||
let depth = 0
|
||||
let outermostUnmatchedOnIdx = -1
|
||||
|
||||
let searchFrom = 0
|
||||
while (searchFrom < data.length) {
|
||||
const onIdx = data.indexOf(ALT_SCREEN_ON, searchFrom)
|
||||
const offIdx = data.indexOf(ALT_SCREEN_OFF, searchFrom)
|
||||
|
||||
if (onIdx === -1 && offIdx === -1) {
|
||||
break
|
||||
}
|
||||
|
||||
if (onIdx !== -1 && (offIdx === -1 || onIdx < offIdx)) {
|
||||
if (depth === 0) {
|
||||
outermostUnmatchedOnIdx = onIdx
|
||||
}
|
||||
depth++
|
||||
searchFrom = onIdx + ALT_SCREEN_ON.length
|
||||
} else {
|
||||
if (depth > 0) {
|
||||
depth--
|
||||
}
|
||||
searchFrom = offIdx + ALT_SCREEN_OFF.length
|
||||
}
|
||||
}
|
||||
|
||||
if (depth > 0 && outermostUnmatchedOnIdx !== -1) {
|
||||
return data.slice(0, outermostUnmatchedOnIdx)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { readNodeFileSyncWithinLimit } from '../../shared/node-bounded-file-reader'
|
||||
import {
|
||||
readNodeFileSyncWithinLimit,
|
||||
readNodeFileWithinLimit
|
||||
} from '../../shared/node-bounded-file-reader'
|
||||
import { assertJsonTextStructureWithinLimits } from '../../shared/json-text-structure-limit'
|
||||
|
||||
export const TERMINAL_HISTORY_JSON_MAX_STRUCTURAL_TOKENS = 1_000_000
|
||||
@@ -20,3 +23,31 @@ export function readTerminalHistoryJson<T>(filePath: string, maxBytes: number):
|
||||
})
|
||||
return JSON.parse(text) as T
|
||||
}
|
||||
|
||||
// Why: cold-restore payload reads must not block the main thread, but need the
|
||||
// same byte and JSON-structure bounds as the sync readers.
|
||||
export async function readTerminalHistoryBufferAsync(
|
||||
filePath: string,
|
||||
maxBytes: number
|
||||
): Promise<Buffer> {
|
||||
return (await readNodeFileWithinLimit(filePath, maxBytes)).buffer
|
||||
}
|
||||
|
||||
export async function readTerminalHistoryTextAsync(
|
||||
filePath: string,
|
||||
maxBytes: number
|
||||
): Promise<string> {
|
||||
return (await readTerminalHistoryBufferAsync(filePath, maxBytes)).toString('utf8')
|
||||
}
|
||||
|
||||
export async function readTerminalHistoryJsonAsync<T>(
|
||||
filePath: string,
|
||||
maxBytes: number
|
||||
): Promise<T> {
|
||||
const text = await readTerminalHistoryTextAsync(filePath, maxBytes)
|
||||
assertJsonTextStructureWithinLimits(text, {
|
||||
structuralTokens: TERMINAL_HISTORY_JSON_MAX_STRUCTURAL_TOKENS,
|
||||
nestingDepth: TERMINAL_HISTORY_JSON_MAX_NESTING_DEPTH
|
||||
})
|
||||
return JSON.parse(text) as T
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, truncateSync } from 'node:fs'
|
||||
@@ -53,7 +53,7 @@ describe('incremental terminal history restore', () => {
|
||||
{ kind: 'output', data: 'second line\r\n' }
|
||||
])
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
const restore = await reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
expect(restore!.scrollbackAnsi).toContain('first line')
|
||||
expect(restore!.scrollbackAnsi).toContain('second line')
|
||||
@@ -66,7 +66,7 @@ describe('incremental terminal history restore', () => {
|
||||
{ kind: 'output', data: 'from tail after checkpoint\r\n' }
|
||||
])
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
const restore = await reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
expect(restore!.scrollbackAnsi).toContain('from base')
|
||||
expect(restore!.scrollbackAnsi).toContain('from tail after checkpoint')
|
||||
@@ -79,7 +79,7 @@ describe('incremental terminal history restore', () => {
|
||||
)
|
||||
await manager.appendIncrements(SESSION_ID, 1, [{ kind: 'output', data: 'tail\r\n' }])
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
const restore = await reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
expect(restore!.oscLinks).toContainEqual({
|
||||
row: 0,
|
||||
@@ -99,7 +99,7 @@ describe('incremental terminal history restore', () => {
|
||||
JSON.stringify({ ...checkpoint, cwd: '/home/user', generation: 1 })
|
||||
)
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
const restore = await reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
expect(restore!.scrollbackAnsi).toContain('base content')
|
||||
expect(restore!.scrollbackAnsi).not.toContain('stale tail')
|
||||
@@ -119,7 +119,7 @@ describe('incremental terminal history restore', () => {
|
||||
])
|
||||
)
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
const restore = await reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
expect(restore!.scrollbackAnsi).toContain('old format base')
|
||||
expect(restore!.scrollbackAnsi).not.toContain('orphan tail')
|
||||
@@ -137,7 +137,7 @@ describe('incremental terminal history restore', () => {
|
||||
])
|
||||
)
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
const restore = await reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
expect(restore!.scrollbackAnsi).toContain('safe base')
|
||||
expect(restore!.scrollbackAnsi).not.toContain('kept')
|
||||
@@ -150,7 +150,7 @@ describe('incremental terminal history restore', () => {
|
||||
const logPath = sessionFile('output.log')
|
||||
truncateSync(logPath, readFileSync(logPath).length - 5)
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
const restore = await reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
expect(restore!.scrollbackAnsi).toContain('complete batch')
|
||||
expect(restore!.scrollbackAnsi).not.toContain('torn batch')
|
||||
@@ -163,7 +163,7 @@ describe('incremental terminal history restore', () => {
|
||||
{ kind: 'output', data: 'after resize\r\n' }
|
||||
])
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
const restore = await reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
expect(restore!.cols).toBe(132)
|
||||
expect(restore!.rows).toBe(40)
|
||||
@@ -177,7 +177,7 @@ describe('incremental terminal history restore', () => {
|
||||
{ kind: 'output', data: 'survives clear\r\n' }
|
||||
])
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
const restore = await reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
expect(restore!.scrollbackAnsi).toContain('survives clear')
|
||||
expect(restore!.scrollbackAnsi).not.toContain('cleared away')
|
||||
@@ -188,7 +188,7 @@ describe('incremental terminal history restore', () => {
|
||||
{ kind: 'output', data: 'normal output\r\n\x1b[?1049halt screen content' }
|
||||
])
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
const restore = await reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
expect(restore!.modes.alternateScreen).toBe(true)
|
||||
expect(restore!.scrollbackAnsi).toContain('normal output')
|
||||
@@ -200,7 +200,7 @@ describe('incremental terminal history restore', () => {
|
||||
await manager.checkpoint(SESSION_ID, snapshotOf(['pre-checkpoint\r\n']))
|
||||
await manager.appendIncrements(SESSION_ID, 2, [{ kind: 'output', data: 'post-checkpoint\r\n' }])
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
const restore = await reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
const occurrences = restore!.scrollbackAnsi.split('pre-checkpoint').length - 1
|
||||
expect(occurrences).toBe(1)
|
||||
@@ -222,7 +222,7 @@ describe('incremental terminal history restore', () => {
|
||||
await manager.appendIncrements(SESSION_ID, 4, [{ kind: 'output', data: 'fresh\r\n' }])
|
||||
).toBe('ok')
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
const restore = await reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
expect(restore!.scrollbackAnsi).toContain('compacted')
|
||||
expect(restore!.scrollbackAnsi).toContain('fresh')
|
||||
@@ -246,9 +246,79 @@ describe('incremental terminal history restore', () => {
|
||||
{ kind: 'output', data: 'after relaunch\r\n' }
|
||||
])
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
const restore = await reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
expect(restore!.scrollbackAnsi).toContain('before relaunch')
|
||||
expect(restore!.scrollbackAnsi).toContain('after relaunch')
|
||||
})
|
||||
|
||||
it('bounds large single-batch replay slices and admits only one replay at a time', async () => {
|
||||
const secondSessionId = `${SESSION_ID}-second`
|
||||
await manager.openSession(secondSessionId, { cwd: '/home/user', cols: 80, rows: 24 })
|
||||
for (const sessionId of [SESSION_ID, secondSessionId]) {
|
||||
await manager.appendIncrements(sessionId, 1, [
|
||||
{ kind: 'output', data: `${'x'.repeat(64 * 1024 - 1)}😀second\r\n` }
|
||||
])
|
||||
}
|
||||
|
||||
const pendingYields: (() => void)[] = []
|
||||
const immediateSpy = vi.spyOn(globalThis, 'setImmediate').mockImplementation(((
|
||||
callback: (...args: unknown[]) => void,
|
||||
...args: unknown[]
|
||||
) => {
|
||||
pendingYields.push(() => callback(...args))
|
||||
return {} as NodeJS.Immediate
|
||||
}) as typeof setImmediate)
|
||||
|
||||
const firstReplay = reader.detectColdRestore(SESSION_ID)
|
||||
const secondReplay = reader.detectColdRestore(secondSessionId)
|
||||
try {
|
||||
await vi.waitFor(() => expect(pendingYields).toHaveLength(1))
|
||||
|
||||
pendingYields.shift()!()
|
||||
expect((await firstReplay)?.scrollbackAnsi).toContain('😀second')
|
||||
await vi.waitFor(() => expect(pendingYields).toHaveLength(1))
|
||||
|
||||
pendingYields.shift()!()
|
||||
expect((await secondReplay)?.scrollbackAnsi).toContain('😀second')
|
||||
expect(pendingYields).toHaveLength(0)
|
||||
} finally {
|
||||
for (const resume of pendingYields.splice(0)) {
|
||||
resume()
|
||||
}
|
||||
immediateSpy.mockRestore()
|
||||
await Promise.allSettled([firstReplay, secondReplay])
|
||||
}
|
||||
})
|
||||
|
||||
it('does not queue header-only checkpoint restores behind replay work', async () => {
|
||||
const checkpointOnlySessionId = `${SESSION_ID}-checkpoint-only`
|
||||
await manager.openSession(checkpointOnlySessionId, { cwd: '/home/user', cols: 80, rows: 24 })
|
||||
await manager.checkpoint(checkpointOnlySessionId, snapshotOf(['checkpoint only\r\n']))
|
||||
await manager.appendIncrements(SESSION_ID, 1, [
|
||||
{ kind: 'output', data: `${'x'.repeat(64 * 1024)}slow replay\r\n` }
|
||||
])
|
||||
|
||||
const pendingYields: (() => void)[] = []
|
||||
const immediateSpy = vi.spyOn(globalThis, 'setImmediate').mockImplementation(((
|
||||
callback: (...args: unknown[]) => void,
|
||||
...args: unknown[]
|
||||
) => {
|
||||
pendingYields.push(() => callback(...args))
|
||||
return {} as NodeJS.Immediate
|
||||
}) as typeof setImmediate)
|
||||
|
||||
const replay = reader.detectColdRestore(SESSION_ID)
|
||||
try {
|
||||
await vi.waitFor(() => expect(pendingYields).toHaveLength(1))
|
||||
const checkpointOnlyRestore = await reader.detectColdRestore(checkpointOnlySessionId)
|
||||
|
||||
expect(checkpointOnlyRestore?.scrollbackAnsi).toContain('checkpoint only')
|
||||
expect(pendingYields).toHaveLength(1)
|
||||
} finally {
|
||||
pendingYields.shift()?.()
|
||||
immediateSpy.mockRestore()
|
||||
await replay
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { join } from 'node:path'
|
||||
import { existsSync } from 'node:fs'
|
||||
import type { SessionMeta } from './history-manager'
|
||||
import type { ColdRestoreInfo } from './history-reader'
|
||||
import { getHistorySessionDirName } from './history-paths'
|
||||
import { readTerminalHistoryTextAsync } from './terminal-history-file-reader'
|
||||
import { TERMINAL_HISTORY_LEGACY_SCROLLBACK_MAX_BYTES } from './terminal-history-file-limits'
|
||||
|
||||
const ALT_SCREEN_ON = '\x1b[?1049h'
|
||||
const ALT_SCREEN_OFF = '\x1b[?1049l'
|
||||
|
||||
// Why: handles the upgrade transition where sessions created before the
|
||||
// checkpoint migration still have scrollback.bin but no checkpoint.json.
|
||||
export async function detectColdRestoreFromLegacyScrollback(
|
||||
basePath: string,
|
||||
sessionId: string,
|
||||
meta: SessionMeta
|
||||
): Promise<ColdRestoreInfo | null> {
|
||||
const scrollbackPath = join(basePath, getHistorySessionDirName(sessionId), 'scrollback.bin')
|
||||
if (!existsSync(scrollbackPath)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const scrollback = await readTerminalHistoryTextAsync(
|
||||
scrollbackPath,
|
||||
TERMINAL_HISTORY_LEGACY_SCROLLBACK_MAX_BYTES
|
||||
)
|
||||
const truncated = truncateAltScreen(scrollback)
|
||||
return {
|
||||
snapshotAnsi: truncated,
|
||||
scrollbackAnsi: truncated,
|
||||
rehydrateSequences: '',
|
||||
cwd: meta.cwd,
|
||||
cols: meta.cols,
|
||||
rows: meta.rows,
|
||||
modes: {
|
||||
bracketedPaste: false,
|
||||
mouseTracking: false,
|
||||
applicationCursor: false,
|
||||
alternateScreen: false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Why: raw scrollback from TUI sessions (vim, less, htop) contains
|
||||
// alternate-screen switches that produce garbled output when replayed.
|
||||
// Truncate before the outermost unmatched alt-screen-on so only normal
|
||||
// terminal output is restored.
|
||||
function truncateAltScreen(data: string): string {
|
||||
let depth = 0
|
||||
let outermostUnmatchedOnIdx = -1
|
||||
|
||||
let searchFrom = 0
|
||||
while (searchFrom < data.length) {
|
||||
const onIdx = data.indexOf(ALT_SCREEN_ON, searchFrom)
|
||||
const offIdx = data.indexOf(ALT_SCREEN_OFF, searchFrom)
|
||||
|
||||
if (onIdx === -1 && offIdx === -1) {
|
||||
break
|
||||
}
|
||||
|
||||
if (onIdx !== -1 && (offIdx === -1 || onIdx < offIdx)) {
|
||||
if (depth === 0) {
|
||||
outermostUnmatchedOnIdx = onIdx
|
||||
}
|
||||
depth++
|
||||
searchFrom = onIdx + ALT_SCREEN_ON.length
|
||||
} else {
|
||||
if (depth > 0) {
|
||||
depth--
|
||||
}
|
||||
searchFrom = offIdx + ALT_SCREEN_OFF.length
|
||||
}
|
||||
}
|
||||
|
||||
if (depth > 0 && outermostUnmatchedOnIdx !== -1) {
|
||||
return data.slice(0, outermostUnmatchedOnIdx)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -1674,6 +1674,14 @@ describe('registerPtyHandlers', () => {
|
||||
}
|
||||
|
||||
describe('spawn environment', () => {
|
||||
it('publishes a lifecycle signal after a successful renderer spawn', async () => {
|
||||
await spawnAndGetEnv()
|
||||
|
||||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:spawned', {
|
||||
id: expect.any(String)
|
||||
})
|
||||
})
|
||||
|
||||
it('marks local Claude launches live until the PTY is killed', async () => {
|
||||
let exitCb: ((info: { exitCode: number }) => void) | undefined
|
||||
spawnMock.mockReturnValue({
|
||||
@@ -5472,6 +5480,48 @@ describe('registerPtyHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('starts local and SSH session inventories concurrently', async () => {
|
||||
let resolveLocal!: (sessions: { id: string; cwd: string; title: string }[]) => void
|
||||
const localSessions = new Promise<{ id: string; cwd: string; title: string }[]>((resolve) => {
|
||||
resolveLocal = resolve
|
||||
})
|
||||
vi.spyOn(getLocalPtyProvider(), 'listProcesses').mockReturnValue(localSessions)
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
|
||||
let resolveSsh!: (sessions: { id: string; cwd: string; title: string }[]) => void
|
||||
const sshSessions = new Promise<{ id: string; cwd: string; title: string }[]>((resolve) => {
|
||||
resolveSsh = resolve
|
||||
})
|
||||
const sshListProcesses = vi.fn(() => sshSessions)
|
||||
registerSshPtyProvider('ssh-1', {
|
||||
spawn: vi.fn(),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
sendSignal: vi.fn(),
|
||||
getCwd: vi.fn(),
|
||||
getInitialCwd: vi.fn(),
|
||||
clearBuffer: vi.fn(),
|
||||
acknowledgeDataEvent: vi.fn(),
|
||||
onData: vi.fn(() => () => {}),
|
||||
onExit: vi.fn(() => () => {}),
|
||||
listProcesses: sshListProcesses,
|
||||
hasChildProcesses: vi.fn(),
|
||||
getForegroundProcess: vi.fn(),
|
||||
serialize: vi.fn(),
|
||||
revive: vi.fn(),
|
||||
getDefaultShell: vi.fn(),
|
||||
getProfiles: vi.fn()
|
||||
} as never)
|
||||
|
||||
const pendingInventory = handlers.get('pty:listSessions')!(null, undefined)
|
||||
|
||||
expect(sshListProcesses).toHaveBeenCalledTimes(1)
|
||||
resolveLocal([])
|
||||
resolveSsh([])
|
||||
await pendingInventory
|
||||
})
|
||||
|
||||
it('reports authoritative snapshot capability with the owning provider context', () => {
|
||||
const capabilityProvider = {
|
||||
authoritativeIds: new Set(['current-pty']),
|
||||
@@ -6479,7 +6529,7 @@ describe('registerPtyHandlers', () => {
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
expect(controller).not.toBeNull()
|
||||
const spawnController = controller as unknown as RuntimeSpawnController
|
||||
await spawnController.spawn({
|
||||
const spawned = await spawnController.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
worktreeId: 'wt-1',
|
||||
@@ -6494,6 +6544,9 @@ describe('registerPtyHandlers', () => {
|
||||
expect.any(String),
|
||||
'term_expected'
|
||||
)
|
||||
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:spawned', {
|
||||
id: spawned.id
|
||||
})
|
||||
})
|
||||
|
||||
it('does not update cached PTY size when runtime controller resize fails', async () => {
|
||||
@@ -10265,6 +10318,7 @@ describe('registerPtyHandlers', () => {
|
||||
cwd: '/tmp'
|
||||
})) as { id: string }
|
||||
const writeListener = getPtyWriteListener()
|
||||
mainWindow.webContents.send.mockClear()
|
||||
|
||||
const pendingOutput = 'x'.repeat(1020)
|
||||
mockProc.emitData(pendingOutput)
|
||||
|
||||
@@ -2709,6 +2709,12 @@ export function registerPtyHandlers(
|
||||
})
|
||||
}
|
||||
|
||||
function sendPtySpawnedToRenderer(id: string): void {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('pty:spawned', { id })
|
||||
}
|
||||
}
|
||||
|
||||
function sendPtyReplayToRenderer(payload: { id: string; data: string }): void {
|
||||
const pending = pendingData.get(payload.id)
|
||||
if (pending) {
|
||||
@@ -3771,6 +3777,8 @@ export function registerPtyHandlers(
|
||||
: null
|
||||
})
|
||||
}
|
||||
// Why: runtime-owned/background spawns bypass mounted-pane state, so inventory consumers need an explicit signal.
|
||||
sendPtySpawnedToRenderer(result.id)
|
||||
const response = {
|
||||
id: result.id,
|
||||
...(result.incarnationId ? { incarnationId: result.incarnationId } : {}),
|
||||
@@ -4943,6 +4951,8 @@ export function registerPtyHandlers(
|
||||
// Why: a daemon-retry race can surface isReattach even for a minted session id, and a reattach must never claim its cwd was remapped.
|
||||
...(startupCwdFallback && !result.isReattach ? { startupCwdFallback } : {})
|
||||
}
|
||||
// Why: renderer tab state cannot reliably infer background and reattached PTYs in the daemon inventory.
|
||||
sendPtySpawnedToRenderer(result.id)
|
||||
return resolvePaneSpawnReservation(reservationPaneKey, paneSpawnReservation, response)
|
||||
} catch (err) {
|
||||
if (pendingRegistrationPtyId) {
|
||||
|
||||
@@ -53,6 +53,31 @@ function makeRpcChild() {
|
||||
return child
|
||||
}
|
||||
|
||||
function respondToRpcRateLimitRead(
|
||||
rpcChild: ReturnType<typeof makeRpcChild>,
|
||||
rateLimits: unknown
|
||||
): void {
|
||||
rpcChild.stdin.write.mockImplementation((line: string) => {
|
||||
const msg = JSON.parse(line) as { id?: number; method?: string }
|
||||
if (msg.method === 'initialize') {
|
||||
setTimeout(() => {
|
||||
rpcChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`)
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
if (msg.method === 'account/rateLimits/read') {
|
||||
setTimeout(() => {
|
||||
rpcChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { rateLimits } })}\n`)
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function makePtyTerm() {
|
||||
let dataHandler: ((data: string) => void) | null = null
|
||||
let exitHandler: (() => void) | null = null
|
||||
@@ -325,38 +350,12 @@ describe('fetchCodexRateLimits', () => {
|
||||
expect(ptySpawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes Codex RPC remaining-minute windows to fixed display durations', async () => {
|
||||
it('normalizes near-canonical Codex RPC windows to fixed display durations', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
rpcChild.stdin.write.mockImplementation((line: string) => {
|
||||
const msg = JSON.parse(line) as { id?: number; method?: string }
|
||||
if (msg.method === 'initialize') {
|
||||
setTimeout(() => {
|
||||
rpcChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`)
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
if (msg.method === 'account/rateLimits/read') {
|
||||
setTimeout(() => {
|
||||
rpcChild.stdout.emit(
|
||||
'data',
|
||||
Buffer.from(
|
||||
`${JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: msg.id,
|
||||
result: {
|
||||
rateLimits: {
|
||||
primary: { usedPercent: 0, windowDurationMins: 299 },
|
||||
secondary: { usedPercent: 0, windowDurationMins: 10079 }
|
||||
}
|
||||
}
|
||||
})}\n`
|
||||
)
|
||||
)
|
||||
}, 0)
|
||||
}
|
||||
respondToRpcRateLimitRead(rpcChild, {
|
||||
primary: { usedPercent: 0, windowDurationMins: 299 },
|
||||
secondary: { usedPercent: 0, windowDurationMins: 10079 }
|
||||
})
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
@@ -368,6 +367,42 @@ describe('fetchCodexRateLimits', () => {
|
||||
expect(result.weekly?.windowMinutes).toBe(10080)
|
||||
})
|
||||
|
||||
it('keeps a weekly-only Codex primary window out of the 5-hour slot', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
respondToRpcRateLimitRead(rpcChild, {
|
||||
primary: { usedPercent: 22, windowDurationMins: 10080 },
|
||||
secondary: null
|
||||
})
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
session: null,
|
||||
weekly: { usedPercent: 22, windowMinutes: 10080 }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not map a duplicate session-duration window into the weekly slot', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
respondToRpcRateLimitRead(rpcChild, {
|
||||
primary: { usedPercent: 11, windowDurationMins: 300 },
|
||||
secondary: { usedPercent: 12, windowDurationMins: 300 }
|
||||
})
|
||||
|
||||
const resultPromise = fetchCodexRateLimits()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
session: { usedPercent: 11, windowMinutes: 300 },
|
||||
weekly: null
|
||||
})
|
||||
})
|
||||
|
||||
it('fills reset-credit count from the backend when the installed app-server omits it', async () => {
|
||||
const rpcChild = makeRpcChild()
|
||||
childSpawnMock.mockReturnValue(rpcChild)
|
||||
|
||||
@@ -11,6 +11,13 @@ import { readFetchResponseJsonWithinLimit } from '../lib/fetch-response-body'
|
||||
import { readIntegrationCredentialFileText } from '../integration-credential-file'
|
||||
import { join } from 'node:path'
|
||||
import { probeCodexAuthPresence } from './codex-auth-presence'
|
||||
import {
|
||||
classifyCodexRateLimitWindows,
|
||||
CODEX_SESSION_WINDOW_MINUTES,
|
||||
CODEX_WEEKLY_WINDOW_MINUTES,
|
||||
type CodexRpcRateLimits,
|
||||
type CodexRpcRateWindow
|
||||
} from './codex-rate-limit-window-classification'
|
||||
import { resolveCodexCommand } from '../codex-cli/command'
|
||||
import { withMacTailscaleDnsHint } from '../network/macos-tailscale-dns-diagnostic'
|
||||
import { getCmdExePath, getSpawnArgsForWindows } from '../win32-utils'
|
||||
@@ -58,12 +65,6 @@ type RpcResponse = {
|
||||
error?: { code: number; message: string }
|
||||
}
|
||||
|
||||
type RpcRateWindow = {
|
||||
usedPercent?: number
|
||||
windowDurationMins?: number
|
||||
resetsAt?: number // Unix seconds
|
||||
}
|
||||
|
||||
type RateLimitResetCredits = {
|
||||
availableCount: number
|
||||
totalEarnedCount?: number
|
||||
@@ -75,14 +76,9 @@ type RateLimitResetCredits = {
|
||||
}[]
|
||||
}
|
||||
|
||||
type RpcRateLimitsResult = {
|
||||
primary?: RpcRateWindow
|
||||
secondary?: RpcRateWindow
|
||||
}
|
||||
|
||||
// Why: the Codex app-server wraps rate limit data as { rateLimits: { primary, secondary, ... } }.
|
||||
type RpcRateLimitsResponse = {
|
||||
rateLimits?: RpcRateLimitsResult
|
||||
rateLimits?: CodexRpcRateLimits | null
|
||||
rateLimitResetCredits?: {
|
||||
availableCount?: number
|
||||
totalEarnedCount?: number
|
||||
@@ -444,7 +440,7 @@ export async function consumeCodexRateLimitResetCredit(options: {
|
||||
}
|
||||
|
||||
function mapRpcWindow(
|
||||
raw: RpcRateWindow | undefined,
|
||||
raw: CodexRpcRateWindow | null | undefined,
|
||||
expectedWindowMinutes: number
|
||||
): RateLimitWindow | null {
|
||||
if (!raw || typeof raw.usedPercent !== 'number' || !Number.isFinite(raw.usedPercent)) {
|
||||
@@ -472,7 +468,7 @@ function mapRpcWindow(
|
||||
|
||||
return {
|
||||
usedPercent: Math.min(100, Math.max(0, raw.usedPercent)),
|
||||
// Why: windowDurationMins reports remaining minutes, but the UI needs the fixed bucket duration for "5h"/"wk" labels.
|
||||
// Why: older app-server builds can report canonical bucket lengths off by one minute.
|
||||
windowMinutes: expectedWindowMinutes,
|
||||
resetsAt,
|
||||
resetDescription
|
||||
@@ -720,8 +716,9 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise<Provi
|
||||
|
||||
const wrapper = msg.result as RpcRateLimitsResponse | undefined
|
||||
const result = wrapper?.rateLimits
|
||||
const session = mapRpcWindow(result?.primary, 300)
|
||||
const weekly = mapRpcWindow(result?.secondary, 10080)
|
||||
const classifiedWindows = classifyCodexRateLimitWindows(result)
|
||||
const session = mapRpcWindow(classifiedWindows.session, CODEX_SESSION_WINDOW_MINUTES)
|
||||
const weekly = mapRpcWindow(classifiedWindows.weekly, CODEX_WEEKLY_WINDOW_MINUTES)
|
||||
const rateLimitResetCredits = mapRpcRateLimitResetCredits(
|
||||
wrapper?.rateLimitResetCredits
|
||||
)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
classifyCodexRateLimitWindows,
|
||||
type CodexRpcRateLimits
|
||||
} from './codex-rate-limit-window-classification'
|
||||
|
||||
function usedPercentByWindow(result: CodexRpcRateLimits | null): {
|
||||
session: number | null
|
||||
weekly: number | null
|
||||
} {
|
||||
const classified = classifyCodexRateLimitWindows(result)
|
||||
return {
|
||||
session: classified.session?.usedPercent ?? null,
|
||||
weekly: classified.weekly?.usedPercent ?? null
|
||||
}
|
||||
}
|
||||
|
||||
describe('classifyCodexRateLimitWindows', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'null windows',
|
||||
result: null,
|
||||
expected: { session: null, weekly: null }
|
||||
},
|
||||
{
|
||||
name: 'reordered known windows',
|
||||
result: {
|
||||
primary: { usedPercent: 81, windowDurationMins: 10080 },
|
||||
secondary: { usedPercent: 21, windowDurationMins: 300 }
|
||||
},
|
||||
expected: { session: 21, weekly: 81 }
|
||||
},
|
||||
{
|
||||
name: 'weekly-only primary window',
|
||||
result: {
|
||||
primary: { usedPercent: 22, windowDurationMins: 10080 },
|
||||
secondary: null
|
||||
},
|
||||
expected: { session: null, weekly: 22 }
|
||||
},
|
||||
{
|
||||
name: 'session-only secondary window',
|
||||
result: {
|
||||
primary: null,
|
||||
secondary: { usedPercent: 31, windowDurationMins: 300 }
|
||||
},
|
||||
expected: { session: 31, weekly: null }
|
||||
},
|
||||
{
|
||||
name: 'duplicate session windows',
|
||||
result: {
|
||||
primary: { usedPercent: 41, windowDurationMins: 300 },
|
||||
secondary: { usedPercent: 42, windowDurationMins: 300 }
|
||||
},
|
||||
expected: { session: 41, weekly: null }
|
||||
},
|
||||
{
|
||||
name: 'duplicate weekly windows',
|
||||
result: {
|
||||
primary: { usedPercent: 51, windowDurationMins: 10080 },
|
||||
secondary: { usedPercent: 52, windowDurationMins: 10080 }
|
||||
},
|
||||
expected: { session: null, weekly: 51 }
|
||||
},
|
||||
{
|
||||
name: 'malformed usage',
|
||||
result: {
|
||||
primary: { usedPercent: Number.NaN, windowDurationMins: 300 },
|
||||
secondary: { usedPercent: 61, windowDurationMins: 10080 }
|
||||
},
|
||||
expected: { session: null, weekly: 61 }
|
||||
},
|
||||
{
|
||||
name: 'malformed duration with positional fallback',
|
||||
result: {
|
||||
primary: { usedPercent: 71, windowDurationMins: '300' },
|
||||
secondary: null
|
||||
},
|
||||
expected: { session: 71, weekly: null }
|
||||
},
|
||||
{
|
||||
name: 'reordered near-canonical windows',
|
||||
result: {
|
||||
primary: { usedPercent: 81, windowDurationMins: 10081 },
|
||||
secondary: { usedPercent: 82, windowDurationMins: 299 }
|
||||
},
|
||||
expected: { session: 82, weekly: 81 }
|
||||
},
|
||||
{
|
||||
name: 'opposite near-canonical boundaries',
|
||||
result: {
|
||||
primary: { usedPercent: 83, windowDurationMins: 10079 },
|
||||
secondary: { usedPercent: 84, windowDurationMins: 301 }
|
||||
},
|
||||
expected: { session: 84, weekly: 83 }
|
||||
},
|
||||
{
|
||||
name: 'outside-tolerance unknown windows',
|
||||
result: {
|
||||
primary: { usedPercent: 91, windowDurationMins: 302 },
|
||||
secondary: { usedPercent: 92, windowDurationMins: 10082 }
|
||||
},
|
||||
expected: { session: 91, weekly: 92 }
|
||||
},
|
||||
{
|
||||
name: 'unknown windows without durations',
|
||||
result: {
|
||||
primary: { usedPercent: 101 },
|
||||
secondary: { usedPercent: 102 }
|
||||
},
|
||||
expected: { session: 101, weekly: 102 }
|
||||
},
|
||||
{
|
||||
name: 'known session wins over unknown primary fallback',
|
||||
result: {
|
||||
primary: { usedPercent: 111, windowDurationMins: 60 },
|
||||
secondary: { usedPercent: 112, windowDurationMins: 300 }
|
||||
},
|
||||
expected: { session: 112, weekly: null }
|
||||
},
|
||||
{
|
||||
name: 'known weekly wins over unknown secondary fallback',
|
||||
result: {
|
||||
primary: { usedPercent: 121, windowDurationMins: 10080 },
|
||||
secondary: { usedPercent: 122, windowDurationMins: 60 }
|
||||
},
|
||||
expected: { session: null, weekly: 121 }
|
||||
}
|
||||
] as const)('$name', ({ result, expected }) => {
|
||||
expect(usedPercentByWindow(result)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
export const CODEX_SESSION_WINDOW_MINUTES = 300
|
||||
export const CODEX_WEEKLY_WINDOW_MINUTES = 10080
|
||||
|
||||
// Why: tolerate the one-minute drift seen in older Codex bucket lengths without absorbing other durations.
|
||||
const CODEX_WINDOW_DURATION_TOLERANCE_MINUTES = 1
|
||||
|
||||
export type CodexRpcRateWindow = {
|
||||
usedPercent?: unknown
|
||||
windowDurationMins?: unknown
|
||||
resetsAt?: unknown
|
||||
}
|
||||
|
||||
export type CodexRpcRateLimits = {
|
||||
primary?: CodexRpcRateWindow | null
|
||||
secondary?: CodexRpcRateWindow | null
|
||||
}
|
||||
|
||||
type MappableCodexRpcRateWindow = CodexRpcRateWindow & { usedPercent: number }
|
||||
type CodexRateLimitWindowKind = 'session' | 'weekly' | null
|
||||
|
||||
function isMappableCodexRpcRateWindow(
|
||||
raw: CodexRpcRateWindow | null | undefined
|
||||
): raw is MappableCodexRpcRateWindow {
|
||||
return typeof raw?.usedPercent === 'number' && Number.isFinite(raw.usedPercent)
|
||||
}
|
||||
|
||||
function classifyWindowDuration(raw: MappableCodexRpcRateWindow): CodexRateLimitWindowKind {
|
||||
const duration = raw.windowDurationMins
|
||||
if (typeof duration !== 'number' || !Number.isFinite(duration)) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
Math.abs(duration - CODEX_SESSION_WINDOW_MINUTES) <= CODEX_WINDOW_DURATION_TOLERANCE_MINUTES
|
||||
) {
|
||||
return 'session'
|
||||
}
|
||||
if (Math.abs(duration - CODEX_WEEKLY_WINDOW_MINUTES) <= CODEX_WINDOW_DURATION_TOLERANCE_MINUTES) {
|
||||
return 'weekly'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function classifyCodexRateLimitWindows(result: CodexRpcRateLimits | null | undefined): {
|
||||
session: MappableCodexRpcRateWindow | null
|
||||
weekly: MappableCodexRpcRateWindow | null
|
||||
} {
|
||||
const primary = isMappableCodexRpcRateWindow(result?.primary) ? result.primary : null
|
||||
const secondary = isMappableCodexRpcRateWindow(result?.secondary) ? result.secondary : null
|
||||
let session: MappableCodexRpcRateWindow | null = null
|
||||
let weekly: MappableCodexRpcRateWindow | null = null
|
||||
|
||||
for (const window of [primary, secondary]) {
|
||||
if (!window) {
|
||||
continue
|
||||
}
|
||||
const kind = classifyWindowDuration(window)
|
||||
if (kind === 'session' && !session) {
|
||||
session = window
|
||||
} else if (kind === 'weekly' && !weekly) {
|
||||
weekly = window
|
||||
}
|
||||
}
|
||||
|
||||
// Why: unknown app-server durations retain Orca's legacy primary/session and secondary/weekly mapping.
|
||||
if (!session && primary && classifyWindowDuration(primary) === null) {
|
||||
session = primary
|
||||
}
|
||||
if (!weekly && secondary && classifyWindowDuration(secondary) === null) {
|
||||
weekly = secondary
|
||||
}
|
||||
|
||||
return { session, weekly }
|
||||
}
|
||||
@@ -1458,6 +1458,7 @@ export type PreloadApi = {
|
||||
onExit: (
|
||||
callback: (data: { id: string; code: number; preserveRendererBinding?: boolean }) => void
|
||||
) => () => void
|
||||
onSpawned: (callback: (data: { id: string }) => void) => () => void
|
||||
onSerializeBufferRequest: (
|
||||
callback: (data: {
|
||||
requestId: string
|
||||
|
||||
@@ -1064,6 +1064,12 @@ const api = {
|
||||
return () => ipcRenderer.removeListener('pty:exit', listener)
|
||||
},
|
||||
|
||||
onSpawned: (callback: (data: { id: string }) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: { id: string }) => callback(data)
|
||||
ipcRenderer.on('pty:spawned', listener)
|
||||
return () => ipcRenderer.removeListener('pty:spawned', listener)
|
||||
},
|
||||
|
||||
onSerializeBufferRequest: (
|
||||
callback: (data: {
|
||||
requestId: string
|
||||
|
||||
+60
-37
@@ -42,6 +42,7 @@ import {
|
||||
ContextMenuTrigger
|
||||
} from '@/components/ui/context-menu'
|
||||
import { useAppStore } from './store'
|
||||
import { WORKTREE_REFRESH_CONCURRENCY } from './store/slices/worktrees'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { isRemoteWorkspaceSnapshotApplyInProgress, useIpcEvents } from './hooks/useIpcEvents'
|
||||
import { useAutomationDispatchEvents } from './hooks/useAutomationDispatchEvents'
|
||||
@@ -131,7 +132,10 @@ import {
|
||||
createShutdownCheckpointBeforeUnloadHandler,
|
||||
createShutdownCheckpointGuard
|
||||
} from './lib/shutdown-checkpoint-guard'
|
||||
import { collectFolderWorkspaceKeysFromSession } from './lib/workspace-session-hydration-keys'
|
||||
import {
|
||||
collectFolderWorkspaceKeysFromSession,
|
||||
collectWorktreeHydrationRepoIdsFromSession
|
||||
} from './lib/workspace-session-hydration-keys'
|
||||
import {
|
||||
getStartupErrorFallbackUI,
|
||||
hydratePersistedUIAfterStartupRead
|
||||
@@ -178,10 +182,13 @@ import {
|
||||
type PhysicalModifierToken
|
||||
} from '../../shared/keybindings'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
isRuntimeOwnedSshTargetId,
|
||||
parseExecutionHostId,
|
||||
toRuntimeExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../shared/execution-host'
|
||||
import { mapWithConcurrency } from '../../shared/map-with-concurrency'
|
||||
import {
|
||||
ModifierDoubleTapDetector,
|
||||
toModifierDoubleTapEvent
|
||||
@@ -430,6 +437,7 @@ function App(): React.JSX.Element {
|
||||
fetchFolderWorkspaces: s.fetchFolderWorkspaces,
|
||||
fetchFolderWorkspacesForAllHosts: s.fetchFolderWorkspacesForAllHosts,
|
||||
fetchAllWorktrees: s.fetchAllWorktrees,
|
||||
fetchWorktrees: s.fetchWorktrees,
|
||||
fetchWorktreeLineage: s.fetchWorktreeLineage,
|
||||
fetchOrcaProfiles: s.fetchOrcaProfiles,
|
||||
fetchSettings: s.fetchSettings,
|
||||
@@ -872,8 +880,7 @@ function App(): React.JSX.Element {
|
||||
actions.fetchReposForAllHosts({ remoteHosts: 'skip' })
|
||||
)
|
||||
// Why: folder workspaces merge against projectGroups (repos.ts fetchFolderWorkspacesForAllHosts),
|
||||
// so keep this two-step catalog chain internally ordered; it is otherwise independent of
|
||||
// repos/worktrees/session and overlaps the worktree scan below.
|
||||
// so keep this chain ordered while overlapping it with session-scoped hydration.
|
||||
const localCatalogChain = (async () => {
|
||||
await timeRendererStartupStep('fetch-project-groups-local', () =>
|
||||
actions.fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' })
|
||||
@@ -882,10 +889,6 @@ function App(): React.JSX.Element {
|
||||
actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' })
|
||||
)
|
||||
})()
|
||||
// Why: chain session-get off runtimeHostsPromise instead of awaiting the host ids here, so
|
||||
// fetch-worktrees and the catalog chain start immediately (neither needs the ids) — awaiting
|
||||
// the host-list IPC first would re-serialize the worktree scan behind host discovery when the
|
||||
// IPC is the slower of the two. Only session-get waits on the ids.
|
||||
const sessionReadPromise = runtimeHostsPromise.then((startupRuntimeHostIds) =>
|
||||
// Why: include saved runtime host ids so per-host worktree session slices restore from local settings without waiting on network reachability; unreadable partitions skip.
|
||||
timeRendererStartupStep('session-get', () =>
|
||||
@@ -896,27 +899,30 @@ function App(): React.JSX.Element {
|
||||
)
|
||||
)
|
||||
)
|
||||
// Why: once repos is loaded, fetch-worktrees (snapshots repos), session-get (repos-independent
|
||||
// local disk read), and the local catalog chain are mutually independent — run them concurrently
|
||||
// so the two disk reads hide behind the O(repos) worktree git scan (the startup long pole).
|
||||
// fetchAllWorktrees({hydrationPurge:'defer'}) returns before its folderWorkspaces read (the purge
|
||||
// guard in worktrees.ts), so it needs no catalog ordering here. session-get is a pure read;
|
||||
// hydrate-session-stores below still runs only after all three settle. See #18.
|
||||
// Why (#18 review): join on allSettled, NOT fail-fast Promise.all. A fast rejection from one branch
|
||||
// would drop into the catch/recovery path (which reconnects terminals and flips readiness) while a
|
||||
// sibling hydration task is still in flight and mutating catalog/worktree state — the old serial flow
|
||||
// guaranteed no hydration step ran during recovery. Wait for all three to settle, then surface the
|
||||
// first rejection so recovery still triggers, but only once nothing is left writing to the store.
|
||||
const [worktreesOutcome, sessionOutcome, catalogOutcome] = await Promise.allSettled([
|
||||
timeRendererStartupStep('fetch-worktrees', () =>
|
||||
actions.fetchAllWorktrees({ hydrationPurge: 'defer' })
|
||||
),
|
||||
sessionReadPromise,
|
||||
const hydrationSessionChain = sessionReadPromise.then(async (sessionRead) => {
|
||||
const hydrationRepoIds = collectWorktreeHydrationRepoIdsFromSession(
|
||||
sessionRead.session,
|
||||
sessionRead.runtimeHostIdByWorkspaceSessionKey
|
||||
)
|
||||
const hydrationRepoIdSet = new Set(hydrationRepoIds)
|
||||
const hydrationRepos = useAppStore.getState().repos.filter(
|
||||
(repo) =>
|
||||
hydrationRepoIdSet.has(repo.id) &&
|
||||
// Why: disconnected SSH repos hydrate from local metadata; only runtime-owned repos use placeholders.
|
||||
parseExecutionHostId(getRepoExecutionHostId(repo))?.kind !== 'runtime'
|
||||
)
|
||||
await timeRendererStartupStep('fetch-hydration-worktrees', () =>
|
||||
mapWithConcurrency(hydrationRepos, WORKTREE_REFRESH_CONCURRENCY, (repo) =>
|
||||
actions.fetchWorktrees(repo.id, { executionHostId: getRepoExecutionHostId(repo) })
|
||||
)
|
||||
)
|
||||
return sessionRead
|
||||
})
|
||||
// Why: wait for both writers to settle before recovery so neither can mutate hydrated state afterward.
|
||||
const [sessionOutcome, catalogOutcome] = await Promise.allSettled([
|
||||
hydrationSessionChain,
|
||||
localCatalogChain
|
||||
])
|
||||
if (worktreesOutcome.status === 'rejected') {
|
||||
throw worktreesOutcome.reason
|
||||
}
|
||||
if (sessionOutcome.status === 'rejected') {
|
||||
throw sessionOutcome.reason
|
||||
}
|
||||
@@ -1042,19 +1048,34 @@ function App(): React.JSX.Element {
|
||||
})
|
||||
void (async () => {
|
||||
try {
|
||||
await timeRendererStartupStep('remote-catalog-refresh', async () => {
|
||||
await actions.fetchReposForAllHosts()
|
||||
await actions.fetchProjectGroupsForAllHosts()
|
||||
await actions.fetchFolderWorkspacesForAllHosts()
|
||||
})
|
||||
if (!cancelled) {
|
||||
await timeRendererStartupStep('remote-worktree-refresh', async () => {
|
||||
await actions.fetchAllWorktrees()
|
||||
await actions.fetchWorktreeLineage()
|
||||
try {
|
||||
await timeRendererStartupStep('remote-catalog-refresh', async () => {
|
||||
await actions.fetchReposForAllHosts()
|
||||
await actions.fetchProjectGroupsForAllHosts()
|
||||
await actions.fetchFolderWorkspacesForAllHosts()
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Remote startup catalog refresh failed:', err)
|
||||
}
|
||||
if (!cancelled) {
|
||||
try {
|
||||
await timeRendererStartupStep('remote-worktree-refresh', async () => {
|
||||
// Why: the full scan is not required for session recovery, so keep it off the startup-critical path.
|
||||
await actions.fetchAllWorktrees()
|
||||
// Why: the startup prune only saw session-referenced repos; use the deferred scan's
|
||||
// authoritative results to drop deleted-worktree visit timestamps that would
|
||||
// otherwise accumulate unbounded (disconnected SSH stays non-authoritative and is kept).
|
||||
actions.pruneLastVisitedTimestamps()
|
||||
await actions.fetchWorktreeLineage()
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Deferred startup worktree refresh failed:', err)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
useAppStore.setState({ startupWorktreeRefreshCompleted: true })
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Remote startup catalog refresh failed:', err)
|
||||
}
|
||||
})()
|
||||
}
|
||||
@@ -1067,6 +1088,8 @@ function App(): React.JSX.Element {
|
||||
error
|
||||
)
|
||||
if (!cancelled) {
|
||||
// Why: degraded mode stays interactive; later repo/runtime changes must not remain gated forever.
|
||||
useAppStore.setState({ startupWorktreeRefreshCompleted: true })
|
||||
// Why (issue #1158): only apply default UI if ui.get() never hydrated; otherwise defaults would clobber ui.json via the debounced writer.
|
||||
const fallbackUI = getStartupErrorFallbackUI(uiHydrated)
|
||||
if (fallbackUI) {
|
||||
|
||||
@@ -6,30 +6,31 @@ describe('renderer startup runtime routing', () => {
|
||||
it('hydrates persisted UI before local catalog and worktree hydration', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
|
||||
const startupBlockStart = source.indexOf('void (async () => {')
|
||||
// Why (#18): worktrees/session-get/catalog now run concurrently, so session-get is no longer the
|
||||
// block terminator. End the slice at hydrate-session-stores (runs after the Promise.all settles) so
|
||||
// every concurrent fetch falls inside the analyzed block.
|
||||
// Why: concurrent startup branches all settle before hydrate-session-stores.
|
||||
const startupBlockEnd = source.indexOf("timeRendererStartupSyncStep('hydrate-session-stores'")
|
||||
const startupBlock = source.slice(startupBlockStart, startupBlockEnd)
|
||||
|
||||
const settingsIndex = startupBlock.indexOf('actions.fetchSettings()')
|
||||
const uiGetIndex = startupBlock.indexOf("timeRendererStartupStep('ui-get'")
|
||||
const hydrateUiIndex = startupBlock.indexOf(
|
||||
"timeRendererStartupSyncStep('hydrate-persisted-ui'"
|
||||
)
|
||||
const localReposIndex = startupBlock.indexOf(
|
||||
const indexInStartupBlock = (needle: string): number => {
|
||||
const relativeIndex = startupBlock.indexOf(needle)
|
||||
return relativeIndex === -1 ? -1 : startupBlockStart + relativeIndex
|
||||
}
|
||||
const settingsIndex = indexInStartupBlock('actions.fetchSettings()')
|
||||
const uiGetIndex = indexInStartupBlock("timeRendererStartupStep('ui-get'")
|
||||
const hydrateUiIndex = indexInStartupBlock("timeRendererStartupSyncStep('hydrate-persisted-ui'")
|
||||
const localReposIndex = indexInStartupBlock(
|
||||
"actions.fetchReposForAllHosts({ remoteHosts: 'skip' })"
|
||||
)
|
||||
const localGroupsIndex = startupBlock.indexOf(
|
||||
const localGroupsIndex = indexInStartupBlock(
|
||||
"actions.fetchProjectGroupsForAllHosts({ remoteHosts: 'skip' })"
|
||||
)
|
||||
const localFoldersIndex = startupBlock.indexOf(
|
||||
const localFoldersIndex = indexInStartupBlock(
|
||||
"actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' })"
|
||||
)
|
||||
const sessionGetIndex = startupBlock.indexOf("timeRendererStartupStep('session-get'")
|
||||
const localWorktreesIndex = startupBlock.indexOf(
|
||||
"actions.fetchAllWorktrees({ hydrationPurge: 'defer' })"
|
||||
const sessionIndex = indexInStartupBlock("timeRendererStartupStep('session-get'")
|
||||
const hydrationWorktreesIndex = source.indexOf(
|
||||
"timeRendererStartupStep('fetch-hydration-worktrees'"
|
||||
)
|
||||
const fullWorktreesIndex = source.indexOf('await actions.fetchAllWorktrees()')
|
||||
const lineageIndex = startupBlock.indexOf('actions.fetchWorktreeLineage()')
|
||||
|
||||
expect(settingsIndex).toBeGreaterThanOrEqual(0)
|
||||
@@ -41,26 +42,44 @@ describe('renderer startup runtime routing', () => {
|
||||
// The local catalog chain stays internally ordered (folders merge against project groups).
|
||||
expect(localReposIndex).toBeLessThan(localGroupsIndex)
|
||||
expect(localGroupsIndex).toBeLessThan(localFoldersIndex)
|
||||
// Worktree scan and session read both start only after repos is loaded (they snapshot/route on it),
|
||||
// but run concurrently with the catalog chain — see the Promise.all assertion below.
|
||||
expect(localReposIndex).toBeLessThan(sessionGetIndex)
|
||||
expect(localReposIndex).toBeLessThan(localWorktreesIndex)
|
||||
// Lineage is deferred to post-hydration remote refresh, not part of the local hydration block.
|
||||
expect(localReposIndex).toBeLessThan(sessionIndex)
|
||||
expect(sessionIndex).toBeLessThan(hydrationWorktreesIndex)
|
||||
const hydrationWorktreeBlock = source.slice(
|
||||
hydrationWorktreesIndex,
|
||||
source.indexOf('await keybindingsPromise')
|
||||
)
|
||||
expect(hydrationWorktreeBlock).toContain(
|
||||
'mapWithConcurrency(hydrationRepos, WORKTREE_REFRESH_CONCURRENCY'
|
||||
)
|
||||
expect(hydrationWorktreeBlock).toContain('executionHostId: getRepoExecutionHostId(repo)')
|
||||
// Why: the pre-hydration fetch must include SSH repos (only runtime-owned repos are
|
||||
// excluded); gating on local-only drops SSH tab/editor/browser chrome at hydration.
|
||||
const hydrationFilterBlock = source.slice(
|
||||
source.indexOf('const hydrationRepos'),
|
||||
hydrationWorktreesIndex
|
||||
)
|
||||
expect(hydrationFilterBlock).toContain(
|
||||
"parseExecutionHostId(getRepoExecutionHostId(repo))?.kind !== 'runtime'"
|
||||
)
|
||||
expect(hydrationFilterBlock).not.toContain('=== LOCAL_EXECUTION_HOST_ID')
|
||||
expect(fullWorktreesIndex).toBeGreaterThan(
|
||||
source.indexOf("logRendererStartupDiagnostic('startup-hydration-done'")
|
||||
)
|
||||
// Why: the deferred full scan must be followed by a re-prune so deleted-worktree visit
|
||||
// timestamps for non-session repos are dropped once every repo is authoritative.
|
||||
expect(
|
||||
source.indexOf('actions.pruneLastVisitedTimestamps()', fullWorktreesIndex)
|
||||
).toBeGreaterThan(fullWorktreesIndex)
|
||||
expect(lineageIndex).toBe(-1)
|
||||
|
||||
// Guard the concurrency itself: fetch-worktrees, the session read, and the local catalog chain must be
|
||||
// joined in a single allSettled so the two disk reads hide behind the O(repos) worktree scan — AND so a
|
||||
// fast rejection can't enter recovery while a sibling is still mutating the store (allSettled, not
|
||||
// fail-fast Promise.all).
|
||||
const joinStart = startupBlock.indexOf('await Promise.allSettled([')
|
||||
// The catalog and selective hydration chains overlap, but both settle before recovery or hydration.
|
||||
const joinStart = indexInStartupBlock('await Promise.allSettled([')
|
||||
expect(joinStart).toBeGreaterThan(hydrateUiIndex)
|
||||
const joinBlock = startupBlock.slice(joinStart)
|
||||
expect(joinBlock).toContain("timeRendererStartupStep('fetch-worktrees'")
|
||||
expect(joinBlock).toContain('sessionReadPromise')
|
||||
const joinBlock = source.slice(joinStart, startupBlockEnd)
|
||||
expect(joinBlock).toContain('hydrationSessionChain')
|
||||
expect(joinBlock).toContain('localCatalogChain')
|
||||
// The join must not be fail-fast: a bare `Promise.all([` on these branches would re-introduce the
|
||||
// recovery-during-hydration race.
|
||||
expect(startupBlock).not.toContain('await Promise.all([')
|
||||
expect(startupBlock).not.toContain("actions.fetchAllWorktrees({ hydrationPurge: 'defer' })")
|
||||
})
|
||||
|
||||
it('refreshes remote catalogs after startup hydration succeeds', () => {
|
||||
@@ -70,15 +89,30 @@ describe('renderer startup runtime routing', () => {
|
||||
)
|
||||
const remoteCatalogIndex = source.indexOf("timeRendererStartupStep('remote-catalog-refresh'")
|
||||
const remoteWorktreeIndex = source.indexOf("timeRendererStartupStep('remote-worktree-refresh'")
|
||||
const remoteCatalogFailureIndex = source.indexOf(
|
||||
"console.warn('Remote startup catalog refresh failed:'"
|
||||
)
|
||||
const lineageIndex = source.indexOf('actions.fetchWorktreeLineage()')
|
||||
const startupRefreshCompletedIndex = source.indexOf('startupWorktreeRefreshCompleted: true')
|
||||
|
||||
expect(hydrationDoneIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(hydrationDoneIndex).toBeLessThan(remoteCatalogIndex)
|
||||
expect(remoteCatalogIndex).toBeLessThan(remoteWorktreeIndex)
|
||||
expect(remoteCatalogIndex).toBeLessThan(remoteCatalogFailureIndex)
|
||||
// Why: a project-group/folder catalog failure must not suppress the independent full worktree scan.
|
||||
expect(remoteCatalogFailureIndex).toBeLessThan(remoteWorktreeIndex)
|
||||
expect(remoteWorktreeIndex).toBeLessThan(lineageIndex)
|
||||
expect(lineageIndex).toBeLessThan(startupRefreshCompletedIndex)
|
||||
expect(source.slice(remoteCatalogIndex, remoteWorktreeIndex)).toContain(
|
||||
'actions.fetchReposForAllHosts()'
|
||||
)
|
||||
|
||||
const startupFailureIndex = source.indexOf(
|
||||
'[startup] Workspace session hydration failed; leaving disk state untouched:'
|
||||
)
|
||||
expect(startupFailureIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(
|
||||
source.indexOf('startupWorktreeRefreshCompleted: true', startupFailureIndex)
|
||||
).toBeGreaterThan(startupFailureIndex)
|
||||
expect(source.slice(remoteCatalogIndex, remoteWorktreeIndex)).toContain(
|
||||
'actions.fetchProjectGroupsForAllHosts()'
|
||||
)
|
||||
@@ -96,6 +130,22 @@ describe('renderer startup runtime routing', () => {
|
||||
expect(servicesIndex).toBeLessThan(reconnectIndex)
|
||||
})
|
||||
|
||||
it('keeps the persisted Automations view from starting its own bootstrap worktree scan', () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), 'src/renderer/src/components/automations/AutomationsPage.tsx'),
|
||||
'utf8'
|
||||
)
|
||||
const fullRefreshStart = source.indexOf('const mountedBeforeStartupWorktreeRefreshRef')
|
||||
const fullRefreshEffect = source.slice(
|
||||
fullRefreshStart,
|
||||
source.indexOf('void refresh()', fullRefreshStart)
|
||||
)
|
||||
|
||||
expect(fullRefreshEffect).toContain('if (!startupWorktreeRefreshCompleted)')
|
||||
expect(fullRefreshEffect).toContain('mountedBeforeStartupWorktreeRefreshRef.current')
|
||||
expect(fullRefreshEffect).toContain('void fetchAllWorktrees()')
|
||||
})
|
||||
|
||||
it('does not eagerly import the floating terminal panel on startup', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
|
||||
|
||||
|
||||
@@ -368,6 +368,7 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const fetchWorktrees = useAppStore((s) => s.fetchWorktrees)
|
||||
const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees)
|
||||
const startupWorktreeRefreshCompleted = useAppStore((s) => s.startupWorktreeRefreshCompleted)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
|
||||
@@ -1051,10 +1052,22 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
useAppStore.getState().hydratePersistedUI(await window.api.ui.get(), 'sync')
|
||||
}, [])
|
||||
|
||||
const mountedBeforeStartupWorktreeRefreshRef = useRef(!startupWorktreeRefreshCompleted)
|
||||
useEffect(() => {
|
||||
if (!startupWorktreeRefreshCompleted) {
|
||||
return
|
||||
}
|
||||
if (mountedBeforeStartupWorktreeRefreshRef.current) {
|
||||
// Why: App just supplied this mount's initial worktrees; a second full scan would duplicate every repo probe.
|
||||
mountedBeforeStartupWorktreeRefreshRef.current = false
|
||||
return
|
||||
}
|
||||
void fetchAllWorktrees()
|
||||
}, [fetchAllWorktrees, startupWorktreeRefreshCompleted])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [fetchAllWorktrees, refresh])
|
||||
}, [refresh])
|
||||
|
||||
useEffect(() => {
|
||||
// Pause the relative-time clock while the window is hidden.
|
||||
|
||||
@@ -11,6 +11,7 @@ import { DashboardAgentRowToolStep } from './DashboardAgentRowToolStep'
|
||||
import type { AgentStatusState } from '../../../../shared/agent-status-types'
|
||||
import type { DashboardAgentRow as DashboardAgentRowData } from './useDashboardData'
|
||||
import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text'
|
||||
import { useAgentRowConversationName } from './use-agent-row-conversation-name'
|
||||
|
||||
// Why: narrow the dashboard's rollup states to shared dot states, defaulting unknowns to 'idle' so a row never crashes.
|
||||
function asDotState(state: AgentStatusState | 'idle'): AgentDotState {
|
||||
@@ -155,7 +156,8 @@ const DashboardAgentRow = React.memo(function DashboardAgentRow({
|
||||
)
|
||||
const startedAt = agent.startedAt > 0 ? agent.startedAt : null
|
||||
const doneAt = lastEnteredDoneAt(agent)
|
||||
const prompt = getAgentRowPrimaryText(agent.entry)
|
||||
const conversationName = useAgentRowConversationName(agent)
|
||||
const prompt = conversationName ?? getAgentRowPrimaryText(agent.entry)
|
||||
// Why: prompt is '' when unknown, so fall back to the state label to keep the row labeled.
|
||||
const displayLabel = prompt || agentStateLabel(asDotState(agent.state))
|
||||
const model = agent.entry.model?.trim() ?? ''
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { useAgentRowConversationName } from './use-agent-row-conversation-name'
|
||||
import type { DashboardAgentRow } from './useDashboardData'
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
current: { settings: {}, tabsByWorktree: {} } as {
|
||||
settings: Record<string, unknown>
|
||||
tabsByWorktree: Record<string, unknown[]>
|
||||
}
|
||||
}))
|
||||
|
||||
// Why: the mocked selector makes the hook a pure function, so tests can call it
|
||||
// directly without mounting a component.
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (selector: (state: AppState) => unknown) =>
|
||||
selector(storeState.current as unknown as AppState)
|
||||
}))
|
||||
|
||||
function makeAgent(overrides: Partial<DashboardAgentRow> = {}): DashboardAgentRow {
|
||||
return {
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
entry: { prompt: 'fix the sidebar' },
|
||||
tab: { id: 'tab-1', worktreeId: 'wt-1', customTitle: 'Patient sync spike', title: '' },
|
||||
agentType: 'claude',
|
||||
state: 'working',
|
||||
startedAt: 0,
|
||||
...overrides
|
||||
} as DashboardAgentRow
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
storeState.current = { settings: {}, tabsByWorktree: {} }
|
||||
})
|
||||
|
||||
describe('useAgentRowConversationName', () => {
|
||||
it('returns the conversation name by default', () => {
|
||||
expect(useAgentRowConversationName(makeAgent())).toBe('Patient sync spike')
|
||||
})
|
||||
|
||||
it('ignores a retired stored opt-out value', () => {
|
||||
storeState.current = { settings: { agentRowsUseConversationName: false }, tabsByWorktree: {} }
|
||||
expect(useAgentRowConversationName(makeAgent())).toBe('Patient sync spike')
|
||||
})
|
||||
|
||||
it('never reads the parent tab for subagent child rows', () => {
|
||||
const tabsByWorktree = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: () => {
|
||||
throw new Error('subagent rows must not read the parent tab')
|
||||
}
|
||||
}
|
||||
)
|
||||
storeState.current = { settings: {}, tabsByWorktree }
|
||||
expect(useAgentRowConversationName(makeAgent({ rowSource: 'subagent' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('does not inherit a same-tab lineage parent conversation name', () => {
|
||||
const tabsByWorktree = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: () => {
|
||||
throw new Error('same-tab child rows must not read the parent tab')
|
||||
}
|
||||
}
|
||||
)
|
||||
storeState.current = { settings: {}, tabsByWorktree }
|
||||
expect(
|
||||
useAgentRowConversationName(
|
||||
makeAgent({
|
||||
entry: {
|
||||
prompt: 'child prompt',
|
||||
orchestration: {
|
||||
parentPaneKey: 'tab-1:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
}
|
||||
},
|
||||
lineage: { depth: 1, isFirstSibling: true, isLastSibling: true, childCount: 0 }
|
||||
} as Partial<DashboardAgentRow>)
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('uses a lineage child conversation name when it owns a separate tab', () => {
|
||||
const agent = makeAgent({
|
||||
entry: {
|
||||
prompt: 'child prompt',
|
||||
orchestration: {
|
||||
parentPaneKey: 'parent-tab:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
}
|
||||
},
|
||||
lineage: { depth: 1, isFirstSibling: true, isLastSibling: true, childCount: 0 }
|
||||
} as Partial<DashboardAgentRow>)
|
||||
expect(useAgentRowConversationName(agent)).toBe('Patient sync spike')
|
||||
})
|
||||
|
||||
it('indexes one immutable tab array once across rows', () => {
|
||||
let tabReads = 0
|
||||
const tabs = new Proxy(
|
||||
[
|
||||
{ id: 'tab-1', worktreeId: 'wt-1', customTitle: 'First name', title: '' },
|
||||
{ id: 'tab-2', worktreeId: 'wt-1', customTitle: 'Second name', title: '' }
|
||||
],
|
||||
{
|
||||
get: (target, property, receiver) => {
|
||||
if (typeof property === 'string' && /^\d+$/.test(property)) {
|
||||
tabReads += 1
|
||||
}
|
||||
return Reflect.get(target, property, receiver)
|
||||
}
|
||||
}
|
||||
)
|
||||
storeState.current = {
|
||||
settings: {},
|
||||
tabsByWorktree: { 'wt-1': tabs }
|
||||
}
|
||||
|
||||
expect(useAgentRowConversationName(makeAgent())).toBe('First name')
|
||||
const readsAfterFirstRow = tabReads
|
||||
expect(
|
||||
useAgentRowConversationName(
|
||||
makeAgent({
|
||||
paneKey: 'tab-2:leaf-1',
|
||||
tab: { id: 'tab-2', worktreeId: 'wt-1', customTitle: null, title: '' }
|
||||
} as Partial<DashboardAgentRow>)
|
||||
)
|
||||
).toBe('Second name')
|
||||
expect(readsAfterFirstRow).toBeGreaterThan(0)
|
||||
expect(tabReads).toBe(readsAfterFirstRow)
|
||||
})
|
||||
|
||||
it('prefers the live store tab over the stale row snapshot', () => {
|
||||
storeState.current = {
|
||||
settings: {},
|
||||
// Why: row data patches entries in place and keeps the creation-time tab
|
||||
// snapshot; a rename landing after that must still surface.
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', worktreeId: 'wt-1', customTitle: 'Renamed later', title: '' }]
|
||||
}
|
||||
}
|
||||
expect(useAgentRowConversationName(makeAgent())).toBe('Renamed later')
|
||||
})
|
||||
|
||||
it('honors the generated-titles setting for generated names', () => {
|
||||
const agent = makeAgent({
|
||||
tab: { customTitle: null, title: '', generatedTitle: 'Fix intake flow' }
|
||||
} as Partial<DashboardAgentRow>)
|
||||
storeState.current = { settings: {}, tabsByWorktree: {} }
|
||||
expect(useAgentRowConversationName(agent)).toBeNull()
|
||||
storeState.current = {
|
||||
settings: { tabAutoGenerateTitle: true },
|
||||
tabsByWorktree: {}
|
||||
}
|
||||
expect(useAgentRowConversationName(agent)).toBe('Fix intake flow')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { getAgentRowConversationName } from '../../../../shared/agent-row-conversation-name'
|
||||
import { parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { DashboardAgentRow } from './useDashboardData'
|
||||
|
||||
type WorktreeTabs = NonNullable<AppState['tabsByWorktree'][string]>
|
||||
|
||||
const tabIndexByTabs = new WeakMap<WorktreeTabs, ReadonlyMap<string, WorktreeTabs[number]>>()
|
||||
|
||||
function getIndexedTab(
|
||||
tabs: WorktreeTabs | undefined,
|
||||
tabId: string
|
||||
): WorktreeTabs[number] | undefined {
|
||||
if (!tabs) {
|
||||
return undefined
|
||||
}
|
||||
let tabIndex = tabIndexByTabs.get(tabs)
|
||||
if (!tabIndex) {
|
||||
tabIndex = new Map(tabs.map((tab) => [tab.id, tab]))
|
||||
tabIndexByTabs.set(tabs, tabIndex)
|
||||
}
|
||||
return tabIndex.get(tabId)
|
||||
}
|
||||
|
||||
/** The row's conversation name, or null when nothing usable exists. */
|
||||
export function useAgentRowConversationName(agent: DashboardAgentRow): string | null {
|
||||
const parentPaneKey = agent.entry.orchestration?.parentPaneKey
|
||||
const usesParentTab =
|
||||
agent.lineage?.depth === 1 &&
|
||||
parentPaneKey !== undefined &&
|
||||
parsePaneKey(parentPaneKey)?.tabId === agent.tab.id
|
||||
const cannotOwnTabName = agent.rowSource === 'subagent' || usesParentTab
|
||||
const generatedTitlesEnabled = useAppStore(
|
||||
(s) => !cannotOwnTabName && s.settings?.tabAutoGenerateTitle === true
|
||||
)
|
||||
const liveTab = useAppStore((s) =>
|
||||
cannotOwnTabName
|
||||
? undefined
|
||||
: getIndexedTab(s.tabsByWorktree[agent.tab.worktreeId], agent.tab.id)
|
||||
)
|
||||
// Why: synthetic and same-tab child rows do not own the parent tab's name.
|
||||
if (cannotOwnTabName) {
|
||||
return null
|
||||
}
|
||||
// Why: retained row snapshots need a fallback after their live tab disappears.
|
||||
return getAgentRowConversationName(liveTab ?? agent.tab, agent.agentType, generatedTitlesEnabled)
|
||||
}
|
||||
@@ -17,12 +17,18 @@ import { keybindingMatchesAction } from '../../../../shared/keybindings'
|
||||
|
||||
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
applyPdfScalePreference,
|
||||
stepPdfScalePreference,
|
||||
type PdfScalePreference
|
||||
} from './pdf-scale-preference'
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = workerUrl
|
||||
|
||||
const MIN_SCALE = 0.25
|
||||
const MAX_SCALE = 5
|
||||
const SCALE_STEP = 1.25
|
||||
const SCALE_BOUNDS = { min: MIN_SCALE, max: MAX_SCALE, step: SCALE_STEP }
|
||||
|
||||
type PdfViewerProps = {
|
||||
content: string
|
||||
@@ -40,10 +46,24 @@ export default function PdfViewer({ content, filePath }: PdfViewerProps): JSX.El
|
||||
const eventBusRef = useRef<InstanceType<typeof EventBus> | null>(null)
|
||||
const findControllerRef = useRef<InstanceType<typeof PDFFindController> | null>(null)
|
||||
const pdfViewerRef = useRef<InstanceType<typeof PdfJsViewer> | null>(null)
|
||||
// Why: content reloads rebuild the pdf.js viewer; keep zoom across updates of
|
||||
// the same file, and only reset when the open path changes.
|
||||
const scalePreferenceRef = useRef<PdfScalePreference>('page-width')
|
||||
|
||||
const filename = useMemo(() => filePath.split(/[/\\]/).pop() || filePath, [filePath])
|
||||
const cleanedContent = useMemo(() => content.replace(/\s/g, ''), [content])
|
||||
|
||||
// Why: reset zoom to fit-width when the open path changes. An effect keeps the
|
||||
// reset out of render (refs mutated in render can leak from discarded renders)
|
||||
// and covers same-content/different-path opens the load effect skips.
|
||||
useEffect(() => {
|
||||
scalePreferenceRef.current = 'page-width'
|
||||
const viewer = pdfViewerRef.current
|
||||
if (viewer) {
|
||||
applyPdfScalePreference(viewer, 'page-width', SCALE_BOUNDS)
|
||||
}
|
||||
}, [filePath])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
const viewerDiv = viewerDivRef.current
|
||||
@@ -107,7 +127,7 @@ export default function PdfViewer({ content, filePath }: PdfViewerProps): JSX.El
|
||||
viewer.setDocument(doc)
|
||||
linkService.setDocument(doc)
|
||||
findController.setDocument(doc)
|
||||
viewer.currentScaleValue = 'page-width'
|
||||
applyPdfScalePreference(viewer, scalePreferenceRef.current, SCALE_BOUNDS)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) {
|
||||
@@ -148,6 +168,30 @@ export default function PdfViewer({ content, filePath }: PdfViewerProps): JSX.El
|
||||
setFindOpen(false)
|
||||
}, [])
|
||||
|
||||
// Why: every zoom entry point (toolbar + keyboard) must record the scale
|
||||
// preference so the next content reload restores it (see scalePreferenceRef).
|
||||
const stepZoom = useCallback((direction: 'in' | 'out') => {
|
||||
const viewer = pdfViewerRef.current
|
||||
if (!viewer) {
|
||||
return
|
||||
}
|
||||
const next = stepPdfScalePreference(viewer.currentScale, direction, SCALE_BOUNDS)
|
||||
viewer.currentScale = next.scale
|
||||
scalePreferenceRef.current = next.preference
|
||||
}, [])
|
||||
|
||||
const zoomIn = useCallback(() => stepZoom('in'), [stepZoom])
|
||||
const zoomOut = useCallback(() => stepZoom('out'), [stepZoom])
|
||||
|
||||
const zoomReset = useCallback(() => {
|
||||
const viewer = pdfViewerRef.current
|
||||
if (!viewer) {
|
||||
return
|
||||
}
|
||||
scalePreferenceRef.current = 'page-width'
|
||||
applyPdfScalePreference(viewer, 'page-width', SCALE_BOUNDS)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||
const platform = getShortcutPlatform()
|
||||
@@ -159,48 +203,18 @@ export default function PdfViewer({ content, filePath }: PdfViewerProps): JSX.El
|
||||
}
|
||||
if (keybindingMatchesAction('zoom.in', e, platform, keybindings)) {
|
||||
e.preventDefault()
|
||||
const viewer = pdfViewerRef.current
|
||||
if (viewer) {
|
||||
viewer.currentScale = Math.min(MAX_SCALE, viewer.currentScale * SCALE_STEP)
|
||||
}
|
||||
zoomIn()
|
||||
} else if (keybindingMatchesAction('zoom.out', e, platform, keybindings)) {
|
||||
e.preventDefault()
|
||||
const viewer = pdfViewerRef.current
|
||||
if (viewer) {
|
||||
viewer.currentScale = Math.max(MIN_SCALE, viewer.currentScale / SCALE_STEP)
|
||||
}
|
||||
zoomOut()
|
||||
} else if (keybindingMatchesAction('zoom.reset', e, platform, keybindings)) {
|
||||
e.preventDefault()
|
||||
const viewer = pdfViewerRef.current
|
||||
if (viewer) {
|
||||
viewer.currentScaleValue = 'page-width'
|
||||
}
|
||||
zoomReset()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true)
|
||||
}, [keybindings])
|
||||
|
||||
const zoomIn = useCallback(() => {
|
||||
const viewer = pdfViewerRef.current
|
||||
if (viewer) {
|
||||
viewer.currentScale = Math.min(MAX_SCALE, viewer.currentScale * SCALE_STEP)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const zoomOut = useCallback(() => {
|
||||
const viewer = pdfViewerRef.current
|
||||
if (viewer) {
|
||||
viewer.currentScale = Math.max(MIN_SCALE, viewer.currentScale / SCALE_STEP)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const zoomReset = useCallback(() => {
|
||||
const viewer = pdfViewerRef.current
|
||||
if (viewer) {
|
||||
viewer.currentScaleValue = 'page-width'
|
||||
}
|
||||
}, [])
|
||||
}, [keybindings, zoomIn, zoomOut, zoomReset])
|
||||
|
||||
const zoomPercent = Math.round(scale * 100)
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyPdfScalePreference,
|
||||
clampPdfScale,
|
||||
stepPdfScalePreference
|
||||
} from './pdf-scale-preference'
|
||||
|
||||
const BOUNDS = { min: 0.25, max: 5, step: 1.25 }
|
||||
|
||||
describe('clampPdfScale', () => {
|
||||
it('clamps to the configured range', () => {
|
||||
expect(clampPdfScale(0.1, BOUNDS.min, BOUNDS.max)).toBe(0.25)
|
||||
expect(clampPdfScale(9, BOUNDS.min, BOUNDS.max)).toBe(5)
|
||||
expect(clampPdfScale(1.5, BOUNDS.min, BOUNDS.max)).toBe(1.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyPdfScalePreference', () => {
|
||||
it('restores an absolute scale after a content reload', () => {
|
||||
const viewer = { currentScale: 1, currentScaleValue: 'auto' }
|
||||
applyPdfScalePreference(viewer, 2.5, BOUNDS)
|
||||
expect(viewer.currentScale).toBe(2.5)
|
||||
})
|
||||
|
||||
it('uses fit-to-width for the default preference', () => {
|
||||
const viewer = { currentScale: 1, currentScaleValue: 'auto' }
|
||||
applyPdfScalePreference(viewer, 'page-width', BOUNDS)
|
||||
expect(viewer.currentScaleValue).toBe('page-width')
|
||||
})
|
||||
|
||||
it('clamps an out-of-range absolute preference', () => {
|
||||
const viewer = { currentScale: 1, currentScaleValue: 'auto' }
|
||||
applyPdfScalePreference(viewer, 99, BOUNDS)
|
||||
expect(viewer.currentScale).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stepPdfScalePreference', () => {
|
||||
it('records the absolute scale so a later reload can restore it', () => {
|
||||
const zoomedIn = stepPdfScalePreference(1, 'in', BOUNDS)
|
||||
expect(zoomedIn.preference).toBe(1.25)
|
||||
expect(zoomedIn.scale).toBe(1.25)
|
||||
|
||||
const zoomedOut = stepPdfScalePreference(1.25, 'out', BOUNDS)
|
||||
expect(zoomedOut.preference).toBe(1)
|
||||
expect(zoomedOut.scale).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
export type PdfScalePreference = 'page-width' | number
|
||||
|
||||
export function clampPdfScale(scale: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, scale))
|
||||
}
|
||||
|
||||
/** Apply a stored zoom preference after pdf.js loads a (re)document. */
|
||||
export function applyPdfScalePreference(
|
||||
viewer: { currentScale: number; currentScaleValue: string },
|
||||
preference: PdfScalePreference,
|
||||
bounds: { min: number; max: number }
|
||||
): void {
|
||||
if (typeof preference === 'number') {
|
||||
viewer.currentScale = clampPdfScale(preference, bounds.min, bounds.max)
|
||||
return
|
||||
}
|
||||
viewer.currentScaleValue = 'page-width'
|
||||
}
|
||||
|
||||
/** Zoom in/out while recording the resulting absolute scale preference. */
|
||||
export function stepPdfScalePreference(
|
||||
currentScale: number,
|
||||
direction: 'in' | 'out',
|
||||
bounds: { min: number; max: number; step: number }
|
||||
): { scale: number; preference: number } {
|
||||
const next =
|
||||
direction === 'in'
|
||||
? clampPdfScale(currentScale * bounds.step, bounds.min, bounds.max)
|
||||
: clampPdfScale(currentScale / bounds.step, bounds.min, bounds.max)
|
||||
return { scale: next, preference: next }
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { cleanup, render, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
|
||||
@@ -105,10 +109,18 @@ function renderSidebar(): string {
|
||||
)
|
||||
}
|
||||
|
||||
function sidebarElement(): ReactNode {
|
||||
return (
|
||||
<Sidebar worktreeScrollOffsetRef={{ current: 0 }} worktreeScrollAnchorRef={{ current: null }} />
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('Sidebar', () => {
|
||||
it('applies left sidebar appearance variables to the workspace sidebar surface', () => {
|
||||
setSidebarState({
|
||||
...getDefaultSettings('/tmp'),
|
||||
...getDefaultSettings(tmpdir()),
|
||||
leftSidebarAppearanceMode: 'match-terminal',
|
||||
terminalColorOverrides: {
|
||||
background: '#101820',
|
||||
@@ -125,11 +137,69 @@ describe('Sidebar', () => {
|
||||
})
|
||||
|
||||
it('passes status bar visibility into the workspace board drawer', () => {
|
||||
setSidebarState(getDefaultSettings('/tmp'), false)
|
||||
setSidebarState(getDefaultSettings(tmpdir()), false)
|
||||
|
||||
const markup = renderSidebar()
|
||||
|
||||
expect(markup).toContain('data-testid="workspace-kanban-drawer"')
|
||||
expect(markup).toContain('data-status-bar-visible="false"')
|
||||
})
|
||||
|
||||
it('does not start a full worktree scan while the startup session is hydrating', () => {
|
||||
setSidebarState(getDefaultSettings(tmpdir()))
|
||||
const fetchAllWorktrees = vi.fn().mockResolvedValue(undefined)
|
||||
mocks.state = {
|
||||
...mocks.state,
|
||||
fetchAllWorktrees,
|
||||
repos: [],
|
||||
startupWorktreeRefreshCompleted: false
|
||||
}
|
||||
const view = render(sidebarElement())
|
||||
|
||||
mocks.state = { ...mocks.state, repos: [{ id: 'repo-a' }] }
|
||||
view.rerender(sidebarElement())
|
||||
expect(fetchAllWorktrees).not.toHaveBeenCalled()
|
||||
|
||||
mocks.state = { ...mocks.state, startupWorktreeRefreshCompleted: true }
|
||||
view.rerender(sidebarElement())
|
||||
expect(fetchAllWorktrees).not.toHaveBeenCalled()
|
||||
|
||||
mocks.state = { ...mocks.state, repos: [{ id: 'repo-a' }, { id: 'repo-b' }] }
|
||||
view.rerender(sidebarElement())
|
||||
expect(fetchAllWorktrees).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not scan when runtime hosts come online during the startup refresh', async () => {
|
||||
setSidebarState(getDefaultSettings(tmpdir()))
|
||||
const fetchAllWorktrees = vi.fn().mockResolvedValue(undefined)
|
||||
mocks.state = {
|
||||
...mocks.state,
|
||||
fetchAllWorktrees,
|
||||
fetchWorktreeLineage: vi.fn().mockResolvedValue(undefined),
|
||||
runtimeStatusByEnvironmentId: new Map(),
|
||||
startupWorktreeRefreshCompleted: false
|
||||
}
|
||||
const view = render(sidebarElement())
|
||||
|
||||
mocks.state = {
|
||||
...mocks.state,
|
||||
runtimeStatusByEnvironmentId: new Map([['runtime-a', { status: 'connected' }]])
|
||||
}
|
||||
view.rerender(sidebarElement())
|
||||
expect(fetchAllWorktrees).not.toHaveBeenCalled()
|
||||
|
||||
mocks.state = { ...mocks.state, startupWorktreeRefreshCompleted: true }
|
||||
view.rerender(sidebarElement())
|
||||
expect(fetchAllWorktrees).not.toHaveBeenCalled()
|
||||
|
||||
mocks.state = {
|
||||
...mocks.state,
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
['runtime-a', { status: 'connected' }],
|
||||
['runtime-b', { status: 'connected' }]
|
||||
])
|
||||
}
|
||||
view.rerender(sidebarElement())
|
||||
await waitFor(() => expect(fetchAllWorktrees).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
})
|
||||
|
||||
+3
-2
@@ -474,8 +474,9 @@ describe('WorktreeCard agent-list <-> child-worktrees expansion coupling', () =>
|
||||
setAgentLineageState({ agentActivityDisplayMode: 'full' })
|
||||
const { container } = await renderWorktreeList()
|
||||
|
||||
// Sanity: real agent rows rendered (parent + its lineage child agent).
|
||||
expect(container.textContent).toContain('PARENT_AGENT_PROMPT')
|
||||
// Same-tab lineage children must not inherit the parent's conversation name.
|
||||
expect(container.textContent).toContain('Parent Terminal')
|
||||
expect(container.textContent).not.toContain('PARENT_AGENT_PROMPT')
|
||||
expect(container.textContent).toContain('CHILD_AGENT_PROMPT')
|
||||
|
||||
// Both controls exist and are independent DOM elements.
|
||||
|
||||
@@ -46,6 +46,7 @@ function Sidebar({
|
||||
const sidebarWidth = useAppStore((s) => s.sidebarWidth)
|
||||
const setSidebarWidth = useAppStore((s) => s.setSidebarWidth)
|
||||
const repos = useAppStore((s) => s.repos)
|
||||
const startupWorktreeRefreshCompleted = useAppStore((s) => s.startupWorktreeRefreshCompleted)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const fetchAllWorktrees = useAppStore((s) => s.fetchAllWorktrees)
|
||||
const activeModal = useAppStore((s) => s.activeModal)
|
||||
@@ -74,13 +75,16 @@ function Sidebar({
|
||||
document.documentElement.style.setProperty('--workspace-sidebar-live-width', `${width}px`)
|
||||
}, [])
|
||||
|
||||
// Fetch worktrees when repos are added/removed
|
||||
const repoCount = repos.length
|
||||
const previousRepoCountRef = React.useRef(repoCount)
|
||||
useEffect(() => {
|
||||
if (repoCount > 0) {
|
||||
fetchAllWorktrees()
|
||||
const repoCountChanged = previousRepoCountRef.current !== repoCount
|
||||
previousRepoCountRef.current = repoCount
|
||||
// Why: App owns the initial all-host scan; partial startup catalogs must not trigger broad scans or stale-state purges.
|
||||
if (startupWorktreeRefreshCompleted && repoCountChanged && repoCount > 0) {
|
||||
void fetchAllWorktrees()
|
||||
}
|
||||
}, [repoCount, fetchAllWorktrees])
|
||||
}, [repoCount, startupWorktreeRefreshCompleted, fetchAllWorktrees])
|
||||
|
||||
// Why: a runtime host coming online/offline must refresh the sidebar so its
|
||||
// worktrees appear/drop, the same way SSH state changes already refetch. Only
|
||||
@@ -109,18 +113,17 @@ function Sidebar({
|
||||
)
|
||||
}
|
||||
useEffect(() => {
|
||||
// Skip the initial value — startup/repoCount effects already fetch. Only
|
||||
// refetch when the online-host set actually changes.
|
||||
if (previousOnlineRuntimeEnvKeyRef.current === null) {
|
||||
previousOnlineRuntimeEnvKeyRef.current = onlineRuntimeEnvKey
|
||||
return
|
||||
}
|
||||
if (previousOnlineRuntimeEnvKeyRef.current === onlineRuntimeEnvKey) {
|
||||
return
|
||||
}
|
||||
const previousOnlineRuntimeEnvKey = previousOnlineRuntimeEnvKeyRef.current
|
||||
previousOnlineRuntimeEnvKeyRef.current = onlineRuntimeEnvKey
|
||||
if (
|
||||
previousOnlineRuntimeEnvKey === null ||
|
||||
previousOnlineRuntimeEnvKey === onlineRuntimeEnvKey ||
|
||||
!startupWorktreeRefreshCompleted
|
||||
) {
|
||||
return
|
||||
}
|
||||
reconnectRefreshRef.current?.request()
|
||||
}, [onlineRuntimeEnvKey])
|
||||
}, [onlineRuntimeEnvKey, startupWorktreeRefreshCompleted])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sidebarOpen && workspaceBoardRenderedOpen) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from '@/lib/utils'
|
||||
import { getAgentDotState } from './worktree-card-agent-summary'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text'
|
||||
import { useAgentRowConversationName } from '@/components/dashboard/use-agent-row-conversation-name'
|
||||
import { lastEnteredDoneAt } from '@/components/dashboard/agent-finished-timestamp'
|
||||
import CacheTimer, { usePromptCacheCountdownForPane } from './CacheTimer'
|
||||
|
||||
@@ -27,8 +28,11 @@ function formatShortTimeAgo(ts: number, now: number): string {
|
||||
return `${Math.floor(hours / 24)}d`
|
||||
}
|
||||
|
||||
function getCompactAgentPrimary(agent: DashboardAgentRowData): string {
|
||||
const prompt = getAgentRowPrimaryText(agent.entry)
|
||||
function getCompactAgentPrimary(
|
||||
agent: DashboardAgentRowData,
|
||||
conversationName: string | null
|
||||
): string {
|
||||
const prompt = conversationName ?? getAgentRowPrimaryText(agent.entry)
|
||||
return prompt || agentStateLabel(getAgentDotState(agent))
|
||||
}
|
||||
|
||||
@@ -116,7 +120,8 @@ export const CompactAgentRow = React.memo(function CompactAgentRow({
|
||||
// "?" glyph. Nesting under the parent already conveys identity.
|
||||
const hideIcon = hideIdentityIcon || agent.rowSource === 'subagent'
|
||||
const dotState = getAgentDotState(agent)
|
||||
const primary = getCompactAgentPrimary(agent)
|
||||
const conversationName = useAgentRowConversationName(agent)
|
||||
const primary = getCompactAgentPrimary(agent, conversationName)
|
||||
const isLineageChild = agent.lineage?.depth === 1
|
||||
const secondary = getCompactAgentSecondary(agent)
|
||||
const model = agent.entry.model?.trim() ?? ''
|
||||
|
||||
+32
-3
@@ -3,21 +3,50 @@ import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const SOURCE_PATH = resolve(__dirname, 'ResourceUsageStatusSegment.tsx')
|
||||
const INVENTORY_HOOK_PATH = resolve(__dirname, 'use-resource-session-inventory.ts')
|
||||
|
||||
describe('ResourceUsageStatusSegment session polling', () => {
|
||||
describe('ResourceUsageStatusSegment session inventory', () => {
|
||||
it('does not poll global terminal sessions while the popover is closed', () => {
|
||||
const source = readFileSync(SOURCE_PATH, 'utf8')
|
||||
const inventoryHookSource = readFileSync(INVENTORY_HOOK_PATH, 'utf8')
|
||||
|
||||
expect(source).not.toContain('installWindowVisibilityInterval')
|
||||
expect(source).not.toContain('SESSIONS_POLL_MS')
|
||||
expect(source.match(/window\.api\.pty\.listSessions\(\)/g) ?? []).toHaveLength(1)
|
||||
expect(inventoryHookSource).not.toContain('setInterval')
|
||||
// Why: every seed/action/lifecycle refresh shares one guarded inventory
|
||||
// read, and the closed path never installs a polling interval.
|
||||
expect(inventoryHookSource.match(/window\.api\.pty\.listSessions\(\)/g) ?? []).toHaveLength(1)
|
||||
|
||||
const openEffectIndex = source.indexOf('if (!open)')
|
||||
const refreshIndex = source.indexOf('void refreshSessions()', openEffectIndex)
|
||||
|
||||
// Why: pty.listSessions() is a global daemon inventory and can pause input
|
||||
// with large preserved-session sets. Keep it on explicit Resource Manager use.
|
||||
// with large preserved-session sets. Keep continuous use off the closed path.
|
||||
expect(openEffectIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(refreshIndex).toBeGreaterThan(openEffectIndex)
|
||||
})
|
||||
|
||||
it('seeds the closed badge from daemon inventory instead of wake-hint bound PTYs', () => {
|
||||
const source = readFileSync(SOURCE_PATH, 'utf8')
|
||||
const inventoryHookSource = readFileSync(INVENTORY_HOOK_PATH, 'utf8')
|
||||
|
||||
expect(source).toContain('useResourceSessionInventory')
|
||||
expect(source).toContain('sessionInventory.count')
|
||||
expect(inventoryHookSource).toContain('window.api.pty.onSpawned')
|
||||
expect(inventoryHookSource).toContain('window.api.pty.onExit')
|
||||
expect(source).not.toContain('createClosedResourceSessionCountSelector')
|
||||
expect(source).not.toContain('boundPtyIds.size')
|
||||
expect(source).not.toContain('closedSessionCount')
|
||||
expect(source).not.toContain('livePtyIdsByTabId')
|
||||
})
|
||||
|
||||
it('seeds memory snapshot for the closed badge without requiring a click', () => {
|
||||
const source = readFileSync(SOURCE_PATH, 'utf8')
|
||||
|
||||
// Why: the ready-seed effect must call fetchSnapshot so RAM is not "—"
|
||||
// until the user opens Resource Manager.
|
||||
const readySeedBlock = source.slice(source.indexOf('// Why: seed RAM after session restore'))
|
||||
expect(readySeedBlock).toContain('void fetchSnapshot()')
|
||||
expect(readySeedBlock).toContain('workspaceSessionReady')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,7 +38,6 @@ import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import { isWorkspaceOldForCleanup } from '../../../../shared/workspace-cleanup'
|
||||
import { mergeSnapshotAndSessions, UNATTRIBUTED_REPO_ID } from './mergeSnapshotAndSessions'
|
||||
import type {
|
||||
DaemonSession,
|
||||
Metric,
|
||||
UnifiedProjectGroup,
|
||||
UnifiedSessionRow,
|
||||
@@ -72,11 +71,10 @@ import {
|
||||
countUnboundDaemonSessions,
|
||||
type ResourceSessionBindingInputs
|
||||
} from './resource-session-bindings'
|
||||
import { createClosedResourceSessionCountSelector } from './resource-session-count-selector'
|
||||
import { useResourceSessionInventory } from './use-resource-session-inventory'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
const POLL_MS = 2_000
|
||||
const selectClosedResourceSessionCount = createClosedResourceSessionCountSelector()
|
||||
|
||||
type SortOption = 'memory' | 'cpu' | 'name'
|
||||
|
||||
@@ -733,7 +731,6 @@ export function ResourceUsageStatusSegment({
|
||||
const memorySnapshotError = useAppStore((s) => s.memorySnapshotError)
|
||||
const fetchSnapshot = useAppStore((s) => s.fetchMemorySnapshot)
|
||||
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
|
||||
const closedSessionCount = useAppStore(selectClosedResourceSessionCount)
|
||||
const setActiveView = useAppStore((s) => s.setActiveView)
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
const openSpacePage = useAppStore((s) => s.openSpacePage)
|
||||
@@ -748,8 +745,15 @@ export function ResourceUsageStatusSegment({
|
||||
const [collapsedRepos, setCollapsedRepos] = useState<Set<string>>(new Set())
|
||||
const [collapsedWorktrees, setCollapsedWorktrees] = useState<Set<string>>(new Set())
|
||||
const [appCollapsed, setAppCollapsed] = useState(true)
|
||||
const [sessions, setSessions] = useState<DaemonSession[]>([])
|
||||
const [sessionsError, setSessionsError] = useState(false)
|
||||
const {
|
||||
sessionInventory,
|
||||
sessionsError,
|
||||
refreshSessions,
|
||||
clearSessionsError,
|
||||
removeSession,
|
||||
removeSessions
|
||||
} = useResourceSessionInventory(workspaceSessionReady)
|
||||
const sessions = sessionInventory.sessions
|
||||
const [killConfirm, setKillConfirm] = useState<UnifiedSessionRow | null>(null)
|
||||
const [killing, setKilling] = useState(false)
|
||||
const [spaceScanSnapshot, setSpaceScanSnapshot] = useState<ResourceUsageSpaceScanSnapshot>(
|
||||
@@ -806,24 +810,9 @@ export function ResourceUsageStatusSegment({
|
||||
[cancelPopoverBodyFocusFrame]
|
||||
)
|
||||
|
||||
const refreshSessions = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.api.pty.listSessions()
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
setSessions(result)
|
||||
setSessionsError(false)
|
||||
} catch {
|
||||
if (mountedRef.current) {
|
||||
setSessionsError(true)
|
||||
}
|
||||
}
|
||||
}, [mountedRef])
|
||||
|
||||
const daemonActions = useDaemonActions({
|
||||
onRestartSettled: () => {
|
||||
setSessionsError(false)
|
||||
clearSessionsError()
|
||||
void fetchSnapshot()
|
||||
void refreshSessions()
|
||||
},
|
||||
@@ -850,7 +839,17 @@ export function ResourceUsageStatusSegment({
|
||||
}
|
||||
const spaceScanReady = nextSpaceScanSnapshot.ready
|
||||
|
||||
// Poll memory only while open; a closed badge must not inventory daemon PTYs (large preserved-session sets stall typing).
|
||||
// Why: seed RAM after session restore so the closed chip does not require a
|
||||
// click; the session-inventory hook independently seeds daemon PTYs.
|
||||
useEffect(() => {
|
||||
if (workspaceSessionReady) {
|
||||
void fetchSnapshot()
|
||||
}
|
||||
}, [workspaceSessionReady, fetchSnapshot])
|
||||
|
||||
// Poll memory only while the popover is open. Session inventory is still
|
||||
// explicit-on-open/action/seed (not a closed interval) because full
|
||||
// listSessions can pause input with large preserved-session sets.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
@@ -959,7 +958,9 @@ export function ResourceUsageStatusSegment({
|
||||
return countUnboundDaemonSessions(sessions, resourceSessionBindings)
|
||||
}, [open, sessions, resourceSessionBindings, workspaceSessionReady])
|
||||
|
||||
const triggerSessionCount = open ? sessions.length : closedSessionCount
|
||||
// Why: open and closed badges share the same daemon inventory cache. The old
|
||||
// closed path used boundPtyIds (wake hints) and inflated the chip to 60+.
|
||||
const triggerSessionCount = sessionInventory.count
|
||||
|
||||
const { totalMemory, totalCpu, hostShare, memBadgeLabel } = useMemo(() => {
|
||||
const memory = resourceSnapshot?.totalMemory ?? 0
|
||||
@@ -1046,7 +1047,7 @@ export function ResourceUsageStatusSegment({
|
||||
(session: UnifiedSessionRow): void => {
|
||||
// Why: orphan sessions have no tab here (no unsaved work to lose), so skip the confirm dialog; bound sessions still confirm.
|
||||
if (!session.bound) {
|
||||
setSessions((prev) => prev.filter((s) => s.id !== session.sessionId))
|
||||
removeSession(session.sessionId)
|
||||
// Why: await the kill before refreshing, else the refresh re-reads the daemon list before the kill lands and re-adds the row.
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -1060,7 +1061,7 @@ export function ResourceUsageStatusSegment({
|
||||
}
|
||||
setKillConfirm(session)
|
||||
},
|
||||
[refreshSessions]
|
||||
[refreshSessions, removeSession]
|
||||
)
|
||||
|
||||
const handleKillOrphans = useCallback(async () => {
|
||||
@@ -1074,10 +1075,10 @@ export function ResourceUsageStatusSegment({
|
||||
}
|
||||
// Why: optimistic removal so rows disappear immediately instead of waiting for the next daemon-side list refresh.
|
||||
const orphanIds = new Set(orphans.map((s) => s.id))
|
||||
setSessions((prev) => prev.filter((s) => !orphanIds.has(s.id)))
|
||||
removeSessions(orphanIds)
|
||||
await Promise.allSettled(orphans.map((s) => window.api.pty.kill(s.id)))
|
||||
void refreshSessions()
|
||||
}, [sessions, resourceSessionBindings, workspaceSessionReady, refreshSessions])
|
||||
}, [sessions, resourceSessionBindings, workspaceSessionReady, refreshSessions, removeSessions])
|
||||
|
||||
const runKillConfirmed = useCallback(async () => {
|
||||
if (!killConfirm) {
|
||||
@@ -1086,7 +1087,7 @@ export function ResourceUsageStatusSegment({
|
||||
const target = killConfirm
|
||||
setKilling(true)
|
||||
// Why: optimistic removal avoids a flash where the dialog closes but the killed row lingers until the next list refresh.
|
||||
setSessions((prev) => prev.filter((s) => s.id !== target.sessionId))
|
||||
removeSession(target.sessionId)
|
||||
try {
|
||||
await window.api.pty.kill(target.sessionId)
|
||||
} catch {
|
||||
@@ -1106,7 +1107,7 @@ export function ResourceUsageStatusSegment({
|
||||
void refreshSessions()
|
||||
}
|
||||
}
|
||||
}, [cancelPopoverBodyFocusFrame, killConfirm, mountedRef, refreshSessions])
|
||||
}, [cancelPopoverBodyFocusFrame, killConfirm, mountedRef, refreshSessions, removeSession])
|
||||
|
||||
const openSpaceResults = useCallback((): void => {
|
||||
setOpen(false)
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types'
|
||||
import {
|
||||
buildResourceSessionBindingIndex,
|
||||
type ResourceSessionBindingInputs
|
||||
} from './resource-session-bindings'
|
||||
import {
|
||||
createClosedResourceSessionCountSelector,
|
||||
type ClosedResourceSessionCountState
|
||||
} from './resource-session-count-selector'
|
||||
|
||||
const TAB_COUNT = 100
|
||||
const TITLE_WRITES = 600
|
||||
|
||||
function makeTab(id: string, ptyId: string | null = null, title = 'Terminal'): TerminalTab {
|
||||
return {
|
||||
id,
|
||||
ptyId,
|
||||
worktreeId: `wt-${id}`,
|
||||
title,
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
}
|
||||
|
||||
function makeLayout(ptyIdsByLeafId: Record<string, string>): TerminalLayoutSnapshot {
|
||||
return {
|
||||
root: { type: 'leaf', leafId: 'leaf-1' },
|
||||
activeLeafId: 'leaf-1',
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId
|
||||
}
|
||||
}
|
||||
|
||||
function makeState(
|
||||
overrides: Partial<ClosedResourceSessionCountState> = {}
|
||||
): ClosedResourceSessionCountState {
|
||||
return {
|
||||
tabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
workspaceSessionReady: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function withCountedIteration(tabs: TerminalTab[], onVisit: () => void): TerminalTab[] {
|
||||
return new Proxy(tabs, {
|
||||
get(target, property, receiver) {
|
||||
if (property !== Symbol.iterator) {
|
||||
return Reflect.get(target, property, receiver)
|
||||
}
|
||||
return function* countedIterator(): Generator<TerminalTab> {
|
||||
for (const tab of target) {
|
||||
onVisit()
|
||||
yield tab
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function makeScaleTabs(onVisit: () => void): Record<string, TerminalTab[]> {
|
||||
return Object.fromEntries(
|
||||
Array.from({ length: TAB_COUNT }, (_, index) => {
|
||||
const worktreeId = `wt-${index}`
|
||||
return [worktreeId, withCountedIteration([makeTab(`tab-${index}`, `pty-${index}`)], onVisit)]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function countNextTabComparison(
|
||||
tab: TerminalTab,
|
||||
onComparison: () => void
|
||||
): [TerminalTab[], () => void] {
|
||||
let counting = true
|
||||
const tabs = new Proxy([tab], {
|
||||
get(target, property, receiver) {
|
||||
if (counting && property === '0') {
|
||||
onComparison()
|
||||
}
|
||||
return Reflect.get(target, property, receiver)
|
||||
}
|
||||
})
|
||||
return [
|
||||
tabs,
|
||||
() => {
|
||||
counting = false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
describe('closed resource session count selector', () => {
|
||||
it('removes full binding-index rebuilds from title-only tab churn at scale', () => {
|
||||
let oldPathTabVisits = 0
|
||||
const initialTabs = makeScaleTabs(() => {
|
||||
oldPathTabVisits += 1
|
||||
})
|
||||
const inputs: ResourceSessionBindingInputs = {
|
||||
tabsByWorktree: initialTabs,
|
||||
ptyIdsByTabId: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
workspaceSessionReady: true
|
||||
}
|
||||
|
||||
// Why: this models the previous useMemo dependency on tabsByWorktree:
|
||||
// every title write replaces that map and rebuilds both tab passes.
|
||||
for (let index = 0; index < TITLE_WRITES; index += 1) {
|
||||
buildResourceSessionBindingIndex({ ...inputs, tabsByWorktree: { ...initialTabs } })
|
||||
}
|
||||
expect(oldPathTabVisits).toBe(120_000)
|
||||
|
||||
let optimizedTabVisits = 0
|
||||
const optimizedTabs = makeScaleTabs(() => {
|
||||
optimizedTabVisits += 1
|
||||
})
|
||||
const buildIndex = vi.fn(buildResourceSessionBindingIndex)
|
||||
const selectCount = createClosedResourceSessionCountSelector(buildIndex)
|
||||
let state = makeState({ tabsByWorktree: optimizedTabs })
|
||||
const currentTabByWorktree = Object.fromEntries(
|
||||
Object.entries(optimizedTabs).map(([worktreeId, tabs]) => [worktreeId, tabs[0]])
|
||||
)
|
||||
let changedArrayTabComparisons = 0
|
||||
|
||||
expect(selectCount(state)).toBe(TAB_COUNT)
|
||||
buildIndex.mockClear()
|
||||
optimizedTabVisits = 0
|
||||
|
||||
for (let index = 0; index < TITLE_WRITES; index += 1) {
|
||||
const worktreeId = `wt-${index % TAB_COUNT}`
|
||||
const nextTab = { ...currentTabByWorktree[worktreeId], title: `Terminal ${index}` }
|
||||
const [nextTabs, stopCounting] = countNextTabComparison(nextTab, () => {
|
||||
changedArrayTabComparisons += 1
|
||||
})
|
||||
state = {
|
||||
...state,
|
||||
tabsByWorktree: {
|
||||
...state.tabsByWorktree,
|
||||
[worktreeId]: nextTabs
|
||||
}
|
||||
}
|
||||
expect(selectCount(state)).toBe(TAB_COUNT)
|
||||
stopCounting()
|
||||
currentTabByWorktree[worktreeId] = nextTab
|
||||
}
|
||||
|
||||
expect(buildIndex).not.toHaveBeenCalled()
|
||||
expect(optimizedTabVisits).toBe(0)
|
||||
expect(changedArrayTabComparisons).toBe(TITLE_WRITES)
|
||||
})
|
||||
|
||||
it('reacts to every binding and readiness input while ignoring display-only tab fields', () => {
|
||||
const buildIndex = vi.fn(buildResourceSessionBindingIndex)
|
||||
const selectCount = createClosedResourceSessionCountSelector(buildIndex)
|
||||
let state = makeState({
|
||||
workspaceSessionReady: false,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [makeTab('tab-1', 'pty-wake')]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'tab-1': ['pty-live']
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': makeLayout({ 'leaf-1': 'pty-layout' })
|
||||
}
|
||||
})
|
||||
|
||||
expect(selectCount(state)).toBe(0)
|
||||
expect(buildIndex).not.toHaveBeenCalled()
|
||||
|
||||
state = { ...state, workspaceSessionReady: true }
|
||||
expect(selectCount(state)).toBe(3)
|
||||
expect(buildIndex).toHaveBeenCalledTimes(1)
|
||||
|
||||
state = {
|
||||
...state,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ ...state.tabsByWorktree['wt-1'][0], title: 'Working' }]
|
||||
}
|
||||
}
|
||||
expect(selectCount(state)).toBe(3)
|
||||
expect(buildIndex).toHaveBeenCalledTimes(1)
|
||||
|
||||
state = {
|
||||
...state,
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-live', 'pty-live-2'] }
|
||||
}
|
||||
expect(selectCount(state)).toBe(4)
|
||||
expect(buildIndex).toHaveBeenCalledTimes(2)
|
||||
|
||||
state = {
|
||||
...state,
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': makeLayout({ 'leaf-1': 'pty-layout', 'leaf-2': 'pty-layout-2' })
|
||||
}
|
||||
}
|
||||
expect(selectCount(state)).toBe(5)
|
||||
expect(buildIndex).toHaveBeenCalledTimes(3)
|
||||
|
||||
state = {
|
||||
...state,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ ...state.tabsByWorktree['wt-1'][0], ptyId: null }]
|
||||
}
|
||||
}
|
||||
expect(selectCount(state)).toBe(4)
|
||||
expect(buildIndex).toHaveBeenCalledTimes(4)
|
||||
|
||||
state = { ...state, tabsByWorktree: {} }
|
||||
expect(selectCount(state)).toBe(2)
|
||||
expect(buildIndex).toHaveBeenCalledTimes(5)
|
||||
|
||||
state = { ...state, workspaceSessionReady: false }
|
||||
expect(selectCount(state)).toBe(0)
|
||||
expect(buildIndex).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
})
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { AppState } from '../../store'
|
||||
import {
|
||||
buildResourceSessionBindingIndex,
|
||||
type ResourceSessionBindingIndex,
|
||||
type ResourceSessionBindingInputs
|
||||
} from './resource-session-bindings'
|
||||
|
||||
export type ClosedResourceSessionCountState = Pick<
|
||||
AppState,
|
||||
'tabsByWorktree' | 'ptyIdsByTabId' | 'terminalLayoutsByTabId' | 'workspaceSessionReady'
|
||||
>
|
||||
|
||||
type BuildResourceSessionBindingIndex = (
|
||||
inputs: ResourceSessionBindingInputs
|
||||
) => ResourceSessionBindingIndex
|
||||
|
||||
export type ClosedResourceSessionCountSelector = (state: ClosedResourceSessionCountState) => number
|
||||
|
||||
function haveSameTabBindings(
|
||||
previous: AppState['tabsByWorktree'],
|
||||
next: AppState['tabsByWorktree']
|
||||
): boolean {
|
||||
if (previous === next) {
|
||||
return true
|
||||
}
|
||||
|
||||
const previousWorktreeIds = Object.keys(previous)
|
||||
const nextWorktreeIds = Object.keys(next)
|
||||
if (previousWorktreeIds.length !== nextWorktreeIds.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const worktreeId of nextWorktreeIds) {
|
||||
const previousTabs = previous[worktreeId]
|
||||
const nextTabs = next[worktreeId]
|
||||
if (previousTabs === nextTabs) {
|
||||
continue
|
||||
}
|
||||
if (!previousTabs || previousTabs.length !== nextTabs.length) {
|
||||
return false
|
||||
}
|
||||
for (let index = 0; index < nextTabs.length; index += 1) {
|
||||
const previousTab = previousTabs[index]
|
||||
const nextTab = nextTabs[index]
|
||||
// Why: the closed badge counts PTY ownership only. Titles and other
|
||||
// display fields can churn per terminal frame without changing it.
|
||||
if (previousTab.id !== nextTab.id || previousTab.ptyId !== nextTab.ptyId) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function createClosedResourceSessionCountSelector(
|
||||
buildBindingIndex: BuildResourceSessionBindingIndex = buildResourceSessionBindingIndex
|
||||
): ClosedResourceSessionCountSelector {
|
||||
// Why: Zustand runs selectors for every store notification. Keep the last
|
||||
// liveness inputs here so unrelated and title-only writes stay scalar-cheap.
|
||||
let initialized = false
|
||||
let previousTabsByWorktree: AppState['tabsByWorktree'] = {}
|
||||
let previousPtyIdsByTabId: AppState['ptyIdsByTabId'] = {}
|
||||
let previousTerminalLayoutsByTabId: AppState['terminalLayoutsByTabId'] = {}
|
||||
let previousWorkspaceSessionReady = false
|
||||
let count = 0
|
||||
|
||||
return (state): number => {
|
||||
const bindingMapChanged =
|
||||
state.ptyIdsByTabId !== previousPtyIdsByTabId ||
|
||||
state.terminalLayoutsByTabId !== previousTerminalLayoutsByTabId
|
||||
const readinessChanged = state.workspaceSessionReady !== previousWorkspaceSessionReady
|
||||
const tabsReferenceChanged = state.tabsByWorktree !== previousTabsByWorktree
|
||||
let tabBindingsChanged = tabsReferenceChanged
|
||||
if (
|
||||
initialized &&
|
||||
state.workspaceSessionReady &&
|
||||
!bindingMapChanged &&
|
||||
!readinessChanged &&
|
||||
tabsReferenceChanged
|
||||
) {
|
||||
tabBindingsChanged = !haveSameTabBindings(previousTabsByWorktree, state.tabsByWorktree)
|
||||
}
|
||||
|
||||
const shouldRebuild =
|
||||
state.workspaceSessionReady &&
|
||||
(!initialized || bindingMapChanged || readinessChanged || tabBindingsChanged)
|
||||
|
||||
if (shouldRebuild) {
|
||||
count = buildBindingIndex(state).boundPtyIds.size
|
||||
} else if (!state.workspaceSessionReady) {
|
||||
count = 0
|
||||
}
|
||||
|
||||
previousTabsByWorktree = state.tabsByWorktree
|
||||
previousPtyIdsByTabId = state.ptyIdsByTabId
|
||||
previousTerminalLayoutsByTabId = state.terminalLayoutsByTabId
|
||||
previousWorkspaceSessionReady = state.workspaceSessionReady
|
||||
initialized = true
|
||||
return count
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
EMPTY_DAEMON_SESSION_INVENTORY,
|
||||
inventoryFromSessions,
|
||||
removeSessionFromInventory,
|
||||
removeSessionsFromInventory
|
||||
} from './resource-session-inventory'
|
||||
import type { DaemonSession } from './resource-usage-merge-types'
|
||||
|
||||
function session(id: string): DaemonSession {
|
||||
return { id, cwd: '/workspace', title: id }
|
||||
}
|
||||
|
||||
describe('resource session inventory', () => {
|
||||
it('builds count from daemon listSessions payloads', () => {
|
||||
const inventory = inventoryFromSessions([session('a'), session('b')])
|
||||
expect(inventory.count).toBe(2)
|
||||
expect(inventory.sessions.map((entry) => entry.id)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('returns a detached sessions array so callers can mutate safely', () => {
|
||||
const source = [session('a')]
|
||||
const inventory = inventoryFromSessions(source)
|
||||
source.pop()
|
||||
expect(inventory.sessions).toEqual([session('a')])
|
||||
expect(inventory.count).toBe(1)
|
||||
})
|
||||
|
||||
it('removes killed or exited sessions without inventing wake-hint ids', () => {
|
||||
const start = inventoryFromSessions([session('live'), session('orphan'), session('other')])
|
||||
const afterOne = removeSessionFromInventory(start, 'orphan')
|
||||
expect(afterOne.count).toBe(2)
|
||||
expect(afterOne.sessions.map((entry) => entry.id)).toEqual(['live', 'other'])
|
||||
|
||||
const afterMany = removeSessionsFromInventory(start, new Set(['live', 'missing', 'other']))
|
||||
expect(afterMany).toEqual(inventoryFromSessions([session('orphan')]))
|
||||
})
|
||||
|
||||
it('is a no-op when removed ids are absent', () => {
|
||||
const start = inventoryFromSessions([session('live')])
|
||||
expect(removeSessionFromInventory(start, 'gone')).toBe(start)
|
||||
expect(removeSessionsFromInventory(start, new Set())).toBe(start)
|
||||
expect(EMPTY_DAEMON_SESSION_INVENTORY.count).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { DaemonSession } from './resource-usage-merge-types'
|
||||
|
||||
/** Last-known daemon terminal inventory for the Resource Manager badge. */
|
||||
export type DaemonSessionInventory = {
|
||||
sessions: DaemonSession[]
|
||||
count: number
|
||||
}
|
||||
|
||||
export const EMPTY_DAEMON_SESSION_INVENTORY: DaemonSessionInventory = {
|
||||
sessions: [],
|
||||
count: 0
|
||||
}
|
||||
|
||||
export function inventoryFromSessions(sessions: readonly DaemonSession[]): DaemonSessionInventory {
|
||||
return {
|
||||
sessions: sessions.slice(),
|
||||
count: sessions.length
|
||||
}
|
||||
}
|
||||
|
||||
export function removeSessionsFromInventory(
|
||||
inventory: DaemonSessionInventory,
|
||||
sessionIds: ReadonlySet<string>
|
||||
): DaemonSessionInventory {
|
||||
if (sessionIds.size === 0 || inventory.sessions.length === 0) {
|
||||
return inventory
|
||||
}
|
||||
const sessions = inventory.sessions.filter((session) => !sessionIds.has(session.id))
|
||||
if (sessions.length === inventory.sessions.length) {
|
||||
return inventory
|
||||
}
|
||||
return {
|
||||
sessions,
|
||||
count: sessions.length
|
||||
}
|
||||
}
|
||||
|
||||
export function removeSessionFromInventory(
|
||||
inventory: DaemonSessionInventory,
|
||||
sessionId: string
|
||||
): DaemonSessionInventory {
|
||||
return removeSessionsFromInventory(inventory, new Set([sessionId]))
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { DaemonSession } from './resource-usage-merge-types'
|
||||
import { useResourceSessionInventory } from './use-resource-session-inventory'
|
||||
|
||||
function session(id: string): DaemonSession {
|
||||
return { id, cwd: '/workspace', title: id }
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('useResourceSessionInventory', () => {
|
||||
const listSessions = vi.fn<() => Promise<DaemonSession[]>>()
|
||||
const unsubscribeSpawned = vi.fn()
|
||||
const unsubscribeExit = vi.fn()
|
||||
let spawnedCallback: ((data: { id: string }) => void) | null = null
|
||||
let exitCallback: ((data: { id: string; code: number }) => void) | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
spawnedCallback = null
|
||||
exitCallback = null
|
||||
listSessions.mockReset()
|
||||
unsubscribeSpawned.mockReset()
|
||||
unsubscribeExit.mockReset()
|
||||
;(window as unknown as { api: unknown }).api = {
|
||||
pty: {
|
||||
listSessions,
|
||||
onSpawned: (callback: (data: { id: string }) => void) => {
|
||||
spawnedCallback = callback
|
||||
return unsubscribeSpawned
|
||||
},
|
||||
onExit: (callback: (data: { id: string; code: number }) => void) => {
|
||||
exitCallback = callback
|
||||
return unsubscribeExit
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { api?: unknown }).api
|
||||
})
|
||||
|
||||
it('seeds from the daemon inventory and resets when session restore is not ready', async () => {
|
||||
listSessions.mockResolvedValue([session('one'), session('two')])
|
||||
const { result, rerender } = renderHook(({ ready }) => useResourceSessionInventory(ready), {
|
||||
initialProps: { ready: false }
|
||||
})
|
||||
|
||||
expect(result.current.sessionInventory.count).toBe(0)
|
||||
expect(listSessions).not.toHaveBeenCalled()
|
||||
|
||||
rerender({ ready: true })
|
||||
await waitFor(() => expect(result.current.sessionInventory.count).toBe(2))
|
||||
expect(listSessions).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender({ ready: false })
|
||||
expect(result.current.sessionInventory.count).toBe(0)
|
||||
expect(result.current.sessionsError).toBe(false)
|
||||
})
|
||||
|
||||
it('recovers inventory and clears the error after a failed readiness seed', async () => {
|
||||
listSessions
|
||||
.mockRejectedValueOnce(new Error('daemon unavailable'))
|
||||
.mockResolvedValueOnce([session('recovered')])
|
||||
const { result } = renderHook(() => useResourceSessionInventory(true))
|
||||
|
||||
await waitFor(() => expect(result.current.sessionsError).toBe(true))
|
||||
await act(async () => {
|
||||
await result.current.refreshSessions()
|
||||
})
|
||||
|
||||
expect(result.current.sessionInventory.sessions).toEqual([session('recovered')])
|
||||
expect(result.current.sessionsError).toBe(false)
|
||||
})
|
||||
|
||||
it('refreshes for background spawns without depending on mounted pane state', async () => {
|
||||
listSessions
|
||||
.mockResolvedValueOnce([session('one')])
|
||||
.mockResolvedValue([session('one'), session('background')])
|
||||
const { result } = renderHook(() => useResourceSessionInventory(true))
|
||||
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
|
||||
|
||||
await act(async () => {
|
||||
spawnedCallback?.({ id: 'one' })
|
||||
spawnedCallback?.({ id: 'background' })
|
||||
spawnedCallback?.({ id: 'background-2' })
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.sessionInventory.count).toBe(2))
|
||||
expect(listSessions).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not inventory again when an existing session reattaches', async () => {
|
||||
listSessions.mockResolvedValue([session('one')])
|
||||
const { result } = renderHook(() => useResourceSessionInventory(true))
|
||||
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
|
||||
|
||||
act(() => {
|
||||
spawnedCallback?.({ id: 'one' })
|
||||
})
|
||||
|
||||
expect(listSessions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not overlap provider-wide inventory reads for spawns during a slow refresh', async () => {
|
||||
listSessions.mockResolvedValueOnce([session('one')])
|
||||
const { result } = renderHook(() => useResourceSessionInventory(true))
|
||||
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
|
||||
|
||||
const inFlight = deferred<DaemonSession[]>()
|
||||
listSessions.mockReturnValueOnce(inFlight.promise)
|
||||
await act(async () => {
|
||||
spawnedCallback?.({ id: 'background-one' })
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
})
|
||||
expect(listSessions).toHaveBeenCalledTimes(2)
|
||||
|
||||
await act(async () => {
|
||||
spawnedCallback?.({ id: 'background-two' })
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
})
|
||||
expect(listSessions).toHaveBeenCalledTimes(2)
|
||||
|
||||
await act(async () => {
|
||||
inFlight.resolve([session('one'), session('background-one'), session('background-two')])
|
||||
await inFlight.promise
|
||||
})
|
||||
await waitFor(() => expect(result.current.sessionInventory.count).toBe(3))
|
||||
expect(listSessions).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reconciles once when an in-flight inventory misses a later spawn', async () => {
|
||||
listSessions.mockResolvedValueOnce([session('one')])
|
||||
const { result } = renderHook(() => useResourceSessionInventory(true))
|
||||
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
|
||||
|
||||
const inFlight = deferred<DaemonSession[]>()
|
||||
listSessions
|
||||
.mockReturnValueOnce(inFlight.promise)
|
||||
.mockResolvedValueOnce([session('one'), session('background-one'), session('background-two')])
|
||||
await act(async () => {
|
||||
spawnedCallback?.({ id: 'background-one' })
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
})
|
||||
act(() => {
|
||||
spawnedCallback?.({ id: 'background-two' })
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
inFlight.resolve([session('one'), session('background-one')])
|
||||
await inFlight.promise
|
||||
})
|
||||
await waitFor(() => expect(result.current.sessionInventory.count).toBe(3))
|
||||
expect(listSessions).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('cancels a queued inventory read when the unknown session exits first', async () => {
|
||||
listSessions.mockResolvedValueOnce([session('one')])
|
||||
const { result } = renderHook(() => useResourceSessionInventory(true))
|
||||
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
|
||||
|
||||
act(() => {
|
||||
spawnedCallback?.({ id: 'short-lived' })
|
||||
exitCallback?.({ id: 'short-lived', code: 0 })
|
||||
})
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
|
||||
expect(listSessions).toHaveBeenCalledTimes(1)
|
||||
expect(result.current.sessionInventory.count).toBe(1)
|
||||
})
|
||||
|
||||
it('does not schedule follow-up inventory after unmount', async () => {
|
||||
listSessions.mockResolvedValueOnce([session('one')])
|
||||
const { result, unmount } = renderHook(() => useResourceSessionInventory(true))
|
||||
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
|
||||
|
||||
const inFlight = deferred<DaemonSession[]>()
|
||||
listSessions.mockReturnValueOnce(inFlight.promise)
|
||||
await act(async () => {
|
||||
spawnedCallback?.({ id: 'background-one' })
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
})
|
||||
act(() => {
|
||||
spawnedCallback?.({ id: 'background-two' })
|
||||
})
|
||||
unmount()
|
||||
|
||||
inFlight.resolve([session('one'), session('background-one')])
|
||||
await inFlight.promise
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||
|
||||
expect(listSessions).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('filters an exit from an in-flight list without losing other new sessions', async () => {
|
||||
listSessions.mockResolvedValueOnce([session('one'), session('exited')])
|
||||
const { result } = renderHook(() => useResourceSessionInventory(true))
|
||||
await waitFor(() => expect(result.current.sessionInventory.count).toBe(2))
|
||||
|
||||
const stale = deferred<DaemonSession[]>()
|
||||
listSessions.mockReturnValueOnce(stale.promise)
|
||||
let refresh!: Promise<void>
|
||||
act(() => {
|
||||
refresh = result.current.refreshSessions()
|
||||
})
|
||||
act(() => {
|
||||
exitCallback?.({ id: 'exited', code: 0 })
|
||||
})
|
||||
expect(result.current.sessionInventory.sessions.map(({ id }) => id)).toEqual(['one'])
|
||||
|
||||
await act(async () => {
|
||||
stale.resolve([session('one'), session('exited'), session('background')])
|
||||
await refresh
|
||||
})
|
||||
expect(result.current.sessionInventory.sessions.map(({ id }) => id)).toEqual([
|
||||
'one',
|
||||
'background'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the newest result when refreshes resolve out of order', async () => {
|
||||
listSessions.mockResolvedValueOnce([session('one')])
|
||||
const { result } = renderHook(() => useResourceSessionInventory(true))
|
||||
await waitFor(() => expect(result.current.sessionInventory.count).toBe(1))
|
||||
|
||||
const older = deferred<DaemonSession[]>()
|
||||
const newer = deferred<DaemonSession[]>()
|
||||
listSessions.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise)
|
||||
let olderRefresh!: Promise<void>
|
||||
let newerRefresh!: Promise<void>
|
||||
act(() => {
|
||||
olderRefresh = result.current.refreshSessions()
|
||||
newerRefresh = result.current.refreshSessions()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
newer.resolve([session('one'), session('two')])
|
||||
await newerRefresh
|
||||
})
|
||||
await act(async () => {
|
||||
older.resolve([session('one')])
|
||||
await olderRefresh
|
||||
})
|
||||
|
||||
expect(result.current.sessionInventory.count).toBe(2)
|
||||
})
|
||||
|
||||
it('unsubscribes from lifecycle events on unmount', () => {
|
||||
listSessions.mockResolvedValue([])
|
||||
const { unmount } = renderHook(() => useResourceSessionInventory(true))
|
||||
|
||||
unmount()
|
||||
|
||||
expect(unsubscribeSpawned).toHaveBeenCalledTimes(1)
|
||||
expect(unsubscribeExit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import {
|
||||
EMPTY_DAEMON_SESSION_INVENTORY,
|
||||
inventoryFromSessions,
|
||||
removeSessionFromInventory,
|
||||
removeSessionsFromInventory,
|
||||
type DaemonSessionInventory
|
||||
} from './resource-session-inventory'
|
||||
|
||||
type ResourceSessionInventory = {
|
||||
sessionInventory: DaemonSessionInventory
|
||||
sessionsError: boolean
|
||||
refreshSessions: () => Promise<void>
|
||||
clearSessionsError: () => void
|
||||
removeSession: (sessionId: string) => void
|
||||
removeSessions: (sessionIds: ReadonlySet<string>) => void
|
||||
}
|
||||
|
||||
type ResourceSessionInventoryState = {
|
||||
ready: boolean
|
||||
sessionInventory: DaemonSessionInventory
|
||||
sessionsError: boolean
|
||||
}
|
||||
|
||||
export function useResourceSessionInventory(ready: boolean): ResourceSessionInventory {
|
||||
const mountedRef = useMountedRef()
|
||||
const refreshGenerationRef = useRef(0)
|
||||
const lifecycleRevisionRef = useRef(0)
|
||||
const removedAtRevisionRef = useRef(new Map<string, number>())
|
||||
const knownSessionIdsRef = useRef(new Set<string>())
|
||||
const [storedState, setStoredState] = useState<ResourceSessionInventoryState>(() => ({
|
||||
ready,
|
||||
sessionInventory: EMPTY_DAEMON_SESSION_INVENTORY,
|
||||
sessionsError: false
|
||||
}))
|
||||
const state =
|
||||
storedState.ready === ready
|
||||
? storedState
|
||||
: {
|
||||
ready,
|
||||
sessionInventory: EMPTY_DAEMON_SESSION_INVENTORY,
|
||||
sessionsError: false
|
||||
}
|
||||
if (state !== storedState) {
|
||||
// Why: readiness changes define a new inventory epoch. Reset during render
|
||||
// so an old workspace count is never exposed for one committed frame.
|
||||
setStoredState(state)
|
||||
}
|
||||
|
||||
const refreshSessions = useCallback(async (): Promise<void> => {
|
||||
if (!ready) {
|
||||
return
|
||||
}
|
||||
const generation = ++refreshGenerationRef.current
|
||||
const lifecycleRevision = lifecycleRevisionRef.current
|
||||
try {
|
||||
const sessions = await window.api.pty.listSessions()
|
||||
// Why: an exit or newer refresh can land while the global provider list
|
||||
// is in flight; stale results must not resurrect dead sessions.
|
||||
if (!mountedRef.current || generation !== refreshGenerationRef.current) {
|
||||
return
|
||||
}
|
||||
const currentRemovedAtRevision = removedAtRevisionRef.current
|
||||
const liveSessions = sessions.filter(
|
||||
({ id }) => (currentRemovedAtRevision.get(id) ?? 0) <= lifecycleRevision
|
||||
)
|
||||
// Tombstones at or before this request cannot suppress later ID reuse;
|
||||
// only exits that raced this request must survive to the next refresh.
|
||||
for (const [id, removedAtRevision] of currentRemovedAtRevision) {
|
||||
if (removedAtRevision <= lifecycleRevision) {
|
||||
currentRemovedAtRevision.delete(id)
|
||||
}
|
||||
}
|
||||
knownSessionIdsRef.current = new Set(liveSessions.map(({ id }) => id))
|
||||
setStoredState({
|
||||
ready: true,
|
||||
sessionInventory: inventoryFromSessions(liveSessions),
|
||||
sessionsError: false
|
||||
})
|
||||
} catch {
|
||||
if (mountedRef.current && generation === refreshGenerationRef.current) {
|
||||
setStoredState((current) => ({ ...current, sessionsError: true }))
|
||||
}
|
||||
}
|
||||
}, [mountedRef, ready])
|
||||
|
||||
const clearSessionsError = useCallback((): void => {
|
||||
setStoredState((current) => ({ ...current, sessionsError: false }))
|
||||
}, [])
|
||||
|
||||
const removeSession = useCallback((sessionId: string): void => {
|
||||
// Why: mark the exact PTY removed while a list may be in flight; filtering
|
||||
// only that id preserves unrelated sessions discovered by the same list.
|
||||
const lifecycleRevision = ++lifecycleRevisionRef.current
|
||||
removedAtRevisionRef.current.set(sessionId, lifecycleRevision)
|
||||
knownSessionIdsRef.current.delete(sessionId)
|
||||
setStoredState((current) => ({
|
||||
...current,
|
||||
sessionInventory: removeSessionFromInventory(current.sessionInventory, sessionId)
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const removeSessions = useCallback((sessionIds: ReadonlySet<string>): void => {
|
||||
const lifecycleRevision = ++lifecycleRevisionRef.current
|
||||
for (const sessionId of sessionIds) {
|
||||
removedAtRevisionRef.current.set(sessionId, lifecycleRevision)
|
||||
knownSessionIdsRef.current.delete(sessionId)
|
||||
}
|
||||
setStoredState((current) => ({
|
||||
...current,
|
||||
sessionInventory: removeSessionsFromInventory(current.sessionInventory, sessionIds)
|
||||
}))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refreshGenerationRef.current += 1
|
||||
if (!ready) {
|
||||
removedAtRevisionRef.current.clear()
|
||||
knownSessionIdsRef.current.clear()
|
||||
return
|
||||
}
|
||||
void refreshSessions()
|
||||
}, [ready, refreshSessions])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) {
|
||||
return
|
||||
}
|
||||
let disposed = false
|
||||
let refreshTimer: number | null = null
|
||||
let lifecycleRefresh: Promise<void> | null = null
|
||||
const pendingSpawnIds = new Set<string>()
|
||||
const scheduleLifecycleRefresh = (): void => {
|
||||
if (disposed || refreshTimer !== null || lifecycleRefresh !== null) {
|
||||
return
|
||||
}
|
||||
refreshTimer = window.setTimeout(() => {
|
||||
refreshTimer = null
|
||||
if (pendingSpawnIds.size === 0) {
|
||||
return
|
||||
}
|
||||
pendingSpawnIds.clear()
|
||||
const refresh = refreshSessions()
|
||||
lifecycleRefresh = refresh
|
||||
void refresh.finally(() => {
|
||||
if (disposed || lifecycleRefresh !== refresh) {
|
||||
return
|
||||
}
|
||||
lifecycleRefresh = null
|
||||
for (const id of pendingSpawnIds) {
|
||||
if (knownSessionIdsRef.current.has(id)) {
|
||||
pendingSpawnIds.delete(id)
|
||||
}
|
||||
}
|
||||
scheduleLifecycleRefresh()
|
||||
})
|
||||
}, 0)
|
||||
}
|
||||
const unsubscribeSpawned = window.api.pty.onSpawned(({ id }) => {
|
||||
// Why: reattach emits the same lifecycle signal; known IDs must not turn remounts into global inventory scans.
|
||||
if (knownSessionIdsRef.current.has(id)) {
|
||||
return
|
||||
}
|
||||
pendingSpawnIds.add(id)
|
||||
// Why: serialize slow provider-wide lists; retry once only when a result missed a later spawn.
|
||||
scheduleLifecycleRefresh()
|
||||
})
|
||||
const unsubscribeExit = window.api.pty.onExit(({ id }) => {
|
||||
pendingSpawnIds.delete(id)
|
||||
if (pendingSpawnIds.size === 0 && refreshTimer !== null) {
|
||||
window.clearTimeout(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
removeSession(id)
|
||||
})
|
||||
return () => {
|
||||
disposed = true
|
||||
pendingSpawnIds.clear()
|
||||
if (refreshTimer !== null) {
|
||||
window.clearTimeout(refreshTimer)
|
||||
}
|
||||
unsubscribeSpawned()
|
||||
unsubscribeExit()
|
||||
}
|
||||
}, [ready, refreshSessions, removeSession])
|
||||
|
||||
return {
|
||||
sessionInventory: state.sessionInventory,
|
||||
sessionsError: state.sessionsError,
|
||||
refreshSessions,
|
||||
clearSessionsError,
|
||||
removeSession,
|
||||
removeSessions
|
||||
}
|
||||
}
|
||||
@@ -2896,16 +2896,23 @@ export default function TerminalPane({
|
||||
onRestartDaemon={() => daemonActions.setPending('restart')}
|
||||
/>
|
||||
)}
|
||||
{showSshReconnectOverlay && sshReconnectTargetId && sshReconnectStatus ? (
|
||||
<TerminalSshReconnectOverlay
|
||||
targetId={sshReconnectTargetId}
|
||||
targetLabel={sshReconnectTargetLabel}
|
||||
status={sshReconnectStatus}
|
||||
targetRemoved={sshReconnectTargetRemoved}
|
||||
worktreeId={worktreeId}
|
||||
sshOwnerEnvironmentId={sshReconnectEnvironmentId}
|
||||
/>
|
||||
) : null}
|
||||
{/* Why: portal into the pane so the banner stacks above the xterm canvas (sibling mount painted under WebGL). */}
|
||||
{showSshReconnectOverlay && sshReconnectTargetId && sshReconnectStatus
|
||||
? managedPanes.map((pane) =>
|
||||
createPortal(
|
||||
<TerminalSshReconnectOverlay
|
||||
targetId={sshReconnectTargetId}
|
||||
targetLabel={sshReconnectTargetLabel}
|
||||
status={sshReconnectStatus}
|
||||
targetRemoved={sshReconnectTargetRemoved}
|
||||
worktreeId={worktreeId}
|
||||
sshOwnerEnvironmentId={sshReconnectEnvironmentId}
|
||||
/>,
|
||||
pane.container,
|
||||
`ssh-reconnect-${pane.id}`
|
||||
)
|
||||
)
|
||||
: null}
|
||||
<DaemonActionDialog api={daemonActions} />
|
||||
{isActive && (
|
||||
<TerminalSessionStateSaveFailureDialog
|
||||
|
||||
@@ -86,7 +86,7 @@ describe('TerminalSshReconnectOverlay', () => {
|
||||
expect(screen.getByText(/This terminal is waiting for devbox/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
const banner = container.querySelector('[data-terminal-ssh-reconnect-banner="disconnected"]')
|
||||
expect(banner).toHaveClass('inset-x-3', 'bottom-3')
|
||||
expect(banner).toHaveClass('inset-x-3', 'bottom-3', 'z-40')
|
||||
expect(banner).not.toHaveClass('inset-0', 'bg-background/75')
|
||||
await user.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
|
||||
|
||||
@@ -136,13 +136,14 @@ export function TerminalSshReconnectOverlay({
|
||||
}
|
||||
}, [isConnecting, mountedRef, setSshConnectionState, sshOwnerEnvironmentId, targetId])
|
||||
|
||||
// Why: z-40 clears pane-local chrome (focus rim z-30); bg-card is fully opaque so terminal text cannot paint through.
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-3 bottom-3 z-30 flex justify-center"
|
||||
className="pointer-events-none absolute inset-x-3 bottom-3 z-40 flex justify-center"
|
||||
data-terminal-ssh-reconnect-banner={status}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto flex w-full max-w-xl items-center gap-3 rounded-md border border-border bg-card/95 px-3 py-3 text-card-foreground shadow-xs backdrop-blur-[1px]"
|
||||
className="pointer-events-auto flex w-full max-w-xl items-center gap-3 rounded-md border border-border bg-card px-3 py-3 text-card-foreground shadow-xs"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { WorkspaceSessionState } from '../../../shared/types'
|
||||
import {
|
||||
collectFolderWorkspaceKeysFromSession,
|
||||
collectWorktreeHydrationRepoIdsFromSession
|
||||
} from './workspace-session-hydration-keys'
|
||||
|
||||
describe('collectFolderWorkspaceKeysFromSession', () => {
|
||||
it('keeps folder workspace selection markers valid without scheduling a Git scan', () => {
|
||||
const session = {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {},
|
||||
activeTabTypeByWorktree: { 'folder:folder-1': 'terminal' }
|
||||
} as unknown as WorkspaceSessionState
|
||||
|
||||
expect(collectFolderWorkspaceKeysFromSession(session)).toEqual(['folder:folder-1'])
|
||||
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectWorktreeHydrationRepoIdsFromSession', () => {
|
||||
it('includes persisted terminal tabs and ignores folder workspaces', () => {
|
||||
const session = {
|
||||
activeWorktreeIdsOnShutdown: [
|
||||
'repo-a::/worktree-a',
|
||||
'repo-b::/worktree-b',
|
||||
'folder:folder-1'
|
||||
],
|
||||
tabsByWorktree: {
|
||||
'repo-a::/worktree-a': [{ ptyId: 'pty-a' }],
|
||||
'repo-b::/worktree-b': [{ ptyId: null }],
|
||||
'folder:folder-1': [{ ptyId: 'pty-folder' }]
|
||||
}
|
||||
} as unknown as WorkspaceSessionState
|
||||
|
||||
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual(['repo-a', 'repo-b'])
|
||||
})
|
||||
|
||||
it('recognizes split-pane and remote persisted sessions', () => {
|
||||
const session = {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-a': { ptyIdsByLeafId: { 'pane:1': 'pty-a' } }
|
||||
},
|
||||
tabsByWorktree: {
|
||||
'repo-a::/worktree-a': [{ id: 'tab-a', ptyId: null }],
|
||||
'repo-b::/worktree-b': [{ id: 'tab-b', ptyId: null }]
|
||||
},
|
||||
remoteSessionIdsByTabId: { 'tab-b': 'remote-session' }
|
||||
} as unknown as WorkspaceSessionState
|
||||
|
||||
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual(['repo-a', 'repo-b'])
|
||||
})
|
||||
|
||||
it('matches canonical session keys against raw shutdown worktree IDs', () => {
|
||||
const rawWorktreeId = 'repo-a::/worktree-a'
|
||||
const session = {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
activeWorktreeIdsOnShutdown: [rawWorktreeId],
|
||||
tabsByWorktree: {
|
||||
[`worktree:${rawWorktreeId}`]: [{ ptyId: 'pty-a' }]
|
||||
}
|
||||
} as unknown as WorkspaceSessionState
|
||||
|
||||
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual(['repo-a'])
|
||||
})
|
||||
|
||||
it('excludes runtime-owned session worktrees for raw and canonical owner keys', () => {
|
||||
const rawWorktreeId = 'repo-a::/remote/worktree'
|
||||
const canonicalWorktreeKey = `worktree:${rawWorktreeId}`
|
||||
const session = {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {
|
||||
[canonicalWorktreeKey]: [{ ptyId: 'pty-a' }]
|
||||
}
|
||||
} as unknown as WorkspaceSessionState
|
||||
|
||||
expect(
|
||||
collectWorktreeHydrationRepoIdsFromSession(session, {
|
||||
[rawWorktreeId]: 'runtime:env-1'
|
||||
})
|
||||
).toEqual([])
|
||||
expect(
|
||||
collectWorktreeHydrationRepoIdsFromSession(session, {
|
||||
[canonicalWorktreeKey]: 'runtime:env-1'
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps SSH-owned session worktrees eligible for local recovery routing', () => {
|
||||
const rawWorktreeId = 'repo-a::/ssh/worktree'
|
||||
const session = {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {
|
||||
[rawWorktreeId]: [{ ptyId: 'ssh:ssh-target@@pty-a' }]
|
||||
}
|
||||
} as unknown as WorkspaceSessionState
|
||||
|
||||
expect(
|
||||
collectWorktreeHydrationRepoIdsFromSession(session, {
|
||||
[rawWorktreeId]: 'ssh:ssh-target'
|
||||
})
|
||||
).toEqual(['repo-a'])
|
||||
})
|
||||
|
||||
it('returns repository IDs in deterministic order', () => {
|
||||
const session = {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {
|
||||
'repo-b::/worktree-b': [{ ptyId: 'pty-b' }],
|
||||
'repo-a::/worktree-a': [{ ptyId: 'pty-a' }]
|
||||
}
|
||||
} as unknown as WorkspaceSessionState
|
||||
|
||||
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual(['repo-a', 'repo-b'])
|
||||
})
|
||||
|
||||
it('excludes repositories referenced only by unbounded history maps (visit recency, default tabs)', () => {
|
||||
const session = {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: { 'repo-chrome::/wt': [{ ptyId: 'pty-a' }] },
|
||||
lastVisitedAtByWorktreeId: {
|
||||
'repo-chrome::/wt': 10,
|
||||
'repo-history-only::/visited': 20
|
||||
},
|
||||
defaultTerminalTabsAppliedByWorktreeId: {
|
||||
'repo-default-only::/applied': true
|
||||
}
|
||||
} as unknown as WorkspaceSessionState
|
||||
|
||||
// Why: history-only repos hydrate their maps unfiltered and would bloat the selective fetch.
|
||||
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual(['repo-chrome'])
|
||||
})
|
||||
|
||||
it('does not scan repositories retained only by empty chrome maps or selection markers', () => {
|
||||
const emptyTabsByWorktree = Object.fromEntries(
|
||||
Array.from({ length: 326 }, (_, index) => [`repo-empty-${index}::/wt`, []])
|
||||
)
|
||||
const staleActiveTabTypes = Object.fromEntries(
|
||||
Array.from({ length: 326 }, (_, index) => [`repo-empty-${index}::/wt`, 'terminal'])
|
||||
)
|
||||
const session = {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {
|
||||
...emptyTabsByWorktree,
|
||||
'repo-live::/wt': [{ ptyId: 'pty-a' }]
|
||||
},
|
||||
openFilesByWorktree: { 'repo-empty-editor::/wt': [] },
|
||||
browserTabsByWorktree: { 'repo-empty-browser::/wt': [] },
|
||||
activeFileIdByWorktree: { 'repo-empty-active-file::/wt': '/stale/file.ts' },
|
||||
activeBrowserTabIdByWorktree: { 'repo-empty-active-browser::/wt': 'stale-browser' },
|
||||
activeTabIdByWorktree: { 'repo-empty-active-terminal::/wt': 'stale-terminal' },
|
||||
activeGroupIdByWorktree: { 'repo-empty-active-group::/wt': 'stale-group' },
|
||||
activeTabTypeByWorktree: staleActiveTabTypes
|
||||
} as unknown as WorkspaceSessionState
|
||||
|
||||
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual(['repo-live'])
|
||||
})
|
||||
|
||||
it('includes a repository referenced only by activeRepoId (no active worktree, no tabs)', () => {
|
||||
const session = {
|
||||
activeRepoId: 'repo-active-only',
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {}
|
||||
} as unknown as WorkspaceSessionState
|
||||
|
||||
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual(['repo-active-only'])
|
||||
})
|
||||
|
||||
it('includes repositories referenced only by active, editor, browser, or sleeping-agent state', () => {
|
||||
const session = {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: 'repo-active::/active',
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
openFilesByWorktree: { 'repo-editor::/editor': [{ filePath: '/editor/file.ts' }] },
|
||||
browserTabsByWorktree: { 'repo-browser::/browser': [{ id: 'browser-a' }] },
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
'tab:leaf': { worktreeId: 'repo-agent::/agent' }
|
||||
}
|
||||
} as unknown as WorkspaceSessionState
|
||||
|
||||
expect(collectWorktreeHydrationRepoIdsFromSession(session)).toEqual([
|
||||
'repo-active',
|
||||
'repo-agent',
|
||||
'repo-browser',
|
||||
'repo-editor'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,26 +1,48 @@
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { parseExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { WorkspaceKey, WorkspaceSessionState } from '../../../shared/types'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { getRepoIdFromWorktreeId } from '../../../shared/worktree-id'
|
||||
|
||||
export type WorkspaceSessionHydrationOptions = {
|
||||
additionalValidWorkspaceKeys?: readonly WorkspaceKey[]
|
||||
}
|
||||
|
||||
const WORKSPACE_KEYED_SESSION_FIELDS = [
|
||||
// Worktree-keyed fields carrying restorable chrome — a repo appears here only if it has live
|
||||
// session state (open tabs, editors, browser) to restore.
|
||||
const WORKSPACE_CHROME_SESSION_FIELDS = [
|
||||
'tabsByWorktree',
|
||||
'openFilesByWorktree',
|
||||
'activeFileIdByWorktree',
|
||||
'activeBrowserTabIdByWorktree',
|
||||
'activeTabTypeByWorktree',
|
||||
'activeTabIdByWorktree',
|
||||
'browserTabsByWorktree',
|
||||
'unifiedTabs',
|
||||
'tabGroups',
|
||||
'tabGroupLayouts',
|
||||
'activeGroupIdByWorktree',
|
||||
'tabGroupLayouts'
|
||||
] as const satisfies readonly (keyof WorkspaceSessionState)[]
|
||||
|
||||
// Why: unbounded per-worktree history — one entry per worktree ever focused / given default tabs.
|
||||
// Folder-key detection still scans them, but repo-enumeration for pre-hydration must NOT: they'd
|
||||
// pull in ~every repo the user ever touched and defeat the selective fetch, and they hydrate
|
||||
// unfiltered regardless (the post-scan re-prune reaps stale entries).
|
||||
const WORKSPACE_HISTORY_SESSION_FIELDS = [
|
||||
'lastVisitedAtByWorktreeId',
|
||||
'defaultTerminalTabsAppliedByWorktreeId'
|
||||
] as const satisfies readonly (keyof WorkspaceSessionState)[]
|
||||
|
||||
// Why: selection markers can outlive their content and cannot restore anything by themselves.
|
||||
const WORKSPACE_SELECTION_SESSION_FIELDS = [
|
||||
'activeFileIdByWorktree',
|
||||
'activeBrowserTabIdByWorktree',
|
||||
'activeTabIdByWorktree',
|
||||
'activeGroupIdByWorktree',
|
||||
'activeTabTypeByWorktree'
|
||||
] as const satisfies readonly (keyof WorkspaceSessionState)[]
|
||||
|
||||
const WORKSPACE_KEYED_SESSION_FIELDS = [
|
||||
...WORKSPACE_CHROME_SESSION_FIELDS,
|
||||
...WORKSPACE_SELECTION_SESSION_FIELDS,
|
||||
...WORKSPACE_HISTORY_SESSION_FIELDS
|
||||
] as const satisfies readonly (keyof WorkspaceSessionState)[]
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
@@ -35,42 +57,108 @@ function addFolderWorkspaceKey(keys: Set<WorkspaceKey>, value: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function collectFolderWorkspaceKeysFromSession(
|
||||
session: WorkspaceSessionState
|
||||
): WorkspaceKey[] {
|
||||
const keys = new Set<WorkspaceKey>()
|
||||
function collectWorkspaceSessionKeys(
|
||||
session: WorkspaceSessionState,
|
||||
fields: readonly (keyof WorkspaceSessionState)[],
|
||||
includeEntry: (value: unknown) => boolean = () => true
|
||||
): string[] {
|
||||
const keys = new Set<string>()
|
||||
const addKey = (value: unknown): void => {
|
||||
if (typeof value === 'string') {
|
||||
keys.add(value)
|
||||
}
|
||||
}
|
||||
|
||||
addFolderWorkspaceKey(keys, session.activeWorkspaceKey)
|
||||
addFolderWorkspaceKey(keys, session.activeWorktreeId)
|
||||
|
||||
for (const field of WORKSPACE_KEYED_SESSION_FIELDS) {
|
||||
addKey(session.activeWorkspaceKey)
|
||||
addKey(session.activeWorktreeId)
|
||||
for (const field of fields) {
|
||||
const value = session[field]
|
||||
if (!isPlainRecord(value)) {
|
||||
continue
|
||||
}
|
||||
for (const key of Object.keys(value)) {
|
||||
addFolderWorkspaceKey(keys, key)
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (includeEntry(entry)) {
|
||||
addKey(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const worktreeId of session.activeWorktreeIdsOnShutdown ?? []) {
|
||||
addFolderWorkspaceKey(keys, worktreeId)
|
||||
addKey(worktreeId)
|
||||
}
|
||||
for (const pages of Object.values(session.browserPagesByWorkspace ?? {})) {
|
||||
if (!Array.isArray(pages)) {
|
||||
continue
|
||||
}
|
||||
for (const page of pages) {
|
||||
addFolderWorkspaceKey(keys, page.worktreeId)
|
||||
addKey(page.worktreeId)
|
||||
}
|
||||
}
|
||||
for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) {
|
||||
addFolderWorkspaceKey(keys, record.worktreeId)
|
||||
addKey(record.worktreeId)
|
||||
}
|
||||
|
||||
return [...keys]
|
||||
}
|
||||
|
||||
function hasRestorableWorkspaceChrome(value: unknown): boolean {
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0
|
||||
}
|
||||
if (isPlainRecord(value)) {
|
||||
return Object.keys(value).length > 0
|
||||
}
|
||||
return value !== null && value !== undefined && value !== ''
|
||||
}
|
||||
|
||||
export function collectFolderWorkspaceKeysFromSession(
|
||||
session: WorkspaceSessionState
|
||||
): WorkspaceKey[] {
|
||||
const keys = new Set<WorkspaceKey>()
|
||||
for (const key of collectWorkspaceSessionKeys(session, WORKSPACE_KEYED_SESSION_FIELDS)) {
|
||||
addFolderWorkspaceKey(keys, key)
|
||||
}
|
||||
|
||||
return [...keys]
|
||||
}
|
||||
|
||||
export function collectWorktreeHydrationRepoIdsFromSession(
|
||||
session: WorkspaceSessionState,
|
||||
runtimeHostIdByWorkspaceSessionKey?: Record<string, ExecutionHostId>
|
||||
): string[] {
|
||||
const repoIds = new Set<string>()
|
||||
const addWorktreeRepoId = (value: unknown): void => {
|
||||
if (typeof value !== 'string') {
|
||||
return
|
||||
}
|
||||
const scope = parseWorkspaceKey(value)
|
||||
if (scope?.type === 'folder') {
|
||||
return
|
||||
}
|
||||
const rawWorktreeId = scope?.type === 'worktree' ? scope.worktreeId : value
|
||||
const isRuntimeOwned = [value, rawWorktreeId].some(
|
||||
(key) => parseExecutionHostId(runtimeHostIdByWorkspaceSessionKey?.[key])?.kind === 'runtime'
|
||||
)
|
||||
if (!isRuntimeOwned) {
|
||||
repoIds.add(getRepoIdFromWorktreeId(rawWorktreeId))
|
||||
}
|
||||
}
|
||||
|
||||
// Why: only chrome-bearing fields — enumerating the unbounded history maps would pull in ~every
|
||||
// repo ever touched and defeat the selective pre-hydration fetch (they hydrate unfiltered).
|
||||
for (const key of collectWorkspaceSessionKeys(
|
||||
session,
|
||||
WORKSPACE_CHROME_SESSION_FIELDS,
|
||||
hasRestorableWorkspaceChrome
|
||||
)) {
|
||||
addWorktreeRepoId(key)
|
||||
}
|
||||
// Why: a repo referenced only by activeRepoId (no active worktree, no tabs) still needs
|
||||
// enumeration so hydrateWorkspaceSession can restore its main worktree from worktreesByRepo.
|
||||
addWorktreeRepoId(session.activeRepoId)
|
||||
|
||||
return [...repoIds].filter(Boolean).sort()
|
||||
}
|
||||
|
||||
export function addAdditionalValidWorkspaceKeys(
|
||||
validWorkspaceIds: Set<string>,
|
||||
options?: WorkspaceSessionHydrationOptions
|
||||
|
||||
@@ -686,42 +686,48 @@ describe('captureAllSleepingAgentSessions', () => {
|
||||
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBe(firstRecord)
|
||||
})
|
||||
|
||||
it('clears the live checkpoint when the agent finishes', () => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
tabsByWorktree: {
|
||||
'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })]
|
||||
// Why: a finished resumable-agent turn leaves the TUI alive at its prompt, so the persisted
|
||||
// recovery anchor must survive `done` (state remapped to 'working' to stay cold-restore
|
||||
// eligible) — else logout→relaunch cold-restores to a bare shell instead of `--resume` (#9454).
|
||||
// Covers claude (the reported agent) and codex; previously this was Pi-only.
|
||||
it.each([
|
||||
['claude', 'Claude'],
|
||||
['codex', 'Codex']
|
||||
] as const)(
|
||||
'retains the recovery anchor when a finished %s session stays resumable (#9454)',
|
||||
(agentType, title) => {
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
tabsByWorktree: { 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] }
|
||||
} as Partial<AppState>)
|
||||
const providerSession = { key: 'session_id', id: `${agentType}-session-1` } as const
|
||||
|
||||
for (const [state, updatedAt] of [
|
||||
['working', 10],
|
||||
['done', 20]
|
||||
] as const) {
|
||||
store
|
||||
.getState()
|
||||
.setAgentStatus(
|
||||
'tab-1:leaf-1',
|
||||
{ state, prompt: 'finish the task', agentType },
|
||||
title,
|
||||
{ updatedAt, stateStartedAt: 10 },
|
||||
{ tabId: 'tab-1', worktreeId: 'wt-1' },
|
||||
{ providerSession }
|
||||
)
|
||||
}
|
||||
} as Partial<AppState>)
|
||||
|
||||
store.getState().setAgentStatus(
|
||||
'tab-1:leaf-1',
|
||||
{
|
||||
state: 'working',
|
||||
prompt: 'finish the task',
|
||||
agentType: 'codex'
|
||||
},
|
||||
'Codex',
|
||||
{ updatedAt: 10, stateStartedAt: 10 },
|
||||
{ tabId: 'tab-1', worktreeId: 'wt-1' },
|
||||
{ providerSession: { key: 'session_id', id: 'codex-session-1' } }
|
||||
)
|
||||
store.getState().setAgentStatus(
|
||||
'tab-1:leaf-1',
|
||||
{
|
||||
state: 'done',
|
||||
prompt: 'finish the task',
|
||||
agentType: 'codex'
|
||||
},
|
||||
'Codex',
|
||||
{ updatedAt: 20, stateStartedAt: 10 },
|
||||
{ tabId: 'tab-1', worktreeId: 'wt-1' },
|
||||
{ providerSession: { key: 'session_id', id: 'codex-session-1' } }
|
||||
)
|
||||
|
||||
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBeUndefined()
|
||||
expect(store.getState().agentLaunchConfigByPaneKey['tab-1:leaf-1']).toBeUndefined()
|
||||
})
|
||||
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({
|
||||
agent: agentType,
|
||||
providerSession,
|
||||
origin: 'live',
|
||||
state: 'working'
|
||||
})
|
||||
// Launch config is still cleared on done: tokens must no longer authorize config reuse.
|
||||
expect(store.getState().agentLaunchConfigByPaneKey['tab-1:leaf-1']).toBeUndefined()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not reuse launch config from a completed same-pane agent', () => {
|
||||
const store = createTestStore()
|
||||
|
||||
@@ -1801,13 +1801,17 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
? registryEntry?.launchConfig
|
||||
: undefined
|
||||
const existingSleepingRecord = s.sleepingAgentSessionsByPaneKey[paneKey]
|
||||
const retainsPiRecoveryIdentity =
|
||||
// Why: a completed turn leaves the TUI session alive and resumable at its prompt for any
|
||||
// resumable agent (Claude/Codex/Pi/…), not just Pi — so keep its persisted recovery anchor
|
||||
// even when done. Else a cold restore after an abrupt app death (macOS logout, #9454) drops
|
||||
// the pane to a bare shell instead of `--resume`-ing the agent logged in.
|
||||
const retainsResumableRecoveryIdentity =
|
||||
payload.state === 'done' &&
|
||||
identity.agentType === 'pi' &&
|
||||
isResumableTuiAgent(identity.agentType) &&
|
||||
providerSession !== undefined &&
|
||||
getAgentResumeArgv('pi', providerSession) !== null
|
||||
getAgentResumeArgv(identity.agentType, providerSession) !== null
|
||||
const matchedSleepingLaunchConfig =
|
||||
(payload.state !== 'done' || retainsPiRecoveryIdentity) &&
|
||||
(payload.state !== 'done' || retainsResumableRecoveryIdentity) &&
|
||||
existingSleepingRecord?.launchConfig &&
|
||||
existingSleepingRecord.agent === identity.agentType &&
|
||||
providerSession &&
|
||||
@@ -1935,14 +1939,14 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
|
||||
(entry) => entry.paneKey === paneKey
|
||||
)
|
||||
const liveRecoveryWorktreeId =
|
||||
entry.state === 'done' && !retainsPiRecoveryIdentity
|
||||
entry.state === 'done' && !retainsResumableRecoveryIdentity
|
||||
? null
|
||||
: (entry.worktreeId ?? findAgentPaneWorktreeId(s, entry.paneKey))
|
||||
const liveRecoveryRecord = liveRecoveryWorktreeId
|
||||
? sleepingRecordFromEntry({
|
||||
state: s,
|
||||
// Why: a completed Pi turn leaves the TUI session alive — keep resume identity active without representing done as pending work.
|
||||
entry: retainsPiRecoveryIdentity
|
||||
// Why: a completed resumable-agent turn leaves the TUI session alive — keep resume identity active without representing done as pending work.
|
||||
entry: retainsResumableRecoveryIdentity
|
||||
? { ...entry, state: 'working', prompt: '', lastAssistantMessage: undefined }
|
||||
: entry,
|
||||
worktreeId: liveRecoveryWorktreeId,
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
addAdditionalValidWorkspaceKeys,
|
||||
type WorkspaceSessionHydrationOptions
|
||||
} from '@/lib/workspace-session-hydration-keys'
|
||||
import { buildValidWorktreeIdsForSessionHydration } from './degraded-repo-worktree-validity'
|
||||
|
||||
type CreateBrowserTabOptions = {
|
||||
activate?: boolean
|
||||
@@ -1582,10 +1583,9 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
||||
hydrateBrowserSession: (session, options) => {
|
||||
const persistedTabsByWorktree = session.browserTabsByWorktree ?? {}
|
||||
const currentState = get()
|
||||
const validWorktreeIdsForCleanup = new Set(
|
||||
Object.values(currentState.worktreesByRepo)
|
||||
.flat()
|
||||
.map((worktree) => worktree.id)
|
||||
const validWorktreeIdsForCleanup = buildValidWorktreeIdsForSessionHydration(
|
||||
currentState,
|
||||
Object.keys(persistedTabsByWorktree)
|
||||
)
|
||||
validWorktreeIdsForCleanup.add(FLOATING_TERMINAL_WORKTREE_ID)
|
||||
for (const workspace of currentState.folderWorkspaces) {
|
||||
@@ -1610,10 +1610,9 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
||||
const persistedPagesByWorkspace = session.browserPagesByWorkspace ?? {}
|
||||
const persistedActiveBrowserTabIdByWorktree = session.activeBrowserTabIdByWorktree ?? {}
|
||||
const persistedActiveTabTypeByWorktree = session.activeTabTypeByWorktree ?? {}
|
||||
const validWorktreeIds = new Set(
|
||||
Object.values(s.worktreesByRepo)
|
||||
.flat()
|
||||
.map((worktree) => worktree.id)
|
||||
const validWorktreeIds = buildValidWorktreeIdsForSessionHydration(
|
||||
s,
|
||||
Object.keys(persistedTabsByWorktree)
|
||||
)
|
||||
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
|
||||
for (const workspace of s.folderWorkspaces) {
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import type { BrowserTab, WorkspaceSessionState } from '../../../../shared/types'
|
||||
import { getDefaultWorkspaceSession } from '../../../../shared/constants'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof AgentStatusModule>()
|
||||
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
|
||||
})
|
||||
|
||||
// @ts-expect-error -- mocked browser preload API
|
||||
globalThis.window = { api: {} }
|
||||
|
||||
import {
|
||||
buildWorkspaceSessionPayload,
|
||||
shouldPersistWorkspaceSession
|
||||
} from '@/lib/workspace-session'
|
||||
import { createTestStore, makeTab } from './store-test-helpers'
|
||||
|
||||
const WORKTREE_ID = 'repo1::/path/degraded'
|
||||
const TERMINAL_ID = 'terminal-degraded'
|
||||
const EDITOR_FILE_ID = '/path/degraded/src/App.tsx'
|
||||
const BROWSER_ID = 'browser-degraded'
|
||||
const GROUP_ID = 'group-degraded'
|
||||
|
||||
function makeBrowserTab(): BrowserTab {
|
||||
return {
|
||||
id: BROWSER_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 3
|
||||
}
|
||||
}
|
||||
|
||||
function makeDegradedRepoSession(): WorkspaceSessionState {
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
activeRepoId: 'repo1',
|
||||
activeWorktreeId: WORKTREE_ID,
|
||||
activeTabId: TERMINAL_ID,
|
||||
tabsByWorktree: {
|
||||
[WORKTREE_ID]: [makeTab({ id: TERMINAL_ID, worktreeId: WORKTREE_ID })]
|
||||
},
|
||||
openFilesByWorktree: {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
filePath: EDITOR_FILE_ID,
|
||||
relativePath: 'src/App.tsx',
|
||||
worktreeId: WORKTREE_ID,
|
||||
language: 'typescript'
|
||||
}
|
||||
]
|
||||
},
|
||||
activeFileIdByWorktree: { [WORKTREE_ID]: EDITOR_FILE_ID },
|
||||
browserTabsByWorktree: { [WORKTREE_ID]: [makeBrowserTab()] },
|
||||
activeBrowserTabIdByWorktree: { [WORKTREE_ID]: BROWSER_ID },
|
||||
activeTabTypeByWorktree: { [WORKTREE_ID]: 'browser' },
|
||||
unifiedTabs: {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
id: TERMINAL_ID,
|
||||
entityId: TERMINAL_ID,
|
||||
groupId: GROUP_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
contentType: 'terminal',
|
||||
label: 'Terminal',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
},
|
||||
{
|
||||
id: EDITOR_FILE_ID,
|
||||
entityId: EDITOR_FILE_ID,
|
||||
groupId: GROUP_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
contentType: 'editor',
|
||||
label: 'App.tsx',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 1,
|
||||
createdAt: 2
|
||||
},
|
||||
{
|
||||
id: BROWSER_ID,
|
||||
entityId: BROWSER_ID,
|
||||
groupId: GROUP_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
contentType: 'browser',
|
||||
label: 'Example',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 2,
|
||||
createdAt: 3
|
||||
}
|
||||
]
|
||||
},
|
||||
tabGroups: {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
id: GROUP_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
activeTabId: BROWSER_ID,
|
||||
tabOrder: [TERMINAL_ID, EDITOR_FILE_ID, BROWSER_ID],
|
||||
recentTabIds: [TERMINAL_ID, EDITOR_FILE_ID, BROWSER_ID]
|
||||
}
|
||||
]
|
||||
},
|
||||
activeGroupIdByWorktree: { [WORKTREE_ID]: GROUP_ID }
|
||||
}
|
||||
}
|
||||
|
||||
function makeTerminalFreeDegradedRepoSession(): WorkspaceSessionState {
|
||||
const session = makeDegradedRepoSession()
|
||||
session.activeTabId = null
|
||||
session.tabsByWorktree = {}
|
||||
session.unifiedTabs![WORKTREE_ID] = session.unifiedTabs![WORKTREE_ID].filter(
|
||||
(tab) => tab.contentType !== 'terminal'
|
||||
)
|
||||
session.tabGroups![WORKTREE_ID] = session.tabGroups![WORKTREE_ID].map((group) => ({
|
||||
...group,
|
||||
tabOrder: [EDITOR_FILE_ID, BROWSER_ID],
|
||||
recentTabIds: [EDITOR_FILE_ID, BROWSER_ID]
|
||||
}))
|
||||
return session
|
||||
}
|
||||
|
||||
function hydrateWithRepoScan(
|
||||
store: ReturnType<typeof createTestStore>,
|
||||
session: WorkspaceSessionState,
|
||||
authoritative = false
|
||||
): void {
|
||||
store.setState({
|
||||
repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }],
|
||||
worktreesByRepo: { repo1: [] },
|
||||
detectedWorktreesByRepo: {
|
||||
repo1: {
|
||||
repoId: 'repo1',
|
||||
authoritative,
|
||||
source: authoritative ? 'git' : 'metadata-fallback',
|
||||
worktrees: []
|
||||
}
|
||||
}
|
||||
})
|
||||
store.getState().hydrateWorkspaceSession(session)
|
||||
store.getState().hydrateTabsSession(session)
|
||||
store.getState().hydrateEditorSession(session)
|
||||
store.getState().hydrateBrowserSession(session)
|
||||
}
|
||||
|
||||
it('keeps tab, editor, and browser chrome through degraded hydration and persistence', () => {
|
||||
const firstStore = createTestStore()
|
||||
hydrateWithRepoScan(firstStore, makeDegradedRepoSession())
|
||||
firstStore.setState({ workspaceSessionReady: true })
|
||||
firstStore.getState().setHydrationSucceeded(true)
|
||||
expect(shouldPersistWorkspaceSession(firstStore.getState())).toBe(true)
|
||||
|
||||
const persisted = buildWorkspaceSessionPayload(firstStore.getState())
|
||||
const restoredStore = createTestStore()
|
||||
hydrateWithRepoScan(restoredStore, persisted)
|
||||
|
||||
const restored = restoredStore.getState()
|
||||
expect(restored.unifiedTabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([
|
||||
TERMINAL_ID,
|
||||
EDITOR_FILE_ID,
|
||||
BROWSER_ID
|
||||
])
|
||||
expect(restored.openFiles.map((file) => file.id)).toEqual([EDITOR_FILE_ID])
|
||||
expect(restored.browserTabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([BROWSER_ID])
|
||||
})
|
||||
|
||||
it('keeps a terminal-free degraded workspace selected through hydration and persistence', () => {
|
||||
const firstStore = createTestStore()
|
||||
hydrateWithRepoScan(firstStore, makeTerminalFreeDegradedRepoSession())
|
||||
|
||||
const first = firstStore.getState()
|
||||
expect(first.activeWorktreeId).toBe(WORKTREE_ID)
|
||||
expect(first.activeTabType).toBe('browser')
|
||||
|
||||
firstStore.setState({ workspaceSessionReady: true })
|
||||
firstStore.getState().setHydrationSucceeded(true)
|
||||
const persisted = buildWorkspaceSessionPayload(firstStore.getState())
|
||||
const restoredStore = createTestStore()
|
||||
hydrateWithRepoScan(restoredStore, persisted)
|
||||
|
||||
const restored = restoredStore.getState()
|
||||
expect(restored.activeWorktreeId).toBe(WORKTREE_ID)
|
||||
expect(restored.unifiedTabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([
|
||||
EDITOR_FILE_ID,
|
||||
BROWSER_ID
|
||||
])
|
||||
expect(restored.activeTabType).toBe('browser')
|
||||
})
|
||||
|
||||
it('drops terminal-free chrome when an authoritative scan proves deletion', () => {
|
||||
const store = createTestStore()
|
||||
hydrateWithRepoScan(store, makeTerminalFreeDegradedRepoSession(), true)
|
||||
|
||||
const state = store.getState()
|
||||
expect(state.activeWorktreeId).toBeNull()
|
||||
expect(state.unifiedTabsByWorktree[WORKTREE_ID]).toBeUndefined()
|
||||
expect(state.openFiles).toEqual([])
|
||||
expect(state.browserTabsByWorktree[WORKTREE_ID]).toBeUndefined()
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import type {
|
||||
DetectedWorktreeListResult,
|
||||
Repo,
|
||||
WorkspaceSessionState,
|
||||
Worktree
|
||||
} from '../../../../shared/types'
|
||||
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
|
||||
|
||||
type WorktreeValidityCatalog = {
|
||||
repos: readonly Pick<Repo, 'id'>[]
|
||||
worktreesByRepo: Readonly<Record<string, readonly Pick<Worktree, 'id'>[]>>
|
||||
detectedWorktreesByRepo?: Readonly<
|
||||
Record<string, Pick<DetectedWorktreeListResult, 'authoritative'> | undefined>
|
||||
>
|
||||
}
|
||||
|
||||
export function collectPersistedWorktreeIdsForSessionHydration(
|
||||
session: WorkspaceSessionState
|
||||
): Set<string> {
|
||||
const persistedWorktreeIds = new Set<string>()
|
||||
for (const worktreeId of Object.keys(session.tabsByWorktree)) {
|
||||
persistedWorktreeIds.add(worktreeId)
|
||||
}
|
||||
for (const worktreeId of Object.keys(session.unifiedTabs ?? {})) {
|
||||
persistedWorktreeIds.add(worktreeId)
|
||||
}
|
||||
for (const worktreeId of Object.keys(session.openFilesByWorktree ?? {})) {
|
||||
persistedWorktreeIds.add(worktreeId)
|
||||
}
|
||||
for (const worktreeId of Object.keys(session.browserTabsByWorktree ?? {})) {
|
||||
persistedWorktreeIds.add(worktreeId)
|
||||
}
|
||||
return persistedWorktreeIds
|
||||
}
|
||||
|
||||
export function buildValidWorktreeIdsForSessionHydration(
|
||||
catalog: WorktreeValidityCatalog,
|
||||
persistedWorktreeIds: Iterable<string>
|
||||
): Set<string> {
|
||||
const worktreesByRepo = catalog.worktreesByRepo
|
||||
const validWorktreeIds = new Set(
|
||||
Object.values(worktreesByRepo)
|
||||
.flat()
|
||||
.map((worktree) => worktree.id)
|
||||
)
|
||||
const knownRepoIds = new Set(catalog.repos.map((repo) => repo.id))
|
||||
const repoIdsWithLoadedWorktrees = new Set(
|
||||
Object.entries(worktreesByRepo)
|
||||
.filter(([, worktrees]) => worktrees.length > 0)
|
||||
.map(([repoId]) => repoId)
|
||||
)
|
||||
const repoIdsWithAuthoritativeDetectedWorktrees = new Set(
|
||||
Object.entries(catalog.detectedWorktreesByRepo ?? {})
|
||||
.filter(([, detected]) => detected?.authoritative)
|
||||
.map(([repoId]) => repoId)
|
||||
)
|
||||
|
||||
for (const worktreeId of persistedWorktreeIds) {
|
||||
if (validWorktreeIds.has(worktreeId) || parseWorkspaceKey(worktreeId)?.type === 'folder') {
|
||||
continue
|
||||
}
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
// Why (#1158): a failed scan cannot prove deletion, while loaded worktrees or an authoritative scan can.
|
||||
if (
|
||||
knownRepoIds.has(repoId) &&
|
||||
!repoIdsWithLoadedWorktrees.has(repoId) &&
|
||||
!repoIdsWithAuthoritativeDetectedWorktrees.has(repoId)
|
||||
) {
|
||||
validWorktreeIds.add(worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
return validWorktreeIds
|
||||
}
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
addAdditionalValidWorkspaceKeys,
|
||||
type WorkspaceSessionHydrationOptions
|
||||
} from '@/lib/workspace-session-hydration-keys'
|
||||
import { buildValidWorktreeIdsForSessionHydration } from './degraded-repo-worktree-validity'
|
||||
import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown'
|
||||
import { extractIpcErrorMessage } from '@/lib/ipc-error'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -4407,11 +4408,9 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||
const persistedActiveTabTypeByWorktree = session.activeTabTypeByWorktree ?? {}
|
||||
const persistedMarkdownFrontmatterVisible = session.markdownFrontmatterVisible ?? {}
|
||||
|
||||
// Why: worktrees may have been deleted between sessions; drop files for worktrees that no longer exist.
|
||||
const validWorktreeIds = new Set(
|
||||
Object.values(s.worktreesByRepo)
|
||||
.flat()
|
||||
.map((w) => w.id)
|
||||
const validWorktreeIds = buildValidWorktreeIdsForSessionHydration(
|
||||
s,
|
||||
Object.keys(openFilesByWorktree)
|
||||
)
|
||||
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
|
||||
for (const workspace of s.folderWorkspaces) {
|
||||
|
||||
@@ -3210,7 +3210,12 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
|
||||
|
||||
const state = store.getState()
|
||||
expect(state.suppressedPtyExitIds['pty-agent']).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toBeUndefined()
|
||||
// Why: a done resumable agent retains its origin:'live' recovery anchor (#9454), so a failed shutdown rolls back to it, not to undefined — and must not commit a worktree-sleep record.
|
||||
expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toMatchObject({
|
||||
origin: 'live',
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'sess-rollback-1' }
|
||||
})
|
||||
expect(state.agentStatusByPaneKey[targetPaneKey]).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -3349,7 +3354,12 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
|
||||
|
||||
const state = store.getState()
|
||||
expect(state.ptyIdsByTabId['tab-1']).toEqual(['pty-agent', 'pty-shell'])
|
||||
expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toBeUndefined()
|
||||
// Why: done resumable agent keeps its origin:'live' anchor (#9454); a failed kill rolls back to it, not undefined, and never commits worktree-sleep.
|
||||
expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toMatchObject({
|
||||
origin: 'live',
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'target-session' }
|
||||
})
|
||||
expect(state.agentStatusByPaneKey[targetPaneKey]).toBeDefined()
|
||||
expect(state.suppressedPtyExitIds['pty-agent']).toBeUndefined()
|
||||
expect(mockRestorePtyDataHandlersAfterFailedShutdown).toHaveBeenCalledWith(handlerSnapshots)
|
||||
@@ -3651,7 +3661,12 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
|
||||
])
|
||||
expect(state.suppressedPtyExitIds['remote:env-1@@terminal-1']).toBeUndefined()
|
||||
expect(state.suppressedPtyExitIds['terminal-1']).toBeUndefined()
|
||||
expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toBeUndefined()
|
||||
// Why: done resumable agent keeps its origin:'live' anchor (#9454); a failed target-only stop rolls back to it, not undefined, and never commits worktree-sleep.
|
||||
expect(state.sleepingAgentSessionsByPaneKey[targetPaneKey]).toMatchObject({
|
||||
origin: 'live',
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'target-session' }
|
||||
})
|
||||
expect(state.agentStatusByPaneKey[targetPaneKey]).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -4497,7 +4512,12 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
|
||||
})
|
||||
).rejects.toThrow('terminal_liveness_unavailable')
|
||||
|
||||
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toBeUndefined()
|
||||
// Why: done resumable agent keeps its origin:'live' anchor (#9454); an inconclusive stop rolls back to it, not undefined, and never commits worktree-sleep.
|
||||
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toMatchObject({
|
||||
origin: 'live',
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'live-session' }
|
||||
})
|
||||
expect(store.getState().agentStatusByPaneKey['tab-1:live']).toBeDefined()
|
||||
expect(store.getState().suppressedPtyExitIds['pty-1']).toBeUndefined()
|
||||
expect(mockApi.pty.kill).not.toHaveBeenCalled()
|
||||
@@ -4551,7 +4571,12 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
|
||||
})
|
||||
).rejects.toThrow('exact_terminal_stop_unverified')
|
||||
|
||||
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toBeUndefined()
|
||||
// Why: done resumable agent keeps its origin:'live' anchor (#9454); an unverified stop rolls back to it, not undefined, and never commits worktree-sleep.
|
||||
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toMatchObject({
|
||||
origin: 'live',
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'live-session' }
|
||||
})
|
||||
expect(store.getState().agentStatusByPaneKey['tab-1:live']).toBeDefined()
|
||||
expect(store.getState().suppressedPtyExitIds['pty-1']).toBeUndefined()
|
||||
expect(mockApi.pty.kill).not.toHaveBeenCalled()
|
||||
@@ -5033,7 +5058,12 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => {
|
||||
})
|
||||
).rejects.toThrow('stop failed')
|
||||
|
||||
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toBeUndefined()
|
||||
// Why: done resumable agent keeps its origin:'live' anchor (#9454); a failed stop rolls back to it, not undefined, and never commits worktree-sleep.
|
||||
expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:live']).toMatchObject({
|
||||
origin: 'live',
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'live-session' }
|
||||
})
|
||||
expect(store.getState().agentStatusByPaneKey['tab-1:live']).toBeDefined()
|
||||
expect(mockUnregisterPtyDataHandlers).not.toHaveBeenCalledWith(['pty-1'])
|
||||
expect(mockApi.pty.kill).not.toHaveBeenCalled()
|
||||
|
||||
@@ -40,6 +40,10 @@ import {
|
||||
addAdditionalValidWorkspaceKeys,
|
||||
type WorkspaceSessionHydrationOptions
|
||||
} from '@/lib/workspace-session-hydration-keys'
|
||||
import {
|
||||
buildValidWorktreeIdsForSessionHydration,
|
||||
collectPersistedWorktreeIdsForSessionHydration
|
||||
} from './degraded-repo-worktree-validity'
|
||||
|
||||
export type TabSplitDirection = 'left' | 'right' | 'up' | 'down'
|
||||
|
||||
@@ -1971,11 +1975,8 @@ export const createTabsSlice: StateCreator<AppState, [], [], TabsSlice> = (set,
|
||||
|
||||
hydrateTabsSession: (session, options) => {
|
||||
const state = get()
|
||||
const validWorktreeIds = new Set(
|
||||
Object.values(state.worktreesByRepo)
|
||||
.flat()
|
||||
.map((w) => w.id)
|
||||
)
|
||||
const persistedWorktreeIds = collectPersistedWorktreeIdsForSessionHydration(session)
|
||||
const validWorktreeIds = buildValidWorktreeIdsForSessionHydration(state, persistedWorktreeIds)
|
||||
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
|
||||
for (const workspace of state.folderWorkspaces) {
|
||||
validWorktreeIds.add(folderWorkspaceKey(workspace.id))
|
||||
|
||||
@@ -104,6 +104,10 @@ import {
|
||||
addAdditionalValidWorkspaceKeys,
|
||||
type WorkspaceSessionHydrationOptions
|
||||
} from '@/lib/workspace-session-hydration-keys'
|
||||
import {
|
||||
buildValidWorktreeIdsForSessionHydration,
|
||||
collectPersistedWorktreeIdsForSessionHydration
|
||||
} from './degraded-repo-worktree-validity'
|
||||
import {
|
||||
collectHibernatedCompletionEvidenceForWorktree,
|
||||
collectSleepingAgentSessionRecordsForWorktree,
|
||||
@@ -3074,45 +3078,21 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
runtimeHostIdByWorkspaceSessionKey: options?.runtimeHostIdByWorkspaceSessionKey ?? {},
|
||||
worktreesByRepo: s.worktreesByRepo
|
||||
})
|
||||
const validWorktreeIds = new Set(
|
||||
Object.values(runtimeSessionPlaceholders.worktreesByRepo)
|
||||
.flat()
|
||||
.map((worktree) => worktree.id)
|
||||
const validWorktreeIds = buildValidWorktreeIdsForSessionHydration(
|
||||
{
|
||||
repos: runtimeSessionPlaceholders.repos,
|
||||
worktreesByRepo: runtimeSessionPlaceholders.worktreesByRepo,
|
||||
detectedWorktreesByRepo: s.detectedWorktreesByRepo
|
||||
},
|
||||
collectPersistedWorktreeIdsForSessionHydration(session)
|
||||
)
|
||||
const knownRepoIds = new Set(runtimeSessionPlaceholders.repos.map((r) => r.id))
|
||||
const repoIdsWithLoadedWorktrees = new Set(
|
||||
Object.entries(runtimeSessionPlaceholders.worktreesByRepo)
|
||||
.filter(([, worktrees]) => worktrees.length > 0)
|
||||
.map(([repoId]) => repoId)
|
||||
)
|
||||
const repoIdsWithAuthoritativeDetectedWorktrees = new Set(
|
||||
Object.entries(s.detectedWorktreesByRepo)
|
||||
.filter(([, detected]) => detected.authoritative)
|
||||
.map(([repoId]) => repoId)
|
||||
)
|
||||
// Why: the Floating Workspace isn't a repo worktree, but its tabs use the normal session pipeline so daemon PTYs survive app restart.
|
||||
validWorktreeIds.add(FLOATING_TERMINAL_WORKTREE_ID)
|
||||
for (const workspace of s.folderWorkspaces) {
|
||||
validWorktreeIds.add(folderWorkspaceKey(workspace.id))
|
||||
}
|
||||
addAdditionalValidWorkspaceKeys(validWorktreeIds, options)
|
||||
for (const worktreeId of Object.keys(session.tabsByWorktree)) {
|
||||
const parsedWorkspaceKey = parseWorkspaceKey(worktreeId)
|
||||
if (parsedWorkspaceKey?.type === 'folder') {
|
||||
continue
|
||||
}
|
||||
if (!validWorktreeIds.has(worktreeId)) {
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
// Why (#1158): an empty/missing list can mean degraded hydration; a non-empty repo list is authoritative for deleted-worktree cleanup.
|
||||
if (
|
||||
knownRepoIds.has(repoId) &&
|
||||
!repoIdsWithLoadedWorktrees.has(repoId) &&
|
||||
!repoIdsWithAuthoritativeDetectedWorktrees.has(repoId)
|
||||
) {
|
||||
validWorktreeIds.add(worktreeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why pendingActivationSpawn: a restored worktree's first mount calls updateTabPtyId, which would bump lastActivityAt and bounce it to the top of Recent; the tag (consumed on the first pty update) suppresses that so only real activity bumps.
|
||||
const tabsByWorktree: Record<string, TerminalTab[]> = Object.fromEntries(
|
||||
Object.entries(session.tabsByWorktree)
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
} from '../../../../shared/types'
|
||||
import type { WorktreeForceDeleteReason } from '../../../../shared/worktree-removal'
|
||||
import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type {
|
||||
PendingWorktreeCreation,
|
||||
WorktreeCreationPhase
|
||||
@@ -116,10 +117,16 @@ export type WorktreeSlice = {
|
||||
* sessions (design §4.4). Session-only; never persisted.
|
||||
*/
|
||||
hasHydratedWorktreePurge: boolean
|
||||
/** Startup owns the initial all-host refresh; sidebar repo-change refreshes stay gated until it finishes. */
|
||||
startupWorktreeRefreshCompleted: boolean
|
||||
fetchDetectedWorktrees: (repoId: string) => Promise<DetectedWorktreeListResult | null>
|
||||
fetchWorktrees: (
|
||||
repoId: string,
|
||||
options?: { requireAuthoritative?: boolean; forceLocalOwner?: boolean }
|
||||
options?: {
|
||||
requireAuthoritative?: boolean
|
||||
executionHostId?: ExecutionHostId
|
||||
forceLocalOwner?: boolean
|
||||
}
|
||||
) => Promise<boolean>
|
||||
fetchAllWorktrees: (options?: { hydrationPurge?: 'allow' | 'defer' }) => Promise<void>
|
||||
fetchWorktreeLineage: (options?: { forceLocalOwner?: boolean }) => Promise<void>
|
||||
|
||||
@@ -1350,6 +1350,45 @@ describe('fetchWorktrees', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('fetches the requested host when duplicate repo ids exist', async () => {
|
||||
const store = createTestStore()
|
||||
const localWorktree = makeWorktree({
|
||||
id: 'same-repo::/local/wt',
|
||||
repoId: 'same-repo',
|
||||
path: '/local/wt'
|
||||
})
|
||||
store.setState({
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
|
||||
repos: [
|
||||
{
|
||||
id: 'same-repo',
|
||||
path: '/local/repo',
|
||||
displayName: 'Local',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
executionHostId: 'local'
|
||||
},
|
||||
{
|
||||
id: 'same-repo',
|
||||
path: '/remote/repo',
|
||||
displayName: 'Runtime',
|
||||
badgeColor: '#111',
|
||||
addedAt: 1,
|
||||
executionHostId: 'runtime:env-1'
|
||||
}
|
||||
]
|
||||
} as Partial<AppState>)
|
||||
mockApi.worktrees.listDetected.mockResolvedValueOnce(
|
||||
makeDetectedResult('same-repo', [localWorktree])
|
||||
)
|
||||
|
||||
await store.getState().fetchWorktrees('same-repo', { executionHostId: 'local' })
|
||||
|
||||
expect(mockApi.worktrees.listDetected).toHaveBeenCalledWith({ repoId: 'same-repo' })
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
|
||||
expect(store.getState().worktreesByRepo['same-repo']).toEqual([localWorktree])
|
||||
})
|
||||
|
||||
it('stamps remote runtime worktrees with the owning repo runtime host', async () => {
|
||||
const store = createTestStore()
|
||||
// Why: a remote runtime returns worktrees from its own perspective, so their hostId arrives as the default "local".
|
||||
|
||||
@@ -2287,6 +2287,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
everActivatedWorktreeIds: new Set<string>(),
|
||||
lastVisitedAtByWorktreeId: {},
|
||||
hasHydratedWorktreePurge: false,
|
||||
startupWorktreeRefreshCompleted: false,
|
||||
|
||||
fetchDetectedWorktrees: async (repoId) => {
|
||||
try {
|
||||
@@ -2337,7 +2338,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
// the local owner without redirecting runtime/SSH-only repos.
|
||||
const useLocalOwner =
|
||||
options?.forceLocalOwner === true && (hasLocalOwner || repoOwners.length === 0)
|
||||
const hostId = useLocalOwner ? LOCAL_EXECUTION_HOST_ID : repoHostId(ownerState, repoId)
|
||||
const hostId = useLocalOwner
|
||||
? LOCAL_EXECUTION_HOST_ID
|
||||
: repoHostId(ownerState, repoId, options?.executionHostId)
|
||||
const ownerWasMissingAtStart = repoOwners.length === 0
|
||||
const setup = getProjectHostSetupForRepoHost(ownerState, repoId, hostId)
|
||||
const ownerSettings = settingsForRepoOwner(ownerState, repoId, hostId)
|
||||
|
||||
@@ -3012,6 +3012,7 @@ function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
|
||||
onReplay: () => noopUnsubscribe,
|
||||
onModelRestoreNeeded: () => noopUnsubscribe,
|
||||
onExit: () => noopUnsubscribe,
|
||||
onSpawned: () => noopUnsubscribe,
|
||||
onSerializeBufferRequest: () => noopUnsubscribe,
|
||||
onClearBufferRequest: () => noopUnsubscribe,
|
||||
sendSerializedBuffer: () => {},
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getAgentRowConversationName,
|
||||
type ConversationNameTab
|
||||
} from './agent-row-conversation-name'
|
||||
|
||||
function makeTab(overrides: Partial<ConversationNameTab> = {}): ConversationNameTab {
|
||||
return { customTitle: null, title: '', ...overrides }
|
||||
}
|
||||
|
||||
describe('getAgentRowConversationName', () => {
|
||||
it('prefers the manual tab rename over every other source', () => {
|
||||
const tab = makeTab({
|
||||
customTitle: 'Patient sync spike',
|
||||
quickCommandLabel: 'Run tests',
|
||||
generatedTitle: 'Fix intake flow',
|
||||
title: '✳ Investigate replay bug'
|
||||
})
|
||||
expect(getAgentRowConversationName(tab, 'claude', true)).toBe('Patient sync spike')
|
||||
})
|
||||
|
||||
it('falls back to the quick-command label before titles', () => {
|
||||
const tab = makeTab({ quickCommandLabel: 'Run tests', title: '✳ Investigate replay bug' })
|
||||
expect(getAgentRowConversationName(tab, 'claude', true)).toBe('Run tests')
|
||||
})
|
||||
|
||||
it('keeps OpenCode semantic session titles whole', () => {
|
||||
const tab = makeTab({ title: 'OC | build the release pipeline' })
|
||||
expect(getAgentRowConversationName(tab, 'opencode', false)).toBe(
|
||||
'OC | build the release pipeline'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the generated title only when generated titles are enabled', () => {
|
||||
const tab = makeTab({ generatedTitle: 'Fix intake flow', title: '✳ Investigate replay bug' })
|
||||
expect(getAgentRowConversationName(tab, 'claude', true)).toBe('Fix intake flow')
|
||||
expect(getAgentRowConversationName(tab, 'claude', false)).toBe('Investigate replay bug')
|
||||
})
|
||||
|
||||
it('strips leading status decoration from agent-set titles', () => {
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: '✳ Fix patient intake flow' }), 'claude', false)
|
||||
).toBe('Fix patient intake flow')
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: '⠋ Refactor replay guard' }), 'codex', false)
|
||||
).toBe('Refactor replay guard')
|
||||
})
|
||||
|
||||
it('rejects spinner+cwd titles instead of surfacing paths as names', () => {
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: '⠋ ~/orca/workspaces' }), 'codex', false)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: '/Users/dev/repo' }), 'codex', false)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: 'C:\\repos\\orca' }), 'codex', false)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: 'orca/workspaces' }), 'codex', false)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(
|
||||
makeTab({ title: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\orca' }),
|
||||
'codex',
|
||||
false
|
||||
)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: 'repos\\orca' }), 'codex', false)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts multi-word titles that merely contain a slash', () => {
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: 'Fix a/b toggle in settings' }), 'codex', false)
|
||||
).toBe('Fix a/b toggle in settings')
|
||||
})
|
||||
|
||||
it('rejects synthetic status titles', () => {
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: 'Codex ready' }), 'codex', false)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: 'Codex - action required' }), 'codex', false)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: 'Cursor Agent' }), 'cursor', false)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects identity-echo, management, and placeholder titles', () => {
|
||||
expect(getAgentRowConversationName(makeTab({ title: 'Claude' }), 'claude', false)).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: '✳ Claude Code' }), 'claude', false)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(
|
||||
makeTab({ title: 'Claude Code - action required' }),
|
||||
'claude',
|
||||
false
|
||||
)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: '✦ Gemini CLI' }), 'gemini', false)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: '◇ Ready (orca)' }), 'gemini', false)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: 'claude agents' }), 'claude', false)
|
||||
).toBeNull()
|
||||
expect(getAgentRowConversationName(makeTab({ title: 'Agent' }), 'claude', false)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects empty, glyph-only, and default terminal titles', () => {
|
||||
expect(getAgentRowConversationName(makeTab(), 'claude', false)).toBeNull()
|
||||
expect(getAgentRowConversationName(makeTab({ title: '✳' }), 'claude', false)).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(makeTab({ title: 'Terminal 1' }), 'claude', false)
|
||||
).toBeNull()
|
||||
expect(
|
||||
getAgentRowConversationName(
|
||||
makeTab({ title: 'Terminal 2', defaultTitle: 'Terminal 2' }),
|
||||
'claude',
|
||||
false
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
// Resolves the stable "conversation name" an agent row can show instead of the
|
||||
// live last-message preview. Sources, in the same precedence the tab bar uses
|
||||
// (tab-title-resolution.ts): manual rename → quick-command label → OpenCode's
|
||||
// semantic session title → Orca's generated title → the agent-set live title.
|
||||
// Live titles are accepted only when they carry a real name — pure status,
|
||||
// identity-echo, and spinner/cwd titles yield null so callers keep the
|
||||
// last-message label.
|
||||
import type { AgentType } from './agent-status-types'
|
||||
import { isClaudeManagementTitle } from './agent-title-core'
|
||||
import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoration'
|
||||
import { formatAgentTypeLabel } from './agent-type-label'
|
||||
import { isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title'
|
||||
import { SYNTHETIC_AGENT_TITLE_PROFILES } from './synthetic-agent-title'
|
||||
import type { TerminalTab } from './types'
|
||||
|
||||
export type ConversationNameTab = Pick<
|
||||
TerminalTab,
|
||||
'customTitle' | 'quickCommandLabel' | 'generatedTitle' | 'title' | 'defaultTitle'
|
||||
>
|
||||
|
||||
// Why: synthetic status titles ("Codex ready", "Cursor - action required") are
|
||||
// state, not names. Precomputed once; the profile table is a module constant.
|
||||
const SYNTHETIC_STATUS_TITLES_LOWER: ReadonlySet<string> = new Set(
|
||||
Object.values(SYNTHETIC_AGENT_TITLE_PROFILES).flatMap((profile) => [
|
||||
profile.workingLabel.toLowerCase(),
|
||||
profile.permissionLabel.toLowerCase(),
|
||||
profile.idleLabel.toLowerCase()
|
||||
])
|
||||
)
|
||||
|
||||
// Why: retained rows without a live tab synthesize `title: 'Agent'`
|
||||
// (worktree-agent-row-fallback-tab.ts); it is a placeholder, not a name.
|
||||
const FALLBACK_TAB_TITLE_LOWER = 'agent'
|
||||
|
||||
const AGENT_IDENTITY_ALIASES_LOWER: Readonly<Record<string, readonly string[]>> = {
|
||||
claude: ['claude code'],
|
||||
gemini: ['gemini cli']
|
||||
}
|
||||
|
||||
const STATUS_WITH_CONTEXT_RE = /^(?:ready|idle|done)(?:\s+\([^)]*\))?$/i
|
||||
const DEFAULT_TERMINAL_TITLE_RE = /^terminal \d+$/i
|
||||
|
||||
function isIdentityStatusTitle(titleLower: string, identityLower: string): boolean {
|
||||
return (
|
||||
titleLower === identityLower ||
|
||||
titleLower === `${identityLower} ready` ||
|
||||
titleLower === `${identityLower} idle` ||
|
||||
titleLower === `${identityLower} done` ||
|
||||
titleLower === `${identityLower} working` ||
|
||||
titleLower === `${identityLower} thinking` ||
|
||||
titleLower === `${identityLower} running` ||
|
||||
titleLower === `${identityLower} - action required`
|
||||
)
|
||||
}
|
||||
|
||||
function isAgentIdentityStatusTitle(
|
||||
titleLower: string,
|
||||
agentType: AgentType | null | undefined,
|
||||
agentTypeLabelLower: string
|
||||
): boolean {
|
||||
if (isIdentityStatusTitle(titleLower, agentTypeLabelLower)) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
AGENT_IDENTITY_ALIASES_LOWER[agentType ?? '']?.some((identity) =>
|
||||
isIdentityStatusTitle(titleLower, identity)
|
||||
) ?? false
|
||||
)
|
||||
}
|
||||
|
||||
function isCwdLikeTitle(title: string): boolean {
|
||||
// Hook-less agents over SSH surface spinner+cwd titles (#8711); once the
|
||||
// spinner is stripped, what remains is a path, not a conversation name.
|
||||
if (/^(?:~|[\\/]|[A-Za-z]:[\\/])/.test(title)) {
|
||||
return true
|
||||
}
|
||||
// A single path-ish token ("orca/workspaces") is still a cwd, not a name.
|
||||
return !/\s/.test(title) && /[\\/]/.test(title)
|
||||
}
|
||||
|
||||
function conversationNameFromLiveTitle(
|
||||
liveTitle: string,
|
||||
agentType: AgentType | null | undefined,
|
||||
agentTypeLabelLower: string,
|
||||
defaultTitle: string | undefined
|
||||
): string | null {
|
||||
const stripped = stripLeadingAgentTitleDecorationOrEmpty(liveTitle.trim()).trim()
|
||||
if (!stripped) {
|
||||
return null
|
||||
}
|
||||
const lower = stripped.toLowerCase()
|
||||
if (
|
||||
SYNTHETIC_STATUS_TITLES_LOWER.has(lower) ||
|
||||
lower === FALLBACK_TAB_TITLE_LOWER ||
|
||||
isAgentIdentityStatusTitle(lower, agentType, agentTypeLabelLower) ||
|
||||
STATUS_WITH_CONTEXT_RE.test(stripped) ||
|
||||
DEFAULT_TERMINAL_TITLE_RE.test(stripped) ||
|
||||
isClaudeManagementTitle(stripped) ||
|
||||
isCwdLikeTitle(stripped)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (defaultTitle && stripped === defaultTitle.trim()) {
|
||||
return null
|
||||
}
|
||||
return stripped
|
||||
}
|
||||
|
||||
/**
|
||||
* The conversation name for an agent row, or null when no usable name exists
|
||||
* and the caller should keep its last-message label.
|
||||
*/
|
||||
export function getAgentRowConversationName(
|
||||
tab: ConversationNameTab,
|
||||
agentType: AgentType | null | undefined,
|
||||
generatedTitlesEnabled: boolean
|
||||
): string | null {
|
||||
const customTitle = tab.customTitle?.trim()
|
||||
if (customTitle) {
|
||||
return customTitle
|
||||
}
|
||||
const quickCommandLabel = tab.quickCommandLabel?.trim()
|
||||
if (quickCommandLabel) {
|
||||
return quickCommandLabel
|
||||
}
|
||||
const liveTitle = tab.title?.trim() ?? ''
|
||||
if (isMeaningfulOpenCodeTerminalTitle(liveTitle)) {
|
||||
return liveTitle
|
||||
}
|
||||
const generatedTitle = generatedTitlesEnabled ? tab.generatedTitle?.trim() : ''
|
||||
if (generatedTitle) {
|
||||
return generatedTitle
|
||||
}
|
||||
if (!liveTitle) {
|
||||
return null
|
||||
}
|
||||
return conversationNameFromLiveTitle(
|
||||
liveTitle,
|
||||
agentType,
|
||||
formatAgentTypeLabel(agentType).toLowerCase(),
|
||||
tab.defaultTitle
|
||||
)
|
||||
}
|
||||
@@ -237,6 +237,7 @@ export async function waitForTerminalReady(page, timeoutMs = 60_000, terminalTab
|
||||
* workspace (which would mask a broken restore).
|
||||
*/
|
||||
export async function ensureTerminal(page, { allowCreate = true, timeoutMs = 60_000 } = {}) {
|
||||
await dismissOverlays(page, 1)
|
||||
const visibleTerminal = page.locator(TERMINAL_SURFACE_VISIBLE).first()
|
||||
if (await visibleTerminal.isVisible().catch(() => false)) {
|
||||
await waitForTerminalReady(page, timeoutMs)
|
||||
|
||||
Reference in New Issue
Block a user