mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
Merge origin/main; reconcile with #7630's pendingData cap
#7630 landed the same 2MB cap + droppedBacklog restore flag this branch introduced independently — adopt its mechanism wholesale (constants, flag naming, renderer threading, cap tests). This branch keeps what #7630 does not cover: releasing the queue + in-flight ACK budget on teardown paths that skip sendPtyExitToRenderer (SSH connection drop, synthetic-kill dedup), and dropping renderer delivery for output arriving inside the synthetic-kill window. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -10,7 +10,14 @@ on:
|
||||
- 'config/scripts/computer-use-skill-guidance.test.mjs'
|
||||
- 'config/scripts/computer-use-smoke.mjs'
|
||||
- 'config/scripts/computer-use-smoke.test.mjs'
|
||||
- 'config/scripts/daemon-boot-smoke.mjs'
|
||||
- 'config/scripts/verify-computer-native.mjs'
|
||||
# Why: the native-smoke job boots the built terminal daemon under plain
|
||||
# Node, so any change to the daemon bundle graph or the main build must
|
||||
# re-run it (the v1.4.129-rc.1 daemon outage shipped with green CI).
|
||||
- 'electron.vite.config.ts'
|
||||
- 'build-plugins/**'
|
||||
- 'src/main/daemon/**'
|
||||
- 'native/computer-use-macos/**'
|
||||
- 'native/computer-use-linux/**'
|
||||
- 'native/computer-use-windows/**'
|
||||
@@ -98,6 +105,12 @@ jobs:
|
||||
- run: pnpm verify:computer-native
|
||||
- run: pnpm build:cli
|
||||
- run: pnpm build:electron-vite
|
||||
# Why: boot the BUILT daemon-entry under plain Node the way production
|
||||
# forks it. v1.4.129-rc.1 shipped a daemon that exited at module load
|
||||
# (leaked electron require) while every other check passed; this fails
|
||||
# the PR when the built daemon cannot start on ubuntu-22.04 / windows.
|
||||
- name: Daemon boot smoke
|
||||
run: node config/scripts/daemon-boot-smoke.mjs
|
||||
- if: runner.os == 'Linux'
|
||||
env:
|
||||
ORCA_COMPUTER_E2E: '1'
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
name: Daemon Relocation Spike
|
||||
|
||||
# Why: Phase 1 of the Windows update-survival work relocates the terminal
|
||||
# daemon's file closure out of the install dir so it survives an update. Before
|
||||
# implementing it in the app, this spike finds the MINIMAL set of packaged files
|
||||
# that a copied Orca.exe (run as node) needs to start the daemon and drive a
|
||||
# ConPTY session from a relocated directory, holding no install-dir file locks.
|
||||
# Runs from the feature branch via push (workflow_dispatch only works on the
|
||||
# default branch); main is never touched.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- Jinwoo-H/windows-update-survival
|
||||
paths:
|
||||
- 'tools/daemon-relocation-spike/**'
|
||||
- '.github/workflows/daemon-relocation-spike.yml'
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: daemon-relocation-spike-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
spike:
|
||||
name: relocation file-set spike
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 40
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# This job only reads the repo to build/run the spike; it never pushes.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Why: cache the unpacked build keyed on the inputs that affect it, so
|
||||
# spike-script-only edits skip the ~15 min electron-builder build.
|
||||
- name: Cache unpacked build
|
||||
id: cache-unpacked
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: dist/win-unpacked
|
||||
key: win-unpacked-${{ hashFiles('src/**', 'config/**', 'package.json', 'pnpm-lock.yaml') }}
|
||||
|
||||
- name: Build unpacked app
|
||||
if: steps.cache-unpacked.outputs.cache-hit != 'true'
|
||||
run: pnpm run build:unpack
|
||||
|
||||
- name: Run relocation spike (all tiers)
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force artifacts | Out-Null
|
||||
$log = "artifacts/spike-output.log"
|
||||
$tiers = @('full', 'no-gpu', 'minimal')
|
||||
$any = $false
|
||||
foreach ($tier in $tiers) {
|
||||
"===== TIER: $tier =====" | Tee-Object -FilePath $log -Append
|
||||
$work = Join-Path $env:RUNNER_TEMP "spike-$tier"
|
||||
# --keep-work-dir so the per-tier daemon stdout/stderr logs survive
|
||||
# for the artifact upload (the spike otherwise removes work-dir),
|
||||
# which is what diagnoses a tier that fails to reach ready.
|
||||
node tools/daemon-relocation-spike/spike.mjs `
|
||||
--app-dir dist/win-unpacked `
|
||||
--work-dir "$work" `
|
||||
--tier $tier `
|
||||
--keep-work-dir 2>&1 | Tee-Object -FilePath $log -Append
|
||||
if ($LASTEXITCODE -eq 0) { $any = $true }
|
||||
}
|
||||
if (-not $any) { throw "No tier passed the relocation spike" }
|
||||
|
||||
- name: Upload spike output
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: daemon-relocation-spike-output
|
||||
path: |
|
||||
artifacts/spike-output.log
|
||||
${{ runner.temp }}/spike-*/**/*.log
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
@@ -69,6 +69,14 @@ jobs:
|
||||
- name: Check reliability gate manifest
|
||||
run: pnpm run check:reliability-gates
|
||||
|
||||
# Why: oxlint fails any file over max-lines that is NOT suppressed, so this
|
||||
# ratchet forbids ADDING a new suppression (inline disable or mobile max
|
||||
# bump). Existing oversized files are grandfathered in
|
||||
# config/max-lines-baseline.txt, which may only shrink — new bypasses fail
|
||||
# here with a clear message instead of silently growing the debt.
|
||||
- name: Enforce max-lines ratchet (no new bypasses)
|
||||
run: pnpm run check:max-lines-ratchet
|
||||
|
||||
# Why: project-owned type declarations must live in .ts so tsc
|
||||
# actually checks them. TypeScript's skipLibCheck: true (inherited
|
||||
# from @electron-toolkit/tsconfig) silently widens unresolved names
|
||||
|
||||
@@ -719,6 +719,10 @@ jobs:
|
||||
~/.cache/electron-builder
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
# Why: hosted runners hard-cap jobs at 6h; the Windows SignPath waits
|
||||
# (1h inner + 4h installer) are budgeted to fit under this with the
|
||||
# build itself, so a slow approval can't kill the job mid-flow.
|
||||
timeout-minutes: 360
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -958,6 +962,244 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
# ── Windows inner-binary signing (issue #7785) ─────────────────────
|
||||
# Why: SignPath cannot deep-sign inside NSIS installers, so inner PE
|
||||
# files (Orca.exe, node-pty *.node, DLLs) are signed via a separate zip
|
||||
# request, then the installer is rebuilt from the signed tree before the
|
||||
# existing installer signing request below. Every step in this chain is
|
||||
# fail-open (continue-on-error + outcome gating): any failure ships the
|
||||
# original installer with unsigned inner binaries, exactly like releases
|
||||
# did before this chain existed. Rehearsed end to end in run 28988432001
|
||||
# (.github/workflows/windows-signing-rehearsal.yml).
|
||||
|
||||
# Why: only unsigned PE files go to SignPath. Files that already carry a
|
||||
# valid signature (Microsoft's OpenConsole.exe) must keep their signer.
|
||||
- name: Stage unsigned inner PE files for signing
|
||||
id: stage-inner
|
||||
if: matrix.platform == 'win'
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
run: |
|
||||
$root = Resolve-Path 'dist/win-unpacked'
|
||||
$stage = New-Item -ItemType Directory -Force -Path 'signing-stage'
|
||||
$list = New-Object System.Collections.Generic.List[string]
|
||||
$skipped = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
Get-ChildItem -Path $root -Recurse -File |
|
||||
Where-Object { $_.Extension -in '.exe', '.dll', '.node' } |
|
||||
ForEach-Object {
|
||||
$relative = [System.IO.Path]::GetRelativePath($root, $_.FullName)
|
||||
$signature = Get-AuthenticodeSignature -FilePath $_.FullName
|
||||
if ($signature.Status -eq 'Valid') {
|
||||
$skipped.Add("$relative <already signed: $($signature.SignerCertificate.Subject)>")
|
||||
return
|
||||
}
|
||||
$destination = Join-Path $stage.FullName $relative
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path $destination) | Out-Null
|
||||
Copy-Item -Path $_.FullName -Destination $destination -Force
|
||||
$list.Add($relative)
|
||||
}
|
||||
|
||||
if (-not ($list -contains 'Orca.exe')) {
|
||||
throw 'Orca.exe was not staged for signing; unpacked layout changed?'
|
||||
}
|
||||
if (-not ($list | Where-Object { $_ -like '*conpty_console_list.node' })) {
|
||||
throw 'node-pty conpty_console_list.node was not staged; this is the file from issue #7785.'
|
||||
}
|
||||
|
||||
Set-Content -Path 'inner-signing-list.txt' -Value ($list -join "`n")
|
||||
Write-Host "Staged $($list.Count) unsigned PE files for signing:"
|
||||
$list | ForEach-Object { Write-Host " $_" }
|
||||
Write-Host "Skipped $($skipped.Count) already-signed files:"
|
||||
$skipped | ForEach-Object { Write-Host " $_" }
|
||||
|
||||
- name: Upload unsigned inner binaries for SignPath
|
||||
id: upload-unsigned-inner
|
||||
if: matrix.platform == 'win' && steps.stage-inner.outcome == 'success'
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: orca-windows-inner-unsigned-${{ needs.cut.outputs.tag }}
|
||||
path: signing-stage/**
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Submit inner binaries signing request
|
||||
id: submit-inner-signing
|
||||
if: matrix.platform == 'win' && steps.upload-unsigned-inner.outcome == 'success'
|
||||
continue-on-error: true
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
|
||||
project-slug: orca
|
||||
signing-policy-slug: release-signing
|
||||
artifact-configuration-slug: windows-inner-binaries-zip
|
||||
github-artifact-id: ${{ steps.upload-unsigned-inner.outputs.artifact-id }}
|
||||
wait-for-completion: false
|
||||
|
||||
- name: Notify Slack that inner-binary signing is waiting for approval
|
||||
id: notify-inner-signing
|
||||
if: matrix.platform == 'win' && steps.submit-inner-signing.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
SIGNPATH_ORGANIZATION_ID: c37aa192-a27a-4377-9c90-5d6c95912dc0
|
||||
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
|
||||
SIGNPATH_REQUEST_URL: ${{ steps.submit-inner-signing.outputs.signing-request-web-url }}
|
||||
TAG: ${{ needs.cut.outputs.tag }}
|
||||
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) {
|
||||
throw 'SLACK_WEBHOOK_URL secret is required so release approvers know when SignPath is waiting.'
|
||||
}
|
||||
|
||||
$requestUrl = $env:SIGNPATH_REQUEST_URL
|
||||
if ([string]::IsNullOrWhiteSpace($requestUrl)) {
|
||||
$requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID"
|
||||
}
|
||||
|
||||
$message = "Orca Windows release $env:TAG inner-binaries signing request (1 of 2) is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>"
|
||||
$payload = @{
|
||||
text = $message
|
||||
blocks = @(
|
||||
@{
|
||||
type = 'section'
|
||||
text = @{
|
||||
type = 'mrkdwn'
|
||||
text = $message
|
||||
}
|
||||
}
|
||||
)
|
||||
} | ConvertTo-Json -Depth 5
|
||||
|
||||
Invoke-RestMethod -Method Post -Uri $env:SLACK_WEBHOOK_URL -ContentType 'application/json' -Body $payload
|
||||
|
||||
# Why gate on the notify outcome too: if nobody was told to approve,
|
||||
# don't hold the release for the approval window — fall through and
|
||||
# ship like today instead. The 1h wait (vs the installer's 4h) keeps
|
||||
# both waits plus the build inside the 360-minute job cap; missing it
|
||||
# falls through to today's unsigned-inner flow rather than blocking.
|
||||
- name: Download signed inner binaries from SignPath
|
||||
id: download-signed-inner
|
||||
if: matrix.platform == 'win' && steps.submit-inner-signing.outcome == 'success' && steps.notify-inner-signing.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
env:
|
||||
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
|
||||
run: |
|
||||
Get-SignedArtifact `
|
||||
-OrganizationId c37aa192-a27a-4377-9c90-5d6c95912dc0 `
|
||||
-ApiToken $env:SIGNPATH_API_TOKEN `
|
||||
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
|
||||
-OutputArtifactPath signed-inner.zip `
|
||||
-Force `
|
||||
-WaitForCompletionTimeoutInSeconds 3600
|
||||
|
||||
New-Item -ItemType Directory -Path signed-inner -Force
|
||||
Expand-Archive -Path signed-inner.zip -DestinationPath signed-inner -Force
|
||||
|
||||
# Why: copy back strictly by the staged list so a layout mismatch in the
|
||||
# returned artifact fails loudly (into fail-open) instead of silently
|
||||
# shipping a mix of signed and unsigned binaries.
|
||||
- name: Restore signed inner binaries into unpacked app
|
||||
id: restore-signed-inner
|
||||
if: matrix.platform == 'win' && steps.download-signed-inner.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
run: |
|
||||
$root = Resolve-Path 'dist/win-unpacked'
|
||||
$failures = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($relative in Get-Content 'inner-signing-list.txt') {
|
||||
$signed = Get-ChildItem -Path signed-inner -Recurse -File |
|
||||
Where-Object { [System.IO.Path]::GetRelativePath((Resolve-Path 'signed-inner'), $_.FullName).TrimStart('\', '/') -like "*$relative" } |
|
||||
Select-Object -First 1
|
||||
if ($null -eq $signed) {
|
||||
$failures.Add("missing from signed artifact: $relative")
|
||||
continue
|
||||
}
|
||||
$signature = Get-AuthenticodeSignature -FilePath $signed.FullName
|
||||
if ($null -eq $signature.SignerCertificate) {
|
||||
$failures.Add("returned without a signature: $relative")
|
||||
continue
|
||||
}
|
||||
Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force
|
||||
Write-Host ("{0,-14} {1} <{2}>" -f $signature.Status, $relative, $signature.SignerCertificate.Subject)
|
||||
}
|
||||
if ($failures.Count -gt 0) {
|
||||
$failures | ForEach-Object { Write-Host "::error::$_" }
|
||||
throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)."
|
||||
}
|
||||
|
||||
# Why this step exists: electron-builder's CopyElevateHelper re-copies a
|
||||
# pristine elevate.exe from its download cache over resources\elevate.exe
|
||||
# on EVERY nsis pack — including the --prepackaged rebuild below — which
|
||||
# clobbered the SignPath signature in v1.4.129-rc.4. There is no supported
|
||||
# way to disable just the copy, so we overwrite the cache's copy with our
|
||||
# signed one (identical bytes plus signature) so the clobber becomes a
|
||||
# no-op. Known quirk: the cache persists across releases via actions/cache,
|
||||
# so later runs may see elevate.exe as already signed and skip staging it —
|
||||
# that is fine (the signature is timestamped) and the evidence gate checks
|
||||
# elevate.exe in the shipped installer unconditionally. If this ever causes
|
||||
# trouble, delete this step; the only effect is elevate.exe shipping
|
||||
# unsigned again, which the evidence gate will flag.
|
||||
- name: Replace cached elevate.exe with the signed copy
|
||||
id: sign-elevate-cache
|
||||
if: matrix.platform == 'win' && steps.restore-signed-inner.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
run: |
|
||||
$signed = 'dist/win-unpacked/resources/elevate.exe'
|
||||
if (-not (Test-Path $signed)) {
|
||||
Write-Host '::warning::No elevate.exe in win-unpacked resources; nothing to protect from the rebuild clobber.'
|
||||
exit 0
|
||||
}
|
||||
$signature = Get-AuthenticodeSignature -FilePath $signed
|
||||
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
|
||||
if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') {
|
||||
Write-Host "::warning::win-unpacked elevate.exe is not SignPath-signed ($($signature.Status), $subject); skipping cache swap."
|
||||
exit 0
|
||||
}
|
||||
$cached = @(Get-ChildItem "$env:LOCALAPPDATA\electron-builder\Cache\nsis" -Recurse -Filter elevate.exe -ErrorAction SilentlyContinue)
|
||||
if ($cached.Count -eq 0) {
|
||||
Write-Host '::warning::No cached elevate.exe found (electron-builder cache layout changed?); the rebuild will pack the unsigned copy and the evidence gate will flag it.'
|
||||
exit 0
|
||||
}
|
||||
foreach ($file in $cached) {
|
||||
Copy-Item -Path $signed -Destination $file.FullName -Force
|
||||
Write-Host "Replaced $($file.FullName) with the SignPath-signed copy."
|
||||
}
|
||||
|
||||
- name: Rebuild NSIS installer from signed unpacked app
|
||||
id: rebuild-nsis-signed
|
||||
if: matrix.platform == 'win' && steps.restore-signed-inner.outcome == 'success'
|
||||
continue-on-error: true
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Why: keep the pre-rebuild artifacts so a failed rebuild can fall
|
||||
# back to shipping them unchanged (fail-open).
|
||||
New-Item -ItemType Directory -Path prepack-backup -Force | Out-Null
|
||||
Copy-Item 'dist/orca-windows-setup.exe' 'prepack-backup/orca-windows-setup.exe' -Force
|
||||
Copy-Item 'dist/latest.yml' 'prepack-backup/latest.yml' -Force
|
||||
|
||||
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
if (-not (Test-Path 'dist/orca-windows-setup.exe')) {
|
||||
throw 'electron-builder --prepackaged did not produce dist/orca-windows-setup.exe'
|
||||
}
|
||||
|
||||
- name: Roll back to original installer after failed rebuild
|
||||
if: matrix.platform == 'win' && steps.rebuild-nsis-signed.outcome == 'failure'
|
||||
shell: pwsh
|
||||
run: |
|
||||
if (Test-Path 'prepack-backup/orca-windows-setup.exe') {
|
||||
Copy-Item 'prepack-backup/orca-windows-setup.exe' 'dist/orca-windows-setup.exe' -Force
|
||||
Copy-Item 'prepack-backup/latest.yml' 'dist/latest.yml' -Force
|
||||
Write-Warning 'Restored pre-rebuild installer; this release ships with unsigned inner binaries.'
|
||||
}
|
||||
# ── End Windows inner-binary signing ───────────────────────────────
|
||||
|
||||
- name: Upload unsigned Windows installer for SignPath
|
||||
if: matrix.platform == 'win'
|
||||
id: upload-unsigned-windows-installer
|
||||
@@ -992,6 +1234,7 @@ jobs:
|
||||
SIGNPATH_REQUEST_URL: ${{ steps.submit-signing-request.outputs.signing-request-web-url }}
|
||||
TAG: ${{ needs.cut.outputs.tag }}
|
||||
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
INNER_SIGNING_SUBMITTED: ${{ steps.submit-inner-signing.outcome == 'success' }}
|
||||
run: |
|
||||
if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) {
|
||||
throw 'SLACK_WEBHOOK_URL secret is required so release approvers know when SignPath is waiting.'
|
||||
@@ -1002,7 +1245,9 @@ jobs:
|
||||
$requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID"
|
||||
}
|
||||
|
||||
$message = "Orca Windows release $env:TAG is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>"
|
||||
# Why: releases where inner signing fell through have only this one request.
|
||||
$stage = if ($env:INNER_SIGNING_SUBMITTED -eq 'true') { 'installer signing request (2 of 2)' } else { 'signing request' }
|
||||
$message = "Orca Windows release $env:TAG $stage is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>"
|
||||
$payload = @{
|
||||
text = $message
|
||||
blocks = @(
|
||||
@@ -1085,6 +1330,90 @@ jobs:
|
||||
}
|
||||
$signature.SignerCertificate | Format-List Subject,Issuer,NotBefore,NotAfter,Thumbprint
|
||||
|
||||
# Why: evidence gate for inner-binary signing (issue #7785, supersedes
|
||||
# PR #7170's Orca.exe-only gate — this covers every staged .exe/.dll/.node
|
||||
# by extracting the shipped installer). Warn-only until the flow has been
|
||||
# proven on a real release, then flip ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED
|
||||
# to 'true' so unsigned inner binaries block the release.
|
||||
- name: Verify Windows inner binary signatures
|
||||
if: matrix.platform == 'win'
|
||||
shell: pwsh
|
||||
env:
|
||||
ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED: 'false'
|
||||
INNER_SIGNING_COMPLETED: ${{ steps.rebuild-nsis-signed.outcome == 'success' }}
|
||||
run: |
|
||||
$required = $env:ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED -eq 'true'
|
||||
if ($env:INNER_SIGNING_COMPLETED -ne 'true') {
|
||||
$message = 'Windows inner-binary signing did not complete; this release ships unsigned inner binaries (fail-open, issue #7785).'
|
||||
if ($required) { throw $message }
|
||||
Write-Host "::warning::$message"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Why try/catch: while the gate is warn-only, even an unexpected
|
||||
# script error (extraction hiccup, missing file) must not block
|
||||
# the release — only the flip to required makes failures fatal.
|
||||
try {
|
||||
$report = New-Object System.Collections.Generic.List[string]
|
||||
$failures = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
# Why: verify the files a user actually gets on disk, not the build
|
||||
# tree — 7z parses the NSIS exe directly as its embedded payload.
|
||||
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
|
||||
New-Item -ItemType Directory -Path inner-evidence-extract -Force | Out-Null
|
||||
& $7za x 'dist/orca-windows-setup.exe' '-oinner-evidence-extract' -y | Out-Null
|
||||
|
||||
$root = Resolve-Path 'inner-evidence-extract'
|
||||
# Why elevate.exe is always appended: staging skips already-signed
|
||||
# files, and the persisted electron-builder cache can carry a
|
||||
# previously signed elevate.exe — so it may be absent from the list
|
||||
# in some runs, yet it is the file most at risk of losing its
|
||||
# signature in the NSIS rebuild. Verify it in every release.
|
||||
$targets = @(Get-Content 'inner-signing-list.txt')
|
||||
if ($targets -notcontains 'resources\elevate.exe') {
|
||||
$targets += 'resources\elevate.exe'
|
||||
}
|
||||
foreach ($relative in $targets) {
|
||||
$path = Join-Path $root $relative
|
||||
if (-not (Test-Path $path)) {
|
||||
$failures.Add("missing from installer payload: $relative")
|
||||
continue
|
||||
}
|
||||
$signature = Get-AuthenticodeSignature -FilePath $path
|
||||
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
|
||||
$line = "{0,-14} {1} <{2}>" -f $signature.Status, $relative, $subject
|
||||
$report.Add($line)
|
||||
Write-Host $line
|
||||
if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') {
|
||||
$failures.Add("not signed by SignPath Foundation: $relative ($($signature.Status), $subject)")
|
||||
}
|
||||
}
|
||||
|
||||
Set-Content -Path 'inner-signing-evidence.txt' -Value ($report -join "`n")
|
||||
if ($failures.Count -gt 0) {
|
||||
$failures | ForEach-Object { Write-Host "::warning::$_" }
|
||||
$message = "Windows inner-binary evidence gate found $($failures.Count) problems."
|
||||
if ($required) { throw $message }
|
||||
Write-Host "::warning::$message Fail-open until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is 'true'."
|
||||
} else {
|
||||
Write-Host "All $($targets.Count) inner binaries in the shipped installer are signed by SignPath Foundation."
|
||||
}
|
||||
} catch {
|
||||
if ($required) { throw }
|
||||
Write-Host "::warning::Windows inner-binary evidence gate errored: $_ (fail-open, issue #7785)."
|
||||
}
|
||||
|
||||
- name: Upload Windows inner signing evidence
|
||||
if: always() && matrix.platform == 'win'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: orca-windows-inner-signing-evidence-${{ needs.cut.outputs.tag }}
|
||||
path: |
|
||||
inner-signing-evidence.txt
|
||||
inner-signing-list.txt
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
|
||||
- name: Publish signed Windows release artifacts
|
||||
if: matrix.platform == 'win'
|
||||
uses: nick-fields/retry@v4
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
name: Windows Update-Survival E2E
|
||||
|
||||
# Why: proves what happens to terminal sessions across a real Windows app update
|
||||
# by installing one released version, updating to another, and asserting on the
|
||||
# packaged artifacts (daemon survival, terminal interactivity, zero console
|
||||
# flashes). This MUST run on a disposable CI Windows: electron-builder's oneClick
|
||||
# installer uninstalls the registry-registered copy of the app before every
|
||||
# install, so running the harness on a machine with a real Orca install would
|
||||
# delete it. A fresh runner has no Orca registered, so it is the only safe home
|
||||
# for install-based testing. Manual dispatch only.
|
||||
|
||||
# Why: workflow_dispatch only works once a workflow is on the default branch.
|
||||
# To exercise this from the unmerged feature branch WITHOUT touching main, a
|
||||
# push trigger scoped to that exact branch runs the workflow from the branch's
|
||||
# own tree. The paths filter keeps it to harness/workflow edits so ordinary
|
||||
# commits don't spend 30 min of Windows runner time. Push runs have no inputs,
|
||||
# so every parameter falls back to a default below.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- Jinwoo-H/windows-update-survival
|
||||
paths:
|
||||
- 'tools/win-update-e2e/**'
|
||||
- '.github/workflows/win-update-e2e.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
from_tag:
|
||||
description: Release tag to install first (e.g. v1.4.124-rc.8)
|
||||
required: true
|
||||
type: string
|
||||
to_tag:
|
||||
description: Release tag to update to (e.g. v1.4.124-rc.9)
|
||||
required: true
|
||||
type: string
|
||||
expect:
|
||||
description: Expected outcome profile
|
||||
required: true
|
||||
type: choice
|
||||
default: cold-restore
|
||||
options:
|
||||
- cold-restore
|
||||
- survival
|
||||
asset_pattern:
|
||||
description: Installer asset glob
|
||||
required: false
|
||||
type: string
|
||||
default: '*windows-setup.exe'
|
||||
soak_seconds:
|
||||
description: Post-relaunch console-window watch duration
|
||||
required: false
|
||||
type: string
|
||||
default: '60'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Why: cancel a superseded run when iterating — a new push to the branch makes
|
||||
# the in-flight Windows job obsolete, so free the runner immediately.
|
||||
concurrency:
|
||||
group: win-update-e2e-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update-e2e:
|
||||
name: update ${{ inputs.from_tag || 'v1.4.124-rc.8' }} -> ${{ inputs.to_tag || 'v1.4.124-rc.9' }} (${{ inputs.expect || 'cold-restore' }})
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
FROM_TAG: ${{ inputs.from_tag || 'v1.4.124-rc.8' }}
|
||||
TO_TAG: ${{ inputs.to_tag || 'v1.4.124-rc.9' }}
|
||||
EXPECT: ${{ inputs.expect || 'cold-restore' }}
|
||||
ASSET_PATTERN: ${{ inputs.asset_pattern || '*windows-setup.exe' }}
|
||||
SOAK_SECONDS: ${{ inputs.soak_seconds || '60' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# This job only reads the repo and downloads a release; it never pushes.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
# Why: the harness only needs its runtime deps (the Playwright Electron
|
||||
# driver); it drives already-built installer artifacts, so no app build.
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Why: fail fast with a readable message if a tag has no matching Windows
|
||||
# installer, rather than deep inside the harness.
|
||||
- name: Download installers
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force artifacts/from | Out-Null
|
||||
New-Item -ItemType Directory -Force artifacts/to | Out-Null
|
||||
gh release download "$env:FROM_TAG" --repo "${{ github.repository }}" --pattern "$env:ASSET_PATTERN" --dir artifacts/from
|
||||
gh release download "$env:TO_TAG" --repo "${{ github.repository }}" --pattern "$env:ASSET_PATTERN" --dir artifacts/to
|
||||
$from = Get-ChildItem artifacts/from -Filter *.exe | Select-Object -First 1
|
||||
$to = Get-ChildItem artifacts/to -Filter *.exe | Select-Object -First 1
|
||||
if (-not $from) { throw "No installer matching '$env:ASSET_PATTERN' in $env:FROM_TAG" }
|
||||
if (-not $to) { throw "No installer matching '$env:ASSET_PATTERN' in $env:TO_TAG" }
|
||||
"FROM_EXE=$($from.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
"TO_EXE=$($to.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
|
||||
# Why: no --install-dir here. A CI runner has no real Orca registered, so
|
||||
# the harness installs to the default per-user location and owns it — the
|
||||
# isolated-install machinery exists only to (imperfectly) protect a real
|
||||
# developer install, which does not apply on a throwaway runner.
|
||||
- name: Run update-survival harness
|
||||
id: harness
|
||||
shell: pwsh
|
||||
env:
|
||||
# Why: on a driving failure the harness dumps a screenshot + DOM state
|
||||
# here so the actual post-onboarding UI is visible in the artifacts.
|
||||
ORCA_E2E_DIAG_DIR: artifacts/diag
|
||||
run: |
|
||||
$log = "artifacts/harness-output.log"
|
||||
node tools/win-update-e2e/run.mjs `
|
||||
--from "$env:FROM_EXE" `
|
||||
--to "$env:TO_EXE" `
|
||||
--expect "$env:EXPECT" `
|
||||
--soak-seconds "$env:SOAK_SECONDS" 2>&1 | Tee-Object -FilePath $log
|
||||
exit $LASTEXITCODE
|
||||
|
||||
- name: Upload harness output
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: win-update-e2e-output
|
||||
# Why: log + diagnostics only, never the multi-hundred-MB installers.
|
||||
path: |
|
||||
artifacts/harness-output.log
|
||||
artifacts/diag/**
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
@@ -0,0 +1,116 @@
|
||||
name: Windows Update-Survival E2E (branch build)
|
||||
|
||||
# Why: proves the Phase 1 daemon-host relocation actually makes terminal
|
||||
# sessions SURVIVE a Windows update. Builds an (unsigned) installer FROM THIS
|
||||
# BRANCH, then runs the win-update-e2e harness with --expect survival: install,
|
||||
# open a terminal, silently update over it, relaunch, and assert the daemon PID
|
||||
# is unchanged and the pre-update terminal is still interactive with no console
|
||||
# flashing. Runs from the feature branch via push (workflow_dispatch only works
|
||||
# on the default branch); main is never touched. A CI runner is the only safe
|
||||
# place to install/update — see the relocation post-mortem.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- Jinwoo-H/windows-update-survival
|
||||
paths:
|
||||
- 'src/main/daemon/**'
|
||||
- 'src/main/pty/**'
|
||||
- 'tools/win-update-e2e/**'
|
||||
- 'config/electron-builder.config.cjs'
|
||||
- '.github/workflows/win-update-survival-e2e.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
expect:
|
||||
description: Expected outcome profile
|
||||
required: true
|
||||
type: choice
|
||||
default: survival
|
||||
options:
|
||||
- survival
|
||||
- cold-restore
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: win-update-survival-e2e-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
survival:
|
||||
name: survival (branch build over itself)
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 50
|
||||
env:
|
||||
EXPECT: ${{ inputs.expect || 'survival' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# This job only builds and runs the harness locally; it never pushes.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Why: cache the built installer keyed on the inputs that affect it, so a
|
||||
# harness-only edit skips the ~20 min electron-builder build. The daemon
|
||||
# relocation code lives under src/, so changing it correctly rebuilds.
|
||||
- name: Cache branch installer
|
||||
id: cache-installer
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: dist/orca-windows-setup.exe
|
||||
key: branch-installer-${{ hashFiles('src/**', 'config/**', 'package.json', 'pnpm-lock.yaml') }}
|
||||
|
||||
- name: Build Windows installer (unsigned)
|
||||
if: steps.cache-installer.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
node config/scripts/ensure-native-runtime.mjs --runtime=electron
|
||||
pnpm run build:desktop
|
||||
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never
|
||||
|
||||
# Why: install the branch build, open a terminal, update the SAME build
|
||||
# over it, and assert the relocated daemon survives. Same build on both
|
||||
# sides isolates the survival mechanism (NSIS kill sweep + userData daemon
|
||||
# + adoption) from cross-version staleness policy. No --install-dir: a CI
|
||||
# runner has no real Orca to protect.
|
||||
- name: Run survival harness
|
||||
id: harness
|
||||
shell: pwsh
|
||||
env:
|
||||
ORCA_E2E_DIAG_DIR: artifacts/diag
|
||||
run: |
|
||||
New-Item -ItemType Directory -Force artifacts | Out-Null
|
||||
$exe = "dist/orca-windows-setup.exe"
|
||||
if (-not (Test-Path $exe)) { throw "Installer not found at $exe" }
|
||||
$log = "artifacts/survival-output.log"
|
||||
node tools/win-update-e2e/run.mjs `
|
||||
--from "$exe" `
|
||||
--to "$exe" `
|
||||
--expect "$env:EXPECT" `
|
||||
--soak-seconds 60 2>&1 | Tee-Object -FilePath $log
|
||||
exit $LASTEXITCODE
|
||||
|
||||
- name: Upload survival output
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: win-update-survival-output
|
||||
path: |
|
||||
artifacts/survival-output.log
|
||||
artifacts/diag/**
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
@@ -0,0 +1,360 @@
|
||||
# Windows inner-binary signing rehearsal.
|
||||
#
|
||||
# Why: SignPath cannot deep-sign inside NSIS installers, so shipping signed
|
||||
# inner binaries (Orca.exe, node-pty *.node, DLLs — see issue #7785) requires
|
||||
# a two-request flow: sign the unpacked PE files first, then build the NSIS
|
||||
# installer from the signed tree, then sign the installer. This workflow
|
||||
# rehearses that entire flow from a branch, end to end, without publishing
|
||||
# anything — so the release pipeline on main is never at risk while we verify.
|
||||
#
|
||||
# Runs only via manual dispatch. Use the test-signing policy for iteration
|
||||
# (auto-approved test certificate) and release-signing to rehearse the
|
||||
# production flow (requires manual SignPath approvals). Rehearsed successfully
|
||||
# with test-signing (run 28987534795) and release-signing (run 28988432001).
|
||||
name: Windows signing rehearsal
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
signing-policy-slug:
|
||||
description: SignPath signing policy
|
||||
type: choice
|
||||
default: test-signing
|
||||
options:
|
||||
- test-signing
|
||||
- release-signing
|
||||
inner-artifact-configuration-slug:
|
||||
description: SignPath artifact configuration for the inner-binaries zip
|
||||
type: string
|
||||
default: windows-inner-binaries-zip
|
||||
|
||||
jobs:
|
||||
rehearse:
|
||||
# Why: SignPath origin verification requires GitHub-hosted runners, and
|
||||
# windows-2022 matches the real release job's build environment.
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 360
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Why: nothing here pushes; keep the token out of .git/config.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Cache electron-builder downloads
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: |
|
||||
~\AppData\Local\electron\Cache
|
||||
~\AppData\Local\electron-builder\Cache
|
||||
key: electron-builder-win-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
electron-builder-win-
|
||||
|
||||
- name: Install dependencies
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: pnpm install --frozen-lockfile
|
||||
|
||||
# Why: rehearsal builds are never published, so the official-build
|
||||
# secrets (telemetry key, diagnostics URL) are intentionally omitted.
|
||||
- name: Build app
|
||||
run: pnpm build:release
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
|
||||
- name: Package unpacked Windows app
|
||||
shell: pwsh
|
||||
run: |
|
||||
node config/scripts/ensure-native-runtime.mjs --runtime=electron
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --dir --publish never
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
if (-not (Test-Path 'dist/win-unpacked/Orca.exe')) {
|
||||
throw 'electron-builder --dir did not produce dist/win-unpacked/Orca.exe'
|
||||
}
|
||||
|
||||
# Why: only unsigned PE files go to SignPath. Files that already carry a
|
||||
# valid signature (Microsoft's OpenConsole.exe, signed vendor DLLs) must
|
||||
# keep their original signer, so they are excluded from the request.
|
||||
- name: Stage unsigned PE files for inner signing
|
||||
id: stage-inner
|
||||
shell: pwsh
|
||||
run: |
|
||||
$root = Resolve-Path 'dist/win-unpacked'
|
||||
$stage = New-Item -ItemType Directory -Force -Path 'signing-stage'
|
||||
$list = New-Object System.Collections.Generic.List[string]
|
||||
$skipped = New-Object System.Collections.Generic.List[string]
|
||||
|
||||
Get-ChildItem -Path $root -Recurse -File |
|
||||
Where-Object { $_.Extension -in '.exe', '.dll', '.node' } |
|
||||
ForEach-Object {
|
||||
$relative = [System.IO.Path]::GetRelativePath($root, $_.FullName)
|
||||
$signature = Get-AuthenticodeSignature -FilePath $_.FullName
|
||||
if ($signature.Status -eq 'Valid') {
|
||||
$skipped.Add("$relative <already signed: $($signature.SignerCertificate.Subject)>")
|
||||
return
|
||||
}
|
||||
$destination = Join-Path $stage.FullName $relative
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path $destination) | Out-Null
|
||||
Copy-Item -Path $_.FullName -Destination $destination -Force
|
||||
$list.Add($relative)
|
||||
}
|
||||
|
||||
if (-not ($list -contains 'Orca.exe')) {
|
||||
throw 'Orca.exe was not staged for signing; unpacked layout changed?'
|
||||
}
|
||||
if (-not ($list | Where-Object { $_ -like '*conpty_console_list.node' })) {
|
||||
throw 'node-pty conpty_console_list.node was not staged; this is the file from issue #7785.'
|
||||
}
|
||||
|
||||
Set-Content -Path 'inner-signing-list.txt' -Value ($list -join "`n")
|
||||
Write-Host "Staged $($list.Count) unsigned PE files for signing:"
|
||||
$list | ForEach-Object { Write-Host " $_" }
|
||||
Write-Host "Skipped $($skipped.Count) already-signed files:"
|
||||
$skipped | ForEach-Object { Write-Host " $_" }
|
||||
|
||||
- name: Upload unsigned inner binaries for SignPath
|
||||
id: upload-unsigned-inner
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: orca-windows-inner-unsigned-${{ github.run_id }}
|
||||
path: signing-stage/**
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Install SignPath PowerShell module
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
$useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue)
|
||||
if ($useResourceGet) {
|
||||
if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
|
||||
Register-PSResourceRepository -PSGallery -Trusted
|
||||
} else {
|
||||
Set-PSResourceRepository -Name PSGallery -Trusted
|
||||
}
|
||||
Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop
|
||||
} else {
|
||||
Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
|
||||
}
|
||||
Import-Module SignPath -ErrorAction Stop
|
||||
Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop
|
||||
|
||||
- name: Submit inner binaries signing request
|
||||
id: submit-inner-signing
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
|
||||
project-slug: orca
|
||||
signing-policy-slug: ${{ inputs.signing-policy-slug || 'test-signing' }}
|
||||
artifact-configuration-slug: ${{ inputs.inner-artifact-configuration-slug || 'windows-inner-binaries-zip' }}
|
||||
github-artifact-id: ${{ steps.upload-unsigned-inner.outputs.artifact-id }}
|
||||
wait-for-completion: false
|
||||
|
||||
- name: Download signed inner binaries from SignPath
|
||||
shell: pwsh
|
||||
env:
|
||||
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
|
||||
run: |
|
||||
Get-SignedArtifact `
|
||||
-ApiToken $env:SIGNPATH_API_TOKEN `
|
||||
-OrganizationId 'c37aa192-a27a-4377-9c90-5d6c95912dc0' `
|
||||
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
|
||||
-OutputArtifactPath signed-inner.zip `
|
||||
-Force `
|
||||
-WaitForCompletionTimeoutInSeconds 3600
|
||||
New-Item -ItemType Directory -Path signed-inner -Force
|
||||
Expand-Archive -Path signed-inner.zip -DestinationPath signed-inner -Force
|
||||
|
||||
# Why: copy back strictly by the staged list so a layout mismatch in the
|
||||
# returned artifact fails loudly instead of silently shipping a mix of
|
||||
# signed and unsigned binaries.
|
||||
- name: Restore signed inner binaries into unpacked app
|
||||
shell: pwsh
|
||||
run: |
|
||||
$root = Resolve-Path 'dist/win-unpacked'
|
||||
$failures = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($relative in Get-Content 'inner-signing-list.txt') {
|
||||
$signed = Get-ChildItem -Path signed-inner -Recurse -File |
|
||||
Where-Object { [System.IO.Path]::GetRelativePath((Resolve-Path 'signed-inner'), $_.FullName).TrimStart('\', '/') -like "*$relative" } |
|
||||
Select-Object -First 1
|
||||
if ($null -eq $signed) {
|
||||
$failures.Add("missing from signed artifact: $relative")
|
||||
continue
|
||||
}
|
||||
$signature = Get-AuthenticodeSignature -FilePath $signed.FullName
|
||||
if ($null -eq $signature.SignerCertificate) {
|
||||
$failures.Add("returned without a signature: $relative")
|
||||
continue
|
||||
}
|
||||
Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force
|
||||
Write-Host ("{0,-14} {1} <{2}>" -f $signature.Status, $relative, $signature.SignerCertificate.Subject)
|
||||
}
|
||||
if ($failures.Count -gt 0) {
|
||||
$failures | ForEach-Object { Write-Host "::error::$_" }
|
||||
throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)."
|
||||
}
|
||||
|
||||
- name: Build NSIS installer from signed unpacked app
|
||||
shell: pwsh
|
||||
run: |
|
||||
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked"
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
if (-not (Test-Path 'dist/orca-windows-setup.exe')) {
|
||||
throw 'electron-builder --prepackaged did not produce dist/orca-windows-setup.exe'
|
||||
}
|
||||
|
||||
- name: Upload unsigned Windows installer for SignPath
|
||||
id: upload-unsigned-installer
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: orca-windows-installer-unsigned-${{ github.run_id }}
|
||||
path: dist/orca-windows-setup.exe
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Submit Windows installer signing request
|
||||
id: submit-installer-signing
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
|
||||
project-slug: orca
|
||||
signing-policy-slug: ${{ inputs.signing-policy-slug || 'test-signing' }}
|
||||
artifact-configuration-slug: github-actions-windows-installer
|
||||
github-artifact-id: ${{ steps.upload-unsigned-installer.outputs.artifact-id }}
|
||||
wait-for-completion: false
|
||||
|
||||
- name: Download signed Windows installer from SignPath
|
||||
shell: pwsh
|
||||
env:
|
||||
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
SIGNPATH_REQUEST_ID: ${{ steps.submit-installer-signing.outputs.signing-request-id }}
|
||||
run: |
|
||||
Get-SignedArtifact `
|
||||
-ApiToken $env:SIGNPATH_API_TOKEN `
|
||||
-OrganizationId 'c37aa192-a27a-4377-9c90-5d6c95912dc0' `
|
||||
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
|
||||
-OutputArtifactPath signed-windows.zip `
|
||||
-Force `
|
||||
-WaitForCompletionTimeoutInSeconds 14400
|
||||
New-Item -ItemType Directory -Path signed-windows -Force
|
||||
Expand-Archive -Path signed-windows.zip -DestinationPath signed-windows -Force
|
||||
|
||||
# Why: signing changes installer bytes, so the updater metadata must be
|
||||
# regenerated exactly like the release job's staging step does.
|
||||
- name: Stage signed Windows installer and regenerate updater metadata
|
||||
shell: pwsh
|
||||
run: |
|
||||
$signedInstaller = Get-ChildItem -Path signed-windows -Recurse -File -Filter 'orca-windows-setup.exe' | Select-Object -First 1
|
||||
if ($null -eq $signedInstaller) {
|
||||
throw 'Signed Windows installer was not returned by SignPath.'
|
||||
}
|
||||
Copy-Item -Path $signedInstaller.FullName -Destination 'dist/orca-windows-setup.exe' -Force
|
||||
& 'node_modules/app-builder-bin/win/x64/app-builder.exe' blockmap --input 'dist/orca-windows-setup.exe' --output 'dist/orca-windows-setup.exe.blockmap'
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
$installer = Get-Item 'dist/orca-windows-setup.exe'
|
||||
$blockmap = Get-Item 'dist/orca-windows-setup.exe.blockmap'
|
||||
$stream = [System.IO.File]::OpenRead($installer.FullName)
|
||||
try {
|
||||
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
||||
$hash = [Convert]::ToBase64String($sha512.ComputeHash($stream))
|
||||
} finally {
|
||||
if ($null -ne $sha512) { $sha512.Dispose() }
|
||||
$stream.Dispose()
|
||||
}
|
||||
$latestYml = Get-Content -Path 'dist/latest.yml' -Raw
|
||||
$latestYml = [regex]::Replace($latestYml, '(?m)^(\s*)sha512: .+$', {
|
||||
param($match)
|
||||
"$($match.Groups[1].Value)sha512: $hash"
|
||||
})
|
||||
$latestYml = $latestYml -replace '(?m)^ size: \d+$', " size: $($installer.Length)"
|
||||
$latestYml = $latestYml -replace '(?m)^ blockMapSize: \d+$', " blockMapSize: $($blockmap.Length)"
|
||||
Set-Content -Path 'dist/latest.yml' -Value $latestYml -NoNewline
|
||||
|
||||
# Why: this is the pass/fail heart of the rehearsal — the same evidence
|
||||
# check that will later gate releases (see PR #7170). Test certificates
|
||||
# do not chain to a trusted root, so Status=Valid is only required for
|
||||
# release-signing runs; test-signing runs require a present signature.
|
||||
- name: Verify signatures end to end
|
||||
shell: pwsh
|
||||
env:
|
||||
SIGNING_POLICY: ${{ inputs.signing-policy-slug || 'test-signing' }}
|
||||
run: |
|
||||
$report = New-Object System.Collections.Generic.List[string]
|
||||
$failures = New-Object System.Collections.Generic.List[string]
|
||||
$requireValid = $env:SIGNING_POLICY -eq 'release-signing'
|
||||
|
||||
function Test-Signature([string]$label, [string]$path) {
|
||||
$signature = Get-AuthenticodeSignature -FilePath $path
|
||||
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
|
||||
$line = "{0,-14} {1} <{2}>" -f $signature.Status, $label, $subject
|
||||
$script:report.Add($line)
|
||||
Write-Host $line
|
||||
if ($null -eq $signature.SignerCertificate -or $signature.Status -eq 'NotSigned') {
|
||||
$script:failures.Add("unsigned: $label")
|
||||
} elseif ($script:requireValid -and $signature.Status -ne 'Valid') {
|
||||
$script:failures.Add("not Valid under release-signing: $label ($($signature.Status))")
|
||||
} elseif ($script:requireValid -and $subject -notlike '*CN=SignPath Foundation*') {
|
||||
$script:failures.Add("unexpected signer: $label ($subject)")
|
||||
}
|
||||
}
|
||||
|
||||
Test-Signature 'installer: orca-windows-setup.exe' 'dist/orca-windows-setup.exe'
|
||||
|
||||
# Extract the signed installer and verify the files a user actually
|
||||
# gets on disk — including the exact file from issue #7785.
|
||||
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
|
||||
New-Item -ItemType Directory -Path extracted-app -Force | Out-Null
|
||||
& $7za x 'dist/orca-windows-setup.exe' '-oextracted-app' -y | Out-Null
|
||||
|
||||
$root = Resolve-Path 'extracted-app'
|
||||
foreach ($relative in Get-Content 'inner-signing-list.txt') {
|
||||
$path = Join-Path $root $relative
|
||||
if (-not (Test-Path $path)) {
|
||||
$failures.Add("missing from installer payload: $relative")
|
||||
continue
|
||||
}
|
||||
Test-Signature "installed: $relative" $path
|
||||
}
|
||||
|
||||
Set-Content -Path 'signing-evidence.txt' -Value ($report -join "`n")
|
||||
if ($failures.Count -gt 0) {
|
||||
$failures | ForEach-Object { Write-Host "::error::$_" }
|
||||
throw "Signing rehearsal failed with $($failures.Count) problems."
|
||||
}
|
||||
Write-Host "All $((Get-Content 'inner-signing-list.txt').Count) inner binaries plus the installer are signed."
|
||||
|
||||
- name: Upload rehearsal evidence and installer
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: windows-signing-rehearsal-${{ inputs.signing-policy-slug || 'test-signing' }}-${{ github.run_id }}
|
||||
path: |
|
||||
signing-evidence.txt
|
||||
inner-signing-list.txt
|
||||
dist/orca-windows-setup.exe
|
||||
dist/orca-windows-setup.exe.blockmap
|
||||
dist/latest.yml
|
||||
retention-days: 7
|
||||
@@ -62,6 +62,7 @@ coverage/
|
||||
|
||||
# Benchmark run output
|
||||
/tools/benchmarks/results/*.json
|
||||
/.bench-fixtures/
|
||||
|
||||
# Temp
|
||||
tmp/
|
||||
|
||||
@@ -12,7 +12,9 @@ Keep comments short — one or two lines. Capture only the non-obvious reason (s
|
||||
|
||||
## Lint Rules: Do Not Disable Max Lines
|
||||
|
||||
Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants). Split the file, extract focused modules, move fixtures/builders into named files, or otherwise reduce the counted lines instead.
|
||||
Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants), and never add a per-file `max-lines` bump in `mobile/.oxlintrc.json`. Split the file, extract focused modules, move fixtures/builders into named files, or otherwise reduce the counted lines instead.
|
||||
|
||||
This is enforced in CI by `pnpm check:max-lines-ratchet` (`config/scripts/check-max-lines-ratchet.mjs`). Files already over the limit are grandfathered in `config/max-lines-baseline.txt`; that list may only **shrink**. Adding a new suppression fails the build. If you remove a suppression (great — split it!), run `pnpm check:max-lines-ratchet --prune` to drop its baseline line.
|
||||
|
||||
## File and Module Naming
|
||||
|
||||
|
||||
@@ -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.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/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.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/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.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
|
||||
- **Android:** [Download APK 0.0.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
@@ -238,9 +238,10 @@ Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
|
||||
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
|
||||
- **WeChat:** Scan the QR code to join the community.
|
||||
- **WeChat:** Scan the QR code to join the community. If the first group is full, use the backup group.
|
||||
|
||||
<img src="docs/assets/wechat-qr.png" alt="WeChat QR code for the Orca community" width="160" />
|
||||
<img src="docs/assets/wechat-qr-backup.jpg" alt="Backup WeChat QR code for the Orca community" width="160" />
|
||||
|
||||
- **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
|
||||
- **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
import type { NormalizedOutputOptions, OutputBundle, OutputChunk, Plugin } from 'rollup'
|
||||
|
||||
// Why: v1.4.129-rc.1 shipped a dead terminal daemon because a shared main
|
||||
// chunk gained `require("electron")` (an import edge added in #7642), and the
|
||||
// daemon is forked as a plain-Node process where electron cannot be required.
|
||||
// Nothing in CI executes the built daemon-entry under plain Node, so the leak
|
||||
// stayed invisible until an adopted old daemon died. This guard fails the
|
||||
// build when any chunk reachable from a plain-Node fork entry requires
|
||||
// electron, and smoke-loads daemon-entry under plain Node to prove its module
|
||||
// graph still resolves.
|
||||
|
||||
// Entries executed as plain Node (ELECTRON_RUN_AS_NODE / no electron runtime):
|
||||
// forked daemon, parcel-watcher and computer sidecars, and the CLI-run
|
||||
// agent-hooks entry. require("electron") throws MODULE_NOT_FOUND in all of them.
|
||||
const PLAIN_NODE_ENTRY_NAMES = [
|
||||
'daemon-entry',
|
||||
'parcel-watcher-process-entry',
|
||||
'computer-sidecar',
|
||||
'agent-hooks/managed-agent-hook-controls'
|
||||
] as const
|
||||
|
||||
const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/
|
||||
|
||||
function collectReachableChunks(
|
||||
entry: OutputChunk,
|
||||
byFileName: Map<string, OutputChunk>
|
||||
): OutputChunk[] {
|
||||
const seen = new Set<string>()
|
||||
const reachable: OutputChunk[] = []
|
||||
const stack = [entry.fileName]
|
||||
while (stack.length > 0) {
|
||||
const fileName = stack.pop() as string
|
||||
if (seen.has(fileName)) {
|
||||
continue
|
||||
}
|
||||
seen.add(fileName)
|
||||
const chunk = byFileName.get(fileName)
|
||||
if (!chunk) {
|
||||
continue
|
||||
}
|
||||
reachable.push(chunk)
|
||||
for (const imported of [...chunk.imports, ...chunk.dynamicImports]) {
|
||||
stack.push(imported)
|
||||
}
|
||||
}
|
||||
return reachable
|
||||
}
|
||||
|
||||
function assertNoElectronRequire(
|
||||
entryName: string,
|
||||
entry: OutputChunk,
|
||||
byFileName: Map<string, OutputChunk>
|
||||
): void {
|
||||
for (const chunk of collectReachableChunks(entry, byFileName)) {
|
||||
if (ELECTRON_REQUIRE_RE.test(chunk.code)) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` +
|
||||
`requires electron. "${entryName}" runs as a plain-Node process, where ` +
|
||||
`require("electron") throws MODULE_NOT_FOUND and kills it at startup (the ` +
|
||||
`v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: proves the whole daemon-entry graph resolves under plain Node (no
|
||||
// unresolved requires). require("electron") does not throw in a dev tree with
|
||||
// node_modules present, so the static scan above — not this smoke — is the
|
||||
// electron regression guard; this only catches gross load failures.
|
||||
function smokeLoadDaemonEntry(outputDir: string): void {
|
||||
const entryPath = join(outputDir, 'daemon-entry.js')
|
||||
const result = spawnSync(process.execPath, [entryPath], {
|
||||
encoding: 'utf8',
|
||||
timeout: 15_000
|
||||
})
|
||||
if (result.error) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] could not smoke-load daemon-entry.js under plain Node: ` +
|
||||
`${result.error.message}`
|
||||
)
|
||||
}
|
||||
const stderr = result.stderr ?? ''
|
||||
if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] daemon-entry.js failed to load under plain Node:\n${stderr}`
|
||||
)
|
||||
}
|
||||
if (!stderr.includes('Usage: daemon-entry')) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] daemon-entry.js did not reach argv parsing under plain Node ` +
|
||||
`(expected the "Usage: daemon-entry" error). stderr:\n${stderr}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function createPlainNodeEntryGuardPlugin(): Plugin {
|
||||
return {
|
||||
name: 'orca-plain-node-entry-guard',
|
||||
writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) {
|
||||
// Why: skip in `electron-vite dev` watch mode — the smoke would respawn on
|
||||
// every rebuild, and the guard only needs to gate produced builds.
|
||||
if (this.meta.watchMode) {
|
||||
return
|
||||
}
|
||||
const chunks = Object.values(bundle).filter(
|
||||
(item): item is OutputChunk => item.type === 'chunk'
|
||||
)
|
||||
const byFileName = new Map(chunks.map((chunk) => [chunk.fileName, chunk]))
|
||||
const entryByName = new Map<string, OutputChunk>()
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.isEntry && chunk.name) {
|
||||
entryByName.set(chunk.name, chunk)
|
||||
}
|
||||
}
|
||||
|
||||
for (const entryName of PLAIN_NODE_ENTRY_NAMES) {
|
||||
const entry = entryByName.get(entryName)
|
||||
if (entry) {
|
||||
assertNoElectronRequire(entryName, entry, byFileName)
|
||||
}
|
||||
}
|
||||
|
||||
if (entryByName.has('daemon-entry') && options.dir) {
|
||||
smokeLoadDaemonEntry(options.dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ const { chmodSync, existsSync, readdirSync } = require('node:fs')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const { join, resolve } = require('node:path')
|
||||
const electronBuilderNativeRebuild = require('./scripts/electron-builder-native-rebuild.cjs')
|
||||
const { verifyPackagedDaemonEntryBoots } = require('./scripts/verify-packaged-daemon-entry.cjs')
|
||||
const {
|
||||
createPackagedRuntimeNodeModuleResources,
|
||||
prunePackagedRuntimeNodeModules,
|
||||
@@ -106,6 +107,7 @@ module.exports = {
|
||||
'out/main/win32-utils.js',
|
||||
'out/main/daemon-entry.js',
|
||||
'out/main/computer-sidecar.js',
|
||||
'out/main/parcel-watcher-process-entry.js',
|
||||
'out/main/chunks/**',
|
||||
'resources/**',
|
||||
'node_modules/ws/**',
|
||||
@@ -129,6 +131,20 @@ module.exports = {
|
||||
}
|
||||
prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch)
|
||||
verifyPackagedMainRuntimeDeps(resourcesDir)
|
||||
// Why: boot the packaged daemon-entry under plain Node, but only for the
|
||||
// slice matching the packaging host's arch — daemon-entry.js is JS, yet it
|
||||
// require()s the native (N-API) node-pty for the TARGET arch, which the host
|
||||
// Node cannot load cross-arch. `Arch` enum: ia32=0, x64=1, armv7l=2,
|
||||
// arm64=3, universal=4 (universal contains the host slice, so run it).
|
||||
const archEnumByNodeArch = { ia32: 0, x64: 1, armv7l: 2, arm64: 3 }
|
||||
const hostArchEnum = archEnumByNodeArch[process.arch]
|
||||
if (context.arch === hostArchEnum || context.arch === 4) {
|
||||
verifyPackagedDaemonEntryBoots(resourcesDir)
|
||||
} else {
|
||||
console.log(
|
||||
`[verify-packaged-daemon-entry] skipped cross-arch slice (target ${context.arch}, host ${process.arch})`
|
||||
)
|
||||
}
|
||||
chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName)
|
||||
chmodMacServeSimHelpers(resourcesDir, context.electronPlatformName)
|
||||
for (const filename of readdirSync(resourcesDir)) {
|
||||
@@ -142,6 +158,10 @@ module.exports = {
|
||||
}
|
||||
if (context.electronPlatformName === 'darwin') {
|
||||
await signMacComputerUseHelper(join(resourcesDir, 'Orca Computer Use.app'), context.packager)
|
||||
await signMacNotificationStatusHelper(
|
||||
join(resourcesDir, '..', 'MacOS', 'orca-notification-status'),
|
||||
context.packager
|
||||
)
|
||||
}
|
||||
},
|
||||
win: {
|
||||
@@ -173,7 +193,11 @@ module.exports = {
|
||||
artifactName: 'orca-windows-setup.${ext}',
|
||||
shortcutName: '${productName}',
|
||||
uninstallDisplayName: '${productName}',
|
||||
createDesktopShortcut: 'always'
|
||||
createDesktopShortcut: 'always',
|
||||
// Why: on a real uninstall, stop and remove the relocated terminal daemon
|
||||
// (which lives outside the install dir under LOCALAPPDATA by design). Guarded
|
||||
// by ${isUpdated} inside so it never runs during an update's uninstallOldVersion.
|
||||
include: resolve(__dirname, 'nsis', 'daemon-host-uninstall.nsh')
|
||||
},
|
||||
mac: {
|
||||
icon: 'resources/build/icon.icns',
|
||||
@@ -229,6 +253,15 @@ module.exports = {
|
||||
},
|
||||
featureWallResources
|
||||
],
|
||||
// Why: the notification-status helper must execute from Contents/MacOS —
|
||||
// on macOS 26 UNUserNotificationCenter aborts (bundleProxyForCurrentProcess
|
||||
// is nil) for executables launched out of Contents/Resources (#7929).
|
||||
extraFiles: [
|
||||
{
|
||||
from: 'native/notification-status-macos/.build/release/orca-notification-status',
|
||||
to: 'MacOS/orca-notification-status'
|
||||
}
|
||||
],
|
||||
target: [
|
||||
{
|
||||
target: 'dmg',
|
||||
@@ -398,6 +431,37 @@ async function signMacComputerUseHelper(helperAppPath, packager) {
|
||||
})
|
||||
}
|
||||
|
||||
async function signMacNotificationStatusHelper(helperPath, packager) {
|
||||
if (!existsSync(helperPath)) {
|
||||
if (isMacRelease) {
|
||||
throw new Error(`Missing orca-notification-status helper at ${helperPath}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
const codeSigningInfo =
|
||||
isMacRelease && process.env.CSC_LINK && packager?.codeSigningInfo?.value
|
||||
? await packager.codeSigningInfo.value
|
||||
: null
|
||||
const identity =
|
||||
process.env.CSC_NAME ??
|
||||
findInstalledMacSigningIdentity(codeSigningInfo?.keychainFile) ??
|
||||
(isMacRelease ? null : '-')
|
||||
if (!identity) {
|
||||
throw new Error('Missing signing identity for orca-notification-status helper')
|
||||
}
|
||||
// Why: macOS keys notification records to the code-signing identifier; the
|
||||
// binary embeds the app's CFBundleIdentifier in __TEXT,__info_plist so this
|
||||
// (and any later) `codesign --force` derives the correct identifier. Sign
|
||||
// before the outer Orca.app is sealed, like the computer-use helper.
|
||||
const args = ['--force', '--sign', identity]
|
||||
if (isMacRelease) {
|
||||
args.push('--options', 'runtime', '--timestamp')
|
||||
}
|
||||
args.push(helperPath)
|
||||
execFileSync('codesign', args, { stdio: 'inherit' })
|
||||
execFileSync('codesign', ['--verify', '--strict', helperPath], { stdio: 'inherit' })
|
||||
}
|
||||
|
||||
function codesignArgs(identity, targetPath) {
|
||||
const args = ['--force', '--deep', '--sign', identity]
|
||||
if (isMacRelease) {
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
# Files/globs currently allowed to exceed the oxlint `max-lines` budget.
|
||||
# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —
|
||||
# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").
|
||||
# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)
|
||||
inline mobile/src/constants/marine-creatures.ts
|
||||
inline src/cli/handlers/automations.ts
|
||||
inline src/cli/handlers/orchestration.ts
|
||||
inline src/cli/help.ts
|
||||
inline src/cli/index.test.ts
|
||||
inline src/main/agent-hooks/server.test.ts
|
||||
inline src/main/agent-hooks/server.ts
|
||||
inline src/main/amp/hook-service.ts
|
||||
inline src/main/antigravity/hook-service.ts
|
||||
inline src/main/attribution/terminal-attribution.ts
|
||||
inline src/main/automations/external-manager.ts
|
||||
inline src/main/automations/hermes-cron-output.ts
|
||||
inline src/main/browser/agent-browser-bridge.test.ts
|
||||
inline src/main/browser/agent-browser-bridge.ts
|
||||
inline src/main/browser/browser-cookie-import.ts
|
||||
inline src/main/browser/browser-guest-ui.ts
|
||||
inline src/main/browser/browser-manager-grab.test.ts
|
||||
inline src/main/browser/browser-manager.test.ts
|
||||
inline src/main/browser/browser-manager.ts
|
||||
inline src/main/browser/browser-screencast-stream.ts
|
||||
inline src/main/browser/browser-session-registry.ts
|
||||
inline src/main/browser/cdp-bridge.ts
|
||||
inline src/main/browser/cdp-ws-proxy.ts
|
||||
inline src/main/browser/grab-guest-script.ts
|
||||
inline src/main/browser/snapshot-engine.ts
|
||||
inline src/main/claude-accounts/runtime-auth-service.test.ts
|
||||
inline src/main/claude-accounts/runtime-auth-service.ts
|
||||
inline src/main/claude-accounts/service.test.ts
|
||||
inline src/main/claude-accounts/service.ts
|
||||
inline src/main/claude-usage/scanner.ts
|
||||
inline src/main/claude-usage/store.ts
|
||||
inline src/main/cli/cli-installer.test.ts
|
||||
inline src/main/cli/cli-installer.ts
|
||||
inline src/main/cli/wsl-cli-installer.ts
|
||||
inline src/main/codex-accounts/runtime-home-service.test.ts
|
||||
inline src/main/codex-accounts/runtime-home-service.ts
|
||||
inline src/main/codex-accounts/service.test.ts
|
||||
inline src/main/codex-accounts/service.ts
|
||||
inline src/main/codex-usage/scanner.ts
|
||||
inline src/main/codex-usage/store.test.ts
|
||||
inline src/main/codex-usage/store.ts
|
||||
inline src/main/codex/config-toml-trust.test.ts
|
||||
inline src/main/codex/config-toml-trust.ts
|
||||
inline src/main/codex/hook-service.test.ts
|
||||
inline src/main/codex/hook-service.ts
|
||||
inline src/main/copilot/hook-service.ts
|
||||
inline src/main/daemon/client.ts
|
||||
inline src/main/daemon/daemon-health.ts
|
||||
inline src/main/daemon/daemon-init.test.ts
|
||||
inline src/main/daemon/daemon-init.ts
|
||||
inline src/main/daemon/daemon-pty-adapter.test.ts
|
||||
inline src/main/daemon/daemon-pty-adapter.ts
|
||||
inline src/main/daemon/daemon-server.ts
|
||||
inline src/main/daemon/pty-subprocess.test.ts
|
||||
inline src/main/daemon/pty-subprocess.ts
|
||||
inline src/main/daemon/session.ts
|
||||
inline src/main/daemon/shell-ready.ts
|
||||
inline src/main/git/remove-worktree.test.ts
|
||||
inline src/main/git/repo.ts
|
||||
inline src/main/git/runner.ts
|
||||
inline src/main/git/status.test.ts
|
||||
inline src/main/git/status.ts
|
||||
inline src/main/git/worktree.test.ts
|
||||
inline src/main/git/worktree.ts
|
||||
inline src/main/github/client.test.ts
|
||||
inline src/main/github/client.ts
|
||||
inline src/main/github/issues.ts
|
||||
inline src/main/github/pr-refresh-coordinator.test.ts
|
||||
inline src/main/github/pr-refresh-coordinator.ts
|
||||
inline src/main/github/project-view.ts
|
||||
inline src/main/github/project-view/mutations.ts
|
||||
inline src/main/github/work-item-details.ts
|
||||
inline src/main/gitlab/client-mr.test.ts
|
||||
inline src/main/gitlab/client.ts
|
||||
inline src/main/gitlab/issues.ts
|
||||
inline src/main/gitlab/work-item-details.ts
|
||||
inline src/main/hermes/hook-service.ts
|
||||
inline src/main/hooks.test.ts
|
||||
inline src/main/hooks.ts
|
||||
inline src/main/index.ts
|
||||
inline src/main/ipc/browser.ts
|
||||
inline src/main/ipc/crash-reporting.ts
|
||||
inline src/main/ipc/filesystem-auth.ts
|
||||
inline src/main/ipc/filesystem-mutations.ts
|
||||
inline src/main/ipc/filesystem-watcher.ts
|
||||
inline src/main/ipc/filesystem.test.ts
|
||||
inline src/main/ipc/filesystem.ts
|
||||
inline src/main/ipc/github.test.ts
|
||||
inline src/main/ipc/github.ts
|
||||
inline src/main/ipc/gitlab.ts
|
||||
inline src/main/ipc/linear.ts
|
||||
inline src/main/ipc/notifications.test.ts
|
||||
inline src/main/ipc/notifications.ts
|
||||
inline src/main/ipc/pet.ts
|
||||
inline src/main/ipc/preflight.test.ts
|
||||
inline src/main/ipc/pty.test.ts
|
||||
inline src/main/ipc/pty.ts
|
||||
inline src/main/ipc/remote-workspace.ts
|
||||
inline src/main/ipc/repos-remote.test.ts
|
||||
inline src/main/ipc/repos.ts
|
||||
inline src/main/ipc/runtime-environments.test.ts
|
||||
inline src/main/ipc/ssh.test.ts
|
||||
inline src/main/ipc/ssh.ts
|
||||
inline src/main/ipc/worktree-remote.ts
|
||||
inline src/main/ipc/worktrees.test.ts
|
||||
inline src/main/ipc/worktrees.ts
|
||||
inline src/main/jira/client.ts
|
||||
inline src/main/jira/issues.ts
|
||||
inline src/main/keybindings/keybinding-file.ts
|
||||
inline src/main/linear/client.ts
|
||||
inline src/main/linear/issues.ts
|
||||
inline src/main/linear/projects.ts
|
||||
inline src/main/memory/collector.ts
|
||||
inline src/main/opencode-usage/scanner.ts
|
||||
inline src/main/opencode-usage/store.ts
|
||||
inline src/main/opencode/hook-service.ts
|
||||
inline src/main/persistence.test.ts
|
||||
inline src/main/persistence.ts
|
||||
inline src/main/ports/advertised-url-watcher.ts
|
||||
inline src/main/ports/local-workspace-port-scanner.ts
|
||||
inline src/main/project-groups/nested-repo-discovery.ts
|
||||
inline src/main/providers/local-pty-provider.test.ts
|
||||
inline src/main/providers/local-pty-provider.ts
|
||||
inline src/main/providers/local-pty-shell-ready.test.ts
|
||||
inline src/main/providers/local-pty-shell-ready.ts
|
||||
inline src/main/providers/ssh-git-provider.test.ts
|
||||
inline src/main/providers/ssh-git-provider.ts
|
||||
inline src/main/rate-limits/claude-fetcher.test.ts
|
||||
inline src/main/rate-limits/claude-fetcher.ts
|
||||
inline src/main/rate-limits/claude-pty.ts
|
||||
inline src/main/rate-limits/codex-fetcher.ts
|
||||
inline src/main/rate-limits/service.test.ts
|
||||
inline src/main/rate-limits/service.ts
|
||||
inline src/main/runtime/orca-runtime-browser.ts
|
||||
inline src/main/runtime/orca-runtime-files.test.ts
|
||||
inline src/main/runtime/orca-runtime-files.ts
|
||||
inline src/main/runtime/orca-runtime-git.ts
|
||||
inline src/main/runtime/orca-runtime.test.ts
|
||||
inline src/main/runtime/orca-runtime.ts
|
||||
inline src/main/runtime/orchestration/coordinator.ts
|
||||
inline src/main/runtime/orchestration/db.ts
|
||||
inline src/main/runtime/rpc/methods/files.ts
|
||||
inline src/main/runtime/rpc/methods/git.ts
|
||||
inline src/main/runtime/rpc/methods/github.ts
|
||||
inline src/main/runtime/rpc/methods/orchestration.test.ts
|
||||
inline src/main/runtime/rpc/methods/orchestration.ts
|
||||
inline src/main/runtime/rpc/methods/terminal.ts
|
||||
inline src/main/runtime/rpc/terminal-multiplex.test.ts
|
||||
inline src/main/runtime/runtime-rpc.test.ts
|
||||
inline src/main/runtime/runtime-rpc.ts
|
||||
inline src/main/source-control/hosted-review-creation.ts
|
||||
inline src/main/speech/model-manager.ts
|
||||
inline src/main/speech/stt-service.ts
|
||||
inline src/main/ssh/ssh-channel-multiplexer.ts
|
||||
inline src/main/ssh/ssh-connection.test.ts
|
||||
inline src/main/ssh/ssh-connection.ts
|
||||
inline src/main/ssh/ssh-relay-deploy.ts
|
||||
inline src/main/ssh/ssh-relay-session.ts
|
||||
inline src/main/star-nag/service.test.ts
|
||||
inline src/main/text-generation/commit-message-text-generation.test.ts
|
||||
inline src/main/text-generation/commit-message-text-generation.ts
|
||||
inline src/main/updater.test.ts
|
||||
inline src/main/updater.ts
|
||||
inline src/main/window/attach-main-window-services.ts
|
||||
inline src/main/window/createMainWindow.test.ts
|
||||
inline src/main/window/createMainWindow.ts
|
||||
inline src/main/workspace-space-analysis.ts
|
||||
inline src/preload/api-types.ts
|
||||
inline src/preload/index.ts
|
||||
inline src/relay/agent-hook-server.ts
|
||||
inline src/relay/dispatcher.ts
|
||||
inline src/relay/external-automations-handler.ts
|
||||
inline src/relay/fs-handler.ts
|
||||
inline src/relay/git-handler.test.ts
|
||||
inline src/relay/git-handler.ts
|
||||
inline src/relay/pty-handler.test.ts
|
||||
inline src/relay/pty-handler.ts
|
||||
inline src/relay/relay.ts
|
||||
inline src/relay/workspace-space-scan.ts
|
||||
inline src/renderer/src/App.tsx
|
||||
inline src/renderer/src/components/GitHubItemDialog.tsx
|
||||
inline src/renderer/src/components/GitLabItemDialog.tsx
|
||||
inline src/renderer/src/components/JiraIssueWorkspace.tsx
|
||||
inline src/renderer/src/components/LinearIssueWorkspace.tsx
|
||||
inline src/renderer/src/components/LinearItemDrawer.tsx
|
||||
inline src/renderer/src/components/NewWorkspaceComposerCard.tsx
|
||||
inline src/renderer/src/components/PullRequestPage.tsx
|
||||
inline src/renderer/src/components/TaskPage.tsx
|
||||
inline src/renderer/src/components/Terminal.tsx
|
||||
inline src/renderer/src/components/UpdateCard.tsx
|
||||
inline src/renderer/src/components/WorktreeJumpPalette.tsx
|
||||
inline src/renderer/src/components/activity/ActivityPrototypePage.test.ts
|
||||
inline src/renderer/src/components/activity/ActivityPrototypePage.tsx
|
||||
inline src/renderer/src/components/automations/AutomationsPage.tsx
|
||||
inline src/renderer/src/components/browser-pane/BrowserPane.tsx
|
||||
inline src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx
|
||||
inline src/renderer/src/components/editor/CombinedDiffViewer.tsx
|
||||
inline src/renderer/src/components/editor/EditorContent.tsx
|
||||
inline src/renderer/src/components/editor/IpynbViewer.tsx
|
||||
inline src/renderer/src/components/editor/MarkdownPreview.tsx
|
||||
inline src/renderer/src/components/editor/MonacoEditor.tsx
|
||||
inline src/renderer/src/components/editor/editor-autosave-controller.ts
|
||||
inline src/renderer/src/components/editor/ipynb-parse.ts
|
||||
inline src/renderer/src/components/editor/useEditorPanelContentState.ts
|
||||
inline src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx
|
||||
inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx
|
||||
inline src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx
|
||||
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx
|
||||
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
|
||||
inline src/renderer/src/components/github-project/ProjectCell.tsx
|
||||
inline src/renderer/src/components/github-project/ProjectPicker.tsx
|
||||
inline src/renderer/src/components/github-project/ProjectViewWrapper.tsx
|
||||
inline src/renderer/src/components/linear-project-view-surfaces.tsx
|
||||
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
|
||||
inline src/renderer/src/components/onboarding/use-onboarding-flow.ts
|
||||
inline src/renderer/src/components/right-sidebar/ChecksPanel.tsx
|
||||
inline src/renderer/src/components/right-sidebar/FileExplorer.test.tsx
|
||||
inline src/renderer/src/components/right-sidebar/FileExplorer.tsx
|
||||
inline src/renderer/src/components/right-sidebar/FileExplorerRow.tsx
|
||||
inline src/renderer/src/components/right-sidebar/PortsPanel.tsx
|
||||
inline src/renderer/src/components/right-sidebar/SourceControl.tsx
|
||||
inline src/renderer/src/components/right-sidebar/checks-panel-content.tsx
|
||||
inline src/renderer/src/components/right-sidebar/index.tsx
|
||||
inline src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts
|
||||
inline src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts
|
||||
inline src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts
|
||||
inline src/renderer/src/components/settings/AccountsPane.tsx
|
||||
inline src/renderer/src/components/settings/AgentsPane.tsx
|
||||
inline src/renderer/src/components/settings/RepositoryHooksSection.tsx
|
||||
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
|
||||
inline src/renderer/src/components/settings/Settings.tsx
|
||||
inline src/renderer/src/components/settings/SettingsFormControls.tsx
|
||||
inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
|
||||
inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeCard.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
|
||||
inline src/renderer/src/components/sidebar/WorktreeList.tsx
|
||||
inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
|
||||
inline src/renderer/src/components/sidebar/worktree-list-groups.test.ts
|
||||
inline src/renderer/src/components/sidebar/worktree-list-groups.ts
|
||||
inline src/renderer/src/components/stats/usage-overview-model.ts
|
||||
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
|
||||
inline src/renderer/src/components/status-bar/StatusBar.tsx
|
||||
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
|
||||
inline src/renderer/src/components/tab-bar/TabBar.tsx
|
||||
inline src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts
|
||||
inline src/renderer/src/components/tab-group/useTabDragSplit.ts
|
||||
inline src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
|
||||
inline src/renderer/src/components/task-page-cache-selectors.ts
|
||||
inline src/renderer/src/components/terminal-pane/TerminalPane.tsx
|
||||
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts
|
||||
inline src/renderer/src/components/terminal-pane/keyboard-handlers.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-connection.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-connection.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-transport.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-transport.ts
|
||||
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
|
||||
inline src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
|
||||
inline src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx
|
||||
inline src/renderer/src/hooks/useAutomationDispatchEvents.ts
|
||||
inline src/renderer/src/hooks/useComposerState.ts
|
||||
inline src/renderer/src/hooks/useEditorExternalWatch.ts
|
||||
inline src/renderer/src/hooks/useIpcEvents.test.ts
|
||||
inline src/renderer/src/hooks/useIpcEvents.ts
|
||||
inline src/renderer/src/hooks/useIssueMetadata.ts
|
||||
inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts
|
||||
inline src/renderer/src/lib/active-agent-note-send.test.ts
|
||||
inline src/renderer/src/lib/file-type-icons.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-manager.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
|
||||
inline src/renderer/src/lib/terminal-links.ts
|
||||
inline src/renderer/src/lib/worktree-activation.test.ts
|
||||
inline src/renderer/src/lib/worktree-activation.ts
|
||||
inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
|
||||
inline src/renderer/src/runtime/runtime-file-client.test.ts
|
||||
inline src/renderer/src/runtime/runtime-file-client.ts
|
||||
inline src/renderer/src/runtime/runtime-git-client.ts
|
||||
inline src/renderer/src/runtime/runtime-linear-client.ts
|
||||
inline src/renderer/src/runtime/sync-runtime-graph.test.ts
|
||||
inline src/renderer/src/runtime/sync-runtime-graph.ts
|
||||
inline src/renderer/src/runtime/web-runtime-session.test.ts
|
||||
inline src/renderer/src/runtime/web-runtime-session.ts
|
||||
inline src/renderer/src/runtime/web-session-tabs-sync.test.ts
|
||||
inline src/renderer/src/runtime/web-session-tabs-sync.ts
|
||||
inline src/renderer/src/store/slices/agent-status.test.ts
|
||||
inline src/renderer/src/store/slices/agent-status.ts
|
||||
inline src/renderer/src/store/slices/browser.test.ts
|
||||
inline src/renderer/src/store/slices/browser.ts
|
||||
inline src/renderer/src/store/slices/diffComments.ts
|
||||
inline src/renderer/src/store/slices/editor.test.ts
|
||||
inline src/renderer/src/store/slices/editor.ts
|
||||
inline src/renderer/src/store/slices/github.test.ts
|
||||
inline src/renderer/src/store/slices/github.ts
|
||||
inline src/renderer/src/store/slices/hosted-review.ts
|
||||
inline src/renderer/src/store/slices/jira.ts
|
||||
inline src/renderer/src/store/slices/linear.test.ts
|
||||
inline src/renderer/src/store/slices/linear.ts
|
||||
inline src/renderer/src/store/slices/repos.ts
|
||||
inline src/renderer/src/store/slices/store-cascades.test.ts
|
||||
inline src/renderer/src/store/slices/store-session-cascades.test.ts
|
||||
inline src/renderer/src/store/slices/tabs.test.ts
|
||||
inline src/renderer/src/store/slices/tabs.ts
|
||||
inline src/renderer/src/store/slices/terminals.ts
|
||||
inline src/renderer/src/store/slices/ui.test.ts
|
||||
inline src/renderer/src/store/slices/ui.ts
|
||||
inline src/renderer/src/store/slices/workspace-cleanup.ts
|
||||
inline src/renderer/src/store/slices/worktrees.test.ts
|
||||
inline src/renderer/src/store/slices/worktrees.ts
|
||||
inline src/renderer/src/web/web-preload-api.test.ts
|
||||
inline src/renderer/src/web/web-preload-api.ts
|
||||
inline src/renderer/src/web/web-runtime-client.ts
|
||||
inline src/shared/agent-hook-listener.test.ts
|
||||
inline src/shared/agent-hook-listener.ts
|
||||
inline src/shared/automation-schedules.ts
|
||||
inline src/shared/commit-message-agent-spec.ts
|
||||
inline src/shared/constants.ts
|
||||
inline src/shared/github-project-types.ts
|
||||
inline src/shared/keybindings.test.ts
|
||||
inline src/shared/keybindings.ts
|
||||
inline src/shared/marine-creatures.ts
|
||||
inline src/shared/remote-runtime-client.ts
|
||||
inline src/shared/runtime-types.ts
|
||||
inline src/shared/source-control-ai.ts
|
||||
inline src/shared/telemetry-events.ts
|
||||
inline src/shared/text-search.ts
|
||||
inline src/shared/types.ts
|
||||
inline tests/e2e/helpers/terminal.ts
|
||||
inline tests/e2e/terminal-panes.spec.ts
|
||||
mobile-config app/h/*/files/*.tsx
|
||||
mobile-config app/h/*/index.tsx
|
||||
mobile-config app/h/*/session/*.tsx
|
||||
mobile-config app/h/*/source-control/*.tsx
|
||||
mobile-config app/h/*/tasks.tsx
|
||||
mobile-config app/index.tsx
|
||||
mobile-config app/pair-scan.tsx
|
||||
mobile-config app/terminal-settings.tsx
|
||||
mobile-config app/troubleshoot.tsx
|
||||
mobile-config scripts/mock-server.ts
|
||||
mobile-config scripts/repro-terminal-colors.ts
|
||||
mobile-config scripts/repro-worktree-startup-stream.ts
|
||||
mobile-config src/browser/MobileBrowserPane.tsx
|
||||
mobile-config src/components/CustomKeyModal.tsx
|
||||
mobile-config src/components/NewWorktreeModal.tsx
|
||||
mobile-config src/components/mobile-rich-markdown-editor-html.ts
|
||||
mobile-config src/terminal/terminal-accessory-keys.ts
|
||||
mobile-config src/terminal/terminal-webview-html.ts
|
||||
mobile-config src/transport/rpc-client.ts
|
||||
@@ -0,0 +1,23 @@
|
||||
; Clean up the relocated terminal daemon on a REAL uninstall.
|
||||
;
|
||||
; Why: the daemon host is deliberately copied to a distinct image name
|
||||
; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app
|
||||
; UPDATES cannot kill it — that relocation is what keeps terminals alive across
|
||||
; updates. The same design means a normal uninstall's process sweep and file
|
||||
; removal both miss it, leaving an orphaned daemon plus its runtime copy behind.
|
||||
;
|
||||
; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as
|
||||
; part of uninstallOldVersion on EVERY update, and killing the daemon there would
|
||||
; defeat the whole feature. Only clean up on a genuine uninstall.
|
||||
;
|
||||
; The image name and the LOCALAPPDATA folder name must stay in sync with
|
||||
; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
|
||||
; src/main/daemon/daemon-host-relocation.ts.
|
||||
!macro customUnInstall
|
||||
${ifNot} ${isUpdated}
|
||||
nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
|
||||
; Give the OS a moment to release the image lock before removing the tree.
|
||||
Sleep 500
|
||||
RMDir /r "$LOCALAPPDATA\Orca\daemon-host"
|
||||
${endIf}
|
||||
!macroend
|
||||
+454
-16
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-07-03",
|
||||
"updatedAt": "2026-07-08",
|
||||
"policy": {
|
||||
"maturityLevels": [
|
||||
"experimental",
|
||||
@@ -105,6 +105,163 @@
|
||||
],
|
||||
"demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner."
|
||||
},
|
||||
{
|
||||
"id": "terminal-session.startup-cwd-missing-dir-recovery",
|
||||
"title": "Fresh local terminal creation cannot be bricked by a deleted startup cwd",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime",
|
||||
"layer": "shared-main-renderer-contract",
|
||||
"surfaces": [
|
||||
"terminal lifecycle",
|
||||
"tab creation",
|
||||
"PTY spawn",
|
||||
"startup cwd persistence"
|
||||
],
|
||||
"platforms": [
|
||||
"macos",
|
||||
"linux",
|
||||
"windows",
|
||||
"mobile"
|
||||
],
|
||||
"providers": [
|
||||
"local",
|
||||
"daemon",
|
||||
"ssh",
|
||||
"wsl",
|
||||
"remote-runtime"
|
||||
],
|
||||
"coveredPlatforms": [
|
||||
"macos"
|
||||
],
|
||||
"coveredProviders": [
|
||||
"local",
|
||||
"ssh",
|
||||
"remote-runtime"
|
||||
],
|
||||
"coverageNotes": "Local macOS evidence covers the shared missing-dir fallback policy, main pty:spawn recovery and metadata, no-flag and reattach strictness, renderer IPC flag routing, SSH-tagged and remote-runtime omission, and the visibility-gated terminal notice. Daemon shares the same pre-provider main cwd decision but lacks a live daemon-provider run; WSL UNC paths are exempt from the probe by design and lack a live run; Linux/Windows and mobile/API strictness are gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/7239",
|
||||
"https://github.com/stablyai/orca/pull/7750",
|
||||
"https://github.com/stablyai/orca/pull/7678"
|
||||
],
|
||||
"invariant": "A fresh local renderer terminal spawn may recover from a saved startup cwd whose directory no longer exists only by spawning at the selected workspace root and printing a generic in-terminal notice; existing directories — including ones outside the worktree (#7685) — spawn as requested, and reattach, SSH, remote-runtime, runtime/API, and mobile callers keep exact cwd semantics.",
|
||||
"oracle": "The shared resolver falls back to the workspace root only when the injected existence probe reports the resolved cwd missing and the workspace root present, and never probes floating terminals or a cwd equal to the root. The renderer sends cwdFallback only for fresh local IPC spawns, main honors it only when connectionId and sessionId are absent, WSL UNC paths never engage the probe-based fallback, main returns fallback metadata only after an actual fallback, the IPC transport preserves that metadata, and the connection layer writes a generic notice that omits the missing path.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/terminal-startup-cwd.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/shared/terminal-startup-cwd.test.ts",
|
||||
"src/main/ipc/pty.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-transport.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-connection.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/shared/terminal-startup-cwd.test.ts",
|
||||
"assertions": [
|
||||
"a missing requested cwd falls back to the workspace root and reports the missing path to the callback",
|
||||
"existing cwds — nested or outside the worktree (#7685) — are never remapped",
|
||||
"no fallback happens when the workspace root is missing too",
|
||||
"floating terminal cwds and root-equal requests are never probed",
|
||||
"non-ASCII worktree roots and folder workspace roots are recovered verbatim"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/pty.test.ts",
|
||||
"assertions": [
|
||||
"local pty:spawn with cwdFallback worktree spawns at the worktree root when the saved cwd is missing and returns fallback metadata",
|
||||
"a missing cwd without the flag still surfaces the provider's missing-directory error",
|
||||
"an existing outside-worktree cwd spawns as requested without fallback metadata",
|
||||
"session reattach spawns ignore the fallback flag and keep exact cwd semantics"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-transport.test.ts",
|
||||
"assertions": [
|
||||
"IPC transport sends cwdFallback only for local fresh spawns",
|
||||
"SSH-tagged and session reattach spawns omit cwdFallback",
|
||||
"IPC transport returns startup cwd fallback metadata to the connection layer"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"assertions": [
|
||||
"fresh local IPC worktree spawns are marked with cwdFallback worktree",
|
||||
"startup cwd fallback metadata prints a generic in-terminal notice",
|
||||
"remote-runtime worktree spawns are not marked with cwdFallback"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-08",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/terminal-startup-cwd.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.2,
|
||||
"summary": "1 test file passed, 21 tests passed; covers the missing-dir fallback policy, #7685 outside-worktree preservation, and root-missing/floating exemptions."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-08",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.9,
|
||||
"summary": "1 test file passed, 225 tests passed; covers main pty:spawn recovery, fallback metadata, and no-flag/reattach provider-error strictness."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-08",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.4,
|
||||
"summary": "1 test file passed, 58 tests passed; covers cwdFallback forwarding only for local fresh spawns and metadata handoff."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-08",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 6.5,
|
||||
"summary": "1 test file passed, 341 tests passed; covers local IPC marking, the generic terminal fallback notice, and remote-runtime omission."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 30,
|
||||
"scope": "focused unit and IPC contract tests"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "New experimental gate added with local deterministic evidence only; needs CI soak before promotion."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The main IPC missing-cwd tests fail with the provider's 'Working directory ... does not exist.' error when the fallback is removed and pass with it. Full live Electron reproduction from a production persisted session is not captured."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "The runtime change adds at most two statSync probes on the fresh-local spawn path (the provider already stats the same paths during validation) and one bounded terminal write only when fallback actually occurs; no polling, provider listing, hidden-pane work, startup awaits, subprocesses, or render-loop work was added."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Attach CI evidence for all declared test files.",
|
||||
"Add a live Electron regression that opens a local terminal whose persisted startupCwd was deleted and proves visible shell input/output at the workspace root.",
|
||||
"Add WSL/mobile/API provider-contract coverage or explicitly narrow their risk scope."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No live Electron fixture seeds a persisted tab whose startupCwd directory was deleted.",
|
||||
"Daemon coverage is via the shared pre-provider main cwd decision, not a live daemon provider spawn.",
|
||||
"WSL UNC paths bypass the probe by design and have no live existence-recovery run; Linux, Windows, and mobile/API strictness are not directly exercised."
|
||||
],
|
||||
"demotionRule": "Demote or quarantine if the gate flakes without a product bug, if an existing directory is ever remapped away from the requested cwd, or if a reattach/remote/API caller can engage the fallback."
|
||||
},
|
||||
{
|
||||
"id": "agent-session.provider-ownership",
|
||||
"title": "Provider sessions are resumed once per workspace ownership claim",
|
||||
@@ -458,6 +615,97 @@
|
||||
],
|
||||
"demotionRule": "Cannot promote while it only checks visual rendering."
|
||||
},
|
||||
{
|
||||
"id": "terminal-scroll.intent-preservation",
|
||||
"title": "Scrollbar drag intent survives tab and visibility resume",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-rendering",
|
||||
"layer": "renderer-unit",
|
||||
"surfaces": [
|
||||
"terminal lifecycle",
|
||||
"hidden-to-visible resume",
|
||||
"tab switching",
|
||||
"xterm scrollbar DOM",
|
||||
"scrollback"
|
||||
],
|
||||
"platforms": [
|
||||
"macos",
|
||||
"linux",
|
||||
"windows"
|
||||
],
|
||||
"providers": [
|
||||
"local",
|
||||
"daemon",
|
||||
"ssh",
|
||||
"wsl",
|
||||
"remote-runtime"
|
||||
],
|
||||
"coveredPlatforms": [
|
||||
"macos"
|
||||
],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Renderer-unit coverage proves the shared xterm DOM intent path. Live Electron evidence is PR validation evidence for local macOS only until the flow has stable automation; live SSH, WSL, Linux, and Windows paths remain unproved.",
|
||||
"motivatingLinks": [
|
||||
"STA-1341"
|
||||
],
|
||||
"invariant": "A user-driven xterm scrollbar thumb or track scroll updates the live terminal scroll intent before tab, visibility, or layout resume enforces intent, so resume preserves the latest dragged viewport instead of an older pinned line.",
|
||||
"oracle": "Pointerdown on .xterm-scrollbar or .xterm-slider followed by xterm viewport movement records the new pinned viewport, and enforcing current intent restores that dragged line instead of stale top intent.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts",
|
||||
"assertions": [
|
||||
"pointer-driven .xterm-scrollbar and .xterm-slider scrolls update terminal scroll intent",
|
||||
"a scrollbar-dragged viewport is restored instead of stale top intent",
|
||||
"terminal body pointer activity is not treated as scrollbar intent"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-05",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.3,
|
||||
"summary": "1 test file(s) passed, 17 tests passed in this worktree."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 10,
|
||||
"scope": "focused renderer unit test"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "New experimental gate; needs soak history before promotion."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The new scrollbar DOM target assertions fail when only .xterm-viewport pointerdown is recognized. Needs saved CI/intentional-break artifact before blocking promotion."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Runtime fix is limited to constant-time class/closest checks on pointerdown. It must not add polling, output parsing, PTY listing, hidden-pane wakeups, resize calls, or startup awaits."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect stable CI soak history for the renderer-unit gate.",
|
||||
"Add stable live Electron automation for the scrollbar drag plus tab-switch repro.",
|
||||
"Attach red/green evidence for stale intent restoring the wrong viewport."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Manifest command is deterministic renderer-unit coverage, not a live Electron scrollbar drag.",
|
||||
"Live daemon, SSH, WSL, remote-runtime, Linux, and Windows validation is not covered by this gate.",
|
||||
"Future xterm scrollbar DOM class changes are only caught if they break the currently modeled class contract."
|
||||
],
|
||||
"demotionRule": "Demote or quarantine if the unit gate flakes without a product bug or harness bug filed to the owner."
|
||||
},
|
||||
{
|
||||
"id": "startup-upgrade.persisted-session-corpus",
|
||||
"title": "Current Orca preserves or recovers old production persisted sessions",
|
||||
@@ -1182,7 +1430,7 @@
|
||||
"macos"
|
||||
],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Local macOS evidence on main@1282f5c2d; identical files to the pending stack. Real OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.",
|
||||
"coverageNotes": "Local macOS evidence on main@1282f5c2d, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy, and a local Electron/CDP live-PTY Sogou-style repro. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/6699",
|
||||
"https://github.com/stablyai/orca/pull/6682",
|
||||
@@ -1190,14 +1438,20 @@
|
||||
"https://github.com/stablyai/orca/pull/6999"
|
||||
],
|
||||
"invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY.",
|
||||
"oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, and paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures. Real IME paths and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
|
||||
"oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary post-IME typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit candidate selectors submit only the committed Chinese text. Real IME paths and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts"
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
|
||||
"pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts"
|
||||
"src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
|
||||
"tests/e2e/chinese-ime-chat-input-repro.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
@@ -1218,34 +1472,80 @@
|
||||
"assertions": [
|
||||
"paste/runtime forwarding avoids duplicate terminal payloads"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts",
|
||||
"assertions": [
|
||||
"empty Sogou-style compositionupdate keeps composition active",
|
||||
"stale composition state expires so editing keys cannot stay suppressed indefinitely",
|
||||
"post-composition candidate guard is armed only by Sogou-style empty updates and clears after real typing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts",
|
||||
"assertions": [
|
||||
"suppressed candidate keydown arms only the matching keypress/keyup release guard",
|
||||
"held-key repeat keydowns stay guarded until their keyup, even past expiry",
|
||||
"fresh keydowns, other keys, modified keys, and expired keypresses are not guarded, and a fresh press drops a stale pending release"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts",
|
||||
"assertions": [
|
||||
"standalone Linux 229 keydowns reach xterm while Windows 229 keydowns stay suppressed",
|
||||
"candidate Space/digit selectors are suppressed only while candidate guards are active"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
|
||||
"assertions": [
|
||||
"macOS standalone Process key behavior and composition-owned key suppression stay intact"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/chinese-ime-chat-input-repro.spec.ts",
|
||||
"assertions": [
|
||||
"Sogou-style Space candidate selection submits only the committed Chinese character",
|
||||
"Sogou-style digit candidate selection submits only the committed Chinese phrase",
|
||||
"Post-composition Sogou-style digit selection stays out of the PTY after compositionend"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-03",
|
||||
"date": "2026-07-07",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.9,
|
||||
"summary": "3 test file(s) passed, 63 tests passed on main@1282f5c2d in a clean checkout."
|
||||
"durationSeconds": 0.7,
|
||||
"summary": "7 test file(s) passed, 144 tests passed on the Linux/Sogou candidate-key branch (includes held-key repeat guard coverage)."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-07",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 72.0,
|
||||
"summary": "Electron/CDP IME repro passed: Sogou candidate selection, post-composition candidate selection, and existing Chinese IME harness passed; real Codex IME test was skipped behind ORCA_E2E_REAL_CODEX_IME."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 30,
|
||||
"scope": "renderer unit plus platform soak"
|
||||
"p95Seconds": 90,
|
||||
"scope": "renderer unit plus focused Electron/CDP IME repro"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "Focused renderer input tests are now registered; needs soak history and platform IME evidence before promotion."
|
||||
"evidence": "Focused renderer input tests and the Electron/CDP Sogou-style repro are now registered; needs soak history and true platform IME evidence before promotion."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Focused tests cover existing native-text, input-source, and paste/runtime forwarding contracts. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix."
|
||||
"evidence": "Focused tests cover existing native-text, input-source, paste/runtime forwarding, Linux/Sogou candidate selector, post-composition guard contracts, and live-PTY Sogou-style candidate commits. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": false,
|
||||
"evidence": "Required if input forwarding adds polling, repeated event listeners, or renderer hot-path work."
|
||||
"required": true,
|
||||
"evidence": "Terminal IME hot-path audit clean on 2026-07-07: candidate guard adds O(1) boolean checks plus a bounded Space/digit release map; composition listeners are disposed with pane lifecycle; no polling, IPC fan-out, subprocess work, or SSH/remote transport cost added."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Cover deterministic byte/cell oracles first.",
|
||||
@@ -1253,7 +1553,7 @@
|
||||
"Pair with Windows ConPTY keyboard reset for Windows standard-key behavior."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Current command is a renderer-unit slice; real OS IME automation may need manual or soak evidence.",
|
||||
"Current commands include renderer-unit coverage and a CDP-driven Electron repro; real OS IME automation may need manual or soak evidence.",
|
||||
"Backspace/Enter during composition, JIS yen, Arabic/RTL, paste edge cases, and Windows ConPTY post-agent key reset still need representative gate coverage."
|
||||
],
|
||||
"demotionRule": "Cannot promote if success is based only on DOM text without PTY byte/cell evidence."
|
||||
@@ -1320,6 +1620,144 @@
|
||||
],
|
||||
"demotionRule": "Cannot promote if standard-key behavior is inferred without PTY byte evidence."
|
||||
},
|
||||
{
|
||||
"id": "terminal-input.agent-prompt-injection",
|
||||
"title": "Orchestration agent prompts arrive as bracketed paste before submit",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-input",
|
||||
"layer": "runtime-contract-and-cli-repro",
|
||||
"surfaces": [
|
||||
"terminal input",
|
||||
"agent prompt injection",
|
||||
"orchestration dispatch",
|
||||
"PTY writes",
|
||||
"bracketed paste"
|
||||
],
|
||||
"platforms": [
|
||||
"macos",
|
||||
"linux",
|
||||
"windows"
|
||||
],
|
||||
"providers": [
|
||||
"local",
|
||||
"daemon",
|
||||
"ssh",
|
||||
"remote-runtime"
|
||||
],
|
||||
"coveredPlatforms": [
|
||||
"macos"
|
||||
],
|
||||
"coveredProviders": [
|
||||
"local"
|
||||
],
|
||||
"coverageNotes": "Local macOS evidence covers the runtime PTY write contract and a live dev-runtime CLI repro. SSH, daemon, remote-runtime, Linux, and Windows remain provider/platform gaps; the product path stays provider-owned and does not add local filesystem or process assumptions.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/7226"
|
||||
],
|
||||
"invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and send Enter only after the paste frame completes.",
|
||||
"oracle": "Runtime tests assert the exact PTY write sequence and failure cleanup; orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness dispatches a 32KB task to a fake Codex-like TUI and requires marker present, bracketed paste present, zero unframed line breaks, and submit observed.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"node tools/repro-orchestration-long-prompt.mjs --cli out/bin/orca-dev --mode codex-like --size-kb 32 --timeout-ms 20000"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/shared/agent-prompt-injection.test.ts",
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration.test.ts",
|
||||
"src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"tools/repro-orchestration-long-prompt.mjs"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/shared/agent-prompt-injection.test.ts",
|
||||
"assertions": [
|
||||
"agent prompts are always framed as bracketed paste",
|
||||
"submit stays separate from the paste frame",
|
||||
"embedded ESC bytes are made inert before framing",
|
||||
"chunk reconstruction preserves the paste frame"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orca-runtime.test.ts",
|
||||
"assertions": [
|
||||
"runtime writes bracketed paste before a delayed submit",
|
||||
"large prompt frames are chunked and reconstructed before submit",
|
||||
"partial prompt write failure closes the paste frame and does not submit"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/orchestration.test.ts",
|
||||
"assertions": [
|
||||
"orchestration.dispatch uses the agent prompt path for injected preambles",
|
||||
"raw terminal.send is not called for injected task prompts",
|
||||
"failed prompt injection rolls back the active dispatch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"assertions": [
|
||||
"coordinator dispatch failures from prompt injection circuit-break through the DB",
|
||||
"silent-skip paths do not attempt prompt injection"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tools/repro-orchestration-long-prompt.mjs",
|
||||
"assertions": [
|
||||
"fake Codex-like worker observes submit after long orchestration dispatch",
|
||||
"32KB task marker survives before submit",
|
||||
"prompt bytes include a bracketed-paste frame with zero unframed line breaks"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-07",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 7.4,
|
||||
"summary": "4 test files passed, 697 tests passed; covers framing, runtime PTY writes, orchestration RPC dispatch, and coordinator dispatch behavior."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-07",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "node tools/repro-orchestration-long-prompt.mjs --cli out/bin/orca-dev --mode codex-like --size-kb 32 --timeout-ms 20000",
|
||||
"result": "passed",
|
||||
"durationSeconds": 2.2,
|
||||
"summary": "Live dev-runtime repro passed: expectedSpecBytes=32830, hasSubmit=true, rawContainsMarker=true, hasBracketedPasteFrame=true, unframedLineBreaks=0, contractOk=true."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 30,
|
||||
"scope": "runtime contract tests plus optional local dev-runtime CLI repro"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "First local macOS evidence only; needs repeated dev-runtime harness runs and provider matrix evidence before promotion."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The live harness reproduced the unsafe raw multiline contract before the fix and passes after the fix; intentional-break evidence is local only and not yet in CI."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Agent prompt dispatch remains O(prompt bytes), uses the existing 16KB terminal input chunking and one existing 500ms submit delay, and adds no polling, provider listing, subprocess churn, hidden-pane wakeups, or renderer work."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Run the live harness in soak with a self-starting dev runtime or provider-contract fixture.",
|
||||
"Add daemon, SSH, remote-runtime, Linux, and Windows evidence or mark narrower provider scope.",
|
||||
"Capture stable red/green intentional-break evidence in CI before blocking promotion."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Live harness command currently expects an already-running dev runtime and generated out/bin/orca-dev wrapper.",
|
||||
"No Windows ConPTY, Linux PTY, SSH, daemon, or remote-runtime live evidence yet.",
|
||||
"Push-on-idle orchestration message banners remain outside this dispatch-prompt gate."
|
||||
],
|
||||
"demotionRule": "Demote or quarantine if the live harness flakes without a product bug or harness bug filed to the terminal-input owner."
|
||||
},
|
||||
{
|
||||
"id": "terminal-render.windows-cjk-repaint",
|
||||
"title": "Windows ConPTY wide glyphs and cursor rewrites repaint without stale cells",
|
||||
|
||||
@@ -8,6 +8,8 @@ if (process.platform !== 'darwin') {
|
||||
}
|
||||
|
||||
runPnpmScript('build:computer-macos')
|
||||
runPnpmScript('build:notification-status-macos')
|
||||
process.exit(0)
|
||||
|
||||
function runPnpmScript(scriptName) {
|
||||
const npmExecPath = process.env.npm_execpath
|
||||
@@ -22,5 +24,7 @@ function runPnpmScript(scriptName) {
|
||||
if (result.signal) {
|
||||
process.kill(process.pid, result.signal)
|
||||
}
|
||||
process.exit(result.status ?? (result.error ? 1 : 0))
|
||||
if (result.status !== 0 || result.error) {
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env node
|
||||
// Builds the orca-notification-status helper binary.
|
||||
//
|
||||
// The helper reads UNUserNotificationCenter settings for the app it ships
|
||||
// inside (see native/notification-status-macos/main.swift). The target
|
||||
// CFBundleIdentifier is embedded as a __TEXT,__info_plist section so every
|
||||
// later `codesign --force` pass (electron-builder's signing, the dev runner's
|
||||
// ad-hoc deep sign) derives the correct code identifier automatically —
|
||||
// macOS keys notification records to that identifier.
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '../..')
|
||||
const sourcePath = path.join(repoRoot, 'native', 'notification-status-macos', 'main.swift')
|
||||
const defaultOutputPath = path.join(
|
||||
repoRoot,
|
||||
'native',
|
||||
'notification-status-macos',
|
||||
'.build',
|
||||
'release',
|
||||
'orca-notification-status'
|
||||
)
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const bundleId = readArg('--bundle-id') ?? 'com.stablyai.orca'
|
||||
const outputPath = readArg('--output') ?? defaultOutputPath
|
||||
// Why: dev launches only need the host architecture; release builds ship a
|
||||
// universal binary matching the app's x64 + arm64 targets.
|
||||
const singleArch = args.includes('--single-arch')
|
||||
|
||||
const workDir = path.join(tmpdir(), `orca-notification-status-${process.pid}`)
|
||||
mkdirSync(workDir, { recursive: true })
|
||||
try {
|
||||
const plistPath = path.join(workDir, 'Info.plist')
|
||||
writeFileSync(plistPath, embeddedInfoPlist(bundleId), 'utf8')
|
||||
const triples = singleArch
|
||||
? [process.arch === 'arm64' ? 'arm64-apple-macosx' : 'x86_64-apple-macosx']
|
||||
: ['arm64-apple-macosx', 'x86_64-apple-macosx']
|
||||
const builtBinaries = triples.map((triple) => {
|
||||
const output = path.join(workDir, `orca-notification-status-${triple}`)
|
||||
execFileSync(
|
||||
'swiftc',
|
||||
[
|
||||
'-O',
|
||||
sourcePath,
|
||||
'-target',
|
||||
triple.replace('-apple-macosx', '-apple-macosx11.0'),
|
||||
'-o',
|
||||
output,
|
||||
'-Xlinker',
|
||||
'-sectcreate',
|
||||
'-Xlinker',
|
||||
'__TEXT',
|
||||
'-Xlinker',
|
||||
'__info_plist',
|
||||
'-Xlinker',
|
||||
plistPath
|
||||
],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
return output
|
||||
})
|
||||
mkdirSync(path.dirname(outputPath), { recursive: true })
|
||||
if (builtBinaries.length === 1) {
|
||||
execFileSync('cp', [builtBinaries[0], outputPath])
|
||||
} else {
|
||||
execFileSync('lipo', ['-create', ...builtBinaries, '-output', outputPath])
|
||||
}
|
||||
execFileSync('chmod', ['755', outputPath])
|
||||
} finally {
|
||||
rmSync(workDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
function readArg(name) {
|
||||
const index = args.indexOf(name)
|
||||
return index >= 0 ? args[index + 1] : undefined
|
||||
}
|
||||
|
||||
function embeddedInfoPlist(identifier) {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>${identifier}</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>orca-notification-status</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
// Ratchet gate for the oxlint `max-lines` rule.
|
||||
//
|
||||
// oxlint already fails any file that exceeds max-lines WITHOUT a suppression, so
|
||||
// the only way a file grows past the budget is by adding an `eslint/oxlint-disable
|
||||
// max-lines` comment or a per-file `max-lines` bump in mobile/.oxlintrc.json. This
|
||||
// check freezes the set of files currently allowed to do that (the baseline) and
|
||||
// fails CI when a NEW bypass appears — the existing over-limit files are
|
||||
// grandfathered; new ones must split instead. The baseline may only shrink.
|
||||
|
||||
const BASELINE_PATH = 'config/max-lines-baseline.txt'
|
||||
const MOBILE_CONFIG_PATH = 'mobile/.oxlintrc.json'
|
||||
// These two files legitimately contain the directive text as data (regex, fixtures),
|
||||
// so scanning them would self-flag. The ratchet does not police itself.
|
||||
const SELF_FILES = new Set([
|
||||
'config/scripts/check-max-lines-ratchet.mjs',
|
||||
'config/scripts/check-max-lines-ratchet.test.mjs'
|
||||
])
|
||||
|
||||
// Default max-lines budgets from .oxlintrc.json (counted lines).
|
||||
export function defaultLimitForPath(p) {
|
||||
if (/\.(test|spec)\.(ts|tsx)$/.test(p)) {
|
||||
return 800
|
||||
}
|
||||
if (p.endsWith('.tsx')) {
|
||||
return 400
|
||||
}
|
||||
if (p.endsWith('.mjs')) {
|
||||
return 600
|
||||
}
|
||||
return 300
|
||||
}
|
||||
|
||||
// True if the source contains an eslint/oxlint disable directive listing `max-lines`
|
||||
// (block or line, bare or compound, with or without a `-- Why:` reason).
|
||||
export function hasMaxLinesDisable(sourceText) {
|
||||
const re = /(?:eslint|oxlint)-disable(?:-next-line|-line)?\b([^\n]*)/g
|
||||
let m
|
||||
while ((m = re.exec(sourceText)) !== null) {
|
||||
let rules = m[1]
|
||||
rules = rules.split('--')[0] // strip the reason
|
||||
const close = rules.indexOf('*/')
|
||||
if (close !== -1) {
|
||||
rules = rules.slice(0, close) // strip block-comment tail
|
||||
}
|
||||
if (/\bmax-lines\b/.test(rules)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Per-file `max-lines` bumps in mobile/.oxlintrc.json whose `max` exceeds the
|
||||
// default for that glob (a lower `max` is stricter, not a bypass).
|
||||
export function collectMobileBumps(configText) {
|
||||
const cfg = JSON.parse(configText)
|
||||
const bumps = []
|
||||
for (const override of cfg.overrides ?? []) {
|
||||
const rule = override.rules?.['max-lines']
|
||||
if (!Array.isArray(rule) || typeof rule[1]?.max !== 'number') {
|
||||
continue
|
||||
}
|
||||
for (const glob of override.files ?? []) {
|
||||
if (rule[1].max > defaultLimitForPath(glob)) {
|
||||
bumps.push(`mobile-config ${glob}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return bumps
|
||||
}
|
||||
|
||||
export function parseBaseline(text) {
|
||||
return new Set(
|
||||
text
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith('#'))
|
||||
)
|
||||
}
|
||||
|
||||
export function diffBaseline(current, baseline) {
|
||||
const cur = new Set(current)
|
||||
const base = baseline instanceof Set ? baseline : new Set(baseline)
|
||||
const added = [...cur].filter((e) => !base.has(e)).sort()
|
||||
const stale = [...base].filter((e) => !cur.has(e)).sort()
|
||||
return { added, stale }
|
||||
}
|
||||
|
||||
// Collect every current suppression entry from the tracked tree.
|
||||
export function collectCurrentSuppressions(root = process.cwd()) {
|
||||
const tracked = execFileSync('git', ['ls-files', '*.ts', '*.tsx', '*.mjs'], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
})
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.filter((f) => !SELF_FILES.has(f))
|
||||
|
||||
const entries = []
|
||||
for (const rel of tracked) {
|
||||
let src
|
||||
try {
|
||||
src = fs.readFileSync(path.join(root, rel), 'utf8')
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (hasMaxLinesDisable(src)) {
|
||||
entries.push(`inline ${rel}`)
|
||||
}
|
||||
}
|
||||
|
||||
const mobileCfgPath = path.join(root, MOBILE_CONFIG_PATH)
|
||||
if (fs.existsSync(mobileCfgPath)) {
|
||||
entries.push(...collectMobileBumps(fs.readFileSync(mobileCfgPath, 'utf8')))
|
||||
}
|
||||
|
||||
return entries.sort()
|
||||
}
|
||||
|
||||
function printAddedFailure(added) {
|
||||
for (const entry of added) {
|
||||
console.error(`::error::New max-lines bypass not allowed: ${entry}`)
|
||||
}
|
||||
console.error('')
|
||||
console.error('╭────────────────────────────────────────────────────────────────────────────╮')
|
||||
console.error('│ ❌ max-lines ratchet failed — a NEW file is trying to exceed the line cap. │')
|
||||
console.error('╰────────────────────────────────────────────────────────────────────────────╯')
|
||||
console.error('')
|
||||
console.error(` ${added.length} file(s)/glob(s) newly bypass the oxlint \`max-lines\` rule:`)
|
||||
console.error('')
|
||||
for (const entry of added) {
|
||||
const [kind, ...rest] = entry.split(' ')
|
||||
const target = rest.join(' ')
|
||||
const how =
|
||||
kind === 'inline'
|
||||
? 'added an eslint/oxlint-disable max-lines comment'
|
||||
: 'added a per-file max-lines bump in mobile/.oxlintrc.json'
|
||||
console.error(` • ${target}\n ↳ ${how}`)
|
||||
}
|
||||
console.error('')
|
||||
console.error(' Orca caps file size (300 .ts / 400 .tsx / 600 .mjs / 800 test — non-blank,')
|
||||
console.error(
|
||||
' non-comment lines). Existing oversized files are grandfathered; NEW ones are not.'
|
||||
)
|
||||
console.error('')
|
||||
console.error(' ✅ Fix it: SPLIT the file into focused modules — do NOT suppress the rule.')
|
||||
console.error(' See AGENTS.md → "Lint Rules: Do Not Disable Max Lines".')
|
||||
console.error('')
|
||||
console.error(' (If you are intentionally, with reviewer sign-off, adding an unavoidable')
|
||||
console.error(` exception, add the exact line(s) above to ${BASELINE_PATH}.)`)
|
||||
console.error('')
|
||||
}
|
||||
|
||||
function printStaleFailure(stale) {
|
||||
for (const entry of stale) {
|
||||
console.error(`::error::Stale max-lines baseline entry (prune it): ${entry}`)
|
||||
}
|
||||
console.error('')
|
||||
console.error('╭────────────────────────────────────────────────────────────────────────────╮')
|
||||
console.error('│ ⚠️ max-lines baseline is out of date — nice work removing a bypass! │')
|
||||
console.error('╰────────────────────────────────────────────────────────────────────────────╯')
|
||||
console.error('')
|
||||
console.error(` ${stale.length} baseline entr(y/ies) no longer have a max-lines suppression.`)
|
||||
console.error(
|
||||
' The baseline may only shrink, so these must be removed to keep re-adding blocked:'
|
||||
)
|
||||
console.error('')
|
||||
for (const entry of stale) {
|
||||
console.error(` • ${entry}`)
|
||||
}
|
||||
console.error('')
|
||||
console.error(` ✅ Fix it (one command): pnpm check:max-lines-ratchet --prune`)
|
||||
console.error('')
|
||||
}
|
||||
|
||||
export function main(root = process.cwd()) {
|
||||
const baselineFile = path.join(root, BASELINE_PATH)
|
||||
if (!fs.existsSync(baselineFile)) {
|
||||
console.error(
|
||||
`::error::Missing ${BASELINE_PATH}. Generate it with: node config/scripts/check-max-lines-ratchet.mjs --init`
|
||||
)
|
||||
return 1
|
||||
}
|
||||
const baseline = parseBaseline(fs.readFileSync(baselineFile, 'utf8'))
|
||||
const current = collectCurrentSuppressions(root)
|
||||
const { added, stale } = diffBaseline(current, baseline)
|
||||
|
||||
if (added.length > 0) {
|
||||
printAddedFailure(added)
|
||||
if (stale.length > 0) {
|
||||
console.error(
|
||||
` (Also: ${stale.length} stale baseline entr(y/ies) can be pruned — see below.)`
|
||||
)
|
||||
printStaleFailure(stale)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if (stale.length > 0) {
|
||||
printStaleFailure(stale)
|
||||
return 1
|
||||
}
|
||||
console.log(
|
||||
`max-lines ratchet OK — ${current.length} grandfathered suppression(s), no new bypasses.`
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
function writeBaseline(root, entries) {
|
||||
const header = [
|
||||
'# Files/globs currently allowed to exceed the oxlint `max-lines` budget.',
|
||||
'# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —',
|
||||
'# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").',
|
||||
'# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)',
|
||||
''
|
||||
].join('\n')
|
||||
fs.writeFileSync(path.join(root, BASELINE_PATH), `${header}${entries.join('\n')}\n`)
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const root = process.cwd()
|
||||
const arg = process.argv[2]
|
||||
if (arg === '--init') {
|
||||
// One-time bootstrap: capture the current suppression set as the baseline.
|
||||
const entries = collectCurrentSuppressions(root)
|
||||
writeBaseline(root, entries)
|
||||
console.log(`Wrote ${BASELINE_PATH} with ${entries.length} entries.`)
|
||||
process.exit(0)
|
||||
}
|
||||
if (arg === '--prune') {
|
||||
// Remove baseline entries whose suppression is gone (shrink only; never adds).
|
||||
const current = new Set(collectCurrentSuppressions(root))
|
||||
const baseline = parseBaseline(fs.readFileSync(path.join(root, BASELINE_PATH), 'utf8'))
|
||||
const kept = [...baseline].filter((e) => current.has(e)).sort()
|
||||
const newlyAdded = [...current].filter((e) => !baseline.has(e))
|
||||
writeBaseline(root, kept)
|
||||
console.log(
|
||||
`Pruned baseline to ${kept.length} entries (removed ${baseline.size - kept.length}).`
|
||||
)
|
||||
if (newlyAdded.length > 0) {
|
||||
console.error(
|
||||
`::error::--prune does not add entries; ${newlyAdded.length} new bypass(es) remain — split those files.`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
process.exit(main(root))
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
collectMobileBumps,
|
||||
defaultLimitForPath,
|
||||
diffBaseline,
|
||||
hasMaxLinesDisable,
|
||||
parseBaseline
|
||||
} from './check-max-lines-ratchet.mjs'
|
||||
|
||||
describe('hasMaxLinesDisable', () => {
|
||||
it('detects a bare block disable', () => {
|
||||
expect(hasMaxLinesDisable('/* eslint-disable max-lines */\nexport const a = 1\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects the oxlint spelling', () => {
|
||||
expect(hasMaxLinesDisable('/* oxlint-disable max-lines */\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects a disable with a -- Why reason', () => {
|
||||
expect(hasMaxLinesDisable('/* eslint-disable max-lines -- Why: one owner. */\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects a multi-line block where the reason wraps', () => {
|
||||
const src =
|
||||
'/* eslint-disable max-lines -- Why: this contract is\n * intentionally centralized. */\nimport x from "y"\n'
|
||||
expect(hasMaxLinesDisable(src)).toBe(true)
|
||||
})
|
||||
|
||||
it('detects max-lines inside a compound rule list', () => {
|
||||
expect(hasMaxLinesDisable('/* eslint-disable no-control-regex, max-lines -- Why: x */\n')).toBe(
|
||||
true
|
||||
)
|
||||
expect(hasMaxLinesDisable('/* eslint-disable max-lines, no-control-regex */\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('detects a line-scoped disable', () => {
|
||||
expect(hasMaxLinesDisable('const a = 1 // eslint-disable-line max-lines\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores a disable for an unrelated rule', () => {
|
||||
expect(hasMaxLinesDisable('/* eslint-disable no-console */\n')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not treat "max-lines" appearing only in the reason text as a suppression', () => {
|
||||
// max-lines is after the `--`, so it is prose, not a suppressed rule.
|
||||
expect(
|
||||
hasMaxLinesDisable('/* eslint-disable no-console -- we could hit max-lines later */\n')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for ordinary source', () => {
|
||||
expect(hasMaxLinesDisable('export function f() {\n return 42\n}\n')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultLimitForPath', () => {
|
||||
it('uses 800 for tests, 400 for tsx, 600 for mjs, 300 otherwise', () => {
|
||||
expect(defaultLimitForPath('a/b.test.ts')).toBe(800)
|
||||
expect(defaultLimitForPath('a/b.spec.tsx')).toBe(800)
|
||||
expect(defaultLimitForPath('a/b.tsx')).toBe(400)
|
||||
expect(defaultLimitForPath('a/b.mjs')).toBe(600)
|
||||
expect(defaultLimitForPath('a/b.ts')).toBe(300)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectMobileBumps', () => {
|
||||
it('captures only overrides whose max exceeds the default for the glob', () => {
|
||||
const cfg = JSON.stringify({
|
||||
overrides: [
|
||||
{ files: ['app/h/*/tasks.tsx'], rules: { 'max-lines': ['error', { max: 14682 }] } }, // bump (>400)
|
||||
{
|
||||
files: ['src/terminal/TerminalWebView.tsx'],
|
||||
rules: { 'max-lines': ['error', { max: 379 }] }
|
||||
}, // stricter (<400), skip
|
||||
{ files: ['scripts/mock-server.ts'], rules: { 'max-lines': ['error', { max: 407 }] } } // bump (>300)
|
||||
]
|
||||
})
|
||||
expect(collectMobileBumps(cfg)).toEqual([
|
||||
'mobile-config app/h/*/tasks.tsx',
|
||||
'mobile-config scripts/mock-server.ts'
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores overrides without a max-lines rule', () => {
|
||||
const cfg = JSON.stringify({
|
||||
overrides: [{ files: ['a.tsx'], rules: { 'no-console': 'off' } }]
|
||||
})
|
||||
expect(collectMobileBumps(cfg)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBaseline', () => {
|
||||
it('drops comments and blank lines', () => {
|
||||
const b = parseBaseline('# header\n\ninline a.ts\nmobile-config x/*.tsx\n')
|
||||
expect(b).toEqual(new Set(['inline a.ts', 'mobile-config x/*.tsx']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('diffBaseline', () => {
|
||||
it('reports added and stale entries', () => {
|
||||
const { added, stale } = diffBaseline(
|
||||
['inline b.ts', 'inline c.ts'],
|
||||
new Set(['inline a.ts', 'inline b.ts'])
|
||||
)
|
||||
expect(added).toEqual(['inline c.ts']) // new bypass
|
||||
expect(stale).toEqual(['inline a.ts']) // suppression removed
|
||||
})
|
||||
|
||||
it('is clean when current matches baseline', () => {
|
||||
const { added, stale } = diffBaseline(['inline a.ts'], new Set(['inline a.ts']))
|
||||
expect(added).toEqual([])
|
||||
expect(stale).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -110,6 +110,39 @@ describe('computer-use e2e workflow', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('boots the built daemon under plain Node in the PR native-smoke job after the main build', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
const steps = workflow.jobs['native-smoke'].steps
|
||||
const runs = steps.map((step) => step.run).filter((run) => typeof run === 'string')
|
||||
const buildIndex = runs.indexOf('pnpm build:electron-vite')
|
||||
const daemonSmokeIndex = runs.indexOf('node config/scripts/daemon-boot-smoke.mjs')
|
||||
|
||||
expect(daemonSmokeIndex, 'native-smoke must boot the built daemon').toBeGreaterThanOrEqual(0)
|
||||
expect(
|
||||
buildIndex,
|
||||
'daemon boot smoke must run after the main bundle is built'
|
||||
).toBeGreaterThanOrEqual(0)
|
||||
expect(daemonSmokeIndex).toBeGreaterThan(buildIndex)
|
||||
})
|
||||
|
||||
it('re-runs the native-smoke job when the daemon bundle graph changes', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
const triggerPaths = workflow.on.pull_request.paths
|
||||
|
||||
expect(triggerPaths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'config/scripts/daemon-boot-smoke.mjs',
|
||||
'electron.vite.config.ts',
|
||||
'build-plugins/**',
|
||||
'src/main/daemon/**'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('runs Linux computer-use e2e in the PR native-smoke job under Xvfb', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Boots the BUILT terminal daemon (out/main/daemon-entry.js) under plain Node —
|
||||
* the exact way production forks it (ELECTRON_RUN_AS_NODE = a plain-Node
|
||||
* process) — and asserts it starts, serves a real PTY, and stops.
|
||||
*
|
||||
* Why this exists: native-smoke CI (and packaging) went green while
|
||||
* v1.4.129-rc.1 shipped a daemon that exited code 1 at module load because an
|
||||
* electron `require` leaked into its bundle graph. Nothing executed the built
|
||||
* entry under plain Node, so the outage was invisible until an adopted old
|
||||
* daemon died in the field. This runs on every PR that touches the daemon.
|
||||
*
|
||||
* Hard assertions (fail the job):
|
||||
* - the daemon signals `{ type: 'ready' }` over IPC within the timeout, and
|
||||
* - it terminates when asked (no hang / zombie).
|
||||
* Best-effort (logged skip, never fails): an end-to-end `ptySpawnHealth` RPC,
|
||||
* because node-pty spawn can be flaky on constrained CI runners.
|
||||
*/
|
||||
import { fork } from 'node:child_process'
|
||||
import { connect } from 'node:net'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const entryPath = join(projectDir, 'out', 'main', 'daemon-entry.js')
|
||||
|
||||
const READY_TIMEOUT_MS = 30_000
|
||||
const PTY_HEALTH_TIMEOUT_MS = 10_000
|
||||
const SHUTDOWN_TIMEOUT_MS = 10_000
|
||||
|
||||
function log(message) {
|
||||
process.stdout.write(`[daemon-boot-smoke] ${message}\n`)
|
||||
}
|
||||
|
||||
// Why: the daemon rejects a hello whose protocol version differs, so read the
|
||||
// current version from source rather than hardcoding a number that can drift.
|
||||
function readProtocolVersion() {
|
||||
const source = readFileSync(join(projectDir, 'src/main/daemon/types.ts'), 'utf8')
|
||||
const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/)
|
||||
if (!match) {
|
||||
throw new Error('could not read PROTOCOL_VERSION from src/main/daemon/types.ts')
|
||||
}
|
||||
return Number(match[1])
|
||||
}
|
||||
|
||||
function makeSocketPath(userDataDir) {
|
||||
// Why: Windows AF_UNIX-style IPC uses named pipes; POSIX uses a filesystem
|
||||
// socket kept under the scratch userData dir so cleanup removes it.
|
||||
if (process.platform === 'win32') {
|
||||
return `\\\\.\\pipe\\orca-daemon-smoke-${process.pid}-${randomUUID()}`
|
||||
}
|
||||
return join(userDataDir, 'daemon.sock')
|
||||
}
|
||||
|
||||
// Best-effort end-to-end PTY check over the daemon's own control-socket RPC.
|
||||
// Connects a single control socket, completes the hello handshake, and calls
|
||||
// `ptySpawnHealth` (the daemon spawns a throwaway PTY internally). Resolves
|
||||
// true on success, false on any failure — never throws.
|
||||
function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
|
||||
return new Promise((resolveCheck) => {
|
||||
let settled = false
|
||||
let buffer = ''
|
||||
const socket = connect(socketPath)
|
||||
const finish = (ok, reason) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
socket.destroy()
|
||||
if (!ok && reason) {
|
||||
log(`PTY spawn health check skipped (best-effort): ${reason}`)
|
||||
}
|
||||
resolveCheck(ok)
|
||||
}
|
||||
const timer = setTimeout(() => finish(false, 'timed out'), PTY_HEALTH_TIMEOUT_MS)
|
||||
|
||||
socket.on('error', (err) => finish(false, err.message))
|
||||
socket.on('connect', () => {
|
||||
const token = readFileSync(tokenPath, 'utf8').trim()
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
type: 'hello',
|
||||
version: protocolVersion,
|
||||
token,
|
||||
clientId: randomUUID(),
|
||||
role: 'control'
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk.toString('utf8')
|
||||
let newlineIdx = buffer.indexOf('\n')
|
||||
while (newlineIdx !== -1) {
|
||||
const line = buffer.slice(0, newlineIdx)
|
||||
buffer = buffer.slice(newlineIdx + 1)
|
||||
let msg
|
||||
try {
|
||||
msg = JSON.parse(line)
|
||||
} catch {
|
||||
finish(false, 'invalid response line')
|
||||
return
|
||||
}
|
||||
if (msg.type === 'hello') {
|
||||
if (!msg.ok) {
|
||||
finish(false, `hello rejected: ${msg.error ?? 'unknown'}`)
|
||||
return
|
||||
}
|
||||
socket.write(`${JSON.stringify({ id: 'health-1', type: 'ptySpawnHealth' })}\n`)
|
||||
} else if (msg.id === 'health-1') {
|
||||
finish(
|
||||
msg.ok === true,
|
||||
msg.ok === true ? undefined : (msg.error ?? 'ptySpawnHealth failed')
|
||||
)
|
||||
return
|
||||
}
|
||||
newlineIdx = buffer.indexOf('\n')
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const userDataDir = mkdtempSync(join(tmpdir(), 'orca-daemon-boot-smoke-'))
|
||||
const socketPath = makeSocketPath(userDataDir)
|
||||
const tokenPath = join(userDataDir, 'daemon.token')
|
||||
const protocolVersion = readProtocolVersion()
|
||||
|
||||
log(`forking ${entryPath} under plain Node (${process.execPath})`)
|
||||
const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], {
|
||||
// Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in
|
||||
// CI, and this is exactly the runtime where a leaked `require("electron")`
|
||||
// throws MODULE_NOT_FOUND — the failure this smoke exists to catch.
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||||
env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir }
|
||||
})
|
||||
|
||||
let stderr = ''
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk.toString('utf8')
|
||||
})
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
process.stdout.write(chunk)
|
||||
})
|
||||
|
||||
const cleanup = () => {
|
||||
if (child.exitCode === null && child.signalCode === null && child.pid) {
|
||||
try {
|
||||
child.kill('SIGKILL')
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise((resolveReady, rejectReady) => {
|
||||
const timer = setTimeout(() => {
|
||||
rejectReady(
|
||||
new Error(
|
||||
`daemon did not signal 'ready' within ${READY_TIMEOUT_MS}ms.\nstderr:\n${stderr}`
|
||||
)
|
||||
)
|
||||
}, READY_TIMEOUT_MS)
|
||||
child.on('message', (msg) => {
|
||||
if (msg && typeof msg === 'object' && msg.type === 'ready') {
|
||||
clearTimeout(timer)
|
||||
resolveReady()
|
||||
}
|
||||
})
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timer)
|
||||
rejectReady(new Error(`daemon fork errored: ${err.message}\nstderr:\n${stderr}`))
|
||||
})
|
||||
child.on('exit', (code, signal) => {
|
||||
clearTimeout(timer)
|
||||
rejectReady(
|
||||
new Error(
|
||||
`daemon exited before 'ready' (code=${code}, signal=${signal}).\nstderr:\n${stderr}`
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
log('daemon signaled ready')
|
||||
|
||||
const ptyHealthy = await runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion)
|
||||
if (ptyHealthy) {
|
||||
log('ptySpawnHealth OK — daemon spawned a real PTY end-to-end')
|
||||
}
|
||||
|
||||
await new Promise((resolveExit, rejectExit) => {
|
||||
const timer = setTimeout(() => {
|
||||
rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`))
|
||||
}, SHUTDOWN_TIMEOUT_MS)
|
||||
child.on('exit', (code, signal) => {
|
||||
clearTimeout(timer)
|
||||
log(`daemon exited after signal (code=${code}, signal=${signal})`)
|
||||
resolveExit()
|
||||
})
|
||||
// Why: SIGTERM is the graceful stop on POSIX (the daemon handles it);
|
||||
// Windows has no POSIX signal delivery, so Node maps this to process
|
||||
// termination. Either way the hard assertion is "it stops, no hang".
|
||||
child.kill('SIGTERM')
|
||||
})
|
||||
|
||||
log('PASS: daemon booted, served, and shut down under plain Node')
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`[daemon-boot-smoke] FAIL: ${error.message}\n`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -63,12 +63,36 @@ describe('electron-builder config', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Why: on macOS 26 UNUserNotificationCenter aborts for executables launched
|
||||
// from Contents/Resources, so the helper must ship in Contents/MacOS (#7929).
|
||||
it('ships the mac notification-status helper in Contents/MacOS, not Resources', () => {
|
||||
expect(electronBuilderConfig.mac.extraFiles).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
from: 'native/notification-status-macos/.build/release/orca-notification-status',
|
||||
to: 'MacOS/orca-notification-status'
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(electronBuilderConfig.mac.extraResources).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ to: 'orca-notification-status' })])
|
||||
)
|
||||
})
|
||||
|
||||
it('unpacks the compiled CommonJS boundary with CLI runtime files', () => {
|
||||
expect(electronBuilderConfig.asarUnpack).toEqual(
|
||||
expect.arrayContaining(['out/package.json', 'out/cli/**', 'out/shared/**'])
|
||||
)
|
||||
})
|
||||
|
||||
// Why: without the unpacked entry the watcher client silently falls back to
|
||||
// in-process @parcel/watcher, reintroducing the #7547 main-process crash.
|
||||
it('unpacks the forked parcel-watcher process entry', () => {
|
||||
expect(electronBuilderConfig.asarUnpack).toEqual(
|
||||
expect.arrayContaining(['out/main/parcel-watcher-process-entry.js'])
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the multi-size icon source for Linux packages', () => {
|
||||
expect(electronBuilderConfig.linux.icon).toBe('resources/build/icon.icns')
|
||||
})
|
||||
|
||||
@@ -221,7 +221,7 @@ describe('Electron runtime package contract', () => {
|
||||
expect(installRun).not.toMatch(/throw\s+\$_/)
|
||||
})
|
||||
|
||||
it('temporarily allows publishing Windows after verifying the signed installer only', () => {
|
||||
it('verifies Windows inner binary signatures fail-open before publishing', () => {
|
||||
const releaseWorkflow = readFileSync(
|
||||
join(projectDir, '.github/workflows/release-cut.yml'),
|
||||
'utf8'
|
||||
@@ -230,12 +230,38 @@ describe('Electron runtime package contract', () => {
|
||||
const steps = parsedWorkflow.jobs.build.steps
|
||||
const stepNames = steps.map((step) => step.name)
|
||||
const outerVerifyIndex = stepNames.indexOf('Verify signed Windows installer')
|
||||
const innerVerifyIndex = stepNames.indexOf('Verify signed Windows inner executable')
|
||||
const innerVerifyIndex = stepNames.indexOf('Verify Windows inner binary signatures')
|
||||
const evidenceIndex = stepNames.indexOf('Upload Windows inner signing evidence')
|
||||
const publishIndex = stepNames.indexOf('Publish signed Windows release artifacts')
|
||||
|
||||
expect(outerVerifyIndex).toBeGreaterThan(-1)
|
||||
expect(innerVerifyIndex).toBe(-1)
|
||||
expect(publishIndex).toBe(outerVerifyIndex + 1)
|
||||
expect(innerVerifyIndex).toBe(outerVerifyIndex + 1)
|
||||
expect(evidenceIndex).toBe(innerVerifyIndex + 1)
|
||||
expect(publishIndex).toBe(evidenceIndex + 1)
|
||||
|
||||
// Why fail-open: unsigned inner binaries must warn, not block, until the
|
||||
// flow is proven on a real release (issue #7785). Flip this to 'true'
|
||||
// together with the workflow env to make the gate required.
|
||||
expect(steps[innerVerifyIndex].env.ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED).toBe('false')
|
||||
|
||||
// Why: every step in the inner-signing chain must be unable to fail the
|
||||
// release — a SignPath outage or timeout falls through to today's
|
||||
// unsigned-inner flow instead of blocking the cut.
|
||||
const innerChainStepNames = [
|
||||
'Stage unsigned inner PE files for signing',
|
||||
'Upload unsigned inner binaries for SignPath',
|
||||
'Submit inner binaries signing request',
|
||||
'Notify Slack that inner-binary signing is waiting for approval',
|
||||
'Download signed inner binaries from SignPath',
|
||||
'Restore signed inner binaries into unpacked app',
|
||||
'Replace cached elevate.exe with the signed copy',
|
||||
'Rebuild NSIS installer from signed unpacked app'
|
||||
]
|
||||
for (const stepName of innerChainStepNames) {
|
||||
const step = steps[stepNames.indexOf(stepName)]
|
||||
expect(step, stepName).toBeDefined()
|
||||
expect(step['continue-on-error'], stepName).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('publishes both Linux release matrix entries', () => {
|
||||
|
||||
@@ -97,16 +97,6 @@ function setPlistValue(plistPath, key, value) {
|
||||
execFileSync('/usr/bin/plutil', ['-replace', key, '-string', value, plistPath])
|
||||
}
|
||||
|
||||
function sanitizeBundleIdPart(value) {
|
||||
return (
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9.-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80) || 'dev'
|
||||
)
|
||||
}
|
||||
|
||||
function sanitizeMacAppBundleName(value) {
|
||||
return (
|
||||
Array.from(value, (char) => {
|
||||
@@ -138,7 +128,10 @@ function prepareMacDevElectronApp() {
|
||||
|
||||
const title = process.env.ORCA_DEV_DOCK_TITLE || 'Orca: dev'
|
||||
const identityKey = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot
|
||||
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v4'
|
||||
// v6: bundle the notification-status helper (real permission readout) and
|
||||
// ad-hoc re-sign after plist edits so Notification Center accepts the
|
||||
// bundle; bumping forces stale cached copies to be recreated.
|
||||
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v6'
|
||||
const hash = createHash('sha1')
|
||||
.update(
|
||||
`${sourceAppPath}\0${electronVersion ?? ''}\0${title}\0${identityKey}\0${bundleLayoutVersion}`
|
||||
@@ -151,7 +144,17 @@ function prepareMacDevElectronApp() {
|
||||
const appBundleName = `${sanitizeMacAppBundleName(title)}.app`
|
||||
const appPath = path.join(distDir, appBundleName)
|
||||
const markerPath = path.join(distDir, 'orca-dev-electron-app.json')
|
||||
const bundleId = `com.stablyai.orca.dev.${sanitizeBundleIdPart(hash)}`
|
||||
// Why: one stable id for every dev instance. Per-instance ids registered a
|
||||
// new macOS Notification Settings entry for each branch × Electron version,
|
||||
// piling up "Orca: <branch>" rows forever and breaking the notification
|
||||
// settings deep-link (System Settings can't resolve an id it has no entry
|
||||
// for and falls back to the root list). macOS keys notification permission
|
||||
// by bundle id, so a single id also means granting notifications to one dev
|
||||
// instance covers all of them. Trade-off: when two dev instances run at
|
||||
// once, macOS may route a notification click to the other instance —
|
||||
// Electron drops clicks for notification ids it didn't create, so the
|
||||
// click is lost, not misdirected.
|
||||
const bundleId = 'com.stablyai.orca.dev'
|
||||
process.env.ORCA_DEV_MACOS_BUNDLE_ID = bundleId
|
||||
const expectedMarker = JSON.stringify(
|
||||
{ title, appBundleName, bundleId, sourceAppPath, electronVersion, bundleLayoutVersion },
|
||||
@@ -206,9 +209,45 @@ function prepareMacDevElectronApp() {
|
||||
setPlistValue(plistPath, 'CFBundleDisplayName', title)
|
||||
setPlistValue(plistPath, 'CFBundleIdentifier', bundleId)
|
||||
|
||||
// Why no re-sign: dev launches execute the copied Electron binary directly,
|
||||
// and Electron's framework bundle is ambiguous to codesign when deep-signing
|
||||
// an already-built distribution. Avoid blocking `pn dev` on local signing.
|
||||
// Why: the notification-status helper reads the app's real macOS
|
||||
// notification authorization (UNUserNotificationCenter has no Electron
|
||||
// API). It must live inside the bundle and carry the dev bundle id as its
|
||||
// embedded/code-sign identifier — macOS keys notification records to the
|
||||
// signing identifier. Non-fatal: without swiftc the permission card falls
|
||||
// back to delivery-probe heuristics.
|
||||
try {
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(repoRoot, 'config', 'scripts', 'build-notification-status-macos.mjs'),
|
||||
'--bundle-id',
|
||||
bundleId,
|
||||
'--single-arch',
|
||||
'--output',
|
||||
path.join(appPath, 'Contents', 'MacOS', 'orca-notification-status')
|
||||
],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[orca-dev] notification-status helper build failed (permission card falls back to probes): ${error?.message ?? error}`
|
||||
)
|
||||
}
|
||||
|
||||
// Why: the plist edits above (and the copy itself) break the bundle's
|
||||
// ad-hoc seal, and macOS refuses Notification Center registration for
|
||||
// invalidly-signed apps — every dev notification fails with UNErrorDomain
|
||||
// error 1 and the app never appears in System Settings > Notifications.
|
||||
// An ad-hoc re-sign restores delivery, the permission prompt, and the
|
||||
// notification-settings deep link for dev builds. Non-fatal: a signing
|
||||
// failure should not block `pnpm dev`.
|
||||
try {
|
||||
execFileSync('/usr/bin/codesign', ['--force', '--deep', '--sign', '-', appPath])
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[orca-dev] ad-hoc codesign failed (dev notifications will not deliver): ${error?.message ?? error}`
|
||||
)
|
||||
}
|
||||
writeFileSync(markerPath, expectedMarker, 'utf8')
|
||||
process.env.ELECTRON_EXEC_PATH = executablePath
|
||||
}
|
||||
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { hostname, tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
|
||||
const scriptDir = import.meta.dirname
|
||||
const repoRoot = path.resolve(scriptDir, '..', '..')
|
||||
const orcaDevScript = path.join(scriptDir, 'orca-dev.mjs')
|
||||
const ensureNativeRuntimeScript = path.join(scriptDir, 'ensure-native-runtime.mjs')
|
||||
const fixedProfileDir = process.env.ORCA_HEADLESS_PAIRING_PROFILE_DIR
|
||||
const parsed = parseArgs(process.argv.slice(2))
|
||||
|
||||
if (parsed.help) {
|
||||
printHelp()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (hasForwardedServeFlag(parsed.serveArgs, 'no-pairing')) {
|
||||
console.error('serve-headless-fresh-profile-pairing: --no-pairing cannot print a pairing code.')
|
||||
process.exit(2)
|
||||
}
|
||||
if (hasForwardedServeFlag(parsed.serveArgs, 'recipe-json')) {
|
||||
console.error(
|
||||
'serve-headless-fresh-profile-pairing: --recipe-json detaches the server and cannot use an ephemeral profile safely.'
|
||||
)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const serveArgs = withDefaultPairingAddress(parsed.serveArgs)
|
||||
|
||||
ensureElectronRuntime()
|
||||
|
||||
const profileDir =
|
||||
fixedProfileDir ?? mkdtempSync(path.join(tmpdir(), 'orca-headless-pairing-profile-'))
|
||||
const ownsProfileDir = !fixedProfileDir
|
||||
mkdirSync(profileDir, { recursive: true })
|
||||
|
||||
let cleanedUp = false
|
||||
let child = null
|
||||
let sawPairingUrl = false
|
||||
let stopAttempts = 0
|
||||
// Why: temp dev worktrees do not have a root-owned chrome-sandbox; this script
|
||||
// is only for local headless testing, not packaged production.
|
||||
const childEnv = {
|
||||
...process.env,
|
||||
ORCA_DEV_USER_DATA_PATH: profileDir,
|
||||
...(process.platform === 'linux'
|
||||
? { ELECTRON_DISABLE_SANDBOX: process.env.ELECTRON_DISABLE_SANDBOX ?? '1' }
|
||||
: {})
|
||||
}
|
||||
|
||||
console.error(`[headless-pairing] userData=${profileDir}`)
|
||||
console.error(`[headless-pairing] starting: orca-dev serve --json${formatForwardedArgs(serveArgs)}`)
|
||||
|
||||
child = spawn(process.execPath, [orcaDevScript, 'serve', '--json', ...serveArgs], {
|
||||
cwd: repoRoot,
|
||||
detached: process.platform !== 'win32',
|
||||
env: childEnv,
|
||||
stdio: ['inherit', 'pipe', 'inherit']
|
||||
})
|
||||
|
||||
const stdoutLines = createInterface({ input: child.stdout })
|
||||
stdoutLines.on('line', (line) => {
|
||||
if (!printReadyLine(line)) {
|
||||
console.log(line)
|
||||
}
|
||||
})
|
||||
|
||||
child.once('error', (error) => {
|
||||
console.error(`[headless-pairing] failed to start server: ${error.message}`)
|
||||
cleanupProfile()
|
||||
process.exitCode = 1
|
||||
})
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
stdoutLines.close()
|
||||
if (!sawPairingUrl && code !== 0) {
|
||||
console.error('[headless-pairing] server exited before printing a pairing URL.')
|
||||
}
|
||||
cleanupProfile()
|
||||
if (typeof code === 'number') {
|
||||
process.exitCode = code
|
||||
return
|
||||
}
|
||||
process.exitCode = signal ? 1 : 0
|
||||
})
|
||||
|
||||
process.on('SIGINT', () => stopChild('SIGINT'))
|
||||
process.on('SIGTERM', () => stopChild('SIGTERM'))
|
||||
|
||||
/**
|
||||
* Parses wrapper flags and forwards everything else to `orca serve`.
|
||||
*/
|
||||
function parseArgs(args) {
|
||||
const serveArgs = []
|
||||
let keepProfile = false
|
||||
let help = false
|
||||
for (const arg of args) {
|
||||
if (arg === '--keep') {
|
||||
keepProfile = true
|
||||
continue
|
||||
}
|
||||
if (arg === '-h' || arg === '--help') {
|
||||
help = true
|
||||
continue
|
||||
}
|
||||
serveArgs.push(arg)
|
||||
}
|
||||
return { help, keepProfile, serveArgs }
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints script usage without touching the dev profile or starting the server.
|
||||
*/
|
||||
function printHelp() {
|
||||
console.log(`Usage: node config/scripts/serve-headless-fresh-profile-pairing.mjs [--keep] [orca serve flags]
|
||||
|
||||
Starts orca-dev serve --json with a fresh isolated userData profile, ensures Electron's dev runtime is usable, and prints the pairing URL.
|
||||
|
||||
Wrapper flags:
|
||||
--keep Keep the fresh profile after the server exits.
|
||||
-h, --help Show this help.
|
||||
|
||||
Forwarded examples:
|
||||
node config/scripts/serve-headless-fresh-profile-pairing.mjs --port 6768
|
||||
node config/scripts/serve-headless-fresh-profile-pairing.mjs --pairing-address 100.64.1.20
|
||||
node config/scripts/serve-headless-fresh-profile-pairing.mjs --mobile-pairing
|
||||
|
||||
Environment:
|
||||
ORCA_HEADLESS_PAIRING_ADDRESS=<host|host:port|ws://...> Override the auto pairing address.
|
||||
ORCA_HEADLESS_PAIRING_PROFILE_DIR=/path/to/profile Use a fixed profile directory.
|
||||
`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a reachable pairing address unless the caller provided one explicitly.
|
||||
*/
|
||||
function withDefaultPairingAddress(args) {
|
||||
if (hasPairingAddress(args)) {
|
||||
return args
|
||||
}
|
||||
const address = resolveDefaultPairingAddress()
|
||||
if (!address) {
|
||||
return args
|
||||
}
|
||||
console.error(`[headless-pairing] pairingAddress=${address} (auto)`)
|
||||
return [...args, '--pairing-address', address]
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks both supported CLI forms for an explicit pairing address.
|
||||
*/
|
||||
function hasPairingAddress(args) {
|
||||
return hasForwardedServeFlag(args, 'pairing-address')
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks both exact and `--flag=value` forms for a forwarded serve flag.
|
||||
*/
|
||||
function hasForwardedServeFlag(args, name) {
|
||||
const flag = `--${name}`
|
||||
return args.some((arg) => arg === flag || arg.startsWith(`${flag}=`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefers an override, then Tailscale, then the OS hostname over loopback.
|
||||
*/
|
||||
function resolveDefaultPairingAddress() {
|
||||
const configured = process.env.ORCA_HEADLESS_PAIRING_ADDRESS?.trim()
|
||||
if (configured) {
|
||||
return configured
|
||||
}
|
||||
const tailscaleAddress = readTailscaleAddress()
|
||||
if (tailscaleAddress) {
|
||||
return tailscaleAddress
|
||||
}
|
||||
const host = hostname().trim()
|
||||
return host && host !== 'localhost' ? host : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the first Tailscale IPv4 address when Tailscale is installed.
|
||||
*/
|
||||
function readTailscaleAddress() {
|
||||
const result = spawnSync('tailscale', ['ip', '-4'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
if (result.error || result.status !== 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
result.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats forwarded arguments for the startup log.
|
||||
*/
|
||||
function formatForwardedArgs(args) {
|
||||
if (args.length === 0) {
|
||||
return ''
|
||||
}
|
||||
return ` ${args.map(formatShellArg).join(' ')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Quotes a shell-ish argument for display only.
|
||||
*/
|
||||
function formatShellArg(value) {
|
||||
if (/^[\w./:=@+-]+$/.test(value)) {
|
||||
return value
|
||||
}
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the same Electron runtime preflight used by dev/start package scripts.
|
||||
*/
|
||||
function ensureElectronRuntime() {
|
||||
console.error('[headless-pairing] ensuring Electron runtime...')
|
||||
const result = spawnSync(process.execPath, [ensureNativeRuntimeScript, '--runtime=electron'], {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
if (result.error) {
|
||||
console.error(`[headless-pairing] Electron runtime preflight failed: ${result.error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the CLI JSON readiness line into the pairing URL testers need.
|
||||
*/
|
||||
function printReadyLine(line) {
|
||||
let payload
|
||||
try {
|
||||
payload = JSON.parse(line)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (!payload || payload.type !== 'orca_server_ready') {
|
||||
return false
|
||||
}
|
||||
console.log(`Orca server ready: ${payload.endpoint ?? 'websocket unavailable'}`)
|
||||
if (payload.pairing?.endpoint) {
|
||||
console.log(`Pairing endpoint: ${payload.pairing.endpoint}`)
|
||||
}
|
||||
if (payload.pairing?.webClientUrl) {
|
||||
console.log(`Web client URL: ${payload.pairing.webClientUrl}`)
|
||||
}
|
||||
if (payload.pairing?.url) {
|
||||
sawPairingUrl = true
|
||||
console.log(`Pairing URL: ${payload.pairing.url}`)
|
||||
console.error('[headless-pairing] server is running; press Ctrl+C to stop.')
|
||||
return true
|
||||
}
|
||||
console.log('Pairing URL: unavailable')
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the foreground server; repeated signals force its process tree down.
|
||||
*/
|
||||
function stopChild(signal) {
|
||||
if (!child || child.killed) {
|
||||
return
|
||||
}
|
||||
stopAttempts += 1
|
||||
const targetSignal = stopAttempts > 1 ? 'SIGKILL' : signal
|
||||
if (process.platform === 'win32' && child.pid) {
|
||||
// Why: child.kill() only targets orca-dev on Windows; taskkill walks the
|
||||
// CLI/Electron descendants so the fresh profile is not left locked.
|
||||
const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
})
|
||||
killer.on('error', () => child?.kill(targetSignal))
|
||||
return
|
||||
}
|
||||
if (process.platform !== 'win32' && child.pid) {
|
||||
// Why: orca-dev synchronously owns the CLI child, which owns Electron; kill
|
||||
// the spawned process group so programmatic shutdown does not orphan serve.
|
||||
try {
|
||||
process.kill(-child.pid, targetSignal)
|
||||
return
|
||||
} catch {
|
||||
// Fall back to the direct child below if the process group already exited.
|
||||
}
|
||||
}
|
||||
child.kill(targetSignal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes only the ephemeral profile directory this script created.
|
||||
*/
|
||||
function cleanupProfile() {
|
||||
if (cleanedUp) {
|
||||
return
|
||||
}
|
||||
cleanedUp = true
|
||||
if (parsed.keepProfile || !ownsProfileDir) {
|
||||
console.error(`[headless-pairing] kept ${profileDir}`)
|
||||
return
|
||||
}
|
||||
if (!existsSync(profileDir) || !profileDir.includes('orca-headless-pairing-profile-')) {
|
||||
console.error(`[headless-pairing] skipped cleanup for unexpected profile path: ${profileDir}`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
rmSync(profileDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
console.error(`[headless-pairing] removed ${profileDir}`)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`[headless-pairing] skipped cleanup for ${profileDir}: ${message}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
const { existsSync } = require('node:fs')
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const { join } = require('node:path')
|
||||
|
||||
// Why: v1.4.129-rc.1 shipped a terminal daemon that could not load (an electron
|
||||
// `require` leaked into its bundle) while every build check passed. This boots
|
||||
// the PACKAGED daemon-entry under plain Node against the asar-unpacked layout,
|
||||
// so a bundling / asar-unpack regression fails packaging instead of reaching
|
||||
// users. Module-load proof only: with no args the entry must reach argv parsing
|
||||
// and print its "Usage: daemon-entry" error — a MODULE_NOT_FOUND or a missing
|
||||
// usage line means the packaged graph does not load and the build must fail.
|
||||
//
|
||||
// resourcesDir is the packaged Resources dir (Contents/Resources on macOS,
|
||||
// <appOutDir>/resources elsewhere). execPath defaults to the packaging Node.
|
||||
function verifyPackagedDaemonEntryBoots(resourcesDir, options = {}) {
|
||||
const execPath = options.execPath || process.execPath
|
||||
const entryPath = join(resourcesDir, 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js')
|
||||
if (!existsSync(entryPath)) {
|
||||
// Why: some targets/layouts do not unpack here; skip rather than fail so
|
||||
// the hook stays safe across platforms it has not verified.
|
||||
console.log(`[verify-packaged-daemon-entry] skipped — no unpacked entry at ${entryPath}`)
|
||||
return
|
||||
}
|
||||
|
||||
const result = spawnSync(execPath, [entryPath], { encoding: 'utf8', timeout: 10_000 })
|
||||
if (result.error) {
|
||||
throw new Error(
|
||||
`[verify-packaged-daemon-entry] could not launch daemon-entry.js: ${result.error.message}`
|
||||
)
|
||||
}
|
||||
const stderr = result.stderr || ''
|
||||
if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) {
|
||||
throw new Error(
|
||||
`[verify-packaged-daemon-entry] packaged daemon-entry.js failed to load under plain Node:\n${stderr}`
|
||||
)
|
||||
}
|
||||
if (!stderr.includes('Usage: daemon-entry')) {
|
||||
throw new Error(
|
||||
`[verify-packaged-daemon-entry] packaged daemon-entry.js did not reach argv parsing ` +
|
||||
`(expected the "Usage: daemon-entry" error). stderr:\n${stderr}`
|
||||
)
|
||||
}
|
||||
console.log('[verify-packaged-daemon-entry] OK — packaged daemon-entry loads under plain Node')
|
||||
}
|
||||
|
||||
module.exports = { verifyPackagedDaemonEntryBoots }
|
||||
@@ -13,9 +13,12 @@
|
||||
"../src/main/codex/codex-config-mirror.ts",
|
||||
"../src/main/codex/codex-config-path-reference-rewrite.ts",
|
||||
"../src/main/codex/codex-home-paths.ts",
|
||||
"../src/main/codex/codex-hook-identity.ts",
|
||||
"../src/main/codex/config-settings-promotion.ts",
|
||||
"../src/main/codex/config-toml-line-scan.ts",
|
||||
"../src/main/codex/config-toml-trust.ts",
|
||||
"../src/main/codex/hook-service.ts",
|
||||
"../src/main/codex/hook-trust-promotion.ts",
|
||||
"../src/main/codex-accounts/fs-utils.ts",
|
||||
"../src/main/command-code/command-code-managed-script.ts",
|
||||
"../src/main/command-code/hook-service.ts",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
"include": [
|
||||
"../electron.vite.config.*",
|
||||
"../build-plugins/**/*",
|
||||
"../src/main/**/*",
|
||||
"../src/preload/**/*",
|
||||
"../src/shared/**/*",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.6m">
|
||||
<title>downloads: 3.6m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 4.1m">
|
||||
<title>downloads: 4.1m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.6m</text>
|
||||
<text x="93.5" y="14">3.6m</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">4.1m</text>
|
||||
<text x="93.5" y="14">4.1m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 943 B |
Binary file not shown.
|
After Width: | Height: | Size: 206 KiB |
@@ -36,7 +36,7 @@
|
||||
|
||||
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
|
||||
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
|
||||
|
||||
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
|
||||
|
||||
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
|
||||
|
||||
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
|
||||
|
||||
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
|
||||
|
||||
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Baixar APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
|
||||
- **Android:** [Baixar APK 0.0.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
@@ -238,10 +238,6 @@ Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
|
||||
|
||||
- **Discord:** Entre na comunidade no **[Discord](https://discord.gg/fzjDKHxv8Q)**.
|
||||
- **Twitter / X:** Siga **[@orca_build](https://x.com/orca_build)** para atualizações e anúncios.
|
||||
- **WeChat:** Escaneie o QR code para entrar na comunidade.
|
||||
|
||||
<img src="../assets/wechat-qr.png" alt="QR code do WeChat para a comunidade Orca" width="160" />
|
||||
|
||||
- **Feedback e ideias:** Lançamos rápido. Sentiu falta de algo? [Peça um novo recurso](https://github.com/stablyai/orca/issues).
|
||||
- **Privacidade:** Veja a [documentação de privacidade e telemetria](https://www.onorca.dev/docs/telemetry) para saber quais dados anônimos de uso o Orca coleta e como desativar.
|
||||
- **Mostre apoio:** Dê uma [estrela](https://github.com/stablyai/orca) neste repositório para acompanhar nossos lançamentos diários.
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
与桌面应用配对,用手机监控并指挥你的智能体。
|
||||
|
||||
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
@@ -235,6 +235,11 @@ yay -S stably-orca-bin
|
||||
|
||||
- **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
|
||||
- **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。
|
||||
- **微信:** 扫描二维码加入社区。如果第一个群已满,请使用备用微信群。
|
||||
|
||||
<img src="../assets/wechat-qr.png" alt="Orca 社区微信群二维码" width="160" />
|
||||
<img src="../assets/wechat-qr-backup.jpg" alt="Orca 社区备用微信群二维码" width="160" />
|
||||
|
||||
- **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。
|
||||
- **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。
|
||||
- **支持我们:** 给这个仓库点 [Star](https://github.com/stablyai/orca),关注我们的日常发布。
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# SSH Typing Latency Under Relay Load
|
||||
|
||||
Date: 2026-07-06
|
||||
|
||||
## Symptom
|
||||
|
||||
Typing in an SSH-host terminal can become extremely slow (hundreds of ms to
|
||||
seconds per echoed keystroke) while other SSH work is active — file previews,
|
||||
source-control refresh, search. A quiet SSH shell stays snappy, which made the
|
||||
reports look unreproducible.
|
||||
|
||||
This is distinct from the local warm-switch lag documented in
|
||||
`terminal-switch-typing-lag-investigation.md` (daemon `listSessions()`
|
||||
snapshot cost — already fixed; `TerminalHost.listSessions()` is now
|
||||
metadata-only via `getAppliedSize()`).
|
||||
|
||||
## Root Cause
|
||||
|
||||
The relay and the Electron client share ONE ordered SSH channel for all
|
||||
JSON-RPC traffic: PTY input/output, file streams, git responses, search, port
|
||||
scans. Two mechanisms turned bulk traffic into typing latency:
|
||||
|
||||
1. **Relay-side head-of-line blocking (primary).** The `fs.readFileStream`
|
||||
pump (`src/relay/fs-handler-file-read.ts`) wrote every 256KB chunk (~340KB
|
||||
framed) into the relay's stdout as fast as local disk reads completed,
|
||||
ignoring the `write() === false` backpressure signal. A 10MB preview
|
||||
enqueued ~13.6MB into the pipe at once; a `pty.data` echo emitted
|
||||
mid-stream queued behind ALL of it. At 2MB/s WAN that is multiple seconds
|
||||
of echo delay per open file. The reproduced measurement: 4,195,592 bytes
|
||||
(the entire remaining 3MB test file, framed) queued ahead of one echo
|
||||
frame.
|
||||
|
||||
2. **Client-side O(n²) frame buffering (secondary).** `FrameDecoder.feed()`
|
||||
in both `src/main/ssh/relay-protocol.ts` and `src/relay/protocol.ts` did
|
||||
`Buffer.concat([buffered, chunk])` per data event, re-copying the whole
|
||||
backlog for every ~32KB TCP chunk — ~2MB of memcpy per 340KB frame,
|
||||
~80MB per 10MB file — on the Electron main thread, delaying `pty:write`
|
||||
IPC dispatch and echo delivery to the renderer.
|
||||
|
||||
## Fix
|
||||
|
||||
- **Bulk lane with sink backpressure** (`RelayDispatcher.notifyBulk`):
|
||||
`fs.streamChunk` frames are serialized per client and each send waits for
|
||||
the sink's `drain` when `write()` returns `false`. Interactive frames
|
||||
(`pty.data`) still use plain `notify()` and are admitted immediately —
|
||||
because the bulk lane keeps the outbound buffer at ~1 frame, an echo jumps
|
||||
ahead of every not-yet-admitted chunk. Sinks (`relay.ts` stdout + Unix
|
||||
socket clients) surface `write()`'s boolean and a one-shot
|
||||
`waitWriteDrain`; every death path (EPIPE, stdin end, detach, dispose)
|
||||
flushes parked waiters so a pump can never hang on a dead pipe.
|
||||
|
||||
- **Credit-based flow control** (`fs.streamAck`): the client requests
|
||||
streams with `flowControl: 'ack'` and acks each processed chunk; the relay
|
||||
caps unacked chunks at `STREAM_ACK_WINDOW_CHUNKS = 4` (~1MB raw). This
|
||||
bounds in-flight bulk bytes even past the relay's own pipe (sshd/TCP
|
||||
buffers) and paces the relay's base64/JSON encode loop so incoming
|
||||
keystroke frames get event-loop turns. A parked pump wakes on ack, cancel,
|
||||
release, client detach, and a 1s staleness recheck.
|
||||
|
||||
- **Cross-version compatibility**: old client + new relay → no
|
||||
`flowControl` param → legacy unpaced pump (still drain-bounded, which is
|
||||
transparent). New client + old relay → `fs.streamAck` is an unknown
|
||||
notification and is silently ignored. `STREAM_CHUNK_SIZE` (256KB) is
|
||||
intentionally unchanged — both sides bake it into chunk offset math, so
|
||||
changing it would corrupt cross-version streams.
|
||||
|
||||
- **FrameDecoder rewrite** (both mirrored protocol files): chunk-list
|
||||
buffering; each frame is assembled exactly once (one copy) instead of
|
||||
re-concatenating the backlog per feed.
|
||||
|
||||
## What Stays Fast / Unchanged
|
||||
|
||||
- Relay PTY output batching (interactive echo fast path in
|
||||
`src/relay/pty-handler.ts`) is untouched.
|
||||
- No `listSessions()` / provider inventory was added anywhere near typing,
|
||||
focus, switch, resume, resize, or render paths.
|
||||
- Remote file streaming correctness: per-chunk length checks, chunk-count and
|
||||
byte-count invariants, cancel paths, and the 12MB round-trip integration
|
||||
test all pass unchanged.
|
||||
|
||||
## Regression Coverage
|
||||
|
||||
- `src/relay/fs-stream-pty-echo-backpressure.integration.test.ts` — real
|
||||
mux ↔ dispatcher ↔ FsHandler over a congestible in-memory pipe; asserts
|
||||
deterministic BYTE bounds (not wall-clock): echo queues behind < 2 framed
|
||||
chunks when congested (pre-fix: whole file), ack window caps in-flight
|
||||
chunks, legacy no-ack clients still get complete streams.
|
||||
- `src/relay/fs-handler-stream.test.ts` — pump parks at the ack window,
|
||||
resumes per ack, and releases its file handle when cancelled while parked.
|
||||
- `src/relay/dispatcher.test.ts` — notifyBulk semantics: drain gating,
|
||||
per-client targeting, dispose releases parked senders, interactive
|
||||
notify() not gated behind a stalled bulk lane.
|
||||
- `src/main/ssh/relay-protocol.test.ts` — decoder byte-at-a-time boundary
|
||||
straddling, oversized-frame resync across odd chunk sizes, and a
|
||||
no-`Buffer.concat`-during-feed guard that locks out the O(n²) shape.
|
||||
- `tests/e2e/ssh-docker-relay-perf.spec.ts` — new "busy relay" scenario:
|
||||
types while two 8MB remote file-read loops and a git.status loop run;
|
||||
budgets median < 500ms, worst < 2000ms. (On loopback Docker the pre-fix
|
||||
HOL is only tens of ms — the in-process byte-bound test is the
|
||||
authoritative red/green; the e2e guards the end-to-end wiring.)
|
||||
|
||||
## Residual Gaps
|
||||
|
||||
- **Large single-frame responses**: `git.*` responses (stdout capped at
|
||||
`MAX_GIT_BUFFER` = 10MB → ~13MB framed) and other request/response results
|
||||
are one atomic frame on the wire; a huge diff can still delay an echo by
|
||||
its own transfer time. Fixing this requires response chunking (a protocol
|
||||
change); bounded by the 16MB `MAX_MESSAGE_SIZE`.
|
||||
- **Outbound direction**: a large `fs.writeFile` request frame (client →
|
||||
relay) can queue ahead of keystroke `pty.data` frames on `channel.stdin`.
|
||||
Same shape, opposite direction, rarer trigger (saving a large remote file
|
||||
while typing).
|
||||
- `pty.ackData` flow control for PTY output remains unenforced
|
||||
(`src/relay/pty-handler.ts` — "not yet enforced"); PTY output floods are
|
||||
already paced by the relay's 8ms batch/16KB slice scheduler.
|
||||
@@ -0,0 +1,368 @@
|
||||
# Source Control Push Failure AI Recovery
|
||||
|
||||
## Problem
|
||||
|
||||
Push, force-push, publish, and sync push-stage failures flow through the shared
|
||||
remote operation formatter in `src/renderer/src/lib/source-control-remote-error.ts:94`
|
||||
and the editor-store toast paths in `src/renderer/src/store/slices/editor.ts:3716`
|
||||
and `src/renderer/src/store/slices/editor.ts:3821`. A local pre-push hook or
|
||||
lint hook is not an auth, protected-branch, or transport problem, so generic
|
||||
remote guidance sends the user in the wrong direction.
|
||||
|
||||
PR #7787 proves the behavior is useful, but the prototype needs tightening before
|
||||
it is maintainable:
|
||||
|
||||
- `src/renderer/src/components/right-sidebar/SourceControl.tsx:1938` and
|
||||
`src/renderer/src/components/right-sidebar/SourceControl.tsx:5799` repeat
|
||||
push-failure predicates and feed live branch/file state into a prompt that says
|
||||
"at failure time".
|
||||
- `src/renderer/src/components/right-sidebar/use-source-control-recovery-ai.ts:63`
|
||||
imports push detection again instead of consuming one derived recovery model.
|
||||
- `src/renderer/src/components/right-sidebar/source-control-ai-commit-failure-launch.ts:24`
|
||||
and `src/renderer/src/components/right-sidebar/source-control-ai-push-failure-launch.ts:24`
|
||||
are copied launch flows with action-specific copy.
|
||||
- `src/shared/source-control-commit-failure-agent-command.ts:6` and
|
||||
`src/shared/source-control-push-failure-agent-command.ts:6` are identical
|
||||
command-template builders.
|
||||
- `src/renderer/src/components/right-sidebar/source-control-fix-split-button.tsx:21`
|
||||
already contains a reusable split button, while `SourceControl.tsx:6415`
|
||||
carries a second inline version.
|
||||
- `src/renderer/src/lib/source-control-remote-error.ts:148` currently treats
|
||||
any `isSync` error as push-like for hook detection. `syncBranch` calls the
|
||||
formatter for fetch, upstream-status, pull, and push failures, so the renderer
|
||||
cannot prove a sync error came from the push stage without an explicit marker.
|
||||
- `src/renderer/src/components/right-sidebar/SourceControl.push-failure-recovery.test.ts:2`
|
||||
imports prompt helpers through the large React module instead of shared prompt
|
||||
modules.
|
||||
|
||||
## Goal
|
||||
|
||||
Add first-class recovery for pre-push hook failures without growing a parallel
|
||||
push-only subsystem:
|
||||
|
||||
1. Toasts for push, force-push, publish, and sync push-stage failures say
|
||||
"blocked" for detected pre-push or lint-hook output.
|
||||
2. Source Control shows a concise Push blocked panel with Details and AI Fix
|
||||
only for a failure snapshot that is both push-like and hook-like.
|
||||
3. AI Fix launches the configured `fixPushFailure` Source Control action with a
|
||||
safe, provider-neutral prompt.
|
||||
4. Commit-failure recovery and push-failure recovery share launch,
|
||||
command-template, split-button, and dense error-panel primitives.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not fix server-side pre-receive hooks, hosted CI failures, or provider-side
|
||||
protected-branch failures from this entry point.
|
||||
- Do not bypass hooks, add `--no-verify`, push from the launched agent, create a
|
||||
PR, or assume GitHub-specific terminology.
|
||||
- Do not add app-wide persistence for this transient failure panel.
|
||||
- Do not redesign the Source Control panel or the Source Control AI settings
|
||||
model.
|
||||
- Do not fold unrelated launch surfaces such as checks recovery into this change
|
||||
unless a tiny shared helper is already needed for commit/push recovery.
|
||||
|
||||
## Design
|
||||
|
||||
1. Keep push-hook detection and prompt building in shared code.
|
||||
- `src/shared/source-control-push-failure.ts` owns normalization, bounded
|
||||
scanning, summary text, Details eligibility, prompt truncation, prompt
|
||||
rules, and changed-file list bounding.
|
||||
- Detection must stay conservative: explicit `pre-push`/`prepush`,
|
||||
`hook declined to push`, hook-runner output in push context, or lint output
|
||||
in push context. Auth, non-fast-forward, protected branch, pre-receive,
|
||||
submodule, and generic transport failures stay on the existing remote-error
|
||||
path.
|
||||
- Keep the output scan bounded at 64 KiB and the prompt failure-output
|
||||
section bounded. Also cap prompt file lines to a small constant with an
|
||||
omitted-count line; do not rely on the git-status cap, which is far too high
|
||||
for prompt context.
|
||||
|
||||
2. Make remote-error formatting sync-stage aware.
|
||||
- Extend `RemoteOperationErrorOptions` with an explicit push-stage flag for
|
||||
sync, for example `isSyncPushStage`.
|
||||
- Gate push-hook classification on `isPush`, `isForcePush`, `publish`, or
|
||||
`isSyncPushStage`; do not use bare `isSync` as evidence of a push failure.
|
||||
- In `syncBranch`, pass the push-stage flag only inside the two inner
|
||||
`pushRuntimeGit` catch blocks. The outer fetch, upstream-status, and pull
|
||||
catches still pass `isSync` for Sync-shaped generic copy but must not render
|
||||
blocked hook copy.
|
||||
- Carry the same push-stage fact back to the renderer, either by tagging the
|
||||
thrown `Error` with a narrow exported marker or by wrapping it in a typed
|
||||
error that preserves the original message/cause. `SourceControl` must set
|
||||
`syncPushStage` only from that marker, not from `kind === 'sync'` plus
|
||||
hook-like stderr.
|
||||
- Keep submodule push messages before hook detection so submodule guidance
|
||||
remains more specific than AI recovery.
|
||||
|
||||
3. Replace one-off command builders with one shared recovery command builder.
|
||||
- Add a shared builder that accepts `{ actionId, promptOverride,
|
||||
commandInputTemplate, basePrompt }` and renders the existing Source Control
|
||||
AI command template for launch actions.
|
||||
- Keep compatibility exports for commit and push helpers if nearby tests or
|
||||
call sites still use those names, but make them wrappers over the generic
|
||||
builder.
|
||||
- Move prompt-builder tests to import from shared modules, not
|
||||
`SourceControl.tsx`.
|
||||
|
||||
4. Capture and derive push recovery state once.
|
||||
- Extend `SourceControlActionError` with the raw error, a push-stage marker
|
||||
for sync failures, the branch name at failure time, a bounded status-entry
|
||||
snapshot at failure time, and a per-worktree sequence token.
|
||||
- `runRemoteAction` clears the worktree's previous error and increments its
|
||||
sequence before starting. A catch may write an error only if its sequence
|
||||
still owns that worktree, so a slow failure cannot overwrite a newer retry
|
||||
or success.
|
||||
- Add a focused renderer helper that accepts the captured
|
||||
`SourceControlActionError` and the current branch name, then returns either
|
||||
`null` or a model with raw/sanitized detail text, summary, details flag,
|
||||
kind label, and AI prompt.
|
||||
- The helper returns `null` unless the operation is `push`, `force_push`,
|
||||
`publish`, or `sync` with the sync push-stage marker and
|
||||
`isPushHookFailure(rawError)` passes.
|
||||
- If the current branch is known and differs from the captured branch, hide or
|
||||
clear the model instead of launching an agent with wrong branch context.
|
||||
- Pass that single model to `useSourceControlAi` and `CommitArea`; neither
|
||||
prop construction nor `CommitArea` should repeat the push-hook predicate.
|
||||
|
||||
5. Use one generic recovery launcher for commit and push failures.
|
||||
- Replace the separate commit and push launch files with
|
||||
`source-control-ai-recovery-launch.ts`, or keep the old files as one-line
|
||||
wrappers if import stability is cheaper.
|
||||
- The generic launcher owns connection resolution, SSH/local agent discovery,
|
||||
saved-agent validation, agent-args validation, agent selection, terminal
|
||||
launch, focus, and success/failure toasts.
|
||||
- Resolve connection without collapsing local and unresolved states: read
|
||||
`const worktreeConnectionId = getConnectionId(worktreeId)`, use it when it
|
||||
is a string or `null`, and only fall back to `sourceRepoConnectionId` when
|
||||
the worktree lookup returns `undefined`. If the fallback is also
|
||||
`undefined`, show the workspace-connection error instead of launching
|
||||
locally. `null` means proven local; a string means SSH/remote.
|
||||
- Validate saved CLI args before agent detection or terminal creation. On
|
||||
Windows, keep using PowerShell-safe planning through
|
||||
`planAgentCliArgsSuffix(..., 'powershell')`.
|
||||
- Action-specific data is limited to action id, base prompt, empty-prompt
|
||||
copy, unavailable-agent copy, and success copy.
|
||||
|
||||
6. Keep the recovery hook thin.
|
||||
- `use-source-control-recovery-ai.ts` builds the commit prompt, accepts the
|
||||
already-derived push recovery prompt/model, keeps independent loading
|
||||
flags, and calls the generic launcher.
|
||||
- It should not import push-hook detection, resolve agents, or duplicate
|
||||
launch plumbing.
|
||||
|
||||
7. Extract and reuse the recovery UI.
|
||||
- Reuse or replace the existing
|
||||
`src/renderer/src/components/right-sidebar/source-control-fix-split-button.tsx`
|
||||
rather than adding another split-button component.
|
||||
- Move the dense recovery notice and Details dialog into focused renderer
|
||||
modules named for source-control recovery. Avoid `helpers`, `utils`, and new
|
||||
max-lines suppressions.
|
||||
- Reuse the same notice/dialog component for commit and push recovery with
|
||||
action-specific labels, summary, details, prompt, saved recipe, and launch
|
||||
callback.
|
||||
- Keep normal remote errors filtered out when a push recovery model is
|
||||
rendered, so the user does not see both Push blocked and generic remote
|
||||
error copy for the same failure.
|
||||
|
||||
8. Retain `fixPushFailure` in the existing Source Control AI action model.
|
||||
- The prototype already adds `fixPushFailure` to
|
||||
`src/shared/source-control-ai-actions.ts`; keep it as a launch action like
|
||||
`fixCommitFailure`.
|
||||
- Verify default settings, normalizers, global settings rows, repository
|
||||
override rows, labels, descriptions, and variable chips all include Push
|
||||
failure fixes.
|
||||
- Do not create a push-specific settings store or migration. Existing
|
||||
`normalizeSourceControlAiSettings` hydrates missing action defaults.
|
||||
|
||||
## Data Flow
|
||||
|
||||
- User clicks Push, Force Push, Publish Branch, or Sync.
|
||||
- `runRemoteAction` clears that worktree's previous remote error, records a new
|
||||
sequence token, and starts the editor-store remote action.
|
||||
- Git push fails and throws.
|
||||
- `pushBranch` or sync's inner push-stage catch calls
|
||||
`resolveRemoteOperationErrorMessage` with push-like options. Sync fetch, pull,
|
||||
and upstream-status failures do not pass the push-stage flag.
|
||||
- Sync's inner push-stage catch marks the rethrown error as push-stage so
|
||||
`runRemoteAction` can preserve `syncPushStage` in the captured snapshot.
|
||||
- The formatter classifies hook output before auth/transport fallbacks only for
|
||||
push-like options and shows blocked toast copy.
|
||||
- `SourceControl` catches the same error, and if the sequence still owns the
|
||||
worktree, stores `{ kind, rawError, message, syncPushStage, branchName,
|
||||
entriesSnapshot }`.
|
||||
- The push recovery helper derives one `pushRecovery` model from that snapshot.
|
||||
- `CommitArea` renders normal remote errors or the Push blocked recovery notice.
|
||||
- AI Fix builds the `fixPushFailure` command input and launches the selected
|
||||
agent in the owning local or SSH runtime.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Auth, missing repo, protected-branch, pre-receive, non-fast-forward, and
|
||||
transport failures must not show the Push blocked panel.
|
||||
- Submodule push errors keep their existing specialized messages before
|
||||
push-hook detection.
|
||||
- Sync failures from fetch, upstream-status, or pull stages must not show Push
|
||||
blocked, even if stderr contains words like "lint" or "hook".
|
||||
- Create PR intent remote failures use the same `runRemoteAction` plumbing; if
|
||||
CommitArea is not visible, blocked toast copy is still required but no hidden
|
||||
panel work is needed.
|
||||
- A newer remote operation for the same worktree must clear stale recovery state
|
||||
immediately and prevent an older in-flight failure from writing after it.
|
||||
- Switching worktrees or branches must not show another worktree or branch's
|
||||
push failure.
|
||||
- External edits or another Orca window may make the failure stale. Do not add
|
||||
cross-window persistence for this feature; rely on local retry/remount/branch
|
||||
mismatch clearing and list this as residual risk.
|
||||
- Source Control AI hidden for a repo still shows the blocked notice and Details;
|
||||
it hides AI Fix.
|
||||
- SSH worktrees must detect and launch agents on the owning connection, not a
|
||||
local fallback.
|
||||
- Windows launch planning must continue to use PowerShell-safe agent args.
|
||||
- Very large hook output and very large changed-file sets must be bounded before
|
||||
prompt generation.
|
||||
- ANSI/control output must not leak into summaries or Details comparison logic.
|
||||
Details may show sanitized text while preserving enough line breaks for
|
||||
debugging.
|
||||
- Empty custom command templates must stay empty so the launcher rejects them
|
||||
with a clear settings error.
|
||||
- Prompt text must treat file paths, branch names, and hook output as data, not
|
||||
instructions.
|
||||
|
||||
## Test Plan
|
||||
|
||||
- Shared unit tests:
|
||||
- `src/shared/source-control-push-failure.test.ts` covers detection positives,
|
||||
auth/protected/pre-receive/non-fast-forward negatives, ANSI/control
|
||||
stripping, bounded scanning, details comparison, prompt output truncation,
|
||||
prompt file-list capping, and provider-neutral prompt rules.
|
||||
- Shared command-template tests cover the generic recovery builder plus
|
||||
commit/push wrappers, including empty templates and prompt overrides.
|
||||
- `src/shared/source-control-ai-actions.test.ts` covers `fixPushFailure`
|
||||
label, default template, variables, normalization, and action-list inclusion.
|
||||
- Remote formatter and store tests:
|
||||
- `src/renderer/src/lib/source-control-remote-error.test.ts` covers push,
|
||||
force-push, publish, and sync push-stage blocked copy plus sync non-push
|
||||
stage negatives, auth/non-fast-forward/protected/pre-receive/submodule
|
||||
regressions.
|
||||
- `src/renderer/src/store/slices/editor.test.ts` covers the `pushBranch` toast
|
||||
path and both sync push-stage and sync non-push-stage toasts.
|
||||
- Renderer model and UI tests:
|
||||
- A focused push recovery derivation test covers operation kind, sync
|
||||
push-stage marker, branch mismatch, stale sequence ownership, snapshot file
|
||||
entries, summary, details flag, prompt contents, and ordinary remote errors.
|
||||
- `CommitArea` tests cover the Push blocked notice, Details dialog, AI Fix
|
||||
visible/hidden, ordinary remote errors, no duplicate remote error when a push
|
||||
model exists, and unchanged commit-failure recovery rendering.
|
||||
- Settings tests cover global and repository Source Control AI action rows
|
||||
showing Push failure fixes.
|
||||
- Launcher tests:
|
||||
- Generic recovery launcher tests cover commit and push action ids, invalid CLI
|
||||
args before detection, empty template rejection, unavailable saved agent,
|
||||
local versus SSH agent detection, successful launch/focus, and success copy.
|
||||
- Checks:
|
||||
- Run targeted vitest files above, `pnpm typecheck`, `pnpm lint`, and
|
||||
`pnpm check:max-lines-ratchet`.
|
||||
|
||||
## UI Quality Bar
|
||||
|
||||
The Source Control recovery UI must match `docs/STYLEGUIDE.md`: monochrome,
|
||||
quiet, dense, token-based, and consistent with adjacent commit-failure recovery.
|
||||
Use existing shadcn `Button`, `DropdownMenu`, and `Dialog` primitives, lucide
|
||||
icons, `card`/`border`/`destructive`/`muted` tokens, and the existing action
|
||||
recipe row patterns. The Push blocked notice should keep the summary readable at
|
||||
narrow right-sidebar widths, avoid nested cards, avoid new color values, show
|
||||
stable split-button geometry while launching, and keep Details as progressive
|
||||
disclosure for long hook output. Dialogs and dropdowns must remain usable in
|
||||
light/dark mode and under SSH latency.
|
||||
|
||||
## Review Screenshots
|
||||
|
||||
1. Source Control after a simulated pre-push hook failure: Push blocked notice
|
||||
with summary, AI Fix, and Details.
|
||||
2. Push failure Details dialog showing hook output and Fix with AI.
|
||||
3. AI Fix customize-launch dialog for `fixPushFailure`.
|
||||
4. Ordinary push auth/protected/remote failure state: regular remote error, no
|
||||
Push blocked notice.
|
||||
5. Sync fetch/pull-stage failure state: Sync-shaped remote error, no Push
|
||||
blocked notice.
|
||||
6. Source Control AI hidden for the repo: Push blocked notice and Details, no AI
|
||||
Fix.
|
||||
7. Source Control AI settings showing the Push failure fixes launch recipe.
|
||||
8. Adjacent commit-failure recovery notice still rendering correctly after the
|
||||
shared UI extraction.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Tighten shared push-failure classifier/prompt bounds and add the generic
|
||||
recovery command builder.
|
||||
2. Make remote formatter/store sync-stage aware and update toast tests.
|
||||
3. Add the `SourceControlActionError` snapshot/sequence data and one push
|
||||
recovery derivation helper.
|
||||
4. Replace duplicated commit/push launchers with the generic recovery launcher
|
||||
and thin hook wiring.
|
||||
5. Extract/reuse source-control recovery UI components, including the existing
|
||||
split-button module.
|
||||
6. Verify `fixPushFailure` settings/default/normalizer coverage and i18n labels.
|
||||
7. Update targeted tests and run typecheck, lint, and max-lines ratchet.
|
||||
8. Validate rendered Electron states with screenshots before opening the PR.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- Scope: Reduced from PR #7787's push-specific parallel path to a shared
|
||||
recovery layer. Keep the feature limited to local pre-push/lint-hook detection,
|
||||
blocked copy, an inline recovery notice, Details, and AI launch. No persistence
|
||||
or provider-specific recovery.
|
||||
- Architecture/data flow: Shared code owns classification, prompt text, and
|
||||
command-template rendering; the editor store owns stage-aware toast formatting;
|
||||
`SourceControl` owns transient worktree-scoped failure snapshots and sequence
|
||||
invalidation; the generic launcher owns local/SSH agent discovery and terminal
|
||||
launch.
|
||||
- Failure modes covered:
|
||||
- Sync stage ambiguity: only the inner push stage may classify as hook-blocked.
|
||||
- Auth/protected/pre-receive/non-fast-forward/submodule/transport errors stay
|
||||
out of push recovery.
|
||||
- Branch or worktree switches suppress stale recovery models.
|
||||
- Per-worktree sequence tokens prevent older in-flight failures from
|
||||
overwriting newer retries or successes.
|
||||
- Empty custom templates, invalid CLI args, unavailable saved agents, missing
|
||||
worktree context, SSH host detection, and Windows shell planning fail before
|
||||
terminal launch.
|
||||
- Large or hostile hook output and file lists are bounded and treated as data
|
||||
in prompts.
|
||||
- Test coverage required:
|
||||
- Shared classifier/prompt/command/action-model tests in `src/shared`.
|
||||
- Remote formatter and editor-store tests for push, force-push, publish, sync
|
||||
push-stage, and sync non-push-stage paths.
|
||||
- Renderer derivation and `CommitArea` tests for Push blocked, Details, AI Fix,
|
||||
hidden AI actions, stale branch/sequence, ordinary remote errors, and
|
||||
existing commit failure recovery.
|
||||
- Generic launcher tests for local/SSH, invalid args, saved-agent unavailable,
|
||||
empty template, and success launch.
|
||||
- Settings tests for global and repo Push failure fixes rows.
|
||||
- Electron validation for all required screenshots; no separate E2E is
|
||||
required if renderer tests cover the model and Electron validates the real UI.
|
||||
- Performance/blast radius: No polling, watchers, migrations, or startup work.
|
||||
Runtime cost is one bounded classifier pass over at most 64 KiB, bounded prompt
|
||||
output, and a bounded file snapshot only when a remote action fails. Largest
|
||||
blast radius is the shared recovery UI and launcher used by existing commit
|
||||
failures, so commit recovery needs tests and a screenshot.
|
||||
- UI quality bar: Match `docs/STYLEGUIDE.md` and adjacent Source Control density:
|
||||
token-based error state, stable split-button geometry, progressive Details
|
||||
disclosure, no nested cards, concise copy, light/dark compatibility, and
|
||||
visible disabled/loading state under SSH latency.
|
||||
- Required review screenshots:
|
||||
1. Push blocked notice after pre-push hook failure.
|
||||
2. Push failure Details dialog with Fix with AI.
|
||||
3. `fixPushFailure` customize-launch dialog.
|
||||
4. Ordinary push auth/protected/remote failure with no Push blocked panel.
|
||||
5. Sync non-push-stage failure with no Push blocked panel.
|
||||
6. Source Control AI hidden: Push blocked without AI Fix.
|
||||
7. Source Control AI settings showing Push failure fixes.
|
||||
8. Existing commit-failure recovery notice after shared UI extraction.
|
||||
- Residual risks: Classifier false positives remain possible for unusual local
|
||||
hook-runner output that mentions push context; keep tests biased toward
|
||||
preserving auth, protected-branch, and non-fast-forward behavior. Failure
|
||||
panels are renderer-local, so another Orca window or an external terminal can
|
||||
fix the problem while the old panel remains visible until local retry, remount,
|
||||
or branch/worktree change. Electron validation may need a mocked or temporary
|
||||
repo scenario to trigger a real pre-push hook without mutating user data.
|
||||
@@ -2,6 +2,7 @@ import { resolve } from 'node:path'
|
||||
import { defineConfig } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { createPlainNodeEntryGuardPlugin } from './build-plugins/plain-node-entry-guard'
|
||||
|
||||
// Why: the telemetry transport is gated by two compile-time constants that
|
||||
// only the official CI release workflow sets. Contributor / `pnpm dev` /
|
||||
@@ -182,13 +183,16 @@ export default defineConfig({
|
||||
'stt-worker': resolve('src/main/speech/stt-worker.ts'),
|
||||
'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'),
|
||||
'file-watcher-worker': resolve('src/main/runtime/file-watcher-worker.ts'),
|
||||
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
|
||||
// can't take down the main process (issue #7547).
|
||||
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
|
||||
// Why: electron-vite cleans out/main in dev. The dev CLI imports
|
||||
// this path for `orca agent hooks ...`, so it must survive rebuilds.
|
||||
'agent-hooks/managed-agent-hook-controls': resolve(
|
||||
'src/main/agent-hooks/managed-agent-hook-controls.ts'
|
||||
)
|
||||
},
|
||||
plugins: [createStartupDiagnosticsBootstrapPlugin()]
|
||||
plugins: [createStartupDiagnosticsBootstrapPlugin(), createPlainNodeEntryGuardPlugin()]
|
||||
}
|
||||
},
|
||||
// Why: compile-time substitution for the telemetry gate. See the block
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Orca",
|
||||
"slug": "orca-mobile",
|
||||
"version": "0.0.22",
|
||||
"version": "0.0.25",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
@@ -75,7 +75,7 @@
|
||||
"allowBackup": false,
|
||||
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
|
||||
"package": "com.stably.orca.mobile",
|
||||
"versionCode": 4
|
||||
"versionCode": 6
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
|
||||
@@ -102,6 +102,14 @@ export default function RootLayout() {
|
||||
return
|
||||
}
|
||||
handledNotificationIdsRef.current.add(notificationId)
|
||||
// Why: RootLayout never unmounts, so cap this tap-dedup set (FIFO) rather
|
||||
// than letting it grow one id per notification tapped for the app's life.
|
||||
if (handledNotificationIdsRef.current.size > 256) {
|
||||
const oldest = handledNotificationIdsRef.current.values().next().value
|
||||
if (oldest !== undefined) {
|
||||
handledNotificationIdsRef.current.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
const path = await getNavigationPath(response.notification.request.content.data)
|
||||
clearLastNotificationResponse()
|
||||
|
||||
@@ -3,14 +3,15 @@ import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from '
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react-native'
|
||||
import { useHostClient } from '../../../../src/transport/client-context'
|
||||
import { useForceReconnect, useHostClient } from '../../../../src/transport/client-context'
|
||||
import type { RpcSuccess } from '../../../../src/transport/types'
|
||||
import { colors, spacing, typography } from '../../../../src/theme/mobile-theme'
|
||||
import { colors, radii, spacing, typography } from '../../../../src/theme/mobile-theme'
|
||||
import {
|
||||
fetchMobileGitHistory,
|
||||
mapMobileCommitRows,
|
||||
type MobileCommitRow
|
||||
} from '../../../../src/source-control/mobile-git-history'
|
||||
import { resolveMobileHistoryScreenView } from '../../../../src/source-control/mobile-history-screen-state'
|
||||
import type { GitBranchChangeEntry } from '../../../../../src/shared/types'
|
||||
|
||||
function firstParam(value: string | string[] | undefined): string {
|
||||
@@ -27,9 +28,11 @@ export default function HistoryScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const { client, state: connState } = useHostClient(hostId)
|
||||
const forceReconnect = useForceReconnect()
|
||||
|
||||
const [rows, setRows] = useState<MobileCommitRow[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [reloadNonce, setReloadNonce] = useState(0)
|
||||
const [expanded, setExpanded] = useState<string | null>(null)
|
||||
const [filesById, setFilesById] = useState<Record<string, GitBranchChangeEntry[] | 'loading'>>({})
|
||||
|
||||
@@ -57,7 +60,20 @@ export default function HistoryScreen() {
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [client, connState, worktreeId])
|
||||
}, [client, connState, reloadNonce, worktreeId])
|
||||
|
||||
const retry = useCallback(() => {
|
||||
setError(null)
|
||||
// Why: retrying the fetch is useless while the transport's reconnect loop
|
||||
// is parked at its backoff cap — revive the connection instead (mirrors
|
||||
// MobileSourceControlPanel / issue #5049). The load effect re-runs via
|
||||
// connState once the fresh client connects.
|
||||
if (connState !== 'connected' && hostId) {
|
||||
void forceReconnect(hostId)
|
||||
return
|
||||
}
|
||||
setReloadNonce((n) => n + 1)
|
||||
}, [connState, forceReconnect, hostId])
|
||||
|
||||
const toggleCommit = useCallback(
|
||||
(row: MobileCommitRow) => {
|
||||
@@ -130,6 +146,12 @@ export default function HistoryScreen() {
|
||||
[expanded, filesById, toggleCommit]
|
||||
)
|
||||
|
||||
const view = resolveMobileHistoryScreenView({
|
||||
connected: client !== null && connState === 'connected',
|
||||
rows,
|
||||
error
|
||||
})
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
@@ -138,21 +160,26 @@ export default function HistoryScreen() {
|
||||
</Pressable>
|
||||
<Text style={styles.title}>Commit History</Text>
|
||||
</View>
|
||||
{error ? (
|
||||
{view.kind === 'error' || view.kind === 'waiting' ? (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateText}>{error}</Text>
|
||||
<Text style={styles.stateText}>
|
||||
{view.kind === 'waiting' ? 'Waiting for desktop...' : view.message}
|
||||
</Text>
|
||||
<Pressable style={styles.retryButton} onPress={retry} accessibilityLabel="Retry">
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : rows === null ? (
|
||||
) : view.kind === 'loading' ? (
|
||||
<View style={styles.state}>
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
</View>
|
||||
) : rows.length === 0 ? (
|
||||
) : view.kind === 'empty' ? (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateText}>No commits.</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={rows}
|
||||
data={view.rows}
|
||||
renderItem={renderCommit}
|
||||
keyExtractor={(row) => row.id}
|
||||
contentContainerStyle={{ paddingBottom: spacing.lg + insets.bottom }}
|
||||
@@ -175,6 +202,14 @@ const styles = StyleSheet.create({
|
||||
title: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' },
|
||||
state: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg },
|
||||
stateText: { color: colors.textMuted, fontSize: typography.bodySize },
|
||||
retryButton: {
|
||||
marginTop: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
retryText: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' },
|
||||
commit: { borderBottomWidth: 1, borderBottomColor: colors.borderSubtle },
|
||||
commitHeader: {
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
AlertTriangle,
|
||||
ArrowUp,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsRight,
|
||||
@@ -100,6 +101,7 @@ import {
|
||||
isTerminalLiveInputWithinByteLimit,
|
||||
scheduleTerminalLiveInputFocus
|
||||
} from '../../../../src/terminal/terminal-live-input'
|
||||
import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keyboard-dismiss'
|
||||
import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender'
|
||||
import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
|
||||
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
|
||||
@@ -1006,6 +1008,7 @@ export default function SessionScreen() {
|
||||
const viewportMeasuredRef = useRef(false)
|
||||
const terminalRefs = useRef<Map<string, TerminalWebViewHandle>>(new Map())
|
||||
const liveInputRef = useRef<TextInput>(null)
|
||||
const commandInputRef = useRef<TextInput>(null)
|
||||
const liveInputFocusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const sendLiveTerminalInputRef = useRef<TerminalLiveInputSender>(async () => false)
|
||||
const sessionTabActionSheetKeyboardHideSubRef = useRef<ReturnType<
|
||||
@@ -3208,6 +3211,15 @@ export default function SessionScreen() {
|
||||
]
|
||||
)
|
||||
|
||||
const dismissSoftwareKeyboard = useCallback(() => {
|
||||
dismissTerminalKeyboard({
|
||||
clearPendingLiveInputFocus: () => clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef),
|
||||
commandInput: commandInputRef.current,
|
||||
dismissKeyboard: () => Keyboard.dismiss(),
|
||||
liveInput: liveInputRef.current
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleTerminalTap = useCallback(
|
||||
(handle: string) => {
|
||||
if (handle !== activeHandleRef.current) {
|
||||
@@ -4780,10 +4792,38 @@ export default function SessionScreen() {
|
||||
>
|
||||
{/* Accessory keys */}
|
||||
<View style={styles.accessoryBar}>
|
||||
{/* Why: a fixed, always-visible escape hatch from the open
|
||||
keyboard. Kept outside the horizontal ScrollView so it does
|
||||
not scroll away, and out of the terminal-byte shortcut path so
|
||||
it cannot be hidden by user shortcut customization (#5106). */}
|
||||
{keyboardLift > 0 && (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.keyboardDismissKey,
|
||||
pressed && styles.accessoryKeyPressed
|
||||
]}
|
||||
onPress={dismissSoftwareKeyboard}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss keyboard"
|
||||
accessibilityHint="Hides the software keyboard and keeps the current terminal session open."
|
||||
>
|
||||
<View style={styles.keyboardDismissGlyph}>
|
||||
<KeyboardIcon size={15} color={colors.textSecondary} strokeWidth={2} />
|
||||
<ChevronDown
|
||||
size={10}
|
||||
color={colors.textSecondary}
|
||||
strokeWidth={2.5}
|
||||
style={styles.keyboardDismissChevron}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
{/* Why: with default tap handling the first tap on any accessory
|
||||
key dismisses the open keyboard and is swallowed, so live
|
||||
input lost its keyboard on every Esc/Tab press (#5106). */}
|
||||
<ScrollView
|
||||
style={styles.accessoryScroll}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.accessoryContent}
|
||||
@@ -5009,6 +5049,7 @@ export default function SessionScreen() {
|
||||
) : (
|
||||
<View style={styles.inputBar}>
|
||||
<TextInput
|
||||
ref={commandInputRef}
|
||||
// Why: Android caches the IME inputType at mount, so toggling
|
||||
// autocomplete must remount there; iOS can update without a focus-costly remount.
|
||||
key={
|
||||
@@ -5020,9 +5061,10 @@ export default function SessionScreen() {
|
||||
}
|
||||
style={styles.textInput}
|
||||
value={input}
|
||||
onChangeText={(text) =>
|
||||
setInput((previousText) => normalizeTerminalTextInput(text, previousText))
|
||||
}
|
||||
// Why: iOS kills an active dictation/IME session when JS
|
||||
// writes a value that differs from the native field text;
|
||||
// store the raw field text and normalize at send time.
|
||||
onChangeText={setInput}
|
||||
placeholder="Type a command…"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
|
||||
@@ -68,10 +68,16 @@ export const mobileSessionCommandInputStyles = StyleSheet.create({
|
||||
zIndex: 20
|
||||
},
|
||||
accessoryBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
accessoryScroll: {
|
||||
flex: 1,
|
||||
minWidth: 0
|
||||
},
|
||||
accessoryContent: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
@@ -110,6 +116,29 @@ export const mobileSessionCommandInputStyles = StyleSheet.create({
|
||||
accessoryKeyTextDisabled: {
|
||||
color: colors.textMuted
|
||||
},
|
||||
keyboardDismissKey: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: spacing.sm,
|
||||
marginVertical: spacing.xs,
|
||||
backgroundColor: colors.bgRaised,
|
||||
paddingHorizontal: spacing.sm + 2,
|
||||
paddingVertical: 0,
|
||||
borderRadius: radii.button,
|
||||
minWidth: 36,
|
||||
height: 28
|
||||
},
|
||||
keyboardDismissGlyph: {
|
||||
alignItems: 'center',
|
||||
height: 18,
|
||||
justifyContent: 'flex-start',
|
||||
position: 'relative',
|
||||
width: 18
|
||||
},
|
||||
keyboardDismissChevron: {
|
||||
bottom: -2,
|
||||
position: 'absolute'
|
||||
},
|
||||
inputBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -21,6 +21,31 @@ const MOCK_FILE_LIST = [
|
||||
{ relativePath: 'dist/archive.zip', basename: 'archive.zip', kind: 'binary' }
|
||||
]
|
||||
|
||||
function readMockDirectory(relativePath: string): Array<{ name: string; isDirectory: boolean }> {
|
||||
const prefix = relativePath ? `${relativePath}/` : ''
|
||||
const children = new Map<string, boolean>()
|
||||
for (const file of MOCK_FILE_LIST) {
|
||||
if (!file.relativePath.startsWith(prefix)) {
|
||||
continue
|
||||
}
|
||||
const rest = file.relativePath.slice(prefix.length)
|
||||
if (!rest) {
|
||||
continue
|
||||
}
|
||||
const [name, ...descendants] = rest.split('/')
|
||||
if (!name) {
|
||||
continue
|
||||
}
|
||||
children.set(name, children.get(name) === true || descendants.length > 0)
|
||||
}
|
||||
return Array.from(children, ([name, isDirectory]) => ({ name, isDirectory })).sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) {
|
||||
return a.isDirectory ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
|
||||
export function handleMockFilePreviewRequest(
|
||||
request: RpcRequest,
|
||||
respond: Respond,
|
||||
@@ -40,6 +65,10 @@ export function handleMockFilePreviewRequest(
|
||||
)
|
||||
return true
|
||||
|
||||
case 'files.readDir':
|
||||
respond(success(request.id, readMockDirectory(String(request.params?.relativePath ?? ''))))
|
||||
return true
|
||||
|
||||
case 'files.read': {
|
||||
const relativePath = String(request.params?.relativePath ?? '')
|
||||
const content = MOCK_FILE_CONTENT[relativePath]
|
||||
|
||||
@@ -2,13 +2,14 @@ import { useEffect } from 'react'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft, X } from 'lucide-react-native'
|
||||
import { ChevronLeft, ExternalLink, X } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
|
||||
import type { ConnectionState } from '../../transport/types'
|
||||
import type { RpcClient } from '../../transport/rpc-client'
|
||||
import type { MobileGitStatusResult } from '../../source-control/mobile-git-status'
|
||||
import { useMobilePrSidebarController } from '../../session/use-mobile-pr-sidebar-controller'
|
||||
import { MobilePRSidebar } from '../MobilePRSidebar'
|
||||
import { openMobilePrUrl } from '../MobilePrComposeSheet'
|
||||
|
||||
type Props = {
|
||||
client: RpcClient | null
|
||||
@@ -73,6 +74,27 @@ export function MobilePrViewPanel({
|
||||
message: 'Current branch unavailable.'
|
||||
} as const)
|
||||
: controller.prSidebarState
|
||||
// Why: open-on-host lives in the chrome so the PR URL is always flush-right of
|
||||
// the screen title, even when the body is scrolled past the PR header.
|
||||
const prUrl =
|
||||
sidebarState.kind === 'ready' && sidebarState.data.pr.url ? sidebarState.data.pr.url : null
|
||||
const prNumber = sidebarState.kind === 'ready' ? sidebarState.data.pr.number : null
|
||||
const openPr = prUrl ? () => openMobilePrUrl(prUrl) : undefined
|
||||
const openPrControl = openPr ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
|
||||
onPress={openPr}
|
||||
hitSlop={8}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={
|
||||
prNumber != null
|
||||
? `Open pull request #${prNumber} on the web`
|
||||
: 'Open pull request on the web'
|
||||
}
|
||||
>
|
||||
<ExternalLink size={18} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
) : null
|
||||
const sidebar = (
|
||||
<MobilePRSidebar
|
||||
state={sidebarState}
|
||||
@@ -104,6 +126,7 @@ export function MobilePrViewPanel({
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
Pull Request
|
||||
</Text>
|
||||
{openPrControl}
|
||||
</View>
|
||||
</View>
|
||||
{sidebar}
|
||||
@@ -126,6 +149,7 @@ export function MobilePrViewPanel({
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
Pull Request
|
||||
</Text>
|
||||
{openPrControl}
|
||||
</View>
|
||||
</View>
|
||||
{sidebar}
|
||||
|
||||
@@ -29,8 +29,8 @@ export function PRSidebarHeader({ pr, details, titleAction }: Props) {
|
||||
const baseRef = item?.baseRefName ?? null
|
||||
const headRef = item?.branchName ?? null
|
||||
const editable = canEditPRTitle(pr.state)
|
||||
// Tapping the state badge or the #number opens the PR on its host (GitHub/etc.)
|
||||
// in the phone browser — pr.url is the canonical web URL.
|
||||
// Badge, #number, and the flush-right external-link control all open pr.url
|
||||
// (canonical host web URL) in the phone browser.
|
||||
const openPr = pr.url ? () => openMobilePrUrl(pr.url) : undefined
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MobileFileExplorerPanel } from './MobileFileExplorerPanel'
|
||||
import type { MobileDirEntry } from './file-tree'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
|
||||
type MockClient = {
|
||||
sendRequest: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const mockTransport = vi.hoisted(() => ({
|
||||
client: null as MockClient | null,
|
||||
connectionState: 'connected',
|
||||
forceReconnect: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
FlatList: (props: {
|
||||
data: unknown[]
|
||||
keyExtractor: (item: unknown, index: number) => string
|
||||
renderItem: (info: {
|
||||
item: unknown
|
||||
index: number
|
||||
separators: Record<string, never>
|
||||
}) => unknown
|
||||
}) =>
|
||||
React.createElement(
|
||||
'FlatList',
|
||||
props,
|
||||
props.data.map((item, index) =>
|
||||
React.createElement(
|
||||
'FlatListItem',
|
||||
{ key: props.keyExtractor(item, index) },
|
||||
props.renderItem({ item, index, separators: {} })
|
||||
)
|
||||
)
|
||||
),
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: {
|
||||
create: (styles: unknown) => styles,
|
||||
hairlineWidth: 1
|
||||
},
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
SafeAreaView: 'SafeAreaView'
|
||||
}))
|
||||
|
||||
vi.mock('expo-router', () => ({
|
||||
useRouter: () => ({
|
||||
back: vi.fn(),
|
||||
push: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
ChevronDown: 'ChevronDown',
|
||||
ChevronLeft: 'ChevronLeft',
|
||||
ChevronRight: 'ChevronRight',
|
||||
File: 'File',
|
||||
FileText: 'FileText',
|
||||
Folder: 'Folder',
|
||||
Image: 'Image',
|
||||
X: 'X'
|
||||
}))
|
||||
|
||||
vi.mock('../platform/haptics', () => ({
|
||||
triggerSelection: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../transport/client-context', () => ({
|
||||
useForceReconnect: () => mockTransport.forceReconnect,
|
||||
useHostClient: () => ({
|
||||
client: mockTransport.client,
|
||||
state: mockTransport.connectionState
|
||||
})
|
||||
}))
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
function entry(name: string, isDirectory = false): MobileDirEntry {
|
||||
return { name, isDirectory }
|
||||
}
|
||||
|
||||
function ok(result: MobileDirEntry[]): RpcResponse {
|
||||
return { id: 'response-id', ok: true, result, _meta: { runtimeId: 'runtime-id' } }
|
||||
}
|
||||
|
||||
function createMockClient(entriesByPath: Record<string, MobileDirEntry[]>): MockClient {
|
||||
return {
|
||||
sendRequest: vi.fn(async (_method: string, params: { relativePath: string }) => {
|
||||
return ok(entriesByPath[params.relativePath] ?? [])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function renderExplorer(): Promise<ReactTestRenderer> {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileFileExplorerPanel, {
|
||||
hostId: 'host-a',
|
||||
worktreeId: 'worktree-a',
|
||||
name: 'Example Worktree',
|
||||
embedded: true
|
||||
})
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
if (!renderer) {
|
||||
throw new Error('MobileFileExplorerPanel did not render')
|
||||
}
|
||||
return renderer
|
||||
}
|
||||
|
||||
async function updateExplorer(renderer: ReactTestRenderer): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer.update(
|
||||
createElement(MobileFileExplorerPanel, {
|
||||
hostId: 'host-a',
|
||||
worktreeId: 'worktree-a',
|
||||
name: 'Example Worktree',
|
||||
embedded: true
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function pressByLabel(
|
||||
renderer: ReactTestRenderer,
|
||||
accessibilityLabel: string
|
||||
): Promise<void> {
|
||||
const pressable = renderer.root
|
||||
.findAllByType('Pressable')
|
||||
.find((node) => node.props.accessibilityLabel === accessibilityLabel)
|
||||
if (!pressable) {
|
||||
throw new Error(`Unable to find pressable: ${accessibilityLabel}`)
|
||||
}
|
||||
await act(async () => {
|
||||
pressable.props.onPress()
|
||||
})
|
||||
}
|
||||
|
||||
function renderedText(renderer: ReactTestRenderer): string {
|
||||
return renderer.root
|
||||
.findAllByType('Text')
|
||||
.flatMap((node) => node.props.children)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
describe('MobileFileExplorerPanel', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
mockTransport.client = null
|
||||
mockTransport.connectionState = 'connected'
|
||||
mockTransport.forceReconnect = vi.fn()
|
||||
})
|
||||
|
||||
it('loads directories lazily and reuses cached children when reopened', async () => {
|
||||
const client = createMockClient({
|
||||
'': [entry('src', true), entry('README.md')],
|
||||
src: [entry('app.ts'), entry('components', true)]
|
||||
})
|
||||
mockTransport.client = client
|
||||
|
||||
const renderer = await renderExplorer()
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(client.sendRequest).toHaveBeenLastCalledWith('files.readDir', {
|
||||
worktree: 'id:worktree-a',
|
||||
relativePath: ''
|
||||
})
|
||||
|
||||
await pressByLabel(renderer, 'Open folder src')
|
||||
await vi.waitFor(() => expect(client.sendRequest).toHaveBeenCalledTimes(2))
|
||||
expect(client.sendRequest).toHaveBeenLastCalledWith('files.readDir', {
|
||||
worktree: 'id:worktree-a',
|
||||
relativePath: 'src'
|
||||
})
|
||||
expect(renderedText(renderer)).toContain('app.ts')
|
||||
|
||||
await pressByLabel(renderer, 'Open folder src')
|
||||
await pressByLabel(renderer, 'Open folder src')
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps the loaded tree visible during a transient disconnect', async () => {
|
||||
const client = createMockClient({
|
||||
'': [entry('src', true), entry('README.md')]
|
||||
})
|
||||
mockTransport.client = client
|
||||
|
||||
const renderer = await renderExplorer()
|
||||
expect(renderedText(renderer)).toContain('src')
|
||||
|
||||
mockTransport.client = null
|
||||
mockTransport.connectionState = 'disconnected'
|
||||
await updateExplorer(renderer)
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(renderedText(renderer)).toContain('src')
|
||||
expect(renderedText(renderer)).toContain('README.md')
|
||||
expect(renderedText(renderer)).not.toContain('Waiting for desktop...')
|
||||
})
|
||||
|
||||
it('refreshes the root in the background after reconnect without blanking the tree', async () => {
|
||||
const client = createMockClient({
|
||||
'': [entry('src', true), entry('README.md')]
|
||||
})
|
||||
mockTransport.client = client
|
||||
|
||||
const renderer = await renderExplorer()
|
||||
expect(renderedText(renderer)).toContain('README.md')
|
||||
|
||||
mockTransport.client = null
|
||||
mockTransport.connectionState = 'disconnected'
|
||||
await updateExplorer(renderer)
|
||||
|
||||
let resolveReload: ((response: RpcResponse) => void) | undefined
|
||||
const reconnectedClient: MockClient = {
|
||||
sendRequest: vi.fn(
|
||||
() =>
|
||||
new Promise<RpcResponse>((resolve) => {
|
||||
resolveReload = resolve
|
||||
})
|
||||
)
|
||||
}
|
||||
mockTransport.client = reconnectedClient
|
||||
mockTransport.connectionState = 'connected'
|
||||
await updateExplorer(renderer)
|
||||
|
||||
expect(reconnectedClient.sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(renderedText(renderer)).toContain('src')
|
||||
expect(renderedText(renderer)).toContain('README.md')
|
||||
|
||||
await act(async () => {
|
||||
resolveReload?.(ok([entry('src', true), entry('README.md'), entry('CHANGELOG.md')]))
|
||||
})
|
||||
expect(renderedText(renderer)).toContain('CHANGELOG.md')
|
||||
})
|
||||
|
||||
it('keeps the cached tree when a post-reconnect root refresh fails', async () => {
|
||||
const client = createMockClient({
|
||||
'': [entry('src', true), entry('README.md')]
|
||||
})
|
||||
mockTransport.client = client
|
||||
|
||||
const renderer = await renderExplorer()
|
||||
expect(renderedText(renderer)).toContain('README.md')
|
||||
|
||||
mockTransport.client = null
|
||||
mockTransport.connectionState = 'disconnected'
|
||||
await updateExplorer(renderer)
|
||||
|
||||
const failingClient: MockClient = {
|
||||
sendRequest: vi.fn(async () => {
|
||||
throw new Error('refresh failed')
|
||||
})
|
||||
}
|
||||
mockTransport.client = failingClient
|
||||
mockTransport.connectionState = 'connected'
|
||||
await updateExplorer(renderer)
|
||||
|
||||
expect(failingClient.sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(renderedText(renderer)).toContain('src')
|
||||
expect(renderedText(renderer)).toContain('README.md')
|
||||
expect(renderedText(renderer)).not.toContain('refresh failed')
|
||||
})
|
||||
|
||||
it('falls back to the capped files.list against desktops without files.readDir', async () => {
|
||||
const legacyClient: MockClient = {
|
||||
sendRequest: vi.fn(async (method: string): Promise<RpcResponse> => {
|
||||
if (method === 'files.readDir') {
|
||||
return {
|
||||
id: 'response-id',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'forbidden',
|
||||
message: "Method 'files.readDir' is not available to mobile clients"
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-id' }
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: 'response-id',
|
||||
ok: true,
|
||||
result: {
|
||||
files: [
|
||||
{ relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' },
|
||||
{ relativePath: 'README.md', basename: 'README.md', kind: 'text' }
|
||||
],
|
||||
totalCount: 2,
|
||||
truncated: false
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-id' }
|
||||
}
|
||||
})
|
||||
}
|
||||
mockTransport.client = legacyClient
|
||||
|
||||
const renderer = await renderExplorer()
|
||||
|
||||
expect(legacyClient.sendRequest).toHaveBeenCalledWith('files.list', {
|
||||
worktree: 'id:worktree-a'
|
||||
})
|
||||
expect(renderedText(renderer)).toContain('src')
|
||||
expect(renderedText(renderer)).toContain('README.md')
|
||||
|
||||
await pressByLabel(renderer, 'Open folder src')
|
||||
expect(renderedText(renderer)).toContain('app.ts')
|
||||
// Every directory comes from the synthesized cache: one readDir attempt
|
||||
// plus one files.list call total, no per-directory RPCs afterwards.
|
||||
expect(legacyClient.sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('surfaces the legacy cap note when the files.list fallback is truncated', async () => {
|
||||
const legacyClient: MockClient = {
|
||||
sendRequest: vi.fn(async (method: string): Promise<RpcResponse> => {
|
||||
if (method === 'files.readDir') {
|
||||
return {
|
||||
id: 'response-id',
|
||||
ok: false,
|
||||
error: { code: 'method_not_found', message: 'Unknown method' },
|
||||
_meta: { runtimeId: 'runtime-id' }
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: 'response-id',
|
||||
ok: true,
|
||||
result: {
|
||||
files: [{ relativePath: 'README.md', basename: 'README.md', kind: 'text' }],
|
||||
totalCount: 6000,
|
||||
truncated: true
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-id' }
|
||||
}
|
||||
})
|
||||
}
|
||||
mockTransport.client = legacyClient
|
||||
|
||||
const renderer = await renderExplorer()
|
||||
|
||||
expect(renderedText(renderer)).toContain('README.md')
|
||||
expect(renderedText(renderer)).toContain('Showing first 5000')
|
||||
})
|
||||
|
||||
it('reports the files.list failure when the fallback itself fails', async () => {
|
||||
const legacyClient: MockClient = {
|
||||
sendRequest: vi.fn(async (method: string): Promise<RpcResponse> => {
|
||||
if (method === 'files.readDir') {
|
||||
return {
|
||||
id: 'response-id',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'forbidden',
|
||||
message: "Method 'files.readDir' is not available to mobile clients"
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-id' }
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: 'response-id',
|
||||
ok: false,
|
||||
error: { code: 'internal', message: 'legacy list failed' },
|
||||
_meta: { runtimeId: 'runtime-id' }
|
||||
}
|
||||
})
|
||||
}
|
||||
mockTransport.client = legacyClient
|
||||
|
||||
const renderer = await renderExplorer()
|
||||
|
||||
expect(renderedText(renderer)).toContain('legacy list failed')
|
||||
expect(renderedText(renderer)).not.toContain('not available to mobile clients')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
@@ -9,34 +9,33 @@ import {
|
||||
} from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
File,
|
||||
FileText,
|
||||
Folder,
|
||||
Image as ImageIcon,
|
||||
X
|
||||
} from 'lucide-react-native'
|
||||
import { ChevronLeft, X } from 'lucide-react-native'
|
||||
import { useHostClient, useForceReconnect } from '../transport/client-context'
|
||||
import { getWorktreeLabel } from '../session/worktree-label'
|
||||
import {
|
||||
buildTree,
|
||||
flattenTree,
|
||||
isMarkdownPath,
|
||||
type FilesListResult,
|
||||
type MobileFileEntry,
|
||||
type TreeNode
|
||||
flattenDirectoryCache,
|
||||
getDirectoryCacheState,
|
||||
type DirectoryCache,
|
||||
type FileExplorerRow,
|
||||
type MobileDirEntry
|
||||
} from './file-tree'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { triggerSelection } from '../platform/haptics'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import { fileExplorerStyles as styles } from './mobile-file-explorer-styles'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import {
|
||||
canPreviewMobileFileRow,
|
||||
navigateToMobileFilePreview
|
||||
} from './mobile-file-preview-navigation'
|
||||
beginDirectoryLoad,
|
||||
createDirectoryLoadRevisions,
|
||||
isCurrentDirectoryLoad,
|
||||
resetDirectoryLoadRevisions,
|
||||
type DirectoryLoadRevisions
|
||||
} from './directory-load-revisions'
|
||||
import {
|
||||
directoryCacheFromFileList,
|
||||
isMobileMethodUnavailableError,
|
||||
type LegacyFilesListResult
|
||||
} from './file-list-fallback'
|
||||
import { fileExplorerStyles as styles } from './mobile-file-explorer-styles'
|
||||
import { MobileFileExplorerRow } from './mobile-file-explorer-row'
|
||||
import { navigateToMobileFilePreview } from './mobile-file-preview-navigation'
|
||||
|
||||
export function MobileFileExplorerPanel(props: {
|
||||
hostId: string
|
||||
@@ -49,58 +48,217 @@ export function MobileFileExplorerPanel(props: {
|
||||
const router = useRouter()
|
||||
const { client, state: connState } = useHostClient(hostId)
|
||||
const forceReconnect = useForceReconnect()
|
||||
const [files, setFiles] = useState<MobileFileEntry[]>([])
|
||||
const scopeRef = useRef('')
|
||||
const scope = `${hostId}:${worktreeId}`
|
||||
scopeRef.current = scope
|
||||
const directoryLoadRevisionsRef = useRef<DirectoryLoadRevisions>(createDirectoryLoadRevisions())
|
||||
const pendingDirectoryRetriesRef = useRef<Set<string>>(new Set())
|
||||
const directoryCacheRef = useRef<DirectoryCache>({})
|
||||
const [directoryCache, setDirectoryCache] = useState<DirectoryCache>({})
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [truncated, setTruncated] = useState(false)
|
||||
const [legacyListTruncated, setLegacyListTruncated] = useState(false)
|
||||
const worktreeLabel = getWorktreeLabel(name, worktreeId)
|
||||
|
||||
const loadFiles = useCallback(async () => {
|
||||
if (!client || connState !== 'connected') {
|
||||
setLoading(false)
|
||||
setError(connState === 'connected' ? 'Connecting to desktop...' : 'Waiting for desktop...')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await client.sendRequest('files.list', { worktree: `id:${worktreeId}` })
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error?.message || 'Unable to load files')
|
||||
const loadDirectory = useCallback(
|
||||
async (relativePath: string) => {
|
||||
const scope = scopeRef.current
|
||||
const loadToken = beginDirectoryLoad(directoryLoadRevisionsRef.current, scope, relativePath)
|
||||
const rootLoad = relativePath === ''
|
||||
|
||||
if (!client || connState !== 'connected') {
|
||||
const message =
|
||||
connState === 'connected' ? 'Connecting to desktop...' : 'Waiting for desktop...'
|
||||
if (rootLoad) {
|
||||
const hasLoadedRoot =
|
||||
(getDirectoryCacheState(directoryCacheRef.current, '')?.entries.length ?? 0) > 0
|
||||
setLoading(false)
|
||||
// Why: transient reconnects should not blank an already browsable tree.
|
||||
setError(hasLoadedRoot ? null : message)
|
||||
} else {
|
||||
setDirectoryCache((prev) => ({
|
||||
...prev,
|
||||
[relativePath]: {
|
||||
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
|
||||
error: message
|
||||
}
|
||||
}))
|
||||
}
|
||||
return
|
||||
}
|
||||
const result = (response as RpcSuccess).result as FilesListResult
|
||||
setFiles(result.files)
|
||||
setTruncated(result.truncated)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to load files')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [client, connState, worktreeId])
|
||||
|
||||
const hadLoadedRoot =
|
||||
rootLoad && (getDirectoryCacheState(directoryCacheRef.current, '')?.entries.length ?? 0) > 0
|
||||
if (rootLoad) {
|
||||
// Why: a reconnect refresh must not blank an already browsable tree —
|
||||
// the full-screen spinner unmounts the list and resets scroll.
|
||||
if (!hadLoadedRoot) {
|
||||
setLoading(true)
|
||||
}
|
||||
setError(null)
|
||||
}
|
||||
setDirectoryCache((prev) => ({
|
||||
...prev,
|
||||
[relativePath]: {
|
||||
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
|
||||
loading: true
|
||||
}
|
||||
}))
|
||||
|
||||
try {
|
||||
const response = await client.sendRequest('files.readDir', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
relativePath
|
||||
})
|
||||
if (!response.ok) {
|
||||
// Why: desktops that predate the files.readDir mobile allowlist
|
||||
// entry still serve the capped files.list; fall back so the Files
|
||||
// tab keeps working until the desktop updates.
|
||||
if (
|
||||
rootLoad &&
|
||||
isMobileMethodUnavailableError(response.error?.code, response.error?.message)
|
||||
) {
|
||||
const legacy = await client.sendRequest('files.list', {
|
||||
worktree: `id:${worktreeId}`
|
||||
})
|
||||
if (legacy.ok) {
|
||||
if (
|
||||
!isCurrentDirectoryLoad(
|
||||
directoryLoadRevisionsRef.current,
|
||||
scopeRef.current,
|
||||
loadToken
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const legacyResult = (legacy as RpcSuccess).result as LegacyFilesListResult
|
||||
setDirectoryCache(directoryCacheFromFileList(legacyResult.files))
|
||||
// Why: the capped list silently omits files past the cap — keep
|
||||
// the legacy explorer's "Showing first 5000" note.
|
||||
setLegacyListTruncated(legacyResult.truncated)
|
||||
return
|
||||
}
|
||||
throw new Error(
|
||||
legacy.error?.message || response.error?.message || 'Unable to load files'
|
||||
)
|
||||
}
|
||||
throw new Error(response.error?.message || 'Unable to load files')
|
||||
}
|
||||
if (
|
||||
!isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const entries = (response as RpcSuccess).result as MobileDirEntry[]
|
||||
if (rootLoad) {
|
||||
setLegacyListTruncated(false)
|
||||
}
|
||||
setDirectoryCache((prev) => ({
|
||||
...prev,
|
||||
[relativePath]: { entries }
|
||||
}))
|
||||
} catch (err) {
|
||||
if (
|
||||
!isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Unable to load files'
|
||||
if (rootLoad) {
|
||||
// Why: a failed background refresh keeps the cached tree browsable;
|
||||
// only a cold load surfaces the full-screen error.
|
||||
setError(hadLoadedRoot ? null : message)
|
||||
} else {
|
||||
setDirectoryCache((prev) => ({
|
||||
...prev,
|
||||
[relativePath]: {
|
||||
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
|
||||
error: message
|
||||
}
|
||||
}))
|
||||
}
|
||||
} finally {
|
||||
if (
|
||||
rootLoad &&
|
||||
isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
|
||||
) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
[client, connState, worktreeId]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
void loadFiles()
|
||||
}, [loadFiles])
|
||||
scopeRef.current = scope
|
||||
resetDirectoryLoadRevisions(directoryLoadRevisionsRef.current)
|
||||
pendingDirectoryRetriesRef.current.clear()
|
||||
directoryCacheRef.current = {}
|
||||
setDirectoryCache({})
|
||||
setExpanded(new Set())
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setLegacyListTruncated(false)
|
||||
}, [scope])
|
||||
|
||||
const rows = useMemo(() => flattenTree(buildTree(files), expanded), [expanded, files])
|
||||
useEffect(() => {
|
||||
directoryCacheRef.current = directoryCache
|
||||
}, [directoryCache])
|
||||
|
||||
const toggleDirectory = useCallback((relativePath: string) => {
|
||||
triggerSelection()
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(relativePath)) {
|
||||
next.delete(relativePath)
|
||||
} else {
|
||||
next.add(relativePath)
|
||||
useEffect(() => {
|
||||
void loadDirectory('')
|
||||
}, [hostId, loadDirectory])
|
||||
|
||||
useEffect(() => {
|
||||
if (connState !== 'connected' || pendingDirectoryRetriesRef.current.size === 0) {
|
||||
return
|
||||
}
|
||||
const pending = [...pendingDirectoryRetriesRef.current]
|
||||
pendingDirectoryRetriesRef.current.clear()
|
||||
for (const relativePath of pending) {
|
||||
void loadDirectory(relativePath)
|
||||
}
|
||||
}, [connState, loadDirectory])
|
||||
|
||||
const rows = useMemo(
|
||||
() => flattenDirectoryCache(directoryCache, expanded),
|
||||
[directoryCache, expanded]
|
||||
)
|
||||
|
||||
const toggleDirectory = useCallback(
|
||||
(relativePath: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(relativePath)) {
|
||||
next.delete(relativePath)
|
||||
} else {
|
||||
next.add(relativePath)
|
||||
}
|
||||
return next
|
||||
})
|
||||
const state = getDirectoryCacheState(directoryCache, relativePath)
|
||||
if (!expanded.has(relativePath) && !state?.loading && (!state?.entries || state.error)) {
|
||||
void loadDirectory(relativePath)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
},
|
||||
[directoryCache, expanded, loadDirectory]
|
||||
)
|
||||
|
||||
const retryDirectory = useCallback(
|
||||
(relativePath: string) => {
|
||||
if (connState !== 'connected' && hostId) {
|
||||
pendingDirectoryRetriesRef.current.add(relativePath)
|
||||
void forceReconnect(hostId)
|
||||
return
|
||||
}
|
||||
void loadDirectory(relativePath)
|
||||
},
|
||||
[connState, forceReconnect, hostId, loadDirectory]
|
||||
)
|
||||
|
||||
const previewFile = useCallback(
|
||||
(relativePath: string, displayName: string) => {
|
||||
triggerSelection()
|
||||
navigateToMobileFilePreview(
|
||||
router,
|
||||
{
|
||||
@@ -116,66 +274,15 @@ export function MobileFileExplorerPanel(props: {
|
||||
[embedded, hostId, name, onRequestClose, router, worktreeId]
|
||||
)
|
||||
|
||||
const renderItem: ListRenderItem<TreeNode> = ({ item }) => {
|
||||
const isDirectory = item.kind === 'directory'
|
||||
const isExpanded = expanded.has(item.relativePath)
|
||||
// Images render in the mobile viewer (via files.readPreview), so a binary
|
||||
// image is openable; only non-previewable binaries are unavailable.
|
||||
const previewable =
|
||||
item.kind !== 'directory' &&
|
||||
canPreviewMobileFileRow({ kind: item.kind, relativePath: item.relativePath })
|
||||
const isImage = item.kind === 'binary' && previewable
|
||||
const disabled = item.kind === 'binary' && !previewable
|
||||
const markdown = item.kind === 'text' && isMarkdownPath(item.relativePath)
|
||||
const renderItem: ListRenderItem<FileExplorerRow> = ({ item }) => {
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.row,
|
||||
{ paddingLeft: spacing.lg + item.depth * 18 },
|
||||
pressed && !disabled && styles.rowPressed,
|
||||
disabled && styles.rowDisabled
|
||||
]}
|
||||
disabled={disabled}
|
||||
onPress={() => {
|
||||
if (isDirectory) {
|
||||
toggleDirectory(item.relativePath)
|
||||
} else if (!disabled) {
|
||||
previewFile(item.relativePath, item.name)
|
||||
}
|
||||
}}
|
||||
accessibilityLabel={
|
||||
isDirectory
|
||||
? `Open folder ${item.name}`
|
||||
: disabled
|
||||
? `${item.name} unavailable on mobile`
|
||||
: `Preview file ${item.name}`
|
||||
}
|
||||
>
|
||||
{isDirectory ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown size={16} color={colors.textSecondary} />
|
||||
) : (
|
||||
<ChevronRight size={16} color={colors.textSecondary} />
|
||||
)
|
||||
) : (
|
||||
<View style={styles.chevronSpacer} />
|
||||
)}
|
||||
{isDirectory ? (
|
||||
<Folder size={17} color={colors.textSecondary} />
|
||||
) : markdown ? (
|
||||
<FileText size={17} color={disabled ? colors.textMuted : colors.textSecondary} />
|
||||
) : isImage ? (
|
||||
<ImageIcon size={17} color={colors.textSecondary} />
|
||||
) : (
|
||||
<File size={17} color={disabled ? colors.textMuted : colors.textSecondary} />
|
||||
)}
|
||||
<View style={styles.rowTextBlock}>
|
||||
<Text style={[styles.rowTitle, disabled && styles.rowTitleDisabled]} numberOfLines={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
{disabled ? <Text style={styles.rowMeta}>Unavailable on mobile</Text> : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
<MobileFileExplorerRow
|
||||
item={item}
|
||||
expanded={expanded}
|
||||
onPreviewFile={previewFile}
|
||||
onRetryDirectory={retryDirectory}
|
||||
onToggleDirectory={toggleDirectory}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -206,7 +313,7 @@ export function MobileFileExplorerPanel(props: {
|
||||
</Text>
|
||||
<Text style={styles.meta} numberOfLines={1}>
|
||||
{worktreeLabel}
|
||||
{truncated ? ' - Showing first 5000' : ''}
|
||||
{legacyListTruncated ? ' - Showing first 5000' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -220,12 +327,12 @@ export function MobileFileExplorerPanel(props: {
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
{/* Why: while disconnected, re-sending the request is useless — revive
|
||||
the parked transport instead (issue #5049); loadFiles re-runs via
|
||||
the parked transport instead (issue #5049); loadDirectory re-runs via
|
||||
its effect once the new client connects. */}
|
||||
<Pressable
|
||||
style={styles.retryButton}
|
||||
onPress={() =>
|
||||
connState !== 'connected' && hostId ? void forceReconnect(hostId) : void loadFiles()
|
||||
connState !== 'connected' && hostId ? void forceReconnect(hostId) : void loadDirectory('')
|
||||
}
|
||||
>
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
beginDirectoryLoad,
|
||||
createDirectoryLoadRevisions,
|
||||
isCurrentDirectoryLoad,
|
||||
resetDirectoryLoadRevisions,
|
||||
type DirectoryLoadRevisions
|
||||
} from './directory-load-revisions'
|
||||
|
||||
describe('directory-load-revisions', () => {
|
||||
it('ignores stale duplicate loads for the same directory and scope', () => {
|
||||
const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions()
|
||||
const older = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src')
|
||||
const newer = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src')
|
||||
|
||||
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', newer)).toBe(true)
|
||||
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', older)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores loads from an old host/worktree scope', () => {
|
||||
const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions()
|
||||
const oldScopeLoad = beginDirectoryLoad(revisions, 'host-a:worktree-a', '')
|
||||
|
||||
expect(isCurrentDirectoryLoad(revisions, 'host-b:worktree-b', oldScopeLoad)).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores loads from an old reset generation in the same scope', () => {
|
||||
const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions()
|
||||
const oldLoad = beginDirectoryLoad(revisions, 'host-a:worktree-a', '')
|
||||
|
||||
resetDirectoryLoadRevisions(revisions)
|
||||
const newLoad = beginDirectoryLoad(revisions, 'host-a:worktree-a', '')
|
||||
|
||||
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', newLoad)).toBe(true)
|
||||
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', oldLoad)).toBe(false)
|
||||
})
|
||||
|
||||
it('tracks directory names that overlap object prototype keys', () => {
|
||||
const revisions: DirectoryLoadRevisions = createDirectoryLoadRevisions()
|
||||
const load = beginDirectoryLoad(revisions, 'host-a:worktree-a', '__proto__')
|
||||
|
||||
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', load)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
export type DirectoryLoadRevisions = {
|
||||
generation: number
|
||||
revisionsByPath: Map<string, number>
|
||||
}
|
||||
|
||||
export type DirectoryLoadToken = {
|
||||
generation: number
|
||||
relativePath: string
|
||||
revision: number
|
||||
scope: string
|
||||
}
|
||||
|
||||
export function createDirectoryLoadRevisions(): DirectoryLoadRevisions {
|
||||
return { generation: 0, revisionsByPath: new Map() }
|
||||
}
|
||||
|
||||
export function resetDirectoryLoadRevisions(revisions: DirectoryLoadRevisions): void {
|
||||
revisions.generation += 1
|
||||
revisions.revisionsByPath.clear()
|
||||
}
|
||||
|
||||
export function beginDirectoryLoad(
|
||||
revisions: DirectoryLoadRevisions,
|
||||
scope: string,
|
||||
relativePath: string
|
||||
): DirectoryLoadToken {
|
||||
const revision = (revisions.revisionsByPath.get(relativePath) ?? 0) + 1
|
||||
revisions.revisionsByPath.set(relativePath, revision)
|
||||
return { generation: revisions.generation, relativePath, revision, scope }
|
||||
}
|
||||
|
||||
export function isCurrentDirectoryLoad(
|
||||
revisions: DirectoryLoadRevisions,
|
||||
currentScope: string,
|
||||
token: DirectoryLoadToken
|
||||
): boolean {
|
||||
return (
|
||||
currentScope === token.scope &&
|
||||
revisions.generation === token.generation &&
|
||||
revisions.revisionsByPath.get(token.relativePath) === token.revision
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { directoryCacheFromFileList, isMobileMethodUnavailableError } from './file-list-fallback'
|
||||
import { getDirectoryCacheState } from './file-tree'
|
||||
|
||||
describe('isMobileMethodUnavailableError', () => {
|
||||
it('detects old-desktop allowlist and missing-method failures', () => {
|
||||
expect(isMobileMethodUnavailableError('forbidden', undefined)).toBe(true)
|
||||
expect(isMobileMethodUnavailableError('method_not_found', undefined)).toBe(true)
|
||||
expect(
|
||||
isMobileMethodUnavailableError(
|
||||
'some_code',
|
||||
"Method 'files.readDir' is not available to mobile clients"
|
||||
)
|
||||
).toBe(true)
|
||||
expect(isMobileMethodUnavailableError('internal', 'boom')).toBe(false)
|
||||
expect(isMobileMethodUnavailableError(undefined, undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('directoryCacheFromFileList', () => {
|
||||
it('synthesizes every ancestor directory from flat paths', () => {
|
||||
const cache = directoryCacheFromFileList([
|
||||
{ relativePath: 'src/lib/util.ts', basename: 'util.ts', kind: 'text' },
|
||||
{ relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' },
|
||||
{ relativePath: 'README.md', basename: 'README.md', kind: 'text' }
|
||||
])
|
||||
expect(cache['']?.entries).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ name: 'src', isDirectory: true },
|
||||
{ name: 'README.md', isDirectory: false }
|
||||
])
|
||||
)
|
||||
expect(cache['src']?.entries).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ name: 'lib', isDirectory: true },
|
||||
{ name: 'app.ts', isDirectory: false }
|
||||
])
|
||||
)
|
||||
expect(cache['src/lib']?.entries).toEqual([{ name: 'util.ts', isDirectory: false }])
|
||||
})
|
||||
|
||||
it('keeps a name a directory when it appears as both file and dir prefix', () => {
|
||||
const cache = directoryCacheFromFileList([
|
||||
{ relativePath: 'src', basename: 'src', kind: 'text' },
|
||||
{ relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' }
|
||||
])
|
||||
expect(cache['']?.entries).toEqual([{ name: 'src', isDirectory: true }])
|
||||
})
|
||||
|
||||
it('returns an empty root for an empty list', () => {
|
||||
const cache = directoryCacheFromFileList([])
|
||||
expect(cache['']?.entries).toEqual([])
|
||||
})
|
||||
|
||||
it('stores a __proto__ directory as an own key instead of mutating the prototype', () => {
|
||||
const cache = directoryCacheFromFileList([
|
||||
{ relativePath: '__proto__/pollute.js', basename: 'pollute.js', kind: 'text' }
|
||||
])
|
||||
expect(Object.getPrototypeOf(cache)).toBe(Object.prototype)
|
||||
expect(cache['']?.entries).toEqual([{ name: '__proto__', isDirectory: true }])
|
||||
expect(getDirectoryCacheState(cache, '__proto__')?.entries).toEqual([
|
||||
{ name: 'pollute.js', isDirectory: false }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
// Fallback for desktops that predate files.readDir in the mobile RPC
|
||||
// allowlist: synthesize the lazy directory cache from the flat, capped
|
||||
// files.list result so the Files tab stays browsable against old desktops.
|
||||
import type { DirectoryCache, MobileDirEntry } from './file-tree'
|
||||
|
||||
export type LegacyMobileFileEntry = {
|
||||
relativePath: string
|
||||
basename: string
|
||||
kind: 'text' | 'binary'
|
||||
}
|
||||
|
||||
export type LegacyFilesListResult = {
|
||||
files: LegacyMobileFileEntry[]
|
||||
totalCount: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
// Same detection shape as isMobileGitUnavailable in mobile-git-status.ts:
|
||||
// 'forbidden' = method exists but is not mobile-allowlisted on the old
|
||||
// desktop; 'method_not_found' = desktop predates the method entirely.
|
||||
export function isMobileMethodUnavailableError(
|
||||
code: string | undefined,
|
||||
message: string | undefined
|
||||
): boolean {
|
||||
return (
|
||||
code === 'forbidden' ||
|
||||
code === 'method_not_found' ||
|
||||
message?.includes('not available to mobile clients') === true
|
||||
)
|
||||
}
|
||||
|
||||
export function directoryCacheFromFileList(files: LegacyMobileFileEntry[]): DirectoryCache {
|
||||
const childrenByDir = new Map<string, Map<string, boolean>>()
|
||||
const ensureDir = (path: string): Map<string, boolean> => {
|
||||
let children = childrenByDir.get(path)
|
||||
if (!children) {
|
||||
children = new Map()
|
||||
childrenByDir.set(path, children)
|
||||
}
|
||||
return children
|
||||
}
|
||||
ensureDir('')
|
||||
for (const file of files) {
|
||||
const parts = file.relativePath.split('/').filter(Boolean)
|
||||
let parentPath = ''
|
||||
parts.forEach((name, index) => {
|
||||
const isDirectory = index < parts.length - 1
|
||||
const children = ensureDir(parentPath)
|
||||
children.set(name, children.get(name) === true || isDirectory)
|
||||
parentPath = parentPath ? `${parentPath}/${name}` : name
|
||||
if (isDirectory) {
|
||||
ensureDir(parentPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Why: plain `cache[path] = ...` with a '__proto__' path segment mutates the
|
||||
// object's prototype instead of storing the directory; fromEntries always
|
||||
// creates own keys.
|
||||
return Object.fromEntries(
|
||||
Array.from(childrenByDir, ([path, children]) => [
|
||||
path,
|
||||
{
|
||||
entries: Array.from(
|
||||
children,
|
||||
([name, isDirectory]): MobileDirEntry => ({
|
||||
name,
|
||||
isDirectory
|
||||
})
|
||||
)
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
@@ -1,32 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildTree, flattenTree, isMarkdownPath, type MobileFileEntry } from './file-tree'
|
||||
import {
|
||||
flattenDirectoryCache,
|
||||
getMobileFileKind,
|
||||
isMarkdownPath,
|
||||
shouldIncludeMobileFileExplorerEntry,
|
||||
type DirectoryCache,
|
||||
type MobileDirEntry
|
||||
} from './file-tree'
|
||||
|
||||
function entry(relativePath: string, kind: 'text' | 'binary' = 'text'): MobileFileEntry {
|
||||
return { relativePath, basename: relativePath.split('/').pop() ?? relativePath, kind }
|
||||
function entry(name: string, isDirectory = false, isSymlink = false): MobileDirEntry {
|
||||
return { name, isDirectory, isSymlink }
|
||||
}
|
||||
|
||||
describe('file-tree', () => {
|
||||
it('nests files under their directories', () => {
|
||||
const root = buildTree([entry('src/app.ts'), entry('src/lib/util.ts'), entry('readme.md')])
|
||||
expect(root.files.map((f) => f.relativePath)).toEqual(['readme.md'])
|
||||
expect(root.directories.get('src')?.directories.get('lib')?.files[0]?.relativePath).toBe(
|
||||
'src/lib/util.ts'
|
||||
)
|
||||
it('flattens cached directories lazily with directories before files', () => {
|
||||
const cache: DirectoryCache = {
|
||||
'': { entries: [entry('zeta.txt'), entry('src', true), entry('readme.md')] },
|
||||
src: { entries: [entry('app.ts'), entry('lib', true)] },
|
||||
'src/lib': { entries: [entry('util.ts')] }
|
||||
}
|
||||
|
||||
const collapsed = flattenDirectoryCache(cache, new Set())
|
||||
expect(collapsed.map((row) => row.id)).toEqual(['dir:src', 'file:readme.md', 'file:zeta.txt'])
|
||||
|
||||
const expanded = flattenDirectoryCache(cache, new Set(['src', 'src/lib']))
|
||||
expect(expanded.map((row) => row.id)).toEqual([
|
||||
'dir:src',
|
||||
'dir:src/lib',
|
||||
'file:src/lib/util.ts',
|
||||
'file:src/app.ts',
|
||||
'file:readme.md',
|
||||
'file:zeta.txt'
|
||||
])
|
||||
})
|
||||
|
||||
it('flattens with directories before files and only expands open dirs', () => {
|
||||
const root = buildTree([entry('src/app.ts'), entry('zeta.txt')])
|
||||
const collapsed = flattenTree(root, new Set())
|
||||
expect(collapsed.map((r) => r.id)).toEqual(['dir:src', 'file:zeta.txt'])
|
||||
it('mirrors desktop default browse exclusions while keeping dotfiles visible', () => {
|
||||
const cache: DirectoryCache = {
|
||||
'': {
|
||||
entries: [
|
||||
entry('.git', true),
|
||||
entry('.env'),
|
||||
entry('node_modules', true),
|
||||
entry('src', true)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const expanded = flattenTree(root, new Set(['src']))
|
||||
expect(expanded.map((r) => r.id)).toEqual(['dir:src', 'file:src/app.ts', 'file:zeta.txt'])
|
||||
expect(flattenDirectoryCache(cache, new Set()).map((row) => row.id)).toEqual([
|
||||
'dir:src',
|
||||
'file:.env'
|
||||
])
|
||||
expect(shouldIncludeMobileFileExplorerEntry(entry('.config', true))).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves the binary kind on flattened rows', () => {
|
||||
const root = buildTree([entry('assets/logo.png', 'binary')])
|
||||
const rows = flattenTree(root, new Set(['assets']))
|
||||
expect(rows.find((r) => r.id === 'file:assets/logo.png')?.kind).toBe('binary')
|
||||
it('renders inline loading and error rows under expanded directories', () => {
|
||||
const cache: DirectoryCache = {
|
||||
'': { entries: [entry('loading-dir', true), entry('error-dir', true)] },
|
||||
'loading-dir': { entries: [], loading: true },
|
||||
'error-dir': { entries: [], error: 'permission denied' }
|
||||
}
|
||||
|
||||
const rows = flattenDirectoryCache(cache, new Set(['loading-dir', 'error-dir']))
|
||||
expect(rows.map((row) => row.id)).toEqual([
|
||||
'dir:error-dir',
|
||||
'error:error-dir',
|
||||
'dir:loading-dir',
|
||||
'loading:loading-dir'
|
||||
])
|
||||
expect(rows.find((row) => row.id === 'error:error-dir')).toMatchObject({
|
||||
kind: 'error',
|
||||
message: 'permission denied'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves symlink metadata for user-initiated activation', () => {
|
||||
const cache: DirectoryCache = {
|
||||
'': { entries: [entry('linked-docs', false, true)] }
|
||||
}
|
||||
|
||||
expect(flattenDirectoryCache(cache, new Set())).toEqual([
|
||||
{
|
||||
id: 'file:linked-docs',
|
||||
name: 'linked-docs',
|
||||
relativePath: 'linked-docs',
|
||||
depth: 0,
|
||||
kind: 'text',
|
||||
isSymlink: true
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('does not treat inherited object keys as loaded directories', () => {
|
||||
const cache: DirectoryCache = {
|
||||
'': { entries: [entry('constructor', true), entry('__proto__', true)] },
|
||||
['__proto__']: { entries: [entry('file.txt')] }
|
||||
}
|
||||
|
||||
expect(
|
||||
flattenDirectoryCache(cache, new Set(['constructor', '__proto__'])).map((row) => row.id)
|
||||
).toEqual(['dir:__proto__', 'file:__proto__/file.txt', 'dir:constructor'])
|
||||
})
|
||||
|
||||
it('classifies binary file paths for existing mobile preview behavior', () => {
|
||||
expect(getMobileFileKind('assets/logo.png')).toBe('binary')
|
||||
expect(getMobileFileKind('src/app.ts')).toBe('text')
|
||||
})
|
||||
|
||||
it('detects markdown paths', () => {
|
||||
|
||||
+130
-69
@@ -1,91 +1,152 @@
|
||||
// Pure tree model for the mobile file explorer: turns the flat files.list
|
||||
// result into a nested directory structure and flattens it into renderable
|
||||
// rows. Kept out of the screen component so the screen stays under its line cap.
|
||||
// Pure tree projection for the mobile file explorer. Mobile mirrors desktop
|
||||
// browse semantics by flattening cached files.readDir results as folders open.
|
||||
|
||||
export type MobileFileEntry = {
|
||||
relativePath: string
|
||||
basename: string
|
||||
kind: 'text' | 'binary'
|
||||
export type MobileDirEntry = {
|
||||
name: string
|
||||
isDirectory: boolean
|
||||
isSymlink?: boolean
|
||||
}
|
||||
|
||||
export type FilesListResult = {
|
||||
files: MobileFileEntry[]
|
||||
totalCount: number
|
||||
truncated: boolean
|
||||
}
|
||||
export type MobileFileKind = 'text' | 'binary'
|
||||
|
||||
export type TreeNode = {
|
||||
id: string
|
||||
name: string
|
||||
relativePath: string
|
||||
depth: number
|
||||
kind: 'directory' | 'text' | 'binary'
|
||||
kind: 'directory' | MobileFileKind
|
||||
isSymlink?: boolean
|
||||
}
|
||||
|
||||
export type DirectoryNode = {
|
||||
name: string
|
||||
export type DirectoryState = {
|
||||
entries: MobileDirEntry[]
|
||||
loading?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type DirectoryCache = Record<string, DirectoryState | undefined>
|
||||
|
||||
export type InlineStatusNode = {
|
||||
id: string
|
||||
relativePath: string
|
||||
directories: Map<string, DirectoryNode>
|
||||
files: MobileFileEntry[]
|
||||
depth: number
|
||||
kind: 'loading' | 'error'
|
||||
message?: string
|
||||
}
|
||||
|
||||
function createDirectoryNode(name: string, relativePath: string): DirectoryNode {
|
||||
return { name, relativePath, directories: new Map(), files: [] }
|
||||
}
|
||||
export type FileExplorerRow = TreeNode | InlineStatusNode
|
||||
|
||||
export function buildTree(files: MobileFileEntry[]): DirectoryNode {
|
||||
const root = createDirectoryNode('', '')
|
||||
for (const file of files) {
|
||||
const parts = file.relativePath.split('/').filter(Boolean)
|
||||
let current = root
|
||||
for (let index = 0; index < parts.length - 1; index += 1) {
|
||||
const name = parts[index]!
|
||||
const relativePath = parts.slice(0, index + 1).join('/')
|
||||
let child = current.directories.get(name)
|
||||
if (!child) {
|
||||
child = createDirectoryNode(name, relativePath)
|
||||
current.directories.set(name, child)
|
||||
}
|
||||
current = child
|
||||
}
|
||||
current.files.push(file)
|
||||
}
|
||||
return root
|
||||
}
|
||||
const DESKTOP_EXCLUDED_NAMES = new Set(['.git', 'node_modules'])
|
||||
const BINARY_EXTENSIONS = new Set([
|
||||
'.avif',
|
||||
'.bmp',
|
||||
'.gif',
|
||||
'.heic',
|
||||
'.ico',
|
||||
'.jpeg',
|
||||
'.jpg',
|
||||
'.mov',
|
||||
'.mp3',
|
||||
'.mp4',
|
||||
'.pdf',
|
||||
'.png',
|
||||
'.webp',
|
||||
'.zip'
|
||||
])
|
||||
|
||||
export function flattenTree(root: DirectoryNode, expanded: ReadonlySet<string>): TreeNode[] {
|
||||
const rows: TreeNode[] = []
|
||||
const visit = (directory: DirectoryNode, depth: number): void => {
|
||||
const dirs = Array.from(directory.directories.values()).sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
)
|
||||
for (const child of dirs) {
|
||||
rows.push({
|
||||
id: `dir:${child.relativePath}`,
|
||||
name: child.name,
|
||||
relativePath: child.relativePath,
|
||||
depth,
|
||||
kind: 'directory'
|
||||
})
|
||||
if (expanded.has(child.relativePath)) {
|
||||
visit(child, depth + 1)
|
||||
}
|
||||
}
|
||||
const files = [...directory.files].sort((a, b) => a.basename.localeCompare(b.basename))
|
||||
for (const file of files) {
|
||||
rows.push({
|
||||
id: `file:${file.relativePath}`,
|
||||
name: file.basename,
|
||||
relativePath: file.relativePath,
|
||||
depth,
|
||||
kind: file.kind
|
||||
})
|
||||
}
|
||||
}
|
||||
visit(root, 0)
|
||||
export function flattenDirectoryCache(
|
||||
cache: DirectoryCache,
|
||||
expanded: ReadonlySet<string>
|
||||
): FileExplorerRow[] {
|
||||
const rows: FileExplorerRow[] = []
|
||||
visitDirectory('', 0, cache, expanded, rows)
|
||||
return rows
|
||||
}
|
||||
|
||||
function visitDirectory(
|
||||
relativePath: string,
|
||||
depth: number,
|
||||
cache: DirectoryCache,
|
||||
expanded: ReadonlySet<string>,
|
||||
rows: FileExplorerRow[]
|
||||
): void {
|
||||
const state = getDirectoryCacheState(cache, relativePath)
|
||||
const entries = state?.entries ?? []
|
||||
const visibleEntries = entries
|
||||
.filter(shouldIncludeMobileFileExplorerEntry)
|
||||
.sort(compareDirectoryEntries)
|
||||
|
||||
for (const entry of visibleEntries) {
|
||||
const childPath = joinRelativePath(relativePath, entry.name)
|
||||
rows.push(toTreeNode(entry, childPath, depth))
|
||||
if (entry.isDirectory && expanded.has(childPath)) {
|
||||
const childState = getDirectoryCacheState(cache, childPath)
|
||||
if (childState?.loading) {
|
||||
rows.push({
|
||||
id: `loading:${childPath}`,
|
||||
relativePath: childPath,
|
||||
depth: depth + 1,
|
||||
kind: 'loading'
|
||||
})
|
||||
} else if (childState?.error) {
|
||||
rows.push({
|
||||
id: `error:${childPath}`,
|
||||
relativePath: childPath,
|
||||
depth: depth + 1,
|
||||
kind: 'error',
|
||||
message: childState.error
|
||||
})
|
||||
} else {
|
||||
visitDirectory(childPath, depth + 1, cache, expanded, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toTreeNode(entry: MobileDirEntry, relativePath: string, depth: number): TreeNode {
|
||||
return {
|
||||
id: `${entry.isDirectory ? 'dir' : 'file'}:${relativePath}`,
|
||||
name: entry.name,
|
||||
relativePath,
|
||||
depth,
|
||||
kind: entry.isDirectory ? 'directory' : getMobileFileKind(relativePath),
|
||||
isSymlink: entry.isSymlink
|
||||
}
|
||||
}
|
||||
|
||||
function compareDirectoryEntries(a: MobileDirEntry, b: MobileDirEntry): number {
|
||||
if (a.isDirectory !== b.isDirectory) {
|
||||
return a.isDirectory ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
|
||||
export function shouldIncludeMobileFileExplorerEntry(entry: MobileDirEntry): boolean {
|
||||
return !DESKTOP_EXCLUDED_NAMES.has(entry.name)
|
||||
}
|
||||
|
||||
export function getDirectoryCacheState(
|
||||
cache: DirectoryCache,
|
||||
relativePath: string
|
||||
): DirectoryState | undefined {
|
||||
// Why: repository paths are arbitrary object keys; inherited keys like
|
||||
// "constructor" must not masquerade as loaded directory state.
|
||||
return Object.prototype.hasOwnProperty.call(cache, relativePath) ? cache[relativePath] : undefined
|
||||
}
|
||||
|
||||
export function joinRelativePath(parentPath: string, name: string): string {
|
||||
return parentPath ? `${parentPath}/${name}` : name
|
||||
}
|
||||
|
||||
export function getMobileFileKind(relativePath: string): MobileFileKind {
|
||||
const basename = relativePath.split('/').pop() ?? relativePath
|
||||
const dotIndex = basename.lastIndexOf('.')
|
||||
if (dotIndex <= 0) {
|
||||
return 'text'
|
||||
}
|
||||
return BINARY_EXTENSIONS.has(basename.slice(dotIndex).toLowerCase()) ? 'binary' : 'text'
|
||||
}
|
||||
|
||||
export function isMarkdownPath(relativePath: string): boolean {
|
||||
return /\.(md|mdx|markdown)$/i.test(relativePath)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
File,
|
||||
FileText,
|
||||
Folder,
|
||||
Image as ImageIcon
|
||||
} from 'lucide-react-native'
|
||||
import { triggerSelection } from '../platform/haptics'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import { type FileExplorerRow, isMarkdownPath, type TreeNode } from './file-tree'
|
||||
import { fileExplorerStyles as styles } from './mobile-file-explorer-styles'
|
||||
import { canPreviewMobileFileRow } from './mobile-file-preview-navigation'
|
||||
|
||||
type Props = {
|
||||
item: FileExplorerRow
|
||||
expanded: ReadonlySet<string>
|
||||
onPreviewFile: (relativePath: string, displayName: string) => void
|
||||
onRetryDirectory: (relativePath: string) => void
|
||||
onToggleDirectory: (relativePath: string) => void
|
||||
}
|
||||
|
||||
export function MobileFileExplorerRow(props: Props) {
|
||||
const { item, expanded, onPreviewFile, onRetryDirectory, onToggleDirectory } = props
|
||||
|
||||
if (item.kind === 'loading') {
|
||||
return (
|
||||
<View style={[styles.inlineStatusRow, { paddingLeft: spacing.lg + item.depth * 18 }]}>
|
||||
<View style={styles.chevronSpacer} />
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
<Text style={styles.inlineStatusText}>Loading...</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.kind === 'error') {
|
||||
return (
|
||||
<View style={[styles.inlineStatusRow, { paddingLeft: spacing.lg + item.depth * 18 }]}>
|
||||
<View style={styles.chevronSpacer} />
|
||||
<Text style={styles.inlineErrorText} numberOfLines={1}>
|
||||
{item.message || 'Unable to load folder'}
|
||||
</Text>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.inlineRetryButton, pressed && styles.rowPressed]}
|
||||
onPress={() => {
|
||||
triggerSelection()
|
||||
onRetryDirectory(item.relativePath)
|
||||
}}
|
||||
accessibilityLabel={`Retry loading ${item.relativePath}`}
|
||||
>
|
||||
<Text style={styles.inlineRetryText}>Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (isTreeNode(item)) {
|
||||
return (
|
||||
<TreeRow
|
||||
item={item}
|
||||
expanded={expanded}
|
||||
onPreviewFile={onPreviewFile}
|
||||
onToggleDirectory={onToggleDirectory}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function isTreeNode(item: FileExplorerRow): item is TreeNode {
|
||||
return item.kind === 'directory' || item.kind === 'text' || item.kind === 'binary'
|
||||
}
|
||||
|
||||
function TreeRow(props: {
|
||||
item: TreeNode
|
||||
expanded: ReadonlySet<string>
|
||||
onPreviewFile: (relativePath: string, displayName: string) => void
|
||||
onToggleDirectory: (relativePath: string) => void
|
||||
}) {
|
||||
const { item, expanded, onPreviewFile, onToggleDirectory } = props
|
||||
const isDirectory = item.kind === 'directory'
|
||||
const isExpanded = expanded.has(item.relativePath)
|
||||
// Images render in the mobile viewer (via files.readPreview), so a binary
|
||||
// image is openable; only non-previewable binaries are unavailable.
|
||||
const previewable =
|
||||
item.kind !== 'directory' &&
|
||||
canPreviewMobileFileRow({ kind: item.kind, relativePath: item.relativePath })
|
||||
const isImage = item.kind === 'binary' && previewable
|
||||
const disabled = item.kind === 'binary' && !previewable
|
||||
const markdown = item.kind === 'text' && isMarkdownPath(item.relativePath)
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.row,
|
||||
{ paddingLeft: spacing.lg + item.depth * 18 },
|
||||
pressed && !disabled && styles.rowPressed,
|
||||
disabled && styles.rowDisabled
|
||||
]}
|
||||
disabled={disabled}
|
||||
onPress={() => {
|
||||
triggerSelection()
|
||||
if (isDirectory) {
|
||||
onToggleDirectory(item.relativePath)
|
||||
} else if (!disabled) {
|
||||
onPreviewFile(item.relativePath, item.name)
|
||||
}
|
||||
}}
|
||||
accessibilityLabel={
|
||||
isDirectory
|
||||
? `Open folder ${item.name}`
|
||||
: disabled
|
||||
? `${item.name} unavailable on mobile`
|
||||
: `Preview file ${item.name}`
|
||||
}
|
||||
>
|
||||
{isDirectory ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown size={16} color={colors.textSecondary} />
|
||||
) : (
|
||||
<ChevronRight size={16} color={colors.textSecondary} />
|
||||
)
|
||||
) : (
|
||||
<View style={styles.chevronSpacer} />
|
||||
)}
|
||||
{isDirectory ? (
|
||||
<Folder size={17} color={colors.textSecondary} />
|
||||
) : markdown ? (
|
||||
<FileText size={17} color={disabled ? colors.textMuted : colors.textSecondary} />
|
||||
) : isImage ? (
|
||||
<ImageIcon size={17} color={colors.textSecondary} />
|
||||
) : (
|
||||
<File size={17} color={disabled ? colors.textMuted : colors.textSecondary} />
|
||||
)}
|
||||
<View style={styles.rowTextBlock}>
|
||||
<Text style={[styles.rowTitle, disabled && styles.rowTitleDisabled]} numberOfLines={1}>
|
||||
{item.name}
|
||||
</Text>
|
||||
{disabled ? <Text style={styles.rowMeta}>Unavailable on mobile</Text> : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
@@ -78,6 +78,37 @@ export const fileExplorerStyles = StyleSheet.create({
|
||||
color: colors.textMuted,
|
||||
fontSize: 11
|
||||
},
|
||||
inlineStatusRow: {
|
||||
minHeight: 36,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingRight: spacing.md
|
||||
},
|
||||
inlineStatusText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
inlineErrorText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
inlineRetryButton: {
|
||||
minHeight: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
paddingHorizontal: spacing.md
|
||||
},
|
||||
inlineRetryText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
state: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
/* oxlint-disable max-lines -- Why: mobile dictation keeps permission, recording,
|
||||
* chunk upload, completion, and cancellation in one hook so native audio state
|
||||
* cannot drift from the runtime RPC lifecycle. */
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro
|
||||
// can't resolve Node's builtin in a React Native bundle.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { subscribeToDesktopNotifications } from './mobile-notifications'
|
||||
import {
|
||||
setScheduledNotificationsMaxForTests,
|
||||
subscribeToDesktopNotifications
|
||||
} from './mobile-notifications'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { loadPushNotificationsEnabled } from '../storage/preferences'
|
||||
|
||||
@@ -268,4 +271,48 @@ describe('subscribeToDesktopNotifications', () => {
|
||||
|
||||
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: notificationId is unique per completion, so the map grew unbounded when
|
||||
// the desktop never sent a dismiss (the remote-mobile case). It is now capped.
|
||||
it('evicts the oldest scheduled entry once the cap is exceeded', async () => {
|
||||
setScheduledNotificationsMaxForTests(1)
|
||||
try {
|
||||
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
vi.mocked(Notifications.scheduleNotificationAsync)
|
||||
.mockResolvedValueOnce('scheduled-old')
|
||||
.mockResolvedValueOnce('scheduled-new')
|
||||
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
|
||||
let onEvent: ((data: unknown) => void) | null = null
|
||||
const client = {
|
||||
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
|
||||
onEvent = callback
|
||||
return vi.fn()
|
||||
}),
|
||||
getState: vi.fn(() => 'connected'),
|
||||
sendRequest: vi.fn()
|
||||
} as unknown as RpcClient
|
||||
|
||||
subscribeToDesktopNotifications(client, 'host-1')
|
||||
onEvent?.({ type: 'notification', title: 't', body: 'b', notificationId: 'agent:old' })
|
||||
await flushAsync()
|
||||
onEvent?.({ type: 'notification', title: 't', body: 'b', notificationId: 'agent:new' })
|
||||
await flushAsync()
|
||||
|
||||
// The older entry was evicted by the cap: dismissing it is a no-op...
|
||||
onEvent?.({ type: 'dismiss', notificationId: 'agent:old' })
|
||||
await flushAsync()
|
||||
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalledWith('scheduled-old')
|
||||
|
||||
// ...while the most-recent entry is retained and still dismissable.
|
||||
onEvent?.({ type: 'dismiss', notificationId: 'agent:new' })
|
||||
await flushAsync()
|
||||
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-new')
|
||||
} finally {
|
||||
setScheduledNotificationsMaxForTests()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,10 +31,42 @@ type ScheduledNotificationState = {
|
||||
|
||||
const scheduledNotificationsByHostAndNotificationId = new Map<string, ScheduledNotificationState>()
|
||||
|
||||
// Why: notificationId embeds a per-completion timestamp (buildAgentNotificationId),
|
||||
// so every agent-task-complete inserts a new, never-reused key. Entries are only
|
||||
// removed when the desktop sends a matching dismiss — which a remote mobile user
|
||||
// (not at the desktop) frequently never gets — so the map grew for the app's whole
|
||||
// life. Bound it; a settled entry only retains a small identifier used for later
|
||||
// programmatic dismissal, unnecessary for long-past completions.
|
||||
const MAX_SCHEDULED_NOTIFICATIONS = 256
|
||||
let maxScheduledNotifications = MAX_SCHEDULED_NOTIFICATIONS
|
||||
|
||||
function getStoredNotificationKey(hostId: string, notificationId: string): string {
|
||||
return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}`
|
||||
}
|
||||
|
||||
// Evict the oldest SETTLED entries (never one mid-schedule) until within the cap.
|
||||
// Map iteration is insertion order, so the first match is the oldest.
|
||||
function boundScheduledNotifications(): void {
|
||||
while (scheduledNotificationsByHostAndNotificationId.size > maxScheduledNotifications) {
|
||||
let evicted = false
|
||||
for (const [key, state] of scheduledNotificationsByHostAndNotificationId) {
|
||||
if (!state.pending) {
|
||||
scheduledNotificationsByHostAndNotificationId.delete(key)
|
||||
evicted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!evicted) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: override the cap (pass no arg to restore the default). */
|
||||
export function setScheduledNotificationsMaxForTests(max?: number): void {
|
||||
maxScheduledNotifications = max ?? MAX_SCHEDULED_NOTIFICATIONS
|
||||
}
|
||||
|
||||
export type NotificationPermissionState = {
|
||||
granted: boolean
|
||||
status: string
|
||||
@@ -155,6 +187,7 @@ async function showLocalNotification(event: NotificationEvent, hostId: string):
|
||||
return
|
||||
}
|
||||
notificationState.identifier = scheduledIdentifier
|
||||
boundScheduledNotifications()
|
||||
} finally {
|
||||
if (notificationState.pending === pending) {
|
||||
notificationState.pending = undefined
|
||||
|
||||
@@ -16,6 +16,7 @@ type Props = {
|
||||
export function MobileSourceControlContent({ state }: Props) {
|
||||
const {
|
||||
insets,
|
||||
connState,
|
||||
busyAction,
|
||||
commitMessage,
|
||||
setCommitMessage,
|
||||
@@ -56,6 +57,15 @@ export function MobileSourceControlContent({ state }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{connState !== 'connected' ? (
|
||||
// Why: once data has loaded the screen looks alive even when the
|
||||
// desktop link is down, so taps appear to do nothing (STA-1511).
|
||||
// Surface the reconnect state where the user is looking.
|
||||
<View style={styles.reconnectBanner}>
|
||||
<ActivityIndicator size="small" color={colors.statusAmber} />
|
||||
<Text style={styles.reconnectBannerText}>Reconnecting to desktop...</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.summaryCard}>
|
||||
<View style={styles.summaryHeader}>
|
||||
<View style={styles.branchLine}>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveMobileHistoryScreenView } from './mobile-history-screen-state'
|
||||
import type { MobileCommitRow } from './mobile-git-history'
|
||||
|
||||
const row: MobileCommitRow = {
|
||||
id: 'abc123',
|
||||
shortId: 'abc123',
|
||||
subject: 'commit',
|
||||
author: 'author',
|
||||
parentId: null,
|
||||
relativeTime: '1m'
|
||||
}
|
||||
|
||||
describe('resolveMobileHistoryScreenView', () => {
|
||||
it('shows waiting (not an endless spinner) when disconnected with nothing loaded', () => {
|
||||
// Regression (STA-1511): the load effect no-ops while disconnected, so
|
||||
// rows stay null forever — the screen must offer a retry, not a spinner.
|
||||
expect(resolveMobileHistoryScreenView({ connected: false, rows: null, error: null })).toEqual({
|
||||
kind: 'waiting'
|
||||
})
|
||||
})
|
||||
|
||||
it('spins only while connected and loading', () => {
|
||||
expect(resolveMobileHistoryScreenView({ connected: true, rows: null, error: null })).toEqual({
|
||||
kind: 'loading'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps already-loaded rows visible across a connection drop', () => {
|
||||
expect(resolveMobileHistoryScreenView({ connected: false, rows: [row], error: null })).toEqual({
|
||||
kind: 'rows',
|
||||
rows: [row]
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers the error state over waiting so failures stay actionable', () => {
|
||||
expect(resolveMobileHistoryScreenView({ connected: false, rows: null, error: 'boom' })).toEqual(
|
||||
{ kind: 'error', message: 'boom' }
|
||||
)
|
||||
})
|
||||
|
||||
it('distinguishes an empty history from a pending load', () => {
|
||||
expect(resolveMobileHistoryScreenView({ connected: true, rows: [], error: null })).toEqual({
|
||||
kind: 'empty'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { MobileCommitRow } from './mobile-git-history'
|
||||
|
||||
export type MobileHistoryScreenView =
|
||||
| { kind: 'error'; message: string }
|
||||
| { kind: 'waiting' }
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'empty' }
|
||||
| { kind: 'rows'; rows: MobileCommitRow[] }
|
||||
|
||||
// Why: the history load effect no-ops while the client isn't connected, so
|
||||
// without an explicit 'waiting' view the screen showed an infinite spinner
|
||||
// with no retry when opened during a reconnect window (STA-1511). Rows loaded
|
||||
// before a drop stay visible — stale history beats a blank screen.
|
||||
export function resolveMobileHistoryScreenView(input: {
|
||||
connected: boolean
|
||||
rows: MobileCommitRow[] | null
|
||||
error: string | null
|
||||
}): MobileHistoryScreenView {
|
||||
const { connected, rows, error } = input
|
||||
if (error) {
|
||||
return { kind: 'error', message: error }
|
||||
}
|
||||
if (rows !== null) {
|
||||
return rows.length === 0 ? { kind: 'empty' } : { kind: 'rows', rows }
|
||||
}
|
||||
if (!connected) {
|
||||
return { kind: 'waiting' }
|
||||
}
|
||||
return { kind: 'loading' }
|
||||
}
|
||||
@@ -125,6 +125,24 @@ const baseStyles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
textTransform: 'capitalize'
|
||||
},
|
||||
reconnectBanner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: spacing.lg,
|
||||
marginBottom: -spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.statusAmber
|
||||
},
|
||||
reconnectBannerText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
actionError: {
|
||||
marginTop: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const sessionRouteSource = readFileSync(
|
||||
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
// Why: iOS terminates an active keyboard-dictation (and IME) session whenever
|
||||
// JS writes a value into the focused field that differs from the native text
|
||||
// (RN applies it via setTextAndSelection / _setAttributedString). Terminal
|
||||
// inputs therefore must echo the raw field text in their controlled value and
|
||||
// apply dash normalization only on the send/mirror path. See stablyai/orca#7925.
|
||||
describe('terminal iOS dictation write-back', () => {
|
||||
it('does not write normalized text back into the buffered command input value', () => {
|
||||
expect(sessionRouteSource).toContain('onChangeText={setInput}')
|
||||
expect(sessionRouteSource).not.toContain(
|
||||
'setInput((previousText) => normalizeTerminalTextInput'
|
||||
)
|
||||
})
|
||||
|
||||
it('still normalizes the buffered command text at send time', () => {
|
||||
expect(sessionRouteSource).toContain('normalizeTerminalTextInput(input)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { dismissTerminalKeyboard } from './terminal-keyboard-dismiss'
|
||||
|
||||
describe('dismissTerminalKeyboard', () => {
|
||||
it('clears pending live input focus before blur and dismiss', () => {
|
||||
const calls: string[] = []
|
||||
const clearPendingLiveInputFocus = vi.fn(() => calls.push('clear'))
|
||||
const liveInput = { blur: vi.fn(() => calls.push('live-blur')) }
|
||||
const commandInput = { blur: vi.fn(() => calls.push('command-blur')) }
|
||||
const dismissKeyboard = vi.fn(() => calls.push('dismiss'))
|
||||
|
||||
dismissTerminalKeyboard({
|
||||
clearPendingLiveInputFocus,
|
||||
commandInput,
|
||||
dismissKeyboard,
|
||||
liveInput
|
||||
})
|
||||
|
||||
expect(calls).toEqual(['clear', 'live-blur', 'command-blur', 'dismiss'])
|
||||
})
|
||||
|
||||
it('blurs both live and buffered command inputs', () => {
|
||||
const liveInput = { blur: vi.fn() }
|
||||
const commandInput = { blur: vi.fn() }
|
||||
|
||||
dismissTerminalKeyboard({
|
||||
clearPendingLiveInputFocus: vi.fn(),
|
||||
commandInput,
|
||||
dismissKeyboard: vi.fn(),
|
||||
liveInput
|
||||
})
|
||||
|
||||
expect(liveInput.blur).toHaveBeenCalledTimes(1)
|
||||
expect(commandInput.blur).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dismisses the keyboard without a live input handle', () => {
|
||||
const commandInput = { blur: vi.fn() }
|
||||
const dismissKeyboard = vi.fn()
|
||||
|
||||
dismissTerminalKeyboard({
|
||||
clearPendingLiveInputFocus: vi.fn(),
|
||||
commandInput,
|
||||
dismissKeyboard,
|
||||
liveInput: null
|
||||
})
|
||||
|
||||
expect(commandInput.blur).toHaveBeenCalledTimes(1)
|
||||
expect(dismissKeyboard).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dismisses the keyboard without a buffered command input handle', () => {
|
||||
const liveInput = { blur: vi.fn() }
|
||||
const dismissKeyboard = vi.fn()
|
||||
|
||||
dismissTerminalKeyboard({
|
||||
clearPendingLiveInputFocus: vi.fn(),
|
||||
commandInput: undefined,
|
||||
dismissKeyboard,
|
||||
liveInput
|
||||
})
|
||||
|
||||
expect(liveInput.blur).toHaveBeenCalledTimes(1)
|
||||
expect(dismissKeyboard).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('still clears focus and dismisses when both input handles are missing', () => {
|
||||
const calls: string[] = []
|
||||
const clearPendingLiveInputFocus = vi.fn(() => calls.push('clear'))
|
||||
const dismissKeyboard = vi.fn(() => calls.push('dismiss'))
|
||||
|
||||
dismissTerminalKeyboard({
|
||||
clearPendingLiveInputFocus,
|
||||
commandInput: undefined,
|
||||
dismissKeyboard,
|
||||
liveInput: null
|
||||
})
|
||||
|
||||
expect(calls).toEqual(['clear', 'dismiss'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
export type TerminalKeyboardDismissHandle = { blur: () => void } | null | undefined
|
||||
|
||||
export type DismissTerminalKeyboardOptions = {
|
||||
clearPendingLiveInputFocus: () => void
|
||||
commandInput: TerminalKeyboardDismissHandle
|
||||
dismissKeyboard: () => void
|
||||
liveInput: TerminalKeyboardDismissHandle
|
||||
}
|
||||
|
||||
export function dismissTerminalKeyboard(options: DismissTerminalKeyboardOptions): void {
|
||||
// Why: clear the queued live-input focus before blurring/dismissing so a
|
||||
// pending deferred focus cannot re-open the iOS keyboard right after Hide.
|
||||
options.clearPendingLiveInputFocus()
|
||||
options.liveInput?.blur()
|
||||
options.commandInput?.blur()
|
||||
options.dismissKeyboard()
|
||||
}
|
||||
@@ -202,6 +202,32 @@ describe('terminal live input commit hook', () => {
|
||||
await vi.waitFor(() => expect(sent).toEqual(['a', 'b']))
|
||||
})
|
||||
|
||||
it('Given iOS smart-dash text When the change arrives Then the capture echoes the raw field text and the PTY gets normalized bytes', async () => {
|
||||
// Given
|
||||
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When: iOS smart punctuation rewrote "--" into an en dash inside the field
|
||||
handlers.handleLiveInputChange('a–')
|
||||
|
||||
// Then: writing "a--" back into the controlled value would kill an active
|
||||
// iOS dictation/IME session, so the capture must keep what iOS produced
|
||||
expect(captures).toEqual(['a–'])
|
||||
await vi.waitFor(() => expect(sent).toEqual(['a--']))
|
||||
})
|
||||
|
||||
it('Given dictation-style hypothesis revisions When changes arrive Then the field is never rewritten and the PTY converges', async () => {
|
||||
// Given
|
||||
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When: iOS dictation replaces its hypothesis as recognition refines
|
||||
handlers.handleLiveInputChange('high')
|
||||
handlers.handleLiveInputChange('hi there')
|
||||
|
||||
// Then: captures only echo the field; the mirror repairs the PTY with DELs
|
||||
expect(captures).toEqual(['high', 'hi there'])
|
||||
await vi.waitFor(() => expect(sent).toEqual(['high', '\x7f\x7f there']))
|
||||
})
|
||||
|
||||
it('Given a trailing space after Hangul When the change arrives Then the space commits the held syllable', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
@@ -109,9 +109,11 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
clearPendingLiveInputCommit()
|
||||
return
|
||||
}
|
||||
const normalizedText = normalizeTerminalTextInput(text)
|
||||
setLiveInputCapture(normalizedText)
|
||||
applyLiveInputMirror(activeHandle, normalizedText)
|
||||
// Why: iOS kills an active dictation/IME session when JS writes a value
|
||||
// that differs from the native field text, so the controlled capture must
|
||||
// echo the field verbatim; only the PTY mirror sees normalized text.
|
||||
setLiveInputCapture(text)
|
||||
applyLiveInputMirror(activeHandle, normalizeTerminalTextInput(text))
|
||||
},
|
||||
[
|
||||
activeHandle,
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ConnectionState } from './types'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
|
||||
const connectMock = vi.fn()
|
||||
const loadHostsMock = vi.fn()
|
||||
|
||||
vi.mock('./rpc-client', () => ({
|
||||
connect: (...args: unknown[]) => connectMock(...args)
|
||||
}))
|
||||
vi.mock('./host-store', () => ({
|
||||
loadHosts: () => loadHostsMock()
|
||||
}))
|
||||
vi.mock('./connection-revival-triggers', () => ({
|
||||
subscribeConnectionRevivalTriggers: () => () => {}
|
||||
}))
|
||||
|
||||
import { RpcClientProvider, useCloseHost, useHostClient } from './client-context'
|
||||
|
||||
type FakeClient = RpcClient & {
|
||||
emitState: (state: ConnectionState) => void
|
||||
closeMock: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function makeFakeClient(initialState: ConnectionState): FakeClient {
|
||||
let state = initialState
|
||||
const listeners = new Set<(state: ConnectionState) => void>()
|
||||
const closeMock = vi.fn()
|
||||
return {
|
||||
sendRequest: vi.fn(),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
updateTerminalSubscriptionViewport: vi.fn(),
|
||||
getState: () => state,
|
||||
getReconnectAttempt: () => 0,
|
||||
getLastConnectedAt: () => null,
|
||||
onStateChange: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
notifyForeground: vi.fn(),
|
||||
close: closeMock,
|
||||
closeMock,
|
||||
emitState: (next) => {
|
||||
state = next
|
||||
for (const listener of listeners) {
|
||||
listener(next)
|
||||
}
|
||||
}
|
||||
} as FakeClient
|
||||
}
|
||||
|
||||
const HOST = {
|
||||
id: 'host-1',
|
||||
name: 'Host 1',
|
||||
endpoint: 'ws://127.0.0.1:1',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: 'key',
|
||||
lastConnected: 0
|
||||
}
|
||||
|
||||
type Harness = {
|
||||
readonly hook: ReturnType<typeof useHostClient>
|
||||
readonly closeHost: (hostId: string) => void
|
||||
readonly unmount: () => void
|
||||
}
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
async function renderHarness(hostId: string): Promise<Harness> {
|
||||
let hook: ReturnType<typeof useHostClient> | null = null
|
||||
let closeHost: ((hostId: string) => void) | null = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
function Probe(): null {
|
||||
hook = useHostClient(hostId)
|
||||
closeHost = useCloseHost()
|
||||
return null
|
||||
}
|
||||
|
||||
const restore = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
if (!hook || !closeHost || !renderer) {
|
||||
throw new Error('harness did not render')
|
||||
}
|
||||
const mounted = renderer as ReactTestRenderer
|
||||
return {
|
||||
get hook() {
|
||||
if (!hook) {
|
||||
throw new Error('hook not rendered')
|
||||
}
|
||||
return hook
|
||||
},
|
||||
closeHost: (id) => {
|
||||
if (!closeHost) {
|
||||
throw new Error('closeHost not rendered')
|
||||
}
|
||||
closeHost(id)
|
||||
},
|
||||
unmount: () => mounted.unmount()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
connectMock.mockReset()
|
||||
loadHostsMock.mockReset()
|
||||
})
|
||||
|
||||
describe('useHostClient', () => {
|
||||
it('drops the closed client when the host entry is removed', async () => {
|
||||
const fake = makeFakeClient('connected')
|
||||
connectMock.mockReturnValue(fake)
|
||||
loadHostsMock.mockResolvedValue([HOST])
|
||||
|
||||
const harness = await renderHarness(HOST.id)
|
||||
expect(harness.hook.client).toBe(fake)
|
||||
expect(harness.hook.state).toBe('connected')
|
||||
|
||||
// Regression (STA-1511): closeHost deletes the entry; before the fix the
|
||||
// hook kept handing out the closed client, so mounted screens kept
|
||||
// driving requests that could never resolve.
|
||||
await act(async () => {
|
||||
harness.closeHost(HOST.id)
|
||||
})
|
||||
expect(fake.closeMock).toHaveBeenCalled()
|
||||
expect(harness.hook.client).toBeNull()
|
||||
expect(harness.hook.state).toBe('disconnected')
|
||||
|
||||
harness.unmount()
|
||||
})
|
||||
|
||||
it('reports disconnected instead of hanging when the host id is unknown', async () => {
|
||||
loadHostsMock.mockResolvedValue([])
|
||||
|
||||
const harness = await renderHarness('missing-host')
|
||||
expect(connectMock).not.toHaveBeenCalled()
|
||||
expect(harness.hook.client).toBeNull()
|
||||
expect(harness.hook.state).toBe('disconnected')
|
||||
|
||||
harness.unmount()
|
||||
})
|
||||
})
|
||||
@@ -133,6 +133,11 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
return null
|
||||
}
|
||||
if (!host) {
|
||||
// Why: returning silently leaves mounted screens on a permanent
|
||||
// spinner (STA-1511) — surface 'disconnected' so they can render
|
||||
// their waiting/retry affordance instead.
|
||||
notifyHostState(hostId, 'disconnected')
|
||||
notifyAllHosts()
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -406,6 +411,12 @@ export function useHostClient(hostId: string | undefined): {
|
||||
if (found && found.client !== clientRef.current) {
|
||||
clientRef.current = found.client
|
||||
force((n) => n + 1)
|
||||
} else if (!found && clientRef.current) {
|
||||
// Why: closeHost deletes the entry without a replacement; holding the
|
||||
// closed client would let screens keep issuing requests that can never
|
||||
// resolve (STA-1511). Null it so they render disconnected states.
|
||||
clientRef.current = null
|
||||
force((n) => n + 1)
|
||||
}
|
||||
})
|
||||
const initial = ctx.acquire(hostId)
|
||||
|
||||
+4
-17
@@ -1,25 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
const tsconfigRaw = JSON.stringify({
|
||||
compilerOptions: {
|
||||
jsx: 'react-jsx',
|
||||
module: 'esnext',
|
||||
moduleResolution: 'bundler',
|
||||
strict: true,
|
||||
target: 'es2022'
|
||||
}
|
||||
})
|
||||
const vitestOxcConfig = { tsconfig: false } as never
|
||||
|
||||
export default defineConfig({
|
||||
root: import.meta.dirname,
|
||||
esbuild: {
|
||||
tsconfigRaw
|
||||
},
|
||||
optimizeDeps: {
|
||||
esbuildOptions: {
|
||||
tsconfigRaw
|
||||
}
|
||||
},
|
||||
// Why: the app tsconfig intentionally excludes tests; Vite 8's OXC transform
|
||||
// otherwise fails before Vitest can run the test modules.
|
||||
oxc: vitestOxcConfig,
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts']
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Prints the app's macOS notification settings as JSON and exits.
|
||||
//
|
||||
// Why this exists: Electron exposes no API for UNUserNotificationCenter
|
||||
// authorization, and scheduling silently succeeds even while macOS suppresses
|
||||
// display, so the renderer cannot know whether the user actually receives
|
||||
// notifications. This binary must run from inside the app bundle (NSBundle
|
||||
// resolves the bundle by walking up from the executable path) and must be
|
||||
// code-signed with the app's identifier — macOS keys notification records to
|
||||
// the signing identifier, which is why the build embeds an Info.plist section
|
||||
// with the target CFBundleIdentifier.
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var authorization = "unknown"
|
||||
var alert = "unknown"
|
||||
UNUserNotificationCenter.current().getNotificationSettings { settings in
|
||||
switch settings.authorizationStatus {
|
||||
case .authorized: authorization = "authorized"
|
||||
case .provisional: authorization = "provisional"
|
||||
case .ephemeral: authorization = "ephemeral"
|
||||
case .denied: authorization = "denied"
|
||||
case .notDetermined: authorization = "not-determined"
|
||||
@unknown default: authorization = "unknown"
|
||||
}
|
||||
switch settings.alertSetting {
|
||||
case .enabled: alert = "enabled"
|
||||
case .disabled: alert = "disabled"
|
||||
case .notSupported: alert = "not-supported"
|
||||
@unknown default: alert = "unknown"
|
||||
}
|
||||
semaphore.signal()
|
||||
}
|
||||
_ = semaphore.wait(timeout: .now() + 3)
|
||||
print("{\"authorization\":\"\(authorization)\",\"alert\":\"\(alert)\"}")
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.124-rc.9",
|
||||
"version": "1.4.131-rc.1",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
@@ -11,7 +11,7 @@
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"format": "oxfmt --write .",
|
||||
"lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage",
|
||||
"lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage",
|
||||
"lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json",
|
||||
"lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs",
|
||||
"lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet",
|
||||
@@ -19,6 +19,7 @@
|
||||
"test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts",
|
||||
"check:styled-scrollbars": "node config/scripts/check-styled-scrollbars.mjs",
|
||||
"check:reliability-gates": "node config/scripts/check-reliability-gates.mjs",
|
||||
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
|
||||
"check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs",
|
||||
"verify:macos-entitlements": "node config/scripts/verify-macos-entitlements.mjs",
|
||||
"vendor:feature-wall-assets": "node config/scripts/vendor-feature-wall-assets.mjs",
|
||||
@@ -41,6 +42,7 @@
|
||||
"dev:web": "vite --config vite.web.config.ts --host 127.0.0.1",
|
||||
"build:relay": "node config/scripts/build-relay.mjs",
|
||||
"build:computer-macos": "node config/scripts/build-computer-macos.mjs",
|
||||
"build:notification-status-macos": "node config/scripts/build-notification-status-macos.mjs",
|
||||
"build:native": "node config/scripts/build-native-for-platform.mjs",
|
||||
"smoke:computer": "node config/scripts/computer-use-smoke.mjs",
|
||||
"verify:computer-native": "node config/scripts/verify-computer-native.mjs",
|
||||
@@ -80,12 +82,14 @@
|
||||
"test:e2e:terminal-perf:check-report": "node config/scripts/check-terminal-perf-report-budgets.mjs",
|
||||
"test:e2e:terminal-perf:summarize": "node config/scripts/summarize-terminal-perf-report.mjs",
|
||||
"test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs",
|
||||
"win-update-e2e": "node tools/win-update-e2e/run.mjs",
|
||||
"test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs",
|
||||
"test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful",
|
||||
"test:e2e:computer": "vitest run --config tests/e2e/vitest.config.ts",
|
||||
"bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs",
|
||||
"bench:startup": "pnpm run ensure:electron-runtime && node tools/benchmarks/startup-time-bench.mjs",
|
||||
"bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tools/benchmarks/daemon-coldstart-bench.mjs",
|
||||
"bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tools/benchmarks/main-thread-jank-bench.mjs",
|
||||
"bench:compare": "node config/scripts/compare-benchmark-artifacts.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,523 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mobile Driver Overlay — Orca Preview</title>
|
||||
<style>
|
||||
/* ── Orca dark tokens (from main.css .dark) ── */
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #fafafa;
|
||||
--card: #171717;
|
||||
--card-foreground: #fafafa;
|
||||
--primary: #e5e5e5;
|
||||
--primary-foreground: #171717;
|
||||
--muted: #262626;
|
||||
--muted-foreground: #a1a1a1;
|
||||
--accent: #404040;
|
||||
--accent-foreground: #fafafa;
|
||||
--border: rgb(255 255 255 / 0.07);
|
||||
--input: rgb(255 255 255 / 0.15);
|
||||
--ring: #737373;
|
||||
--radius: 0.625rem;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||
'Courier New', monospace;
|
||||
--orca-pane-title-height: 24px;
|
||||
--orca-pane-title-fg: rgb(255 255 255 / 0.52);
|
||||
--orca-pane-title-separator: rgb(255 255 255 / 0.06);
|
||||
--shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--xterm-bg: #000000;
|
||||
--xterm-fg: #cccccc;
|
||||
--xterm-green: #23d18b;
|
||||
--xterm-blue: #3b8eea;
|
||||
--xterm-dim: #6a6a6a;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
src: url('../src/renderer/src/assets/fonts/Geist-Variable.woff2') format('woff2');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Geist', ui-sans-serif, system-ui, sans-serif;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* ── Preview chrome (switcher only — not part of Orca UI) ── */
|
||||
.preview-chrome {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.625rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--card) 92%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.preview-chrome h1 {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.state-tabs {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.state-tab {
|
||||
height: 1.75rem;
|
||||
padding: 0 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.state-tab[aria-selected='true'] {
|
||||
border-color: var(--input);
|
||||
background: color-mix(in srgb, var(--input) 30%, transparent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* ── Orca workspace frame ── */
|
||||
.workspace {
|
||||
height: 100%;
|
||||
padding-top: 3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.workspace-main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* Terminal pane — matches .pane + .terminal-container */
|
||||
.pane {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
min-height: 28rem;
|
||||
overflow: hidden;
|
||||
background: var(--xterm-bg);
|
||||
}
|
||||
|
||||
.pane[data-has-title]::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: var(--orca-pane-title-height);
|
||||
left: -5px;
|
||||
right: -5px;
|
||||
height: 1px;
|
||||
background: var(--orca-pane-title-separator);
|
||||
z-index: 6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pane-title-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--orca-pane-title-height);
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--orca-pane-title-fg);
|
||||
background: transparent;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.xterm-container {
|
||||
position: relative;
|
||||
width: calc(100% - 4px);
|
||||
height: calc(100% - var(--orca-pane-title-height));
|
||||
margin-top: var(--orca-pane-title-height);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.xterm-screen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 8px 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
color: var(--xterm-fg);
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-screen .green {
|
||||
color: var(--xterm-green);
|
||||
}
|
||||
|
||||
.xterm-screen .blue {
|
||||
color: var(--xterm-blue);
|
||||
}
|
||||
|
||||
.xterm-screen .dim {
|
||||
color: var(--xterm-dim);
|
||||
}
|
||||
|
||||
/* ── MobileDriverOverlay (from MobileDriverOverlay.tsx) ── */
|
||||
.mobile-driver-banner {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.mobile-driver-banner.is-chip {
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.overlay-card {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 30rem;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--card);
|
||||
color: var(--card-foreground);
|
||||
padding: 1.5rem 1.5rem 1.25rem;
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.overlay-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.phone-icon-badge {
|
||||
display: flex;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.phone-icon-badge.tone-driving {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.phone-icon-badge.tone-held {
|
||||
background: color-mix(in srgb, var(--muted) 60%, transparent);
|
||||
}
|
||||
|
||||
.phone-icon-badge svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.overlay-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.overlay-eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.025em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.overlay-eyebrow.tone-driving {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.overlay-eyebrow.tone-held {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--foreground);
|
||||
}
|
||||
|
||||
.overlay-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.overlay-body {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.625;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.overlay-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Button primitive (button.tsx, dark theme) */
|
||||
[data-slot='button'] {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
border: 1px solid transparent;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
outline: none;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
[data-slot='button'][data-size='sm'] {
|
||||
height: 2rem;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
[data-slot='button'][data-size='xs'] {
|
||||
height: 1.5rem;
|
||||
gap: 0.25rem;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
[data-slot='button'][data-variant='default'] {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
[data-slot='button'][data-variant='default'].is-focused {
|
||||
border-color: var(--ring);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent);
|
||||
}
|
||||
|
||||
[data-slot='button'][data-variant='outline'] {
|
||||
border-color: var(--input);
|
||||
background: color-mix(in srgb, var(--input) 30%, transparent);
|
||||
color: var(--foreground);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
[data-slot='button'][data-variant='ghost'] {
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
/* LockChip */
|
||||
.mobile-driver-banner .lock-chip {
|
||||
pointer-events: auto;
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
top: 0.5rem;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9999px;
|
||||
background: var(--card);
|
||||
color: var(--card-foreground);
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.lock-chip svg {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.state-panel[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="preview-chrome">
|
||||
<h1>Mobile Driver Overlay — production layout preview</h1>
|
||||
<div class="state-tabs" role="tablist" aria-label="Overlay state">
|
||||
<button class="state-tab" type="button" role="tab" data-state="driving" aria-selected="false">
|
||||
Phone in control
|
||||
</button>
|
||||
<button class="state-tab" type="button" role="tab" data-state="held" aria-selected="true">
|
||||
Phone session ended
|
||||
</button>
|
||||
<button class="state-tab" type="button" role="tab" data-state="chip" aria-selected="false">
|
||||
Collapsed chip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workspace">
|
||||
<div class="workspace-main">
|
||||
<div class="pane" data-has-title>
|
||||
<div class="pane-title-bar">
|
||||
<span class="pane-title-text">bash — fix-phone-resizing</span>
|
||||
</div>
|
||||
|
||||
<div class="xterm-container">
|
||||
<div class="xterm-screen" aria-hidden="true">
|
||||
<span class="green">➜</span> fix-phone-resizing <span class="dim">git:(</span><span class="blue">fix-phone-resizing</span><span class="dim">) ✗</span>
|
||||
<span class="green">➜</span> fix-phone-resizing <span class="dim">git status</span>
|
||||
On branch fix-phone-resizing
|
||||
Changes not staged for commit:
|
||||
<span class="dim">modified:</span> src/renderer/src/components/terminal-pane/MobileDriverOverlay.tsx
|
||||
<span class="dim">modified:</span> src/renderer/src/i18n/locales/en.json
|
||||
|
||||
<span class="green">➜</span> fix-phone-resizing <span class="dim">_</span></div>
|
||||
|
||||
<!-- Driving -->
|
||||
<div class="state-panel mobile-driver-banner" id="panel-driving" role="dialog" hidden>
|
||||
<div class="overlay-card">
|
||||
<div class="overlay-header">
|
||||
<div class="phone-icon-badge tone-driving">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
|
||||
<path d="M12 18h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="overlay-copy">
|
||||
<div class="overlay-eyebrow tone-driving">
|
||||
<span class="live-dot" aria-hidden="true"></span>
|
||||
<span>From your phone</span>
|
||||
</div>
|
||||
<div class="overlay-title">Your phone is in control</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overlay-body">
|
||||
Desktop keyboard is paused. Take back this terminal to type here, take back all terminals your phone controls, or collapse to keep watching.
|
||||
</div>
|
||||
<div class="overlay-actions">
|
||||
<button data-slot="button" data-variant="outline" data-size="sm" type="button">Collapse</button>
|
||||
<button data-slot="button" data-variant="outline" data-size="sm" type="button">Take back all terminals</button>
|
||||
<button data-slot="button" data-variant="default" data-size="sm" type="button" class="is-focused">Take back this terminal</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Held -->
|
||||
<div class="state-panel mobile-driver-banner" id="panel-held" role="dialog">
|
||||
<div class="overlay-card">
|
||||
<div class="overlay-header">
|
||||
<div class="phone-icon-badge tone-held">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
|
||||
<path d="M12 18h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="overlay-copy">
|
||||
<div class="overlay-eyebrow tone-held">
|
||||
<span>From your phone</span>
|
||||
</div>
|
||||
<div class="overlay-title">Your phone left this at phone size</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overlay-body">
|
||||
Your phone session ended. Restore to desktop size for this terminal, or for all terminals your phone left at phone size.
|
||||
</div>
|
||||
<div class="overlay-actions">
|
||||
<button data-slot="button" data-variant="outline" data-size="sm" type="button">Restore all terminals</button>
|
||||
<button data-slot="button" data-variant="default" data-size="sm" type="button" class="is-focused">Restore this terminal</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chip -->
|
||||
<div class="state-panel mobile-driver-banner is-chip" id="panel-chip" hidden>
|
||||
<div class="lock-chip mobile-driver-banner">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect width="14" height="20" x="5" y="2" rx="2" ry="2" />
|
||||
<path d="M12 18h.01" />
|
||||
</svg>
|
||||
<button data-slot="button" data-variant="ghost" data-size="xs" type="button">Phone driving</button>
|
||||
<button data-slot="button" data-variant="default" data-size="xs" type="button">Take back</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const tabs = document.querySelectorAll('.state-tab')
|
||||
const panels = {
|
||||
driving: document.getElementById('panel-driving'),
|
||||
held: document.getElementById('panel-held'),
|
||||
chip: document.getElementById('panel-chip')
|
||||
}
|
||||
|
||||
function showState(state) {
|
||||
for (const [key, panel] of Object.entries(panels)) {
|
||||
panel.hidden = key !== state
|
||||
}
|
||||
tabs.forEach((tab) => {
|
||||
tab.setAttribute('aria-selected', tab.dataset.state === state ? 'true' : 'false')
|
||||
})
|
||||
}
|
||||
|
||||
tabs.forEach((tab) => {
|
||||
tab.addEventListener('click', () => showState(tab.dataset.state))
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable max-lines -- Why: this file groups every CLI browser-command test (page targeting, profiles, waits, viewport) so test-fixture imports and the runtime-client mock stay shared in one place. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable max-lines -- Why: file CLI coverage shares one mocked runtime setup across command contracts. */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
|
||||
@@ -434,6 +434,38 @@ describe('orca cli worktree awareness', () => {
|
||||
expect(logSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('resolves the invocation cwd from ORCA_CLI_CWD when no cwd is passed', async () => {
|
||||
// Why: the SSH relay bridge runs the CLI on the Orca host with the remote
|
||||
// shell's cwd carried in ORCA_CLI_CWD (#7716); cwd-based selectors must
|
||||
// resolve against it, not the host process cwd.
|
||||
process.env.ORCA_CLI_CWD = '/tmp/repo/feature/src'
|
||||
try {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
worktreeListFixture([
|
||||
buildWorktree('/tmp/repo', 'main'),
|
||||
buildWorktree('/tmp/repo/feature', 'feature/foo')
|
||||
]),
|
||||
okFixture('req_1', {
|
||||
worktree: {
|
||||
id: 'repo::/tmp/repo/feature',
|
||||
branch: 'feature/foo',
|
||||
path: '/tmp/repo/feature'
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['worktree', 'current', '--json'])
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', {
|
||||
worktree: 'id:repo::/tmp/repo/feature'
|
||||
})
|
||||
} finally {
|
||||
delete process.env.ORCA_CLI_CWD
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'prepares and starts Claude Agent Teams in the current Orca terminal',
|
||||
async () => {
|
||||
|
||||
+13
-1
@@ -25,7 +25,19 @@ function shouldIgnoreRemoteSelection(commandPath: string[]): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise<void> {
|
||||
// Why: the SSH relay bridge executes this CLI on the Orca host while the
|
||||
// caller's shell cwd lives on the remote machine (which cannot be chdir'd
|
||||
// into). ORCA_CLI_CWD carries that remote cwd so cwd-based selectors like
|
||||
// `--worktree active` resolve against the caller's directory.
|
||||
function resolveInvocationCwd(): string {
|
||||
const override = process.env.ORCA_CLI_CWD
|
||||
return typeof override === 'string' && override.length > 0 ? override : process.cwd()
|
||||
}
|
||||
|
||||
export async function main(
|
||||
argv = process.argv.slice(2),
|
||||
cwd = resolveInvocationCwd()
|
||||
): Promise<void> {
|
||||
if (argv[0] === 'agent-teams-tmux') {
|
||||
await runAgentTeamsTmuxShim(argv.slice(1))
|
||||
return
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable max-lines -- Why: this fixture verifies the shared remote hook installer fake across every managed agent so SSH regressions are caught together. */
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SFTPWrapper } from 'ssh2'
|
||||
|
||||
@@ -9,6 +8,7 @@ vi.mock('electron', () => ({
|
||||
}))
|
||||
|
||||
import { CodexHookService } from '../codex/hook-service'
|
||||
import { DroidHookService } from '../droid/hook-service'
|
||||
import { CursorHookService } from '../cursor/hook-service'
|
||||
import { CommandCodeHookService } from '../command-code/hook-service'
|
||||
import { GeminiHookService } from '../gemini/hook-service'
|
||||
@@ -21,6 +21,24 @@ import { HermesHookService } from '../hermes/hook-service'
|
||||
import { DevinHookService } from '../devin/hook-service'
|
||||
import { KimiHookService } from '../kimi/hook-service'
|
||||
import { openClaudeHookService } from '../openclaude/hook-service'
|
||||
import { ampHookService } from '../amp/hook-service'
|
||||
import { antigravityHookService } from '../antigravity/hook-service'
|
||||
import { claudeHookService } from '../claude/hook-service'
|
||||
import { codexHookService } from '../codex/hook-service'
|
||||
import { copilotHookService } from '../copilot/hook-service'
|
||||
import { cursorHookService } from '../cursor/hook-service'
|
||||
import { droidHookService } from '../droid/hook-service'
|
||||
import { commandCodeHookService } from '../command-code/hook-service'
|
||||
import { geminiHookService } from '../gemini/hook-service'
|
||||
import { devinHookService } from '../devin/hook-service'
|
||||
import { grokHookService } from '../grok/hook-service'
|
||||
import { hermesHookService } from '../hermes/hook-service'
|
||||
import { kimiHookService } from '../kimi/hook-service'
|
||||
import { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-controls'
|
||||
import {
|
||||
installRemoteManagedAgentHooks,
|
||||
REMOTE_MANAGED_HOOK_INSTALLER_AGENTS
|
||||
} from './remote-managed-hook-installers'
|
||||
|
||||
type FakeFs = {
|
||||
files: Map<string, string>
|
||||
@@ -167,6 +185,10 @@ describe('remote hook service installers', () => {
|
||||
{
|
||||
path: '/home/dev/.orca/agent-hooks/devin-hook.sh',
|
||||
install: (sftp: SFTPWrapper) => new DevinHookService().installRemote(sftp, '/home/dev')
|
||||
},
|
||||
{
|
||||
path: '/home/dev/.orca/agent-hooks/droid-hook.sh',
|
||||
install: (sftp: SFTPWrapper) => new DroidHookService().installRemote(sftp, '/home/dev')
|
||||
}
|
||||
]
|
||||
|
||||
@@ -583,6 +605,107 @@ describe('remote hook service installers', () => {
|
||||
expect(fs.modes.get('/home/dev/.orca/agent-hooks/copilot-hook.sh')).toBe(0o755)
|
||||
})
|
||||
|
||||
// Why: Droid (and Copilot) each shipped a working installRemote but were never
|
||||
// registered in REMOTE_MANAGED_HOOK_INSTALLERS, so their status silently never
|
||||
// appeared over SSH (issue #7253). Guard the whole bug class, not one agent:
|
||||
// every locally-managed hook service that implements installRemote MUST be
|
||||
// wired into the remote installer.
|
||||
it('registers every managed agent that implements installRemote in the remote installer (issue #7253)', () => {
|
||||
const servicesByAgent = new Map<string, { installRemote?: unknown }>([
|
||||
['claude', claudeHookService],
|
||||
['openclaude', openClaudeHookService],
|
||||
['codex', codexHookService],
|
||||
['gemini', geminiHookService],
|
||||
['antigravity', antigravityHookService],
|
||||
['amp', ampHookService],
|
||||
['cursor', cursorHookService],
|
||||
['droid', droidHookService],
|
||||
['command-code', commandCodeHookService],
|
||||
['grok', grokHookService],
|
||||
['copilot', copilotHookService],
|
||||
['hermes', hermesHookService],
|
||||
['devin', devinHookService],
|
||||
['kimi', kimiHookService]
|
||||
])
|
||||
|
||||
// Guard against a service silently missing from the map above as new agents land.
|
||||
for (const [agent] of MANAGED_AGENT_HOOK_INSTALLERS) {
|
||||
expect(servicesByAgent.has(agent)).toBe(true)
|
||||
}
|
||||
|
||||
const registered = new Set<string>(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS)
|
||||
const missing: string[] = []
|
||||
for (const [agent, service] of servicesByAgent) {
|
||||
if (typeof service.installRemote === 'function' && !registered.has(agent)) {
|
||||
missing.push(agent)
|
||||
}
|
||||
}
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
|
||||
it('installs Droid and Copilot when running the aggregate remote installer (issue #7253)', async () => {
|
||||
const { sftp } = createFakeSftp()
|
||||
const results = await installRemoteManagedAgentHooks(sftp, '/home/dev')
|
||||
const byAgent = new Map(results.map((r) => [r.agent, r.state]))
|
||||
expect(byAgent.get('droid')).toBe('installed')
|
||||
expect(byAgent.get('copilot')).toBe('installed')
|
||||
})
|
||||
|
||||
it('installs remote Droid hooks into Factory settings.json (issue #7253)', async () => {
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
|
||||
const status = await new DroidHookService().installRemote(sftp, '/home/dev')
|
||||
|
||||
expect(status.state).toBe('installed')
|
||||
expect(status.configPath).toBe('/home/dev/.factory/settings.json')
|
||||
const config = JSON.parse(fs.files.get('/home/dev/.factory/settings.json')!) as {
|
||||
hooks: Record<string, { matcher?: string; hooks?: { command: string }[] }[]>
|
||||
}
|
||||
for (const eventName of [
|
||||
'SessionStart',
|
||||
'UserPromptSubmit',
|
||||
'Stop',
|
||||
'SubagentStop',
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PermissionRequest',
|
||||
'Notification'
|
||||
]) {
|
||||
const definition = config.hooks[eventName]?.[0]
|
||||
const command = definition?.hooks?.[0]?.command
|
||||
expect(command).toContain('/home/dev/.orca/agent-hooks/droid-hook.sh')
|
||||
expect(command).toMatch(/^if \[ -x /)
|
||||
}
|
||||
// Tool/permission events carry a `*` matcher; lifecycle events do not.
|
||||
expect(config.hooks.PreToolUse?.[0]?.matcher).toBe('*')
|
||||
expect(config.hooks.PostToolUse?.[0]?.matcher).toBe('*')
|
||||
expect(config.hooks.PermissionRequest?.[0]?.matcher).toBe('*')
|
||||
expect(config.hooks.Stop?.[0]?.matcher).toBeUndefined()
|
||||
const script = fs.files.get('/home/dev/.orca/agent-hooks/droid-hook.sh')
|
||||
expect(script).toContain('#!/bin/sh')
|
||||
expect(script).toContain('/hook/droid')
|
||||
expect(fs.modes.get('/home/dev/.orca/agent-hooks/droid-hook.sh')).toBe(0o755)
|
||||
})
|
||||
|
||||
it('does not overwrite a malformed remote Factory settings.json', async () => {
|
||||
const original = '{"hooks": }'
|
||||
const { sftp, fs } = createFakeSftp({
|
||||
'/home/dev/.factory/settings.json': original
|
||||
})
|
||||
|
||||
const status = await new DroidHookService().installRemote(sftp, '/home/dev')
|
||||
|
||||
expect(status).toMatchObject({
|
||||
agent: 'droid',
|
||||
state: 'error',
|
||||
configPath: '/home/dev/.factory/settings.json',
|
||||
managedHooksPresent: false,
|
||||
detail: 'Could not parse remote Factory settings.json'
|
||||
})
|
||||
expect(fs.files.get('/home/dev/.factory/settings.json')).toBe(original)
|
||||
expect(fs.files.get('/home/dev/.orca/agent-hooks/droid-hook.sh')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('installs remote Hermes plugin files and enables the plugin', async () => {
|
||||
const { sftp, fs } = createFakeSftp()
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import { geminiHookService } from '../gemini/hook-service'
|
||||
import { antigravityHookService } from '../antigravity/hook-service'
|
||||
import { cursorHookService } from '../cursor/hook-service'
|
||||
import { commandCodeHookService } from '../command-code/hook-service'
|
||||
import { copilotHookService } from '../copilot/hook-service'
|
||||
import { devinHookService } from '../devin/hook-service'
|
||||
import { droidHookService } from '../droid/hook-service'
|
||||
import { grokHookService } from '../grok/hook-service'
|
||||
import { hermesHookService } from '../hermes/hook-service'
|
||||
import { kimiHookService } from '../kimi/hook-service'
|
||||
@@ -27,12 +29,20 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [
|
||||
['amp', (sftp, remoteHome) => ampHookService.installRemote(sftp, remoteHome)],
|
||||
['cursor', (sftp, remoteHome) => cursorHookService.installRemote(sftp, remoteHome)],
|
||||
['command-code', (sftp, remoteHome) => commandCodeHookService.installRemote(sftp, remoteHome)],
|
||||
['copilot', (sftp, remoteHome) => copilotHookService.installRemote(sftp, remoteHome)],
|
||||
['grok', (sftp, remoteHome) => grokHookService.installRemote(sftp, remoteHome)],
|
||||
['droid', (sftp, remoteHome) => droidHookService.installRemote(sftp, remoteHome)],
|
||||
['hermes', (sftp, remoteHome) => hermesHookService.installRemote(sftp, remoteHome)],
|
||||
['devin', (sftp, remoteHome) => devinHookService.installRemote(sftp, remoteHome)],
|
||||
['kimi', (sftp, remoteHome) => kimiHookService.installRemote(sftp, remoteHome)]
|
||||
]
|
||||
|
||||
/** Agents wired into the remote (SSH) hook installer. Exported so an invariant
|
||||
* test can assert every locally-managed agent that implements `installRemote`
|
||||
* is registered here — the omission that hid Droid/Copilot status over SSH. */
|
||||
export const REMOTE_MANAGED_HOOK_INSTALLER_AGENTS: readonly AgentHookInstallStatus['agent'][] =
|
||||
REMOTE_MANAGED_HOOK_INSTALLERS.map(([agent]) => agent)
|
||||
|
||||
export async function installRemoteManagedAgentHooks(
|
||||
sftp: SFTPWrapper,
|
||||
remoteHome: string
|
||||
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { AgentHookServer, agentHookServer, _internals } from './server'
|
||||
import {
|
||||
AgentHookServer,
|
||||
agentHookServer,
|
||||
CLOSED_AGENT_STATUS_TAB_IDS_MAX,
|
||||
_internals
|
||||
} from './server'
|
||||
import {
|
||||
AGENT_STATUS_MAX_FIELD_LENGTH,
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
@@ -6848,3 +6853,23 @@ describe('AgentHookServer ingestTerminalStatus', () => {
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentHookServer closed-tab suppression bound', () => {
|
||||
it('bounds closedAgentStatusTabIds with LRU eviction as tabs close', () => {
|
||||
const server = new AgentHookServer()
|
||||
const internals = server as unknown as {
|
||||
markTabClosedForAgentStatus: (tabId: string) => void
|
||||
closedAgentStatusTabIds: Set<string>
|
||||
}
|
||||
|
||||
const total = CLOSED_AGENT_STATUS_TAB_IDS_MAX + 200
|
||||
for (let i = 0; i < total; i += 1) {
|
||||
internals.markTabClosedForAgentStatus(`closed-tab-${i}`)
|
||||
}
|
||||
|
||||
// Set stays bounded; oldest ids are evicted, most-recent are retained.
|
||||
expect(internals.closedAgentStatusTabIds.size).toBe(CLOSED_AGENT_STATUS_TAB_IDS_MAX)
|
||||
expect(internals.closedAgentStatusTabIds.has('closed-tab-0')).toBe(false)
|
||||
expect(internals.closedAgentStatusTabIds.has(`closed-tab-${total - 1}`)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -119,6 +119,12 @@ const AGENT_PROMPT_SENT_AGENT_KINDS = new Set<AgentKind>(AGENT_KIND_VALUES)
|
||||
// not resurrect on hydrate.
|
||||
const HYDRATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
// Why: closed-tab suppression only needs to cover recently closed tabs — a
|
||||
// status event for a long-closed tab cannot arrive once its process/hooks are
|
||||
// gone. Bound the set so it can't grow one entry per tab close for the whole
|
||||
// session (it is otherwise only cleared at app quit).
|
||||
export const CLOSED_AGENT_STATUS_TAB_IDS_MAX = 1024
|
||||
|
||||
type LastStatusFile = {
|
||||
version: number
|
||||
entries: Record<string, EnrichedAgentHookEventPayload>
|
||||
@@ -596,7 +602,17 @@ export class AgentHookServer {
|
||||
}
|
||||
|
||||
private markTabClosedForAgentStatus(tabId: string): void {
|
||||
// Delete-then-add keeps recently closed tabs most-recent so eviction only
|
||||
// sheds the oldest ids, which can no longer receive status events.
|
||||
this.closedAgentStatusTabIds.delete(tabId)
|
||||
this.closedAgentStatusTabIds.add(tabId)
|
||||
while (this.closedAgentStatusTabIds.size > CLOSED_AGENT_STATUS_TAB_IDS_MAX) {
|
||||
const oldest = this.closedAgentStatusTabIds.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
this.closedAgentStatusTabIds.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
private shouldSuppressClosedTabStatus(paneKey: string): boolean {
|
||||
|
||||
@@ -5,17 +5,15 @@ import { parseCodexSessionContent } from './session-scanner-codex-parser'
|
||||
import { parseDevinSessionContent } from './session-scanner-devin-parser'
|
||||
import { parseDroidSessionContent } from './session-scanner-droid-parser'
|
||||
import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers'
|
||||
import {
|
||||
parseClaudeSessionContent,
|
||||
parseGeminiSessionContent
|
||||
} from './session-scanner-primary-parsers'
|
||||
import { parseClaudeSessionContent } from './session-scanner-primary-parsers'
|
||||
import { parseGeminiSessionContent } from './session-scanner-gemini-parsers'
|
||||
import {
|
||||
parseCopilotSessionContent,
|
||||
parseCursorSessionContent,
|
||||
parseHermesSessionContent
|
||||
} from './session-scanner-secondary-parsers'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import { normalizePiSessionsDir } from './session-scanner-values'
|
||||
import { normalizeAgentSessionsDir } from './session-scanner-values'
|
||||
import { remoteCodexIndexTitles } from './remote-session-scanner-codex-index'
|
||||
import type {
|
||||
RemoteParserOptions,
|
||||
@@ -36,13 +34,19 @@ export function remoteSessionSources(
|
||||
): RemoteSessionSource[] {
|
||||
return [
|
||||
...remoteCodexSources(remoteHome, hostPlatform),
|
||||
jsonlSource(
|
||||
'claude',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.claude', 'projects'],
|
||||
parseClaudeSessionContent
|
||||
),
|
||||
{
|
||||
...jsonlSource(
|
||||
'claude',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.claude', 'projects'],
|
||||
parseClaudeSessionContent
|
||||
),
|
||||
// The remote host owns the transcript disk, so the local readdir in the
|
||||
// Claude parser is skipped; the walked listing supplies the sibling
|
||||
// subagent counts instead.
|
||||
collectSubagentSiblingCounts: true
|
||||
},
|
||||
source(
|
||||
'gemini',
|
||||
remoteHome,
|
||||
@@ -83,6 +87,7 @@ export function remoteSessionSources(
|
||||
parseDevinSessionContent
|
||||
),
|
||||
jsonlSource('pi', remoteHome, hostPlatform, remotePiSessionsSegments(), piParser),
|
||||
jsonlSource('omp', remoteHome, hostPlatform, remoteOmpSessionsSegments(), ompParser),
|
||||
jsonlSource(
|
||||
'droid',
|
||||
remoteHome,
|
||||
@@ -203,6 +208,15 @@ function piParser(
|
||||
return parseMessageGraphSessionContent('pi', file, content, platform, options)
|
||||
}
|
||||
|
||||
function ompParser(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('omp', file, content, platform, options)
|
||||
}
|
||||
|
||||
function openClawParser(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
@@ -217,5 +231,9 @@ function remotePathSegments(path: string): string[] {
|
||||
}
|
||||
|
||||
function remotePiSessionsSegments(): string[] {
|
||||
return normalizePiSessionsDir('/.pi/agent/sessions').split('/').filter(Boolean)
|
||||
return normalizeAgentSessionsDir('/.pi/agent/sessions', '.pi').split('/').filter(Boolean)
|
||||
}
|
||||
|
||||
function remoteOmpSessionsSegments(): string[] {
|
||||
return normalizeAgentSessionsDir('/.omp/agent/sessions', '.omp').split('/').filter(Boolean)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ export type RemoteSessionSource = {
|
||||
rootDir: string
|
||||
extensions: readonly string[]
|
||||
filePredicate?: (path: string) => boolean
|
||||
// Claude layout: count `<session>/subagents/*.jsonl` siblings from the walked
|
||||
// listing and drop them from candidates instead of indexing them as sessions.
|
||||
collectSubagentSiblingCounts?: boolean
|
||||
parse: (
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
@@ -31,4 +34,5 @@ export type RemoteSessionSource = {
|
||||
export type RemoteSessionCandidate = {
|
||||
source: RemoteSessionSource
|
||||
file: FileWithMtime
|
||||
subagentTranscriptCount?: number
|
||||
}
|
||||
|
||||
@@ -201,6 +201,79 @@ describe('scanRemoteAiVaultSessions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('counts remote sibling subagent transcripts for zero-turn Claude sessions', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.claude/projects/repo/lost-session.jsonl',
|
||||
jsonLines([{ type: 'mode', mode: 'default', sessionId: 'lost-session' }]),
|
||||
50
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.claude/projects/repo/lost-session/subagents/agent-a.jsonl',
|
||||
jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask A' } }]),
|
||||
51
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.claude/projects/repo/lost-session/subagents/agent-b.jsonl',
|
||||
jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask B' } }]),
|
||||
52
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
// Subagent transcripts must not surface as standalone sessions; they only
|
||||
// contribute recoverable signal to their zero-turn parent.
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
agent: 'claude',
|
||||
sessionId: 'lost-session',
|
||||
messageCount: 0,
|
||||
subagentTranscriptCount: 2,
|
||||
filePath: '/home/ada/.claude/projects/repo/lost-session.jsonl'
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores remote subagent siblings for Claude sessions with real turns', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
'/home/ada/.claude/projects/repo/live-session.jsonl',
|
||||
jsonLines([
|
||||
{
|
||||
sessionId: 'live-session',
|
||||
type: 'user',
|
||||
message: { content: [{ type: 'text', text: 'Do the thing' }] }
|
||||
}
|
||||
]),
|
||||
60
|
||||
)
|
||||
provider.addFile(
|
||||
'/home/ada/.claude/projects/repo/live-session/subagents/agent-a.jsonl',
|
||||
jsonLines([{ type: 'user', message: { role: 'user', content: 'Subtask' } }]),
|
||||
61
|
||||
)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
sessionId: 'live-session',
|
||||
messageCount: 1,
|
||||
subagentTranscriptCount: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('builds resume commands with the remote host platform', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
provider.addFile(
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { FileStat, IFilesystemProvider } from '../providers/types'
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import { sessionSortTime } from './session-scanner-accumulator'
|
||||
import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import { errorMessage } from './session-scanner-values'
|
||||
import { remoteSessionSources } from './remote-session-scanner-sources'
|
||||
@@ -77,11 +78,15 @@ async function discoverRemoteSourceCandidates(args: {
|
||||
context: RemoteScannerContext
|
||||
issues: AiVaultScanIssue[]
|
||||
}): Promise<RemoteSessionCandidate[]> {
|
||||
const paths = await walkRemoteSessionFiles(
|
||||
const walked = await walkRemoteSessionFiles(
|
||||
args.source,
|
||||
args.context.provider,
|
||||
args.context.hostPlatform
|
||||
)
|
||||
const partition = args.source.collectSubagentSiblingCounts
|
||||
? partitionSubagentTranscriptPaths(walked)
|
||||
: null
|
||||
const paths = partition ? partition.sessionFilePaths : walked
|
||||
const files = await mapRemoteScanConcurrently(paths, (path) =>
|
||||
statRemoteFile(
|
||||
args.context.provider,
|
||||
@@ -93,7 +98,11 @@ async function discoverRemoteSourceCandidates(args: {
|
||||
)
|
||||
return files
|
||||
.filter((file): file is FileWithMtime => Boolean(file))
|
||||
.map((file) => ({ source: args.source, file }))
|
||||
.map((file) => ({
|
||||
source: args.source,
|
||||
file,
|
||||
subagentTranscriptCount: partition?.subagentTranscriptCounts.get(file.path) ?? 0
|
||||
}))
|
||||
}
|
||||
|
||||
async function walkRemoteSessionFiles(
|
||||
@@ -199,7 +208,14 @@ async function parseRemoteSessionCandidate(
|
||||
if (read.isBinary) {
|
||||
return null
|
||||
}
|
||||
return candidate.source.parse(candidate.file, read.content, context)
|
||||
const session = await candidate.source.parse(candidate.file, read.content, context)
|
||||
// Mirror the local zero-turn-only rule: sibling subagent transcripts are
|
||||
// recoverable signal only when the parent conversation persisted no turns.
|
||||
const subagentTranscriptCount = candidate.subagentTranscriptCount ?? 0
|
||||
if (session && session.messageCount === 0 && subagentTranscriptCount > 0) {
|
||||
return { ...session, subagentTranscriptCount }
|
||||
}
|
||||
return session
|
||||
} catch (err) {
|
||||
issues.push({
|
||||
executionHostId: context.executionHostId,
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
callRuntimeEnvironment: vi.fn(),
|
||||
listEnvironments: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../shared/runtime-environment-store', () => ({
|
||||
listEnvironments: mocks.listEnvironments
|
||||
}))
|
||||
|
||||
vi.mock('../ipc/runtime-environment-transport-routing', () => ({
|
||||
callRuntimeEnvironment: mocks.callRuntimeEnvironment
|
||||
}))
|
||||
|
||||
const { getSavedRuntimeAiVaultHostInfos, scanRuntimeAiVaultSessions } =
|
||||
await import('./runtime-session-scanner')
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.listEnvironments.mockReturnValue([])
|
||||
mocks.callRuntimeEnvironment.mockResolvedValue({
|
||||
ok: true,
|
||||
result: result([session('runtime:env-1', 'session-1')])
|
||||
})
|
||||
})
|
||||
|
||||
describe('runtime AI Vault session scanner', () => {
|
||||
it('lists saved runtime environments as runtime execution hosts', () => {
|
||||
mocks.listEnvironments.mockReturnValue([
|
||||
{ id: 'env-1', name: 'Server 1' },
|
||||
{ id: 'env-2', name: 'Server 2' }
|
||||
])
|
||||
|
||||
expect(getSavedRuntimeAiVaultHostInfos('/user-data')).toEqual([
|
||||
{ environmentId: 'env-1', executionHostId: 'runtime:env-1' },
|
||||
{ environmentId: 'env-2', executionHostId: 'runtime:env-2' }
|
||||
])
|
||||
})
|
||||
|
||||
it('forwards scan options to the runtime transport', async () => {
|
||||
await scanRuntimeAiVaultSessions(
|
||||
'/user-data',
|
||||
'env-1',
|
||||
{
|
||||
limit: 25,
|
||||
force: true,
|
||||
scopePaths: ['/srv/app']
|
||||
},
|
||||
{ timeoutMs: 3000 }
|
||||
)
|
||||
|
||||
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
|
||||
'/user-data',
|
||||
'env-1',
|
||||
'aiVault.listSessions',
|
||||
{
|
||||
limit: 25,
|
||||
force: true,
|
||||
scopePaths: ['/srv/app'],
|
||||
executionHostId: 'runtime:env-1'
|
||||
},
|
||||
3000
|
||||
)
|
||||
})
|
||||
|
||||
it('stamps sessions and issues returned for a different execution host', async () => {
|
||||
mocks.callRuntimeEnvironment.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: result(
|
||||
[session('local', 'session-1')],
|
||||
[
|
||||
{
|
||||
executionHostId: 'ssh:dev-box',
|
||||
agent: 'codex',
|
||||
path: '/sessions/session-1.jsonl',
|
||||
message: 'could not parse session'
|
||||
}
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
const scanResult = await scanRuntimeAiVaultSessions('/user-data', 'env-1', {})
|
||||
|
||||
expect(scanResult.sessions).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'runtime:env-1:codex:session-1:/sessions/session-1.jsonl',
|
||||
executionHostId: 'runtime:env-1'
|
||||
})
|
||||
])
|
||||
expect(scanResult.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
executionHostId: 'runtime:env-1',
|
||||
agent: 'codex',
|
||||
path: '/sessions/session-1.jsonl'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('stamps accepted sessions with the requested runtime host', async () => {
|
||||
const scanResult = await scanRuntimeAiVaultSessions('/user-data', 'env-1', {})
|
||||
|
||||
expect(scanResult.sessions).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'runtime:env-1:codex:session-1:/sessions/session-1.jsonl',
|
||||
executionHostId: 'runtime:env-1'
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function result(
|
||||
sessions: AiVaultSession[],
|
||||
issues: AiVaultListResult['issues'] = []
|
||||
): AiVaultListResult {
|
||||
return { sessions, issues, scannedAt: '2026-07-04T00:00:00.000Z' }
|
||||
}
|
||||
|
||||
function session(
|
||||
executionHostId: AiVaultSession['executionHostId'],
|
||||
sessionId: string
|
||||
): AiVaultSession {
|
||||
return {
|
||||
id: `${executionHostId}:codex:${sessionId}:/sessions/${sessionId}.jsonl`,
|
||||
executionHostId,
|
||||
executionHostPlatform: 'linux',
|
||||
agent: 'codex',
|
||||
sessionId,
|
||||
title: sessionId,
|
||||
cwd: '/srv/app',
|
||||
branch: null,
|
||||
model: null,
|
||||
filePath: `/sessions/${sessionId}.jsonl`,
|
||||
codexHome: null,
|
||||
createdAt: null,
|
||||
updatedAt: '2026-07-04T03:00:00.000Z',
|
||||
modifiedAt: '2026-07-04T00:00:00.000Z',
|
||||
messageCount: 1,
|
||||
totalTokens: 0,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
resumeCommand: `codex resume ${sessionId}`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
AI_VAULT_AGENTS,
|
||||
type AiVaultListArgs,
|
||||
type AiVaultListResult,
|
||||
type AiVaultSession
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { normalizeExecutionHostId, toRuntimeExecutionHostId } from '../../shared/execution-host'
|
||||
import { listEnvironments } from '../../shared/runtime-environment-store'
|
||||
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
|
||||
|
||||
export type RuntimeAiVaultHostInfo = {
|
||||
environmentId: string
|
||||
executionHostId: `runtime:${string}`
|
||||
}
|
||||
|
||||
export type RuntimeAiVaultScanOptions = {
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
const nodePlatformSchema = z.enum([
|
||||
'aix',
|
||||
'android',
|
||||
'darwin',
|
||||
'freebsd',
|
||||
'haiku',
|
||||
'linux',
|
||||
'openbsd',
|
||||
'sunos',
|
||||
'win32',
|
||||
'cygwin',
|
||||
'netbsd'
|
||||
] satisfies NodeJS.Platform[])
|
||||
|
||||
const aiVaultSessionPreviewMessageSchema = z.object({
|
||||
role: z.enum(['user', 'assistant', 'system', 'tool', 'unknown']),
|
||||
text: z.string(),
|
||||
timestamp: z.string().nullable()
|
||||
})
|
||||
|
||||
const executionHostIdSchema = z.string().transform((value, ctx) => {
|
||||
const normalized = normalizeExecutionHostId(value)
|
||||
if (normalized) {
|
||||
return normalized
|
||||
}
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'Invalid execution host id'
|
||||
})
|
||||
return z.NEVER
|
||||
})
|
||||
|
||||
const aiVaultListResultSchema = z.object({
|
||||
sessions: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
executionHostId: executionHostIdSchema,
|
||||
executionHostPlatform: nodePlatformSchema.nullable().optional(),
|
||||
agent: z.enum(AI_VAULT_AGENTS),
|
||||
sessionId: z.string(),
|
||||
title: z.string(),
|
||||
cwd: z.string().nullable(),
|
||||
branch: z.string().nullable(),
|
||||
model: z.string().nullable(),
|
||||
filePath: z.string(),
|
||||
codexHome: z.string().nullable(),
|
||||
createdAt: z.string().nullable(),
|
||||
updatedAt: z.string().nullable(),
|
||||
modifiedAt: z.string(),
|
||||
messageCount: z.number(),
|
||||
totalTokens: z.number(),
|
||||
previewMessages: z.array(aiVaultSessionPreviewMessageSchema),
|
||||
// Default keeps remote hosts running an older build (no recoverable-signal
|
||||
// fields) parseable; they simply report no recoverable-empty sessions.
|
||||
queuedMessageCount: z.number().default(0),
|
||||
subagentTranscriptCount: z.number().default(0),
|
||||
resumeCommand: z.string()
|
||||
})
|
||||
),
|
||||
issues: z.array(
|
||||
z.object({
|
||||
executionHostId: executionHostIdSchema.optional(),
|
||||
agent: z.enum(AI_VAULT_AGENTS),
|
||||
path: z.string(),
|
||||
message: z.string()
|
||||
})
|
||||
),
|
||||
scannedAt: z.string()
|
||||
})
|
||||
|
||||
export function getSavedRuntimeAiVaultHostInfos(
|
||||
userDataPath: string
|
||||
): readonly RuntimeAiVaultHostInfo[] {
|
||||
return listEnvironments(userDataPath).map((environment) => ({
|
||||
environmentId: environment.id,
|
||||
executionHostId: toRuntimeExecutionHostId(environment.id)
|
||||
}))
|
||||
}
|
||||
|
||||
export async function scanRuntimeAiVaultSessions(
|
||||
userDataPath: string,
|
||||
environmentId: string,
|
||||
args: AiVaultListArgs,
|
||||
options: RuntimeAiVaultScanOptions = {}
|
||||
): Promise<AiVaultListResult> {
|
||||
const executionHostId = toRuntimeExecutionHostId(environmentId)
|
||||
const response = await callRuntimeEnvironment(
|
||||
userDataPath,
|
||||
environmentId,
|
||||
'aiVault.listSessions',
|
||||
{
|
||||
limit: args.limit,
|
||||
force: args.force,
|
||||
scopePaths: args.scopePaths,
|
||||
executionHostId
|
||||
},
|
||||
options.timeoutMs
|
||||
)
|
||||
if (response.ok === true) {
|
||||
const parsed = aiVaultListResultSchema.safeParse(response.result)
|
||||
if (parsed.success) {
|
||||
return withRuntimeExecutionHost(parsed.data, executionHostId)
|
||||
}
|
||||
return runtimeScanIssueResult({
|
||||
executionHostId,
|
||||
environmentId,
|
||||
message: `Invalid aiVault.listSessions response: ${
|
||||
parsed.error.issues[0]?.message ?? 'unexpected result shape'
|
||||
}`
|
||||
})
|
||||
}
|
||||
return runtimeScanIssueResult({
|
||||
executionHostId,
|
||||
environmentId,
|
||||
message: response.error.message
|
||||
})
|
||||
}
|
||||
|
||||
function withRuntimeExecutionHost(
|
||||
result: AiVaultListResult,
|
||||
executionHostId: `runtime:${string}`
|
||||
): AiVaultListResult {
|
||||
return {
|
||||
sessions: result.sessions.map((session) => retagRuntimeSession(session, executionHostId)),
|
||||
issues: result.issues.map((issue) => ({ ...issue, executionHostId })),
|
||||
scannedAt: result.scannedAt
|
||||
}
|
||||
}
|
||||
|
||||
function retagRuntimeSession(
|
||||
session: AiVaultSession,
|
||||
executionHostId: `runtime:${string}`
|
||||
): AiVaultSession {
|
||||
// The paired server is the transport, but the parent owns which concrete
|
||||
// runtime host was scanned; never trust returned host ids across the boundary.
|
||||
return {
|
||||
...session,
|
||||
executionHostId,
|
||||
id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}`
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeScanIssueResult(args: {
|
||||
executionHostId: `runtime:${string}`
|
||||
environmentId: string
|
||||
message: string
|
||||
}): AiVaultListResult {
|
||||
return {
|
||||
sessions: [],
|
||||
issues: [
|
||||
{
|
||||
executionHostId: args.executionHostId,
|
||||
agent: 'codex',
|
||||
path: args.environmentId,
|
||||
message: args.message
|
||||
}
|
||||
],
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type {
|
||||
AiVaultListResult,
|
||||
AiVaultScanIssue,
|
||||
AiVaultSession
|
||||
} from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import { sessionSortTime } from './session-scanner-accumulator'
|
||||
|
||||
export function aiVaultScanIssueResult(args: {
|
||||
executionHostId?: ExecutionHostId
|
||||
path: string
|
||||
message: string
|
||||
}): AiVaultListResult {
|
||||
return {
|
||||
sessions: [],
|
||||
issues: [
|
||||
{
|
||||
...(args.executionHostId ? { executionHostId: args.executionHostId } : {}),
|
||||
agent: 'codex',
|
||||
path: args.path,
|
||||
message: args.message
|
||||
}
|
||||
],
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeAiVaultListResults(
|
||||
results: readonly AiVaultListResult[],
|
||||
rawLimit: number | undefined
|
||||
): AiVaultListResult {
|
||||
const limit = rawLimit && rawLimit > 0 ? Math.floor(rawLimit) : 1000
|
||||
const byId = new Map<string, AiVaultSession>()
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
for (const result of results) {
|
||||
for (const session of result.sessions) {
|
||||
byId.set(session.id, session)
|
||||
}
|
||||
issues.push(...result.issues)
|
||||
}
|
||||
return {
|
||||
sessions: [...byId.values()]
|
||||
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
|
||||
.slice(0, limit),
|
||||
issues,
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
type AiVaultSessionPreviewMessage
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import type {
|
||||
FileWithMtime,
|
||||
ResumableSessionParseState,
|
||||
SessionAccumulator
|
||||
} from './session-scanner-types'
|
||||
import {
|
||||
extractPreviewContentText,
|
||||
extractString,
|
||||
@@ -37,10 +41,37 @@ export function createAccumulator(args: {
|
||||
messageCount: 0,
|
||||
totalTokens: 0,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
latestTimestampMs: 0
|
||||
}
|
||||
}
|
||||
|
||||
export function cloneSessionAccumulator(accumulator: SessionAccumulator): SessionAccumulator {
|
||||
return { ...accumulator, previewMessages: [...accumulator.previewMessages] }
|
||||
}
|
||||
|
||||
// Resumable fold for parsers whose only parse state is the accumulator itself
|
||||
// (cursor, copilot, droid, openclaw/pi, gemini-jsonl). Parsers with extra
|
||||
// closure state (claude, codex) build their own ResumableSessionParseState.
|
||||
export function accumulatorFoldResumeState(
|
||||
accumulator: SessionAccumulator,
|
||||
consumeRecordLine: (accumulator: SessionAccumulator, line: string) => void
|
||||
): ResumableSessionParseState {
|
||||
return {
|
||||
consumeLine: (line) => consumeRecordLine(accumulator, line),
|
||||
clone: () =>
|
||||
accumulatorFoldResumeState(cloneSessionAccumulator(accumulator), consumeRecordLine),
|
||||
touchFile: (file) => {
|
||||
accumulator.modifiedAt = file.modifiedAt
|
||||
},
|
||||
// Finalize a snapshot: the live accumulator (and its preview array) keeps
|
||||
// accumulating appended lines after this session object is handed out.
|
||||
finalize: (platform, options) =>
|
||||
finalizeSession(cloneSessionAccumulator(accumulator), platform, options)
|
||||
}
|
||||
}
|
||||
|
||||
export function finalizeSession(
|
||||
accumulator: SessionAccumulator,
|
||||
platform: NodeJS.Platform,
|
||||
@@ -81,9 +112,12 @@ export function finalizeSession(
|
||||
messageCount: accumulator.messageCount,
|
||||
totalTokens: accumulator.totalTokens,
|
||||
previewMessages: accumulator.previewMessages,
|
||||
queuedMessageCount: accumulator.queuedMessageCount,
|
||||
subagentTranscriptCount: accumulator.subagentTranscriptCount,
|
||||
resumeCommand: buildAiVaultResumeCommand({
|
||||
agent: accumulator.agent,
|
||||
sessionId,
|
||||
resumeFilePath: accumulator.filePath,
|
||||
cwd: accumulator.cwd,
|
||||
platform,
|
||||
codexHome: options.codexHome
|
||||
|
||||
@@ -6,7 +6,8 @@ import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-sc
|
||||
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
|
||||
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
|
||||
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
|
||||
import { parseClaudeSessionFile, parseGeminiSessionFile } from './session-scanner-primary-parsers'
|
||||
import { parseClaudeSessionFile } from './session-scanner-primary-parsers'
|
||||
import { parseGeminiSessionFile } from './session-scanner-gemini-parsers'
|
||||
import { parseCodexSessionFile } from './session-scanner-codex-parser'
|
||||
import {
|
||||
parseCopilotSessionFile,
|
||||
@@ -64,6 +65,8 @@ export async function parseAgentSessionFile(
|
||||
return parseMessageGraphSessionFile('openclaw', candidate.file, platform)
|
||||
case 'pi':
|
||||
return parseMessageGraphSessionFile('pi', candidate.file, platform)
|
||||
case 'omp':
|
||||
return parseMessageGraphSessionFile('omp', candidate.file, platform)
|
||||
case 'droid':
|
||||
return parseDroidSessionFile(candidate.file, platform)
|
||||
case 'devin':
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { IncrementalAgentFixture } from './session-scanner-incremental-fixtures'
|
||||
|
||||
// Codex fixture lines for the incremental-parse differential tests, split out
|
||||
// of session-scanner-incremental-fixtures.ts to respect the max-lines budget.
|
||||
|
||||
const CODEX_SESSION_ID = '019f0000-1111-7222-8333-444444444444'
|
||||
|
||||
function codexLine(record: Record<string, unknown>): string {
|
||||
return JSON.stringify(record)
|
||||
}
|
||||
|
||||
export function codexFixture(): IncrementalAgentFixture {
|
||||
return {
|
||||
agent: 'codex',
|
||||
fileName: `rollout-2026-05-01T10-00-00-${CODEX_SESSION_ID}.jsonl`,
|
||||
seedLines: [
|
||||
codexLine({
|
||||
timestamp: '2026-05-01T10:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: CODEX_SESSION_ID, cwd: '/repo/app', git: { branch: 'feature/vault' } }
|
||||
}),
|
||||
codexLine({
|
||||
timestamp: '2026-05-01T10:00:05.000Z',
|
||||
type: 'response_item',
|
||||
payload: { type: 'message', role: 'user', content: 'codex seed question' }
|
||||
}),
|
||||
codexLine({
|
||||
timestamp: '2026-05-01T10:00:10.000Z',
|
||||
type: 'response_item',
|
||||
payload: { type: 'message', role: 'assistant', content: 'codex seed answer' }
|
||||
}),
|
||||
codexLine({
|
||||
timestamp: '2026-05-01T10:00:11.000Z',
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: { total_token_usage: { input_tokens: 100, output_tokens: 40, total_tokens: 140 } },
|
||||
model: 'gpt-5.1-codex'
|
||||
}
|
||||
})
|
||||
],
|
||||
appendLines: [
|
||||
codexLine({
|
||||
timestamp: '2026-05-01T10:05:00.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'codex follow-up' }
|
||||
}),
|
||||
codexLine({
|
||||
timestamp: '2026-05-01T10:05:20.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'agent_message', message: 'codex incremental answer' }
|
||||
}),
|
||||
codexLine({
|
||||
timestamp: '2026-05-01T10:05:21.000Z',
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: { total_token_usage: { input_tokens: 220, output_tokens: 90, total_tokens: 310 } }
|
||||
}
|
||||
})
|
||||
],
|
||||
truncatedLines: [
|
||||
codexLine({
|
||||
timestamp: '2026-05-01T10:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: CODEX_SESSION_ID, cwd: '/repo/app' }
|
||||
}),
|
||||
codexLine({
|
||||
timestamp: '2026-05-01T10:00:05.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'rewritten only turn' }
|
||||
})
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function codexWorkerFixtureLines(): string[] {
|
||||
return [
|
||||
codexLine({
|
||||
timestamp: '2026-05-01T10:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: CODEX_SESSION_ID, cwd: '/repo/app', thread_source: 'subagent' }
|
||||
}),
|
||||
codexLine({
|
||||
timestamp: '2026-05-01T10:00:05.000Z',
|
||||
type: 'event_msg',
|
||||
payload: { type: 'user_message', message: 'worker turn' }
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export const CODEX_FIXTURE_SESSION_ID = CODEX_SESSION_ID
|
||||
@@ -1,18 +1,25 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { readCodexSessionIndexTitle } from './session-scanner-codex-title-index'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
addPreviewContent,
|
||||
cloneSessionAccumulator,
|
||||
createAccumulator,
|
||||
finalizeSession,
|
||||
sessionIdFromFileName,
|
||||
updateTimeline
|
||||
} from './session-scanner-accumulator'
|
||||
import type { CodexUsageSnapshot, FileWithMtime } from './session-scanner-types'
|
||||
import type {
|
||||
CodexUsageSnapshot,
|
||||
FileWithMtime,
|
||||
ResumableParseFinalizeOptions,
|
||||
ResumableSessionParseState,
|
||||
SessionAccumulator
|
||||
} from './session-scanner-types'
|
||||
import {
|
||||
addCodexUsage,
|
||||
asRecord,
|
||||
extractContentText,
|
||||
extractGitBranch,
|
||||
@@ -24,15 +31,6 @@ import {
|
||||
subtractCodexUsage
|
||||
} from './session-scanner-values'
|
||||
|
||||
const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl'
|
||||
|
||||
type CodexSessionIndexTitleCacheEntry = {
|
||||
signature: string
|
||||
titles: Map<string, string>
|
||||
}
|
||||
|
||||
const codexSessionIndexTitleCache = new Map<string, Promise<CodexSessionIndexTitleCacheEntry>>()
|
||||
|
||||
export async function parseCodexSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
@@ -74,6 +72,213 @@ export async function parseCodexSessionContent(args: {
|
||||
})
|
||||
}
|
||||
|
||||
type CodexSessionParseState = {
|
||||
accumulator: SessionAccumulator
|
||||
previousTotals: CodexUsageSnapshot | null
|
||||
rejectedWorkerSession: boolean
|
||||
sawSessionMeta: boolean
|
||||
// Which source set the current title; an index-file title outranks the raw
|
||||
// first user prompt, so finalize must know whether 'meta' already won.
|
||||
titleSource: 'meta' | 'user' | null
|
||||
}
|
||||
|
||||
function createCodexParseState(file: FileWithMtime): CodexSessionParseState {
|
||||
return {
|
||||
accumulator: createAccumulator({
|
||||
agent: 'codex',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
}),
|
||||
previousTotals: null,
|
||||
rejectedWorkerSession: false,
|
||||
sawSessionMeta: false,
|
||||
titleSource: null
|
||||
}
|
||||
}
|
||||
|
||||
function cloneCodexParseState(state: CodexSessionParseState): CodexSessionParseState {
|
||||
return {
|
||||
// previousTotals snapshots are replaced, never mutated, so sharing is safe.
|
||||
...state,
|
||||
accumulator: cloneSessionAccumulator(state.accumulator)
|
||||
}
|
||||
}
|
||||
|
||||
function consumeCodexRecordLine(state: CodexSessionParseState, line: string): void {
|
||||
if (state.rejectedWorkerSession) {
|
||||
return
|
||||
}
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
return
|
||||
}
|
||||
const { accumulator } = state
|
||||
|
||||
updateTimeline(accumulator, extractString(record.timestamp))
|
||||
|
||||
const payload = asRecord(record.payload)
|
||||
if (record.type === 'session_meta' && payload) {
|
||||
if (isCodexWorkerSession(payload)) {
|
||||
// Why: Codex writes internal worker/sub-agent transcripts into the same
|
||||
// history tree; AI Vault should show user-started sessions only.
|
||||
state.rejectedWorkerSession = true
|
||||
return
|
||||
}
|
||||
state.sawSessionMeta = true
|
||||
const sessionId = extractString(payload.id)
|
||||
if (sessionId) {
|
||||
accumulator.sessionId = sessionId
|
||||
}
|
||||
const metadataTitle = extractCodexSessionMetadataTitle(payload)
|
||||
if (metadataTitle) {
|
||||
accumulator.title = metadataTitle
|
||||
state.titleSource = 'meta'
|
||||
}
|
||||
const cwd = extractString(payload.cwd)
|
||||
if (cwd) {
|
||||
accumulator.cwd = cwd
|
||||
}
|
||||
accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch
|
||||
return
|
||||
}
|
||||
|
||||
if (record.type === 'turn_context' && payload) {
|
||||
const cwd = extractString(payload.cwd)
|
||||
if (cwd) {
|
||||
accumulator.cwd = cwd
|
||||
}
|
||||
const model = extractModel(payload)
|
||||
if (model) {
|
||||
accumulator.model = model
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
return
|
||||
}
|
||||
|
||||
if (record.type === 'response_item' && payload.type === 'message') {
|
||||
accumulator.messageCount++
|
||||
if (payload.role === 'user' && !accumulator.title) {
|
||||
accumulator.title = extractContentText(payload.content)
|
||||
state.titleSource = accumulator.title ? 'user' : state.titleSource
|
||||
}
|
||||
addPreviewContent(
|
||||
accumulator,
|
||||
payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown',
|
||||
payload.content,
|
||||
record.timestamp
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (record.type !== 'event_msg') {
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.type === 'user_message') {
|
||||
accumulator.messageCount++
|
||||
if (!accumulator.title) {
|
||||
accumulator.title = extractContentText(payload.message)
|
||||
state.titleSource = accumulator.title ? 'user' : state.titleSource
|
||||
}
|
||||
addPreviewContent(accumulator, 'user', payload.message, record.timestamp)
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.type === 'agent_message') {
|
||||
accumulator.messageCount++
|
||||
addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp)
|
||||
return
|
||||
}
|
||||
|
||||
if (payload.type !== 'token_count') {
|
||||
return
|
||||
}
|
||||
|
||||
const info = asRecord(payload.info)
|
||||
if (!info) {
|
||||
return
|
||||
}
|
||||
const totalUsage = normalizeCodexUsage(info.total_token_usage)
|
||||
const lastUsage = normalizeCodexUsage(info.last_token_usage)
|
||||
let delta: CodexUsageSnapshot | null = null
|
||||
if (totalUsage) {
|
||||
delta = subtractCodexUsage(totalUsage, state.previousTotals)
|
||||
state.previousTotals = totalUsage
|
||||
} else if (lastUsage) {
|
||||
delta = lastUsage
|
||||
state.previousTotals = state.previousTotals
|
||||
? addCodexUsage(state.previousTotals, lastUsage)
|
||||
: lastUsage
|
||||
}
|
||||
if (delta) {
|
||||
accumulator.totalTokens += delta.totalTokens
|
||||
}
|
||||
const model = extractModel(payload)
|
||||
if (model) {
|
||||
accumulator.model = model
|
||||
}
|
||||
}
|
||||
|
||||
async function finalizeCodexParseState(
|
||||
state: CodexSessionParseState,
|
||||
platform: NodeJS.Platform,
|
||||
args: {
|
||||
codexHome: string | null
|
||||
titleReader?: (sessionId: string) => Promise<string | null>
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
}
|
||||
): Promise<AiVaultSession | null> {
|
||||
if (state.rejectedWorkerSession) {
|
||||
return null
|
||||
}
|
||||
// Finalize a snapshot: the live state keeps accumulating appended lines.
|
||||
const snapshot = cloneCodexParseState(state)
|
||||
// Why: Codex names threads lazily in session_index.jsonl, so the lookup runs
|
||||
// per finalize (the index read is signature-cached) — a title that appears
|
||||
// after the transcript was first parsed must still replace the raw prompt.
|
||||
if (snapshot.sawSessionMeta && snapshot.titleSource !== 'meta') {
|
||||
const indexedTitle = await args.titleReader?.(snapshot.accumulator.sessionId)
|
||||
if (indexedTitle) {
|
||||
snapshot.accumulator.title = indexedTitle
|
||||
}
|
||||
}
|
||||
return finalizeSession(snapshot.accumulator, platform, {
|
||||
codexHome: args.codexHome,
|
||||
executionHostId: args.executionHostId,
|
||||
executionHostPlatform: args.executionHostPlatform
|
||||
})
|
||||
}
|
||||
|
||||
export function createCodexSessionResumeState(
|
||||
file: FileWithMtime,
|
||||
codexHome: string | null
|
||||
): ResumableSessionParseState {
|
||||
return codexResumeStateFromParseState(createCodexParseState(file), codexHome, (sessionId) =>
|
||||
readCodexSessionIndexTitle(file.path, codexHome, sessionId)
|
||||
)
|
||||
}
|
||||
|
||||
function codexResumeStateFromParseState(
|
||||
state: CodexSessionParseState,
|
||||
codexHome: string | null,
|
||||
titleReader: (sessionId: string) => Promise<string | null>
|
||||
): ResumableSessionParseState {
|
||||
return {
|
||||
consumeLine: (line) => consumeCodexRecordLine(state, line),
|
||||
clone: () =>
|
||||
codexResumeStateFromParseState(cloneCodexParseState(state), codexHome, titleReader),
|
||||
touchFile: (file) => {
|
||||
state.accumulator.modifiedAt = file.modifiedAt
|
||||
},
|
||||
finalize: (platform, options?: ResumableParseFinalizeOptions) =>
|
||||
finalizeCodexParseState(state, platform, { codexHome, titleReader, ...options })
|
||||
}
|
||||
}
|
||||
|
||||
async function parseCodexSessionLines(args: {
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
@@ -83,142 +288,22 @@ async function parseCodexSessionLines(args: {
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
titleReader?: (sessionId: string) => Promise<string | null>
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'codex',
|
||||
file: args.file,
|
||||
sessionId: sessionIdFromFileName(args.file.path)
|
||||
})
|
||||
let previousTotals: CodexUsageSnapshot | null = null
|
||||
|
||||
const state = createCodexParseState(args.file)
|
||||
for await (const line of args.lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
|
||||
updateTimeline(accumulator, extractString(record.timestamp))
|
||||
|
||||
const payload = asRecord(record.payload)
|
||||
if (record.type === 'session_meta' && payload) {
|
||||
if (isCodexWorkerSession(payload)) {
|
||||
// Why: Codex writes internal worker/sub-agent transcripts into the same
|
||||
// history tree; AI Vault should show user-started sessions only.
|
||||
return null
|
||||
}
|
||||
const sessionId = extractString(payload.id)
|
||||
if (sessionId) {
|
||||
accumulator.sessionId = sessionId
|
||||
}
|
||||
const indexedTitle =
|
||||
extractCodexSessionMetadataTitle(payload) ??
|
||||
(await args.titleReader?.(accumulator.sessionId))
|
||||
if (indexedTitle) {
|
||||
accumulator.title = indexedTitle
|
||||
}
|
||||
const cwd = extractString(payload.cwd)
|
||||
if (cwd) {
|
||||
accumulator.cwd = cwd
|
||||
}
|
||||
accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch
|
||||
continue
|
||||
}
|
||||
|
||||
if (record.type === 'turn_context' && payload) {
|
||||
const cwd = extractString(payload.cwd)
|
||||
if (cwd) {
|
||||
accumulator.cwd = cwd
|
||||
}
|
||||
const model = extractModel(payload)
|
||||
if (model) {
|
||||
accumulator.model = model
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (record.type === 'response_item' && payload.type === 'message') {
|
||||
accumulator.messageCount++
|
||||
if (payload.role === 'user' && !accumulator.title) {
|
||||
accumulator.title = extractContentText(payload.content)
|
||||
}
|
||||
addPreviewContent(
|
||||
accumulator,
|
||||
payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown',
|
||||
payload.content,
|
||||
record.timestamp
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (record.type !== 'event_msg') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (payload.type === 'user_message') {
|
||||
accumulator.messageCount++
|
||||
if (!accumulator.title) {
|
||||
accumulator.title = extractContentText(payload.message)
|
||||
}
|
||||
addPreviewContent(accumulator, 'user', payload.message, record.timestamp)
|
||||
continue
|
||||
}
|
||||
|
||||
if (payload.type === 'agent_message') {
|
||||
accumulator.messageCount++
|
||||
addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp)
|
||||
continue
|
||||
}
|
||||
|
||||
if (payload.type !== 'token_count') {
|
||||
continue
|
||||
}
|
||||
|
||||
const info = asRecord(payload.info)
|
||||
if (!info) {
|
||||
continue
|
||||
}
|
||||
const totalUsage = normalizeCodexUsage(info.total_token_usage)
|
||||
const lastUsage = normalizeCodexUsage(info.last_token_usage)
|
||||
let delta: CodexUsageSnapshot | null = null
|
||||
if (totalUsage) {
|
||||
delta = subtractCodexUsage(totalUsage, previousTotals)
|
||||
previousTotals = totalUsage
|
||||
} else if (lastUsage) {
|
||||
delta = lastUsage
|
||||
previousTotals = previousTotals ? addCodexUsage(previousTotals, lastUsage) : lastUsage
|
||||
}
|
||||
if (delta) {
|
||||
accumulator.totalTokens += delta.totalTokens
|
||||
}
|
||||
const model = extractModel(payload)
|
||||
if (model) {
|
||||
accumulator.model = model
|
||||
consumeCodexRecordLine(state, line)
|
||||
if (state.rejectedWorkerSession) {
|
||||
// Worker transcripts are excluded outright; stop reading early.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, args.platform, {
|
||||
return finalizeCodexParseState(state, args.platform, {
|
||||
codexHome: args.codexHome,
|
||||
titleReader: args.titleReader,
|
||||
executionHostId: args.executionHostId,
|
||||
executionHostPlatform: args.executionHostPlatform
|
||||
})
|
||||
}
|
||||
|
||||
function addCodexUsage(
|
||||
base: CodexUsageSnapshot,
|
||||
increment: CodexUsageSnapshot
|
||||
): CodexUsageSnapshot {
|
||||
return {
|
||||
inputTokens: base.inputTokens + increment.inputTokens,
|
||||
cachedInputTokens: base.cachedInputTokens + increment.cachedInputTokens,
|
||||
outputTokens: base.outputTokens + increment.outputTokens,
|
||||
reasoningOutputTokens: base.reasoningOutputTokens + increment.reasoningOutputTokens,
|
||||
totalTokens: base.totalTokens + increment.totalTokens
|
||||
}
|
||||
}
|
||||
|
||||
function extractCodexThreadSource(payload: Record<string, unknown>): string | null {
|
||||
return extractString(payload.thread_source) ?? extractString(payload.threadSource)
|
||||
}
|
||||
@@ -240,79 +325,3 @@ function extractCodexSessionMetadataTitle(payload: Record<string, unknown>): str
|
||||
normalizeTitleText(extractString(payload.threadName) ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
async function readCodexSessionIndexTitle(
|
||||
sessionFilePath: string,
|
||||
codexHome: string | null,
|
||||
sessionId: string
|
||||
): Promise<string | null> {
|
||||
const resolvedCodexHome = codexHome ?? codexHomeFromSessionFilePath(sessionFilePath)
|
||||
if (!resolvedCodexHome) {
|
||||
return null
|
||||
}
|
||||
const titleBySessionId = await readCodexSessionIndexTitles(resolvedCodexHome)
|
||||
return titleBySessionId.get(sessionId) ?? null
|
||||
}
|
||||
|
||||
function codexHomeFromSessionFilePath(sessionFilePath: string): string | null {
|
||||
let currentDir = dirname(sessionFilePath)
|
||||
while (currentDir && dirname(currentDir) !== currentDir) {
|
||||
if (basename(currentDir) === 'sessions') {
|
||||
return dirname(currentDir)
|
||||
}
|
||||
currentDir = dirname(currentDir)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function readCodexSessionIndexTitles(codexHome: string): Promise<Map<string, string>> {
|
||||
const indexPath = join(codexHome, CODEX_SESSION_INDEX_FILE)
|
||||
let signature: string
|
||||
try {
|
||||
const indexStat = await stat(indexPath)
|
||||
signature = `${indexStat.size}:${indexStat.mtimeMs}`
|
||||
} catch {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
const cached = codexSessionIndexTitleCache.get(codexHome)
|
||||
if (cached) {
|
||||
const entry = await cached
|
||||
if (entry.signature === signature) {
|
||||
return entry.titles
|
||||
}
|
||||
}
|
||||
|
||||
const pending = readCodexSessionIndexTitlesFromDisk(indexPath).then((titles) => ({
|
||||
signature,
|
||||
titles
|
||||
}))
|
||||
codexSessionIndexTitleCache.set(codexHome, pending)
|
||||
return (await pending).titles
|
||||
}
|
||||
|
||||
async function readCodexSessionIndexTitlesFromDisk(
|
||||
indexPath: string
|
||||
): Promise<Map<string, string>> {
|
||||
const titleBySessionId = new Map<string, string>()
|
||||
try {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(indexPath, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
for await (const line of lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
const sessionId = extractString(record.id)
|
||||
const title = normalizeTitleText(extractString(record.thread_name) ?? '')
|
||||
if (sessionId && title) {
|
||||
titleBySessionId.set(sessionId, title)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Codex creates the index opportunistically; older homes may only have raw transcripts.
|
||||
}
|
||||
return titleBySessionId
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values'
|
||||
|
||||
// Codex names threads lazily in <CODEX_HOME>/session_index.jsonl; transcripts
|
||||
// carry no title of their own, so parsers look the thread name up here.
|
||||
|
||||
const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl'
|
||||
|
||||
type CodexSessionIndexTitleCacheEntry = {
|
||||
signature: string
|
||||
titles: Map<string, string>
|
||||
}
|
||||
|
||||
const codexSessionIndexTitleCache = new Map<string, Promise<CodexSessionIndexTitleCacheEntry>>()
|
||||
|
||||
export async function readCodexSessionIndexTitle(
|
||||
sessionFilePath: string,
|
||||
codexHome: string | null,
|
||||
sessionId: string
|
||||
): Promise<string | null> {
|
||||
const resolvedCodexHome = codexHome ?? codexHomeFromSessionFilePath(sessionFilePath)
|
||||
if (!resolvedCodexHome) {
|
||||
return null
|
||||
}
|
||||
const titleBySessionId = await readCodexSessionIndexTitles(resolvedCodexHome)
|
||||
return titleBySessionId.get(sessionId) ?? null
|
||||
}
|
||||
|
||||
function codexHomeFromSessionFilePath(sessionFilePath: string): string | null {
|
||||
let currentDir = dirname(sessionFilePath)
|
||||
while (currentDir && dirname(currentDir) !== currentDir) {
|
||||
if (basename(currentDir) === 'sessions') {
|
||||
return dirname(currentDir)
|
||||
}
|
||||
currentDir = dirname(currentDir)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function readCodexSessionIndexTitles(codexHome: string): Promise<Map<string, string>> {
|
||||
const indexPath = join(codexHome, CODEX_SESSION_INDEX_FILE)
|
||||
let signature: string
|
||||
try {
|
||||
const indexStat = await stat(indexPath)
|
||||
signature = `${indexStat.size}:${indexStat.mtimeMs}`
|
||||
} catch {
|
||||
return new Map()
|
||||
}
|
||||
|
||||
const cached = codexSessionIndexTitleCache.get(codexHome)
|
||||
if (cached) {
|
||||
const entry = await cached
|
||||
if (entry.signature === signature) {
|
||||
return entry.titles
|
||||
}
|
||||
}
|
||||
|
||||
const pending = readCodexSessionIndexTitlesFromDisk(indexPath).then((titles) => ({
|
||||
signature,
|
||||
titles
|
||||
}))
|
||||
codexSessionIndexTitleCache.set(codexHome, pending)
|
||||
return (await pending).titles
|
||||
}
|
||||
|
||||
async function readCodexSessionIndexTitlesFromDisk(
|
||||
indexPath: string
|
||||
): Promise<Map<string, string>> {
|
||||
const titleBySessionId = new Map<string, string>()
|
||||
try {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(indexPath, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
for await (const line of lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
const sessionId = extractString(record.id)
|
||||
const title = normalizeTitleText(extractString(record.thread_name) ?? '')
|
||||
if (sessionId && title) {
|
||||
titleBySessionId.set(sessionId, title)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Codex creates the index opportunistically; older homes may only have raw transcripts.
|
||||
}
|
||||
return titleBySessionId
|
||||
}
|
||||
@@ -117,6 +117,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => {
|
||||
openclawStateDir: join(root, 'openclaw-state'),
|
||||
openclawLegacyStateDir: join(root, 'openclaw-legacy-state'),
|
||||
piSessionsDir: join(root, 'pi-sessions'),
|
||||
ompSessionsDir: join(root, 'omp-sessions'),
|
||||
droidSessionsDir: join(root, 'droid-sessions'),
|
||||
droidProjectsDir: join(root, 'droid-projects'),
|
||||
kimiSessionsDir: join(root, 'kimi-sessions'),
|
||||
|
||||
@@ -25,7 +25,8 @@ export async function discoverFiles(args: {
|
||||
files.push({
|
||||
path,
|
||||
mtimeMs: fileStat.mtimeMs,
|
||||
modifiedAt: fileStat.mtime.toISOString()
|
||||
modifiedAt: fileStat.mtime.toISOString(),
|
||||
sizeBytes: fileStat.size
|
||||
})
|
||||
} catch (err) {
|
||||
args.issues.push({ agent: args.agent, path, message: errorMessage(err) })
|
||||
|
||||
@@ -2,11 +2,15 @@ import { createReadStream } from 'node:fs'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import type {
|
||||
FileWithMtime,
|
||||
ResumableSessionParseState,
|
||||
SessionAccumulator
|
||||
} from './session-scanner-types'
|
||||
import {
|
||||
accumulatorFoldResumeState,
|
||||
addPreviewMessage,
|
||||
createAccumulator,
|
||||
finalizeSession,
|
||||
sessionIdFromFileName,
|
||||
updateTimeline
|
||||
} from './session-scanner-accumulator'
|
||||
@@ -50,51 +54,57 @@ export async function parseDroidSessionContent(
|
||||
})
|
||||
}
|
||||
|
||||
function consumeDroidRecordLine(accumulator: SessionAccumulator, line: string): void {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
return
|
||||
}
|
||||
updateTimeline(accumulator, record.timestamp)
|
||||
if (record.type === 'session_start') {
|
||||
accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId
|
||||
accumulator.title = normalizeTitleText(extractString(record.title) ?? '')
|
||||
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
|
||||
return
|
||||
}
|
||||
if (record.type === 'system') {
|
||||
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
|
||||
accumulator.model = extractString(record.model) ?? accumulator.model
|
||||
}
|
||||
const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId)
|
||||
if (streamSessionId) {
|
||||
accumulator.sessionId = streamSessionId
|
||||
}
|
||||
if (record.type === 'message') {
|
||||
consumeDroidMessage(accumulator, record)
|
||||
} else if (record.type === 'completion') {
|
||||
accumulator.messageCount++
|
||||
accumulator.totalTokens += tokenTotal(record.usage)
|
||||
addPreviewMessage(accumulator, {
|
||||
role: 'assistant',
|
||||
text: extractString(record.finalText),
|
||||
timestamp: record.timestamp
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function createDroidSessionResumeState(file: FileWithMtime): ResumableSessionParseState {
|
||||
return accumulatorFoldResumeState(
|
||||
createAccumulator({ agent: 'droid', file, sessionId: sessionIdFromFileName(file.path) }),
|
||||
consumeDroidRecordLine
|
||||
)
|
||||
}
|
||||
|
||||
async function parseDroidSessionLines(args: {
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
options?: ParserSessionOptions
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'droid',
|
||||
file: args.file,
|
||||
sessionId: sessionIdFromFileName(args.file.path)
|
||||
})
|
||||
|
||||
const state = createDroidSessionResumeState(args.file)
|
||||
for await (const line of args.lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
updateTimeline(accumulator, record.timestamp)
|
||||
if (record.type === 'session_start') {
|
||||
accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId
|
||||
accumulator.title = normalizeTitleText(extractString(record.title) ?? '')
|
||||
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
|
||||
continue
|
||||
}
|
||||
if (record.type === 'system') {
|
||||
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
|
||||
accumulator.model = extractString(record.model) ?? accumulator.model
|
||||
}
|
||||
const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId)
|
||||
if (streamSessionId) {
|
||||
accumulator.sessionId = streamSessionId
|
||||
}
|
||||
if (record.type === 'message') {
|
||||
consumeDroidMessage(accumulator, record)
|
||||
} else if (record.type === 'completion') {
|
||||
accumulator.messageCount++
|
||||
accumulator.totalTokens += tokenTotal(record.usage)
|
||||
addPreviewMessage(accumulator, {
|
||||
role: 'assistant',
|
||||
text: extractString(record.finalText),
|
||||
timestamp: record.timestamp
|
||||
})
|
||||
}
|
||||
state.consumeLine(line)
|
||||
}
|
||||
return finalizeSession(accumulator, args.platform, args.options)
|
||||
return state.finalize(args.platform, args.options)
|
||||
}
|
||||
|
||||
function consumeDroidMessage(
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
FileWithMtime,
|
||||
ResumableParseFinalizeOptions,
|
||||
ResumableSessionParseState,
|
||||
SessionAccumulator
|
||||
} from './session-scanner-types'
|
||||
import {
|
||||
accumulatorFoldResumeState,
|
||||
addPreviewContent,
|
||||
createAccumulator,
|
||||
finalizeSession,
|
||||
sessionIdFromFileName,
|
||||
updateTimeline
|
||||
} from './session-scanner-accumulator'
|
||||
import {
|
||||
arrayValue,
|
||||
asRecord,
|
||||
extractContentText,
|
||||
extractString,
|
||||
parseJsonObject,
|
||||
tokenTotal
|
||||
} from './session-scanner-values'
|
||||
|
||||
export async function parseGeminiSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
if (file.path.endsWith('.jsonl')) {
|
||||
return parseGeminiJsonlSessionFile(file, platform)
|
||||
}
|
||||
|
||||
return parseGeminiJsonSessionContent(file, await readFile(file.path, 'utf-8'), platform)
|
||||
}
|
||||
|
||||
export async function parseGeminiSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ResumableParseFinalizeOptions = {}
|
||||
): Promise<AiVaultSession | null> {
|
||||
if (file.path.endsWith('.jsonl')) {
|
||||
return parseGeminiJsonlSessionLines({
|
||||
file,
|
||||
lines: content.split(/\r?\n/),
|
||||
platform,
|
||||
options
|
||||
})
|
||||
}
|
||||
return parseGeminiJsonSessionContent(file, content, platform, options)
|
||||
}
|
||||
|
||||
function parseGeminiJsonSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform,
|
||||
options: ResumableParseFinalizeOptions = {}
|
||||
): AiVaultSession | null {
|
||||
const record = asRecord(JSON.parse(content) as unknown)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'gemini',
|
||||
file,
|
||||
sessionId: extractString(record.sessionId) ?? sessionIdFromFileName(file.path)
|
||||
})
|
||||
updateTimeline(accumulator, extractString(record.startTime))
|
||||
updateTimeline(accumulator, extractString(record.lastUpdated))
|
||||
for (const message of arrayValue(record.messages)) {
|
||||
consumeGeminiMessage(accumulator, asRecord(message))
|
||||
}
|
||||
return finalizeSession(accumulator, platform, options)
|
||||
}
|
||||
|
||||
export async function parseGeminiJsonlSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
return parseGeminiJsonlSessionLines({ file, lines, platform })
|
||||
}
|
||||
|
||||
function consumeGeminiJsonlRecordLine(accumulator: SessionAccumulator, line: string): void {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
return
|
||||
}
|
||||
const setRecord = asRecord(record.$set)
|
||||
if (setRecord) {
|
||||
updateTimeline(accumulator, extractString(setRecord.lastUpdated))
|
||||
return
|
||||
}
|
||||
const sessionId = extractString(record.sessionId)
|
||||
if (sessionId) {
|
||||
accumulator.sessionId = sessionId
|
||||
}
|
||||
updateTimeline(accumulator, extractString(record.startTime))
|
||||
updateTimeline(accumulator, extractString(record.lastUpdated))
|
||||
consumeGeminiMessage(accumulator, record)
|
||||
}
|
||||
|
||||
// Resumable only for the JSONL log format; Gemini's legacy single-JSON
|
||||
// session documents are rewritten in place and must be re-read whole.
|
||||
export function createGeminiJsonlSessionResumeState(
|
||||
file: FileWithMtime
|
||||
): ResumableSessionParseState {
|
||||
return accumulatorFoldResumeState(
|
||||
createAccumulator({ agent: 'gemini', file, sessionId: sessionIdFromFileName(file.path) }),
|
||||
consumeGeminiJsonlRecordLine
|
||||
)
|
||||
}
|
||||
|
||||
async function parseGeminiJsonlSessionLines(args: {
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
options?: ResumableParseFinalizeOptions
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const state = createGeminiJsonlSessionResumeState(args.file)
|
||||
for await (const line of args.lines) {
|
||||
state.consumeLine(line)
|
||||
}
|
||||
return state.finalize(args.platform, args.options)
|
||||
}
|
||||
|
||||
export function consumeGeminiMessage(
|
||||
accumulator: SessionAccumulator,
|
||||
record: Record<string, unknown> | null
|
||||
): void {
|
||||
if (!record) {
|
||||
return
|
||||
}
|
||||
updateTimeline(accumulator, extractString(record.timestamp))
|
||||
if (record.type === 'user') {
|
||||
accumulator.messageCount++
|
||||
accumulator.title ??= extractContentText(record.content)
|
||||
addPreviewContent(accumulator, 'user', record.content, record.timestamp)
|
||||
return
|
||||
}
|
||||
if (record.type === 'gemini') {
|
||||
accumulator.messageCount++
|
||||
addPreviewContent(accumulator, 'assistant', record.content, record.timestamp)
|
||||
const model = extractString(record.model)
|
||||
if (model) {
|
||||
accumulator.model = model
|
||||
}
|
||||
accumulator.totalTokens += tokenTotal(record.tokens)
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,13 @@ import { basename, dirname, join } from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import type {
|
||||
FileWithMtime,
|
||||
ResumableSessionParseState,
|
||||
SessionAccumulator
|
||||
} from './session-scanner-types'
|
||||
import {
|
||||
accumulatorFoldResumeState,
|
||||
addPreviewContent,
|
||||
addPreviewMessage,
|
||||
createAccumulator,
|
||||
@@ -158,8 +163,12 @@ export function rovoPartsText(parts: unknown[], role: 'user' | 'assistant'): str
|
||||
return extractContentText(textParts)
|
||||
}
|
||||
|
||||
// Agents whose transcripts are append-only message-graph JSONL (session +
|
||||
// model_change + message records). OMP is a Pi fork and shares the format.
|
||||
export type MessageGraphAgent = 'openclaw' | 'pi' | 'omp'
|
||||
|
||||
export async function parseMessageGraphSessionFile(
|
||||
agent: 'openclaw' | 'pi',
|
||||
agent: MessageGraphAgent,
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
@@ -171,7 +180,7 @@ export async function parseMessageGraphSessionFile(
|
||||
}
|
||||
|
||||
export async function parseMessageGraphSessionContent(
|
||||
agent: 'openclaw' | 'pi',
|
||||
agent: MessageGraphAgent,
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
@@ -186,53 +195,64 @@ export async function parseMessageGraphSessionContent(
|
||||
})
|
||||
}
|
||||
|
||||
function consumeMessageGraphRecordLine(accumulator: SessionAccumulator, line: string): void {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
return
|
||||
}
|
||||
updateTimeline(accumulator, extractString(record.timestamp))
|
||||
if (record.type === 'session') {
|
||||
const sessionId = extractString(record.id)
|
||||
if (sessionId) {
|
||||
accumulator.sessionId = sessionId
|
||||
}
|
||||
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
|
||||
return
|
||||
}
|
||||
if (record.type === 'model_change') {
|
||||
// Pi writes `modelId`; OMP writes `model`. Prefer either so an in-progress
|
||||
// session shows its model before the first assistant reply lands.
|
||||
accumulator.model =
|
||||
extractString(record.modelId) ?? extractString(record.model) ?? accumulator.model
|
||||
return
|
||||
}
|
||||
if (record.type !== 'message') {
|
||||
return
|
||||
}
|
||||
const message = asRecord(record.message)
|
||||
const role = extractString(message?.role)
|
||||
if (role === 'user' || role === 'assistant') {
|
||||
accumulator.messageCount++
|
||||
if (role === 'user') {
|
||||
accumulator.title ??= extractMessageText(message)
|
||||
} else {
|
||||
accumulator.model = extractString(message?.model) ?? accumulator.model
|
||||
accumulator.totalTokens += tokenTotal(message?.usage)
|
||||
}
|
||||
addPreviewContent(accumulator, role, message?.content, record.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
export function createMessageGraphSessionResumeState(
|
||||
agent: MessageGraphAgent,
|
||||
file: FileWithMtime
|
||||
): ResumableSessionParseState {
|
||||
return accumulatorFoldResumeState(
|
||||
createAccumulator({ agent, file, sessionId: sessionIdFromFileName(file.path) }),
|
||||
consumeMessageGraphRecordLine
|
||||
)
|
||||
}
|
||||
|
||||
async function parseMessageGraphSessionLines(args: {
|
||||
agent: 'openclaw' | 'pi'
|
||||
agent: MessageGraphAgent
|
||||
file: FileWithMtime
|
||||
lines: AsyncIterable<string> | Iterable<string>
|
||||
platform: NodeJS.Platform
|
||||
options?: ParserSessionOptions
|
||||
}): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: args.agent,
|
||||
file: args.file,
|
||||
sessionId: sessionIdFromFileName(args.file.path)
|
||||
})
|
||||
|
||||
const state = createMessageGraphSessionResumeState(args.agent, args.file)
|
||||
for await (const line of args.lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
updateTimeline(accumulator, extractString(record.timestamp))
|
||||
if (record.type === 'session') {
|
||||
const sessionId = extractString(record.id)
|
||||
if (sessionId) {
|
||||
accumulator.sessionId = sessionId
|
||||
}
|
||||
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
|
||||
continue
|
||||
}
|
||||
if (record.type === 'model_change') {
|
||||
accumulator.model = extractString(record.modelId) ?? accumulator.model
|
||||
continue
|
||||
}
|
||||
if (record.type !== 'message') {
|
||||
continue
|
||||
}
|
||||
const message = asRecord(record.message)
|
||||
const role = extractString(message?.role)
|
||||
if (role === 'user' || role === 'assistant') {
|
||||
accumulator.messageCount++
|
||||
if (role === 'user') {
|
||||
accumulator.title ??= extractMessageText(message)
|
||||
} else {
|
||||
accumulator.model = extractString(message?.model) ?? accumulator.model
|
||||
accumulator.totalTokens += tokenTotal(message?.usage)
|
||||
}
|
||||
addPreviewContent(accumulator, role, message?.content, record.timestamp)
|
||||
}
|
||||
state.consumeLine(line)
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, args.platform, args.options)
|
||||
return state.finalize(args.platform, args.options)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import type { AiVaultAgent } from '../../shared/ai-vault-types'
|
||||
|
||||
import { codexFixture } from './session-scanner-codex-fixtures'
|
||||
|
||||
// Line builders for the incremental-parse differential tests: each agent gets
|
||||
// a seed transcript, an appended continuation, and a truncated rewrite, all in
|
||||
// that agent's real on-disk JSONL record shapes.
|
||||
|
||||
export type IncrementalAgentFixture = {
|
||||
agent: AiVaultAgent
|
||||
fileName: string
|
||||
seedLines: string[]
|
||||
appendLines: string[]
|
||||
truncatedLines: string[]
|
||||
}
|
||||
|
||||
export function cursorFixture(): IncrementalAgentFixture {
|
||||
const line = (role: string, text: string, at: string) =>
|
||||
JSON.stringify({ role, message: { content: text }, timestamp: at })
|
||||
return {
|
||||
agent: 'cursor',
|
||||
fileName: 'agent-transcripts-aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl',
|
||||
seedLines: [
|
||||
line('user', 'cursor seed question', '2026-05-01T10:00:00.000Z'),
|
||||
line('assistant', 'cursor seed answer', '2026-05-01T10:01:00.000Z')
|
||||
],
|
||||
appendLines: [
|
||||
line('user', 'cursor follow-up', '2026-05-01T10:02:00.000Z'),
|
||||
line('assistant', 'cursor incremental answer', '2026-05-01T10:03:00.000Z')
|
||||
],
|
||||
truncatedLines: [line('user', 'cursor rewritten', '2026-05-01T10:00:00.000Z')]
|
||||
}
|
||||
}
|
||||
|
||||
export function copilotFixture(): IncrementalAgentFixture {
|
||||
const line = (type: string, data: Record<string, unknown>, at: string) =>
|
||||
JSON.stringify({ type, data, timestamp: at })
|
||||
return {
|
||||
agent: 'copilot',
|
||||
fileName: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl',
|
||||
seedLines: [
|
||||
line(
|
||||
'session.start',
|
||||
{ sessionId: 'copilot-session-1', startTime: '2026-05-01T10:00:00.000Z' },
|
||||
'2026-05-01T10:00:00.000Z'
|
||||
),
|
||||
line('user.message', { content: 'copilot seed question' }, '2026-05-01T10:00:05.000Z'),
|
||||
line('assistant.message', { content: 'copilot seed answer' }, '2026-05-01T10:00:30.000Z')
|
||||
],
|
||||
appendLines: [
|
||||
line('user.message', { content: 'copilot follow-up' }, '2026-05-01T10:05:00.000Z'),
|
||||
line(
|
||||
'assistant.message',
|
||||
{ content: 'copilot incremental answer' },
|
||||
'2026-05-01T10:05:30.000Z'
|
||||
),
|
||||
line(
|
||||
'session.shutdown',
|
||||
{ currentModel: 'gpt-5.1', currentTokens: 340 },
|
||||
'2026-05-01T10:06:00.000Z'
|
||||
)
|
||||
],
|
||||
truncatedLines: [
|
||||
line(
|
||||
'session.start',
|
||||
{ sessionId: 'copilot-session-1', startTime: '2026-05-01T10:00:00.000Z' },
|
||||
'2026-05-01T10:00:00.000Z'
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function droidFixture(): IncrementalAgentFixture {
|
||||
return {
|
||||
agent: 'droid',
|
||||
fileName: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl',
|
||||
seedLines: [
|
||||
JSON.stringify({
|
||||
type: 'session_start',
|
||||
id: 'droid-session-1',
|
||||
title: 'Droid seed task',
|
||||
cwd: '/repo/app',
|
||||
timestamp: '2026-05-01T10:00:00.000Z'
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
text: 'droid seed question',
|
||||
timestamp: '2026-05-01T10:00:05.000Z'
|
||||
})
|
||||
],
|
||||
appendLines: [
|
||||
JSON.stringify({
|
||||
type: 'completion',
|
||||
finalText: 'droid incremental answer',
|
||||
usage: { input_tokens: 50, output_tokens: 25 },
|
||||
timestamp: '2026-05-01T10:01:00.000Z'
|
||||
})
|
||||
],
|
||||
truncatedLines: [
|
||||
JSON.stringify({
|
||||
type: 'session_start',
|
||||
id: 'droid-session-1',
|
||||
title: 'Droid rewritten',
|
||||
timestamp: '2026-05-01T10:00:00.000Z'
|
||||
})
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function openclawFixture(): IncrementalAgentFixture {
|
||||
return {
|
||||
agent: 'openclaw',
|
||||
fileName: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl',
|
||||
seedLines: [
|
||||
JSON.stringify({
|
||||
type: 'session',
|
||||
id: 'openclaw-session-1',
|
||||
cwd: '/repo/app',
|
||||
timestamp: '2026-05-01T10:00:00.000Z'
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
message: { role: 'user', content: 'openclaw seed question' },
|
||||
timestamp: '2026-05-01T10:00:05.000Z'
|
||||
})
|
||||
],
|
||||
appendLines: [
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'openclaw incremental answer',
|
||||
model: 'claw-1',
|
||||
usage: { input_tokens: 40, output_tokens: 20 }
|
||||
},
|
||||
timestamp: '2026-05-01T10:01:00.000Z'
|
||||
})
|
||||
],
|
||||
truncatedLines: [
|
||||
JSON.stringify({
|
||||
type: 'session',
|
||||
id: 'openclaw-session-1',
|
||||
timestamp: '2026-05-01T10:00:00.000Z'
|
||||
})
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Pi shares OpenClaw's message-graph format and factory, but gets its own
|
||||
// fixture so the registry's 'pi' branch is exercised explicitly.
|
||||
export function piFixture(): IncrementalAgentFixture {
|
||||
return {
|
||||
agent: 'pi',
|
||||
fileName: 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff.jsonl',
|
||||
seedLines: [
|
||||
JSON.stringify({
|
||||
type: 'session',
|
||||
id: 'pi-session-1',
|
||||
cwd: '/repo/app',
|
||||
timestamp: '2026-05-01T10:00:00.000Z'
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
message: { role: 'user', content: 'pi seed question' },
|
||||
timestamp: '2026-05-01T10:00:05.000Z'
|
||||
})
|
||||
],
|
||||
appendLines: [
|
||||
JSON.stringify({
|
||||
type: 'model_change',
|
||||
modelId: 'pi-2',
|
||||
timestamp: '2026-05-01T10:00:30.000Z'
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: 'pi incremental answer',
|
||||
usage: { input_tokens: 30, output_tokens: 10 }
|
||||
},
|
||||
timestamp: '2026-05-01T10:01:00.000Z'
|
||||
})
|
||||
],
|
||||
truncatedLines: [
|
||||
JSON.stringify({ type: 'session', id: 'pi-session-1', timestamp: '2026-05-01T10:00:00.000Z' })
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// OMP is a Pi fork sharing the message-graph format, but keys the model on
|
||||
// `model` (not Pi's `modelId`); its own fixture exercises the registry's 'omp'
|
||||
// branch and that model-key difference. (The session `title` is included for
|
||||
// realism only — the parser derives the title from the first user message.)
|
||||
export function ompFixture(): IncrementalAgentFixture {
|
||||
return {
|
||||
agent: 'omp',
|
||||
fileName: '2026-05-01T10-00-00-000Z_cccccccc-dddd-4eee-8fff-000000000000.jsonl',
|
||||
seedLines: [
|
||||
JSON.stringify({
|
||||
type: 'session',
|
||||
version: 3,
|
||||
id: 'cccccccc-dddd-4eee-8fff-000000000000',
|
||||
cwd: '/repo/app',
|
||||
title: 'OMP session title',
|
||||
timestamp: '2026-05-01T10:00:00.000Z'
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'model_change',
|
||||
model: 'gpt-5.4-mini',
|
||||
timestamp: '2026-05-01T10:00:01.000Z'
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'omp seed question' }] },
|
||||
timestamp: '2026-05-01T10:00:05.000Z'
|
||||
})
|
||||
],
|
||||
appendLines: [
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'omp incremental answer' }],
|
||||
model: 'gpt-5.4-mini',
|
||||
usage: { input: 40, output: 20, totalTokens: 60 }
|
||||
},
|
||||
timestamp: '2026-05-01T10:01:00.000Z'
|
||||
})
|
||||
],
|
||||
truncatedLines: [
|
||||
JSON.stringify({
|
||||
type: 'session',
|
||||
id: 'cccccccc-dddd-4eee-8fff-000000000000',
|
||||
timestamp: '2026-05-01T10:00:00.000Z'
|
||||
})
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function geminiJsonlFixture(): IncrementalAgentFixture {
|
||||
return {
|
||||
agent: 'gemini',
|
||||
fileName: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl',
|
||||
seedLines: [
|
||||
JSON.stringify({
|
||||
sessionId: 'gemini-session-1',
|
||||
startTime: '2026-05-01T10:00:00.000Z',
|
||||
type: 'user',
|
||||
content: 'gemini seed question',
|
||||
timestamp: '2026-05-01T10:00:00.000Z'
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'gemini',
|
||||
content: 'gemini seed answer',
|
||||
model: 'gemini-3-pro',
|
||||
tokens: { input: 80, output: 30 },
|
||||
timestamp: '2026-05-01T10:00:30.000Z'
|
||||
})
|
||||
],
|
||||
appendLines: [
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
content: 'gemini follow-up',
|
||||
timestamp: '2026-05-01T10:01:00.000Z'
|
||||
}),
|
||||
JSON.stringify({ $set: { lastUpdated: '2026-05-01T10:01:05.000Z' } })
|
||||
],
|
||||
truncatedLines: [
|
||||
JSON.stringify({
|
||||
sessionId: 'gemini-session-1',
|
||||
type: 'user',
|
||||
content: 'gemini rewritten',
|
||||
timestamp: '2026-05-01T10:00:00.000Z'
|
||||
})
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function allIncrementalAgentFixtures(): IncrementalAgentFixture[] {
|
||||
return [
|
||||
codexFixture(),
|
||||
cursorFixture(),
|
||||
copilotFixture(),
|
||||
droidFixture(),
|
||||
openclawFixture(),
|
||||
piFixture(),
|
||||
ompFixture(),
|
||||
geminiJsonlFixture()
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user