mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 00:02:10 +00:00
Merge origin/main into ota-fu-terminal-cell-metrics
Main's 14 commits since52dc32f9eamerge without source conflicts. The RPC corpus conflicted on every golden's `baseline` and on the manifest: main's #23535 repinned to78771646afwhile this branch pins its own commit. Main moved no golden body since the merge base, so each golden takes main's bytes with this branch's `baseline` and, on the 14 goldens `terminal-mount-adapters.ts` mounts, this branch's `adapterSha256`; the manifest keeps the branch pin. The next commit repins to this merge. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -2,6 +2,7 @@ name: Bun profile persistence
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'config/**'
|
||||
@@ -17,6 +18,8 @@ on:
|
||||
- '.github/actions/install-node-dependencies/**'
|
||||
- '.github/workflows/bun-profile-tests.yml'
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '30 11 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -32,6 +35,8 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
should_run: ${{ steps.scope.outputs.should_run }}
|
||||
qualification: ${{ steps.scope.outputs.qualification }}
|
||||
runners: ${{ steps.scope.outputs.runners }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
@@ -56,7 +61,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-22.04, ubuntu-24.04-arm, macos-14, macos-15-intel, windows-2022, windows-11-arm]
|
||||
os: ${{ fromJSON(needs.changes.outputs.runners || '["ubuntu-22.04","ubuntu-24.04-arm","macos-14","macos-15-intel","windows-2022","windows-11-arm"]') }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
@@ -82,9 +87,12 @@ jobs:
|
||||
node out/orcad/orcad.js --orcad-profile-state-preflight 00000000-0000-4000-8000-000000000018
|
||||
|
||||
linux_glibc_floor:
|
||||
needs: changes
|
||||
# Missing/failed detection runs the full matrix; manual runs remain unconditional.
|
||||
if: ${{ !cancelled() && needs.changes.outputs.should_run != 'false' }}
|
||||
needs: [changes, persistence]
|
||||
# A failed smoke already blocks qualification; missing scope still selects every platform.
|
||||
if: >-
|
||||
${{ !cancelled() && needs.persistence.result == 'success' &&
|
||||
needs.changes.outputs.should_run != 'false' &&
|
||||
needs.changes.outputs.qualification != 'false' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -107,9 +115,12 @@ jobs:
|
||||
- run: pnpm test:bun:profile --artifact
|
||||
|
||||
linux_musl:
|
||||
needs: changes
|
||||
# Missing/failed detection runs the full matrix; manual runs remain unconditional.
|
||||
if: ${{ !cancelled() && needs.changes.outputs.should_run != 'false' }}
|
||||
needs: [changes, persistence]
|
||||
# A failed smoke already blocks qualification; missing scope still selects every platform.
|
||||
if: >-
|
||||
${{ !cancelled() && needs.persistence.result == 'success' &&
|
||||
needs.changes.outputs.should_run != 'false' &&
|
||||
needs.changes.outputs.qualification != 'false' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
name: CI runner demand
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '23 4 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: ci-runner-demand
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
report:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: config/scripts
|
||||
persist-credentials: false
|
||||
- name: Measure the previous complete 24 hours
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: node config/scripts/ci-runner-demand.mjs
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ci-runner-demand-${{ github.run_id }}
|
||||
path: ci-demand/
|
||||
retention-days: 30
|
||||
@@ -32,9 +32,8 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
schedule:
|
||||
# Why: GitHub cron uses UTC; these slots map to 10am and 3pm
|
||||
# America/Phoenix for the default-branch E2E run.
|
||||
- cron: '0 17,22 * * *'
|
||||
# One complete daily reference run; targeted PR coverage remains unchanged.
|
||||
- cron: '0 17 * * *'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -201,7 +200,14 @@ jobs:
|
||||
node config/scripts/ci-e2e-shard-plan.mjs --verify ci-shards/assignment.json ci-shards/selected-discovery.json
|
||||
|
||||
- name: Run E2E tests (${{ matrix.shard_name }})
|
||||
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --test-list=ci-shards/selected.txt
|
||||
env:
|
||||
PLAYWRIGHT_JSON_OUTPUT_FILE: ci-shards/results.json
|
||||
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay" pnpm run test:e2e --test-list=ci-shards/selected.txt --reporter=list,json
|
||||
|
||||
- name: Summarize E2E failures
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: node config/scripts/ci-e2e-failure-summary.mjs ci-shards/results.json
|
||||
|
||||
- name: Upload E2E shard assignment
|
||||
if: always()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: PR Checks
|
||||
run-name: 'PR ${{ github.event.pull_request.number }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }}'
|
||||
run-name: "PR ${{ github.event.pull_request.number }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }} | unit ${{ github.event.pull_request.draft && vars.ORCA_UNIT_SELECTION_MODE == 'selected' && 'selected' || 'full' }}"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -161,7 +161,17 @@ jobs:
|
||||
filter: blob:none
|
||||
persist-credentials: false
|
||||
|
||||
# Why two guarded installs: the mixed root+mobile store entry is 537 MB against
|
||||
# 321 MB for root alone, and restoring it costs 8.6s against 4.6s. Most PRs skip the
|
||||
# mobile install below, so they were paying 216 MB for packages they never link. The
|
||||
# root-only key is also the one the hourly warmer reseeds. Only one of these runs.
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
if: needs.code_paths.outputs.mobile_dependencies != 'true'
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
if: needs.code_paths.outputs.mobile_dependencies == 'true'
|
||||
with:
|
||||
native-runtime: node
|
||||
cache-dependency-path: |
|
||||
@@ -620,16 +630,19 @@ jobs:
|
||||
node-version: '24'
|
||||
|
||||
test:
|
||||
needs: [code_paths, test_native_cache]
|
||||
needs: [code_paths, test_native_cache, static_analysis, typecheck]
|
||||
# Honor cancellation while allowing the optional native-cache primer to skip.
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
needs.code_paths.outputs.test == 'true' &&
|
||||
needs.static_analysis.result == 'success' &&
|
||||
needs.typecheck.result == 'success' &&
|
||||
(needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped')
|
||||
uses: ./.github/workflows/unit-tests.yml
|
||||
with:
|
||||
node_versions: '["24"]'
|
||||
runner: ubuntu-24.04-arm
|
||||
selection_mode: ${{ vars.ORCA_UNIT_SELECTION_MODE || 'shadow' }}
|
||||
|
||||
# Why a separate job: the test needs a real Chrome, and the sharded `test` matrix
|
||||
# would pay for it on every shard to run one file in whichever shard it landed in.
|
||||
@@ -784,6 +797,7 @@ jobs:
|
||||
tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts
|
||||
tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts
|
||||
tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts
|
||||
tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts
|
||||
|
||||
managed_hook_node18:
|
||||
name: managed hooks on Node 18
|
||||
@@ -812,7 +826,7 @@ jobs:
|
||||
|
||||
package:
|
||||
name: package
|
||||
needs: [code_paths]
|
||||
needs: [code_paths, static_analysis, typecheck]
|
||||
if: needs.code_paths.outputs.package == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# Let the serial Docker gates reach their own deadlines and report cleanup failures.
|
||||
@@ -960,7 +974,7 @@ jobs:
|
||||
|
||||
package_windows:
|
||||
name: package (windows)
|
||||
needs: [code_paths]
|
||||
needs: [code_paths, static_analysis, typecheck]
|
||||
if: needs.code_paths.outputs.package_windows == 'true'
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PULLFROG ACTION — DO NOT EDIT EXCEPT WHERE INDICATED
|
||||
# Explicit review identities share workflow concurrency; legacy names use ordered cancellation.
|
||||
name: Pullfrog
|
||||
run-name: ${{ inputs.name || github.workflow }}
|
||||
run-name: ${{ inputs.name || github.workflow }}${{ inputs.pull_request_number && format(' | PR {0}', inputs.pull_request_number) || '' }}
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -11,21 +11,71 @@ on:
|
||||
type: string
|
||||
description: Run name
|
||||
|
||||
pull_request_number:
|
||||
type: string
|
||||
description: Optional PR identity for cancelling superseded reviews
|
||||
head_sha:
|
||||
type: string
|
||||
description: Optional expected PR head; stale reviews are skipped
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ inputs.pull_request_number && format('pullfrog-pr-{0}', inputs.pull_request_number) || format('pullfrog-run-{0}', github.run_id) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
review_scope:
|
||||
runs-on: ubuntu-slim
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
actions: write
|
||||
outputs:
|
||||
current: ${{ steps.scope.outputs.current }}
|
||||
number: ${{ steps.scope.outputs.number }}
|
||||
head: ${{ steps.scope.outputs.head }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: config/scripts/pullfrog-review-scope.cjs
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
- uses: actions/github-script@v8
|
||||
id: scope
|
||||
with:
|
||||
script: |
|
||||
const { reviewScope } = require('./config/scripts/pullfrog-review-scope.cjs')
|
||||
await reviewScope({ github, context, core })
|
||||
|
||||
pullfrog:
|
||||
needs: review_scope
|
||||
if: needs.review_scope.outputs.current == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Recheck review head before starting agent
|
||||
id: freshness
|
||||
if: needs.review_scope.outputs.number != ''
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
REVIEW_NUMBER: ${{ needs.review_scope.outputs.number }}
|
||||
REVIEW_HEAD: ${{ needs.review_scope.outputs.head }}
|
||||
with:
|
||||
script: |
|
||||
const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: Number(process.env.REVIEW_NUMBER) })
|
||||
core.setOutput('current', pr.state === 'open' && pr.head.sha === process.env.REVIEW_HEAD)
|
||||
- name: Run agent
|
||||
if: needs.review_scope.outputs.number == '' || steps.freshness.outputs.current == 'true'
|
||||
uses: pullfrog/pullfrog@v0
|
||||
with:
|
||||
prompt: ${{ inputs.prompt }}
|
||||
|
||||
@@ -14,19 +14,48 @@ on:
|
||||
default: ubuntu-latest
|
||||
type: string
|
||||
|
||||
selection_mode:
|
||||
description: Shadow validates selection; selected applies it only to draft PRs.
|
||||
required: false
|
||||
default: shadow
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
plan:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
shards: ${{ steps.plan.outputs.shards }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
- name: Plan unit selection
|
||||
id: plan
|
||||
env:
|
||||
ORCA_UNIT_SELECTION_MODE: ${{ inputs.selection_mode }}
|
||||
run: node config/scripts/ci-unit-plan.mjs
|
||||
- uses: actions/upload-artifact@v7
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: unit-selection-attempt-${{ github.run_attempt }}
|
||||
path: ci-shards/unit-selection.json
|
||||
retention-days: 14
|
||||
|
||||
test:
|
||||
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
needs: plan
|
||||
name: tests node ${{ matrix.node }} ${{ matrix.shard.index }}/${{ matrix.shard.count }}
|
||||
runs-on: ${{ inputs.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ${{ fromJSON(inputs.node_versions) }}
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shard_total: [8]
|
||||
shard: ${{ fromJSON(needs.plan.outputs.shards) }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -43,6 +72,12 @@ jobs:
|
||||
- name: Install Electron package binary for tests
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: unit-selection-attempt-${{ github.run_attempt }}
|
||||
path: ci-shards/
|
||||
|
||||
- name: Test shard
|
||||
env:
|
||||
ORCA_BALANCE_UNIT_SHARDS: '1'
|
||||
@@ -50,27 +85,7 @@ jobs:
|
||||
run: |
|
||||
export ORCA_SHARD_SOURCE_SHA="$(git rev-parse HEAD)"
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
|
||||
--exclude=src/main/daemon/shell-ready.test.ts \
|
||||
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
|
||||
--exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
|
||||
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
--exclude=src/main/pty/omp-shell-wrapper-alias-safety.test.ts \
|
||||
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
--exclude=src/main/shell-startup-feature-channel.test.ts \
|
||||
--exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
|
||||
--exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
|
||||
--exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
|
||||
--exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
|
||||
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \
|
||||
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
--exclude=src/shared/posix-command-path-lookup.test.ts \
|
||||
--exclude=tests/e2e/relay-region-compatibility.unit.test.ts \
|
||||
--exclude=tests/e2e/relay-region-correction.unit.test.ts \
|
||||
--exclude=tests/e2e/cross-version-wire/** \
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
--shard=${{ matrix.shard.index }}/${{ matrix.shard.count }}
|
||||
|
||||
- name: Upload unit shard assignment
|
||||
if: always()
|
||||
@@ -78,11 +93,40 @@ jobs:
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: unit-shard-node-${{ matrix.node }}-${{ matrix.shard }}-attempt-${{ github.run_attempt }}
|
||||
name: unit-shard-node-${{ matrix.node }}-${{ matrix.shard.index }}-attempt-${{ github.run_attempt }}
|
||||
path: ci-shards/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
selection_evidence:
|
||||
needs: test
|
||||
if: ${{ !cancelled() }}
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-slim
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
sparse-checkout: config/scripts/ci-unit-selection-review.mjs
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: unit-shard-node-*-attempt-${{ github.run_attempt }}
|
||||
path: unit-evidence/
|
||||
- name: Compare selection with full results
|
||||
continue-on-error: true
|
||||
run: node config/scripts/ci-unit-selection-review.mjs unit-evidence
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: unit-selection-review-attempt-${{ github.run_attempt }}
|
||||
path: unit-evidence/selection-review.json
|
||||
retention-days: 30
|
||||
|
||||
relay_integration:
|
||||
name: relay integration node ${{ matrix.node }}
|
||||
strategy:
|
||||
|
||||
@@ -179,6 +179,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca.
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a>
|
||||
<a href="https://deepseek-harness.github.io/deepseek-harness/"><kbd><img src="src/shared/agent-icons/dsh.png" alt="DeepSeek Harness logo" width="16" valign="middle" /> DeepSeek Harness</kbd></a>
|
||||
<a href="https://zcode.z.ai/en/docs"><kbd><img src="src/shared/agent-icons/zcode.png" alt="ZCode logo" width="16" valign="middle" /> ZCode</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="MiMo Code logo" width="16" valign="middle" /> MiMo Code</kbd></a>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildUnits, packShards, planShards } from './run-anti-slop-shards.mjs'
|
||||
|
||||
// Why these properties: the sharded pass equals a single pass only if every file is linted by
|
||||
// exactly one shard. Coverage and disjointness are what carry that, so they are asserted
|
||||
// directly rather than by diffing two multi-minute lint runs.
|
||||
function filesUnder(unit, files) {
|
||||
return files.filter((file) => file === unit || file.startsWith(`${unit}/`))
|
||||
}
|
||||
|
||||
function coveredBy(units, files) {
|
||||
return files.filter((file) => units.some((unit) => file === unit || file.startsWith(`${unit}/`)))
|
||||
}
|
||||
|
||||
const SAMPLE = [
|
||||
'src/renderer/src/components/a.tsx',
|
||||
'src/renderer/src/components/b.tsx',
|
||||
'src/renderer/src/hooks/c.ts',
|
||||
'src/renderer/index.ts',
|
||||
'src/main/agent/d.ts',
|
||||
'src/main/agent/e.ts',
|
||||
'src/main/f.ts',
|
||||
'src/shared/g.ts',
|
||||
'config/scripts/h.mjs',
|
||||
'tests/e2e/i.spec.ts',
|
||||
'mobile/src/j.tsx',
|
||||
'mobile/src/k.tsx'
|
||||
]
|
||||
|
||||
describe('anti-slop shard planning', () => {
|
||||
it('covers every file exactly once across units', () => {
|
||||
const units = buildUnits(SAMPLE, 3).map((entry) => entry.unit)
|
||||
for (const file of SAMPLE) {
|
||||
const owners = units.filter((unit) => file === unit || file.startsWith(`${unit}/`))
|
||||
expect(owners, `${file} owned by ${JSON.stringify(owners)}`).toHaveLength(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('reports a unit count that matches the files it owns', () => {
|
||||
for (const { unit, count } of buildUnits(SAMPLE, 3)) {
|
||||
expect(count).toBe(filesUnder(unit, SAMPLE).length)
|
||||
}
|
||||
})
|
||||
|
||||
it('splits a directory larger than the target instead of leaving it whole', () => {
|
||||
// src/renderer holds 4 of 12 sample files through a single child directory, so the
|
||||
// splitter has to descend more than one level to get under a small target.
|
||||
const units = buildUnits(SAMPLE, 2).map((entry) => entry.unit)
|
||||
expect(units).not.toContain('src')
|
||||
expect(units.some((unit) => unit.startsWith('src/renderer/'))).toBe(true)
|
||||
})
|
||||
|
||||
it('assigns every unit to exactly one shard and keeps shards disjoint', () => {
|
||||
const { units, bins } = planShards(SAMPLE, 3)
|
||||
const assigned = bins.flatMap((bin) => bin.units)
|
||||
expect(assigned.slice().sort()).toEqual(units.map((entry) => entry.unit).sort())
|
||||
expect(new Set(assigned).size).toBe(assigned.length)
|
||||
expect(coveredBy(assigned, SAMPLE)).toHaveLength(SAMPLE.length)
|
||||
})
|
||||
|
||||
it('keeps the heaviest shard near the mean so wall time is not bound by one shard', () => {
|
||||
const files = Array.from({ length: 400 }, (_, index) => `src/pkg${index % 40}/file${index}.ts`)
|
||||
const { bins } = planShards(files, 4)
|
||||
const heaviest = Math.max(...bins.map((bin) => bin.count))
|
||||
expect(heaviest).toBeLessThanOrEqual(Math.ceil(files.length / 4) * 1.35)
|
||||
})
|
||||
|
||||
it('never emits more shards than there are units', () => {
|
||||
expect(packShards(buildUnits(['src/a.ts'], 1), 4)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ORCAD_ENTRY_POINT
|
||||
} from './orcad-entry-build.mjs'
|
||||
import { bunProfileTestPaths } from './bun-profile-test-paths.mjs'
|
||||
import { bunProfileQualification } from './bun-profile-qualification.mjs'
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '../..')
|
||||
const BUILD_SCRIPTS = [
|
||||
@@ -29,7 +30,9 @@ const ALWAYS_FILES = new Set([
|
||||
'tsconfig.json',
|
||||
'.github/workflows/bun-profile-tests.yml',
|
||||
'config/scripts/bun-profile-change-scope.mjs',
|
||||
'config/scripts/bun-profile-change-scope.test.mjs'
|
||||
'config/scripts/bun-profile-change-scope.test.mjs',
|
||||
'config/scripts/bun-profile-qualification.mjs',
|
||||
'config/scripts/bun-profile-qualification.test.mjs'
|
||||
])
|
||||
const ALWAYS_PREFIXES = [
|
||||
'.github/actions/install-node-dependencies/',
|
||||
@@ -110,7 +113,11 @@ export async function classifyBunProfileChanges(changedFiles, collect = collectB
|
||||
reason: matched ? `Runtime or test dependency changed: ${matched}` : 'No Bun inputs changed'
|
||||
}
|
||||
} catch (error) {
|
||||
return { shouldRun: true, reason: `Dependency graph unavailable: ${String(error)}` }
|
||||
return {
|
||||
shouldRun: true,
|
||||
graphUnavailable: true,
|
||||
reason: `Dependency graph unavailable: ${String(error)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +125,14 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
|
||||
const changedFiles = readFileSync(process.argv[2], 'utf8').split('\0').filter(Boolean)
|
||||
const result = await classifyBunProfileChanges(changedFiles)
|
||||
console.log(result.reason)
|
||||
const output = `should_run=${String(result.shouldRun)}\n`
|
||||
let event = {}
|
||||
try {
|
||||
event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'))
|
||||
} catch {
|
||||
// Missing event evidence retains full qualification.
|
||||
}
|
||||
const policy = bunProfileQualification(changedFiles, result, event)
|
||||
const output = `should_run=${result.shouldRun}\nqualification=${policy.qualification}\nrunners=${JSON.stringify(policy.runners)}\n`
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, output)
|
||||
} else {
|
||||
|
||||
@@ -78,6 +78,7 @@ it.each([
|
||||
'.npmrc',
|
||||
'tsconfig.json',
|
||||
'config/tsconfig.node.json',
|
||||
'config/scripts/bun-profile-qualification.mjs',
|
||||
'config/patches/node-pty@1.1.0.patch',
|
||||
'native/windows-registry/src/addon.cc',
|
||||
'.github/actions/install-node-dependencies/action.yml',
|
||||
@@ -140,12 +141,15 @@ it('keeps all ten platform jobs and runs them when detection is skipped or fails
|
||||
expect(workflow.jobs.changes.steps[0].with['persist-credentials']).toBe(false)
|
||||
const detect = workflow.jobs.changes.steps.find((step) => step.id === 'scope')
|
||||
expect(detect.run).toContain('git diff --name-only --no-renames -z HEAD^1 HEAD')
|
||||
let count = 0
|
||||
for (const jobName of ['persistence', 'linux_glibc_floor', 'linux_musl']) {
|
||||
expect(workflow.on.pull_request.types).toContain('ready_for_review')
|
||||
expect(workflow.on.schedule).toHaveLength(1)
|
||||
expect(workflow.jobs.persistence.strategy.matrix.os).toContain('needs.changes.outputs.runners')
|
||||
for (const jobName of ['linux_glibc_floor', 'linux_musl']) {
|
||||
const job = workflow.jobs[jobName]
|
||||
expect(job.needs).toBe('changes')
|
||||
expect(job.if).toBe("${{ !cancelled() && needs.changes.outputs.should_run != 'false' }}")
|
||||
count += job.strategy.matrix.os.length
|
||||
expect(job.needs).toEqual(['changes', 'persistence'])
|
||||
expect(job.if).toContain("needs.persistence.result == 'success'")
|
||||
expect(job.if).toContain("needs.changes.outputs.qualification != 'false'")
|
||||
expect(job.if).toContain("needs.changes.outputs.should_run != 'false'")
|
||||
expect(job.strategy.matrix.os).toEqual(['ubuntu-22.04', 'ubuntu-24.04-arm'])
|
||||
}
|
||||
expect(count).toBe(10)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
export const BUN_PERSISTENCE_RUNNERS = [
|
||||
'ubuntu-22.04',
|
||||
'ubuntu-24.04-arm',
|
||||
'macos-14',
|
||||
'macos-15-intel',
|
||||
'windows-2022',
|
||||
'windows-11-arm'
|
||||
]
|
||||
|
||||
const QUALIFICATION_PREFIXES = [
|
||||
'config/',
|
||||
'native/',
|
||||
'resources/',
|
||||
'.github/',
|
||||
'src/main/persistence/',
|
||||
'src/main/sqlite/',
|
||||
'src/main/orcad/',
|
||||
'src/main/providers/',
|
||||
'src/main/daemon/',
|
||||
'src/main/ssh/',
|
||||
'src/relay/',
|
||||
'src/shared/child-process/'
|
||||
]
|
||||
|
||||
export function bunProfileQualification(changedFiles, scope, event = {}) {
|
||||
const sensitive = changedFiles.some(
|
||||
(file) =>
|
||||
!file.includes('/') ||
|
||||
QUALIFICATION_PREFIXES.some((prefix) => file.startsWith(prefix)) ||
|
||||
/(?:^|[/.-])(?:windows|win32|wsl|macos|darwin|linux|posix|bun)(?:[/.-]|$)/i.test(file)
|
||||
)
|
||||
// Only a proven unrelated platform change in a draft may defer qualification.
|
||||
const full =
|
||||
event.pull_request?.draft !== true ||
|
||||
changedFiles.length === 0 ||
|
||||
scope.graphUnavailable === true ||
|
||||
sensitive
|
||||
return {
|
||||
qualification: full,
|
||||
runners: full ? BUN_PERSISTENCE_RUNNERS : ['ubuntu-22.04']
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { BUN_PERSISTENCE_RUNNERS, bunProfileQualification } from './bun-profile-qualification.mjs'
|
||||
|
||||
const draft = { pull_request: { draft: true } }
|
||||
const scope = { shouldRun: true }
|
||||
|
||||
it('defers only platform qualification for ordinary draft runtime changes', () => {
|
||||
expect(
|
||||
bunProfileQualification(['src/main/runtime/rpc/methods/example.ts'], scope, draft)
|
||||
).toEqual({
|
||||
qualification: false,
|
||||
runners: ['ubuntu-22.04']
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'package.json',
|
||||
'native/windows-registry/src/addon.cc',
|
||||
'config/vitest.config.ts',
|
||||
'src/main/ssh/ssh-provider.ts',
|
||||
'src/main/providers/local-pty-provider.ts',
|
||||
'src/shared/child-process/run-process.ts',
|
||||
'src/main/persistence/profile-state/store.ts',
|
||||
'src/main/sqlite/database.ts',
|
||||
'src/main/orcad/entry.ts',
|
||||
'src/main/runtime/windows-terminal.ts',
|
||||
'src/shared/linux-glibc.ts',
|
||||
'src/main/daemon/entry.ts',
|
||||
'src/relay/index.ts',
|
||||
'src/main/wsl/runner.ts'
|
||||
])('retains all platforms for sensitive input %s', (file) => {
|
||||
expect(bunProfileQualification([file], scope, draft)).toEqual({
|
||||
qualification: true,
|
||||
runners: BUN_PERSISTENCE_RUNNERS
|
||||
})
|
||||
})
|
||||
|
||||
it('qualifies every ready commit, scheduled/manual runs and incomplete evidence', () => {
|
||||
const paths = ['src/main/runtime/rpc/methods/example.ts']
|
||||
for (const event of [{}, { pull_request: { draft: false } }]) {
|
||||
expect(bunProfileQualification(paths, scope, event).qualification).toBe(true)
|
||||
}
|
||||
expect(bunProfileQualification([], scope, draft).qualification).toBe(true)
|
||||
expect(
|
||||
bunProfileQualification(paths, { ...scope, graphUnavailable: true }, draft).qualification
|
||||
).toBe(true)
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { BUN_PERSISTENCE_RUNNERS } from './bun-profile-qualification.mjs'
|
||||
|
||||
const readWorkflow = (name) =>
|
||||
parse(readFileSync(new URL(`../../.github/workflows/${name}.yml`, import.meta.url), 'utf8'))
|
||||
@@ -50,9 +51,8 @@ it('bounds warming to the required platforms and validates changes without grant
|
||||
|
||||
it('warms and probes both Windows images with the persistence job runtime', () => {
|
||||
const job = workflow.jobs['warm-windows']
|
||||
const persistence = readWorkflow('bun-profile-tests').jobs.persistence
|
||||
expect(job.strategy.matrix.os).toEqual(
|
||||
persistence.strategy.matrix.os.filter((os) => os.startsWith('windows-'))
|
||||
BUN_PERSISTENCE_RUNNERS.filter((os) => os.startsWith('windows-'))
|
||||
)
|
||||
expect(job['runs-on']).toBe('${{ matrix.os }}')
|
||||
expect(job.strategy['fail-fast']).toBe(false)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { appendFileSync, readFileSync } from 'node:fs'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { trackE2eFailures } from './ci-e2e-failure-tracking.mjs'
|
||||
|
||||
export function e2eFailureSummary(report) {
|
||||
const failures = []
|
||||
let passed = 0
|
||||
let skipped = 0
|
||||
function visit(suite, parents = [], parentFile) {
|
||||
const titles = [...parents, suite.title].filter(Boolean)
|
||||
const file = suite.file ?? parentFile
|
||||
for (const spec of suite.specs ?? []) {
|
||||
for (const test of spec.tests ?? []) {
|
||||
if (test.status === 'expected') {
|
||||
passed++
|
||||
continue
|
||||
}
|
||||
if (test.status === 'skipped') {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
const errors = test.results.flatMap((result) => result.errors ?? [])
|
||||
failures.push({
|
||||
file: spec.file ?? file,
|
||||
title: [...titles, spec.title].join(' › '),
|
||||
project: test.projectName,
|
||||
status: test.status,
|
||||
message: errors
|
||||
.map((error) => error.message ?? error.value ?? '')
|
||||
.join('\n')
|
||||
.slice(0, 2000)
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const child of suite.suites ?? []) {
|
||||
visit(child, titles, file)
|
||||
}
|
||||
}
|
||||
for (const suite of report.suites ?? []) {
|
||||
visit(suite)
|
||||
}
|
||||
return { passed, skipped, failures, errors: report.errors ?? [] }
|
||||
}
|
||||
|
||||
export function renderE2eFailures(summary, records = [], now = new Date()) {
|
||||
const escape = (value) =>
|
||||
String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
const tracked = trackE2eFailures(summary.failures, records, now)
|
||||
const details = (failure) =>
|
||||
`<details><summary>${escape(failure.status)}: ${escape(failure.file)} — ${escape(failure.title)}</summary><pre>${escape(failure.message)}</pre></details>`
|
||||
return [
|
||||
'## E2E results',
|
||||
'',
|
||||
`${summary.passed} expected results; ${summary.skipped} skipped; ${summary.failures.length} unexpected/flaky results; ${summary.errors.length} run errors.`,
|
||||
'',
|
||||
`### Untracked failures (${tracked.untracked.length})`,
|
||||
'',
|
||||
...tracked.untracked.map(details),
|
||||
'',
|
||||
`### Tracked failures (${tracked.known.length})`,
|
||||
'',
|
||||
...tracked.known.flatMap((failure) => [
|
||||
details(failure),
|
||||
`Owner: ${escape(failure.tracking.owner)}; ${escape(failure.tracking.issue)}; expires ${escape(failure.tracking.expires)}.`
|
||||
]),
|
||||
...(tracked.invalid.length
|
||||
? [`${tracked.invalid.length} invalid/expired tracking entries were not used.`]
|
||||
: []),
|
||||
...summary.errors.map((error) => `<pre>${escape(error.message ?? error.value)}</pre>`),
|
||||
'',
|
||||
'Failures retain their original verdict. Repeated failures need a tracked owner, reproduction and review date; do not treat a red baseline as passing.',
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
try {
|
||||
const summary = e2eFailureSummary(JSON.parse(readFileSync(process.argv[2], 'utf8')))
|
||||
const records = JSON.parse(
|
||||
readFileSync(new URL('../e2e-failure-tracking.json', import.meta.url), 'utf8')
|
||||
)
|
||||
appendFileSync(process.env.GITHUB_STEP_SUMMARY, renderE2eFailures(summary, records))
|
||||
} catch (error) {
|
||||
appendFileSync(
|
||||
process.env.GITHUB_STEP_SUMMARY,
|
||||
`E2E report unavailable (${error.code ?? 'invalid report'}); inspect the failing step and traces.\n`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { e2eFailureSummary, renderE2eFailures } from './ci-e2e-failure-summary.mjs'
|
||||
import { trackE2eFailures } from './ci-e2e-failure-tracking.mjs'
|
||||
|
||||
it('requires exact failures with an owner, issue and unexpired review date', () => {
|
||||
const failure = {
|
||||
file: 'test.spec.ts',
|
||||
title: 'case',
|
||||
project: 'electron',
|
||||
message: 'expected focus failed'
|
||||
}
|
||||
const record = {
|
||||
...failure,
|
||||
message: 'expected focus',
|
||||
owner: '@owner',
|
||||
issue: 'https://github.com/stablyai/orca/issues/123',
|
||||
expires: '2026-10-01'
|
||||
}
|
||||
const now = new Date('2026-09-28')
|
||||
expect(trackE2eFailures([failure], [null, record], now)).toMatchObject({
|
||||
known: [{ ...failure, tracking: record }],
|
||||
invalid: [null]
|
||||
})
|
||||
expect(trackE2eFailures([failure], null, now).untracked).toEqual([failure])
|
||||
for (const change of [
|
||||
{ expires: '2026-09-27' },
|
||||
{ expires: '2026-09-31' },
|
||||
{ owner: '' },
|
||||
{ issue: '' },
|
||||
{ title: 'different' },
|
||||
{ message: 'different' },
|
||||
{ project: 'other' }
|
||||
]) {
|
||||
expect(trackE2eFailures([failure], [{ ...record, ...change }], now).untracked).toEqual([
|
||||
failure
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps failure, flaky, skipped and startup-error evidence separate', () => {
|
||||
const report = {
|
||||
errors: [{ message: 'startup failed' }],
|
||||
suites: [
|
||||
{
|
||||
title: 'file',
|
||||
file: 'tests/e2e/test.spec.ts',
|
||||
suites: [
|
||||
{
|
||||
title: 'feature',
|
||||
specs: [
|
||||
{
|
||||
title: 'case',
|
||||
tests: [
|
||||
{ status: 'expected' },
|
||||
{ status: 'skipped' },
|
||||
{
|
||||
status: 'unexpected',
|
||||
projectName: 'electron-headless',
|
||||
results: [{ errors: [{ message: '<timeout>' }] }]
|
||||
},
|
||||
{
|
||||
status: 'flaky',
|
||||
results: [{ errors: [{ message: 'first try' }] }, { errors: [] }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
const summary = e2eFailureSummary(report)
|
||||
expect(summary).toMatchObject({ passed: 1, skipped: 1, errors: report.errors })
|
||||
expect(summary.failures).toHaveLength(2)
|
||||
expect(summary.failures[0].title).toBe('file › feature › case')
|
||||
expect(renderE2eFailures(summary)).toContain('<timeout>')
|
||||
expect(renderE2eFailures(summary)).toContain('startup failed')
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
export function trackE2eFailures(failures, records, now = new Date()) {
|
||||
const known = []
|
||||
const untracked = []
|
||||
const invalid = []
|
||||
const active = (Array.isArray(records) ? records : [records]).filter((record) => {
|
||||
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
||||
invalid.push(record)
|
||||
return false
|
||||
}
|
||||
const expiry = new Date(`${record.expires}T23:59:59Z`)
|
||||
const valid =
|
||||
typeof record.file === 'string' &&
|
||||
typeof record.title === 'string' &&
|
||||
typeof record.message === 'string' &&
|
||||
record.message.length > 0 &&
|
||||
/^@[\w-]+(?:\/[\w-]+)?$/.test(record.owner ?? '') &&
|
||||
/^https:\/\/github\.com\/stablyai\/orca\/issues\/\d+$/.test(record.issue ?? '') &&
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(record.expires ?? '') &&
|
||||
Number.isFinite(expiry.getTime()) &&
|
||||
expiry.toISOString().slice(0, 10) === record.expires &&
|
||||
expiry.getTime() >= now.getTime()
|
||||
if (!valid) {
|
||||
invalid.push(record)
|
||||
}
|
||||
return valid
|
||||
})
|
||||
for (const failure of failures) {
|
||||
const record = active.find(
|
||||
(entry) =>
|
||||
entry.file === failure.file &&
|
||||
entry.title === failure.title &&
|
||||
entry.project === failure.project &&
|
||||
failure.message.includes(entry.message)
|
||||
)
|
||||
if (record) {
|
||||
known.push({ ...failure, tracking: record })
|
||||
} else {
|
||||
untracked.push(failure)
|
||||
}
|
||||
}
|
||||
return { known, untracked, invalid }
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { runnerDemand, sampleWorkflowRuns } from './ci-runner-metrics.mjs'
|
||||
|
||||
async function api(path, env) {
|
||||
const response = await fetch(
|
||||
`${env.GITHUB_API_URL ?? 'https://api.github.com'}/repos/${env.GITHUB_REPOSITORY}/${path}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.GH_TOKEN}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28'
|
||||
},
|
||||
signal: AbortSignal.timeout(30_000)
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Actions API: ${response.status}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function collectRunnerDemand(env = process.env, request = (path) => api(path, env)) {
|
||||
const end = new Date(env.CI_METRICS_END ?? Date.now())
|
||||
end.setUTCMinutes(0, 0, 0)
|
||||
if (!Number.isFinite(end.getTime())) {
|
||||
throw new Error('Invalid window end')
|
||||
}
|
||||
const runs = []
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
const until = new Date(end.getTime() - hour * 3_600_000 - 1000)
|
||||
const since = new Date(end.getTime() - (hour + 1) * 3_600_000)
|
||||
const range = `${since.toISOString()}..${until.toISOString()}`
|
||||
const path = `actions/runs?per_page=100&created=${encodeURIComponent(range)}`
|
||||
const first = await request(path)
|
||||
if (first.total_count > 1000) {
|
||||
throw new Error('Hourly run inventory exceeds API limit; split the interval')
|
||||
}
|
||||
runs.push(...first.workflow_runs)
|
||||
for (let page = 2; page <= Math.ceil(first.total_count / 100); page++) {
|
||||
runs.push(...(await request(`${path}&page=${page}`)).workflow_runs)
|
||||
}
|
||||
}
|
||||
const samples = sampleWorkflowRuns(runs)
|
||||
// Bound API concurrency and preserve every selected observation, including zero-job runs.
|
||||
for (let index = 0; index < samples.length; index += 4) {
|
||||
await Promise.all(
|
||||
samples.slice(index, index + 4).map(async (sample) => {
|
||||
const path = `actions/runs/${sample.run.id}/jobs?per_page=100`
|
||||
const first = await request(path)
|
||||
sample.jobs = first.jobs
|
||||
for (let page = 2; page <= Math.ceil(first.total_count / 100); page++) {
|
||||
sample.jobs.push(...(await request(`${path}&page=${page}`)).jobs)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
const report = {
|
||||
start: new Date(end.getTime() - 86_400_000).toISOString(),
|
||||
end: end.toISOString(),
|
||||
method:
|
||||
'Stratified by workflow/conclusion, six sampled runs per stratum; latest attempts only; incomplete jobs excluded',
|
||||
...runnerDemand(runs, samples)
|
||||
}
|
||||
return { report, runs, samples }
|
||||
}
|
||||
|
||||
export function demandMarkdown(report) {
|
||||
return [
|
||||
`## CI demand: ${report.start} to ${report.end}`,
|
||||
'',
|
||||
`Estimated full job duration for runs created in this window (not window-clipped occupancy); ${report.sampledRuns}/${report.populationRuns} runs sampled. ${report.method}.`,
|
||||
'',
|
||||
'| Workflow | Runs | Runner hours | Cancelled-run hours | Minutes/completed PR run |',
|
||||
'| --- | ---: | ---: | ---: | ---: |',
|
||||
...report.workflows.map(
|
||||
(row) =>
|
||||
`| ${row.workflow} | ${row.runs} | ${(row.runnerMinutes / 60).toFixed(1)} | ${(row.cancelledRunnerMinutes / 60).toFixed(1)} | ${row.runnerMinutesPerCompletedPrRun?.toFixed(1) ?? '—'} |`
|
||||
),
|
||||
'',
|
||||
'| Runner labels | Hours | Queue/provisioning p95 minutes |',
|
||||
'| --- | ---: | ---: |',
|
||||
...report.pools.map(
|
||||
(pool) =>
|
||||
`| ${pool.label} | ${(pool.runnerMinutes / 60).toFixed(1)} | ${pool.queueP95Minutes?.toFixed(1) ?? '—'} |`
|
||||
),
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const evidence = await collectRunnerDemand()
|
||||
mkdirSync('ci-demand', { recursive: true })
|
||||
writeFileSync('ci-demand/evidence.json', JSON.stringify(evidence))
|
||||
const markdown = demandMarkdown(evidence.report)
|
||||
writeFileSync('ci-demand/report.md', markdown)
|
||||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||||
appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
const minutesBetween = (start, end) => {
|
||||
const value = (Date.parse(end) - Date.parse(start)) / 60_000
|
||||
return Number.isFinite(value) ? Math.max(0, value) : 0
|
||||
}
|
||||
|
||||
const workflowPath = (run) => run.path.split('@')[0]
|
||||
const workflowKey = (run) => run.workflow_id ?? workflowPath(run)
|
||||
|
||||
export function weightedPercentile(values, percentile) {
|
||||
const sorted = [...values].sort((a, b) => a.value - b.value)
|
||||
const target = sorted.reduce((sum, item) => sum + item.weight, 0) * percentile
|
||||
let seen = 0
|
||||
for (const item of sorted) {
|
||||
seen += item.weight
|
||||
if (seen >= target) {
|
||||
return item.value
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function runnerDemand(runs, samples) {
|
||||
const workflows = new Map()
|
||||
const pools = new Map()
|
||||
for (const run of runs) {
|
||||
if (!workflows.has(workflowKey(run))) {
|
||||
workflows.set(workflowKey(run), {
|
||||
workflow: workflowPath(run),
|
||||
runs: 0,
|
||||
sampledRuns: 0,
|
||||
runnerMinutes: 0,
|
||||
cancelledRunnerMinutes: 0,
|
||||
completedPrRuns: 0,
|
||||
completedPrRunnerMinutes: 0
|
||||
})
|
||||
}
|
||||
workflows.get(workflowKey(run)).runs++
|
||||
}
|
||||
let incompleteJobs = 0
|
||||
for (const { run, jobs, weight } of samples) {
|
||||
const row = workflows.get(workflowKey(run))
|
||||
row.sampledRuns++
|
||||
for (const job of jobs) {
|
||||
if (!job.runner_name || !job.started_at) {
|
||||
continue
|
||||
}
|
||||
if (!job.completed_at) {
|
||||
incompleteJobs++
|
||||
continue
|
||||
}
|
||||
const duration = minutesBetween(job.started_at, job.completed_at)
|
||||
const weighted = duration * weight
|
||||
row.runnerMinutes += weighted
|
||||
if (run.conclusion === 'cancelled') {
|
||||
row.cancelledRunnerMinutes += weighted
|
||||
}
|
||||
if (run.event === 'pull_request' && ['success', 'failure'].includes(run.conclusion)) {
|
||||
row.completedPrRunnerMinutes += weighted
|
||||
}
|
||||
const label = [...job.labels].sort().join(', ') || 'unknown'
|
||||
if (!pools.has(label)) {
|
||||
pools.set(label, { label, runnerMinutes: 0, waits: [] })
|
||||
}
|
||||
const pool = pools.get(label)
|
||||
pool.runnerMinutes += weighted
|
||||
if (job.created_at) {
|
||||
pool.waits.push({ value: minutesBetween(job.created_at, job.started_at), weight })
|
||||
}
|
||||
}
|
||||
if (run.event === 'pull_request' && ['success', 'failure'].includes(run.conclusion)) {
|
||||
row.completedPrRuns += weight
|
||||
}
|
||||
}
|
||||
return {
|
||||
populationRuns: runs.length,
|
||||
sampledRuns: samples.length,
|
||||
incompleteJobs,
|
||||
workflows: [...workflows.values()]
|
||||
.map((row) => ({
|
||||
...row,
|
||||
runnerMinutesPerCompletedPrRun: row.completedPrRuns
|
||||
? row.completedPrRunnerMinutes / row.completedPrRuns
|
||||
: null
|
||||
}))
|
||||
.sort((a, b) => b.runnerMinutes - a.runnerMinutes),
|
||||
pools: [...pools.values()]
|
||||
.map(({ waits, ...pool }) => ({ ...pool, queueP95Minutes: weightedPercentile(waits, 0.95) }))
|
||||
.sort((a, b) => b.runnerMinutes - a.runnerMinutes)
|
||||
}
|
||||
}
|
||||
|
||||
export function sampleWorkflowRuns(runs, perStratum = 6, random = Math.random) {
|
||||
const groups = new Map()
|
||||
for (const run of runs) {
|
||||
const key = `${workflowKey(run)}:${run.conclusion ?? run.status}`
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, [])
|
||||
}
|
||||
groups.get(key).push(run)
|
||||
}
|
||||
return [...groups.values()].flatMap((group) => {
|
||||
const shuffled = [...group]
|
||||
for (let index = shuffled.length - 1; index > 0; index--) {
|
||||
const target = Math.floor(random() * (index + 1))
|
||||
;[shuffled[index], shuffled[target]] = [shuffled[target], shuffled[index]]
|
||||
}
|
||||
const selected = shuffled.slice(0, perStratum)
|
||||
return selected.map((run) => ({ run, weight: group.length / selected.length }))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { runnerDemand, sampleWorkflowRuns } from './ci-runner-metrics.mjs'
|
||||
import { collectRunnerDemand } from './ci-runner-demand.mjs'
|
||||
|
||||
const run = {
|
||||
id: 1,
|
||||
path: '.github/workflows/pr.yml',
|
||||
event: 'pull_request',
|
||||
conclusion: 'success'
|
||||
}
|
||||
const job = {
|
||||
runner_name: 'hosted',
|
||||
labels: ['ubuntu-latest'],
|
||||
created_at: '2026-09-27T00:00:00Z',
|
||||
started_at: '2026-09-27T00:03:00Z',
|
||||
completed_at: '2026-09-27T00:08:00Z'
|
||||
}
|
||||
|
||||
it('separates occupancy, queueing, cancellations and zero-job observations', () => {
|
||||
const cancelled = { ...run, id: 2, conclusion: 'cancelled' }
|
||||
const report = runnerDemand(
|
||||
[run, cancelled],
|
||||
[
|
||||
{ run, weight: 2, jobs: [job, { ...job, runner_name: null }] },
|
||||
{ run: cancelled, weight: 3, jobs: [job, { ...job, completed_at: null }] }
|
||||
]
|
||||
)
|
||||
expect(report.workflows[0]).toMatchObject({
|
||||
runnerMinutes: 25,
|
||||
cancelledRunnerMinutes: 15,
|
||||
runnerMinutesPerCompletedPrRun: 5
|
||||
})
|
||||
expect(report.pools[0].queueP95Minutes).toBe(3)
|
||||
expect(report.incompleteJobs).toBe(1)
|
||||
expect(runnerDemand([run], [{ run, weight: 1, jobs: [] }]).workflows[0].runnerMinutes).toBe(0)
|
||||
})
|
||||
|
||||
it('weights each workflow/outcome stratum back to the full inventory', () => {
|
||||
const runs = Array.from({ length: 20 }, (_, index) => ({
|
||||
...run,
|
||||
id: index,
|
||||
conclusion: index < 10 ? 'success' : 'failure'
|
||||
}))
|
||||
const sample = sampleWorkflowRuns(runs, 2, () => 0.5)
|
||||
expect(sample).toHaveLength(4)
|
||||
expect(sample.reduce((sum, row) => sum + row.weight, 0)).toBe(20)
|
||||
expect(new Set(sample.map((row) => row.run.id)).size).toBe(4)
|
||||
})
|
||||
|
||||
it('paginates jobs and bounds run discovery to 24 complete hours', async () => {
|
||||
const requests = []
|
||||
const result = await collectRunnerDemand(
|
||||
{ CI_METRICS_END: '2026-09-28T04:59:00Z' },
|
||||
async (path) => {
|
||||
requests.push(path)
|
||||
if (path.includes('/jobs')) {
|
||||
return {
|
||||
total_count: 101,
|
||||
jobs: path.includes('page=2') ? [job] : Array.from({ length: 100 }, () => job)
|
||||
}
|
||||
}
|
||||
return {
|
||||
total_count: requests.length === 1 ? 1 : 0,
|
||||
workflow_runs: requests.length === 1 ? [run] : []
|
||||
}
|
||||
}
|
||||
)
|
||||
expect(result.report.start).toBe('2026-09-27T04:00:00.000Z')
|
||||
expect(result.report.end).toBe('2026-09-28T04:00:00.000Z')
|
||||
expect(requests.filter((path) => path.startsWith('actions/runs?'))).toHaveLength(24)
|
||||
expect(result.samples[0].jobs).toHaveLength(101)
|
||||
expect(result.report.workflows[0].runnerMinutes).toBe(505)
|
||||
})
|
||||
|
||||
it('refuses to publish a silently truncated inventory', async () => {
|
||||
await expect(
|
||||
collectRunnerDemand({}, async () => ({ total_count: 1001, workflow_runs: [] }))
|
||||
).rejects.toThrow('API limit')
|
||||
})
|
||||
|
||||
it('groups ref-qualified paths under the stable workflow ID', () => {
|
||||
const runs = [
|
||||
{ ...run, id: 1, workflow_id: 42, path: '.github/workflows/pr.yml@main' },
|
||||
{ ...run, id: 2, workflow_id: 42, path: '.github/workflows/pr.yml@feature' }
|
||||
]
|
||||
const sample = sampleWorkflowRuns(runs, 1, () => 0.5)
|
||||
expect(sample).toHaveLength(1)
|
||||
expect(sample[0].weight).toBe(2)
|
||||
expect(
|
||||
runnerDemand(
|
||||
runs,
|
||||
sample.map((row) => ({ ...row, jobs: [job] }))
|
||||
).workflows
|
||||
).toMatchObject([{ workflow: '.github/workflows/pr.yml', runs: 2, runnerMinutes: 10 }])
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { posix, join } from 'node:path'
|
||||
import ts from 'typescript-api'
|
||||
|
||||
const EXTENSIONS = [
|
||||
'',
|
||||
'.ts',
|
||||
'.tsx',
|
||||
'.mjs',
|
||||
'.js',
|
||||
'.cjs',
|
||||
'.json',
|
||||
'/index.ts',
|
||||
'/index.tsx',
|
||||
'/index.js'
|
||||
]
|
||||
const INDIRECT_INPUT =
|
||||
/\b(?:readFile\w*|readdir\w*|glob\w*|spawn\w*|execFile\w*|execSync|runProcess\w*|fork|Worker)\b|\bimport\s*\(\s*[^'"\s]|\brequire\s*\(\s*[^'"\s]|\bnew\s+URL\s*\(/
|
||||
|
||||
function localPath(file, specifier) {
|
||||
if (specifier.startsWith('.')) {
|
||||
return posix.join(posix.dirname(file), specifier)
|
||||
}
|
||||
if (specifier.startsWith('@renderer/')) {
|
||||
return `src/renderer/src/${specifier.slice(10)}`
|
||||
}
|
||||
if (specifier.startsWith('@/')) {
|
||||
return `src/renderer/src/${specifier.slice(2)}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function buildUnitDependencyGraph(sources) {
|
||||
const reverse = new Map()
|
||||
const opaque = new Set()
|
||||
for (const [file, source] of sources) {
|
||||
if (INDIRECT_INPUT.test(source) || file.startsWith('config/') || file.startsWith('tests/')) {
|
||||
opaque.add(file)
|
||||
}
|
||||
for (const imported of ts.preProcessFile(source, true, true).importedFiles) {
|
||||
const path = localPath(file, imported.fileName)
|
||||
if (path === null) {
|
||||
continue
|
||||
}
|
||||
const resolved = EXTENSIONS.map((extension) => path + extension).find((candidate) =>
|
||||
sources.has(candidate)
|
||||
)
|
||||
if (!resolved) {
|
||||
opaque.add(file)
|
||||
continue
|
||||
}
|
||||
if (!reverse.has(resolved)) {
|
||||
reverse.set(resolved, new Set())
|
||||
}
|
||||
reverse.get(resolved).add(file)
|
||||
}
|
||||
}
|
||||
return { reverse, opaque }
|
||||
}
|
||||
|
||||
export function collectUnitDependencyGraph(root = process.cwd()) {
|
||||
const files = globSync(
|
||||
[
|
||||
'src/**/*.{ts,tsx,js,mjs,cjs,json}',
|
||||
'config/**/*.{ts,tsx,js,mjs,cjs,json}',
|
||||
'tests/**/*.{ts,tsx,js,mjs,cjs,json}'
|
||||
],
|
||||
{ cwd: root }
|
||||
)
|
||||
const sources = new Map(
|
||||
files.map((file) => [file.replaceAll('\\', '/'), readFileSync(join(root, file), 'utf8')])
|
||||
)
|
||||
return { ...buildUnitDependencyGraph(sources), files: new Set(sources.keys()) }
|
||||
}
|
||||
|
||||
export function unitConsumers(seeds, reverse) {
|
||||
const result = new Set(seeds)
|
||||
for (const file of result) {
|
||||
for (const consumer of reverse.get(file) ?? []) {
|
||||
result.add(consumer)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { globSync } from 'node:fs'
|
||||
import { defaultExclude } from 'vitest/config'
|
||||
|
||||
export const UNIT_INCLUDE = [
|
||||
'src/**/*.test.ts',
|
||||
'src/**/*.test.tsx',
|
||||
'config/scripts/**/*.test.ts',
|
||||
'config/scripts/**/*.test.mjs',
|
||||
'tests/tools/**/*.test.mjs',
|
||||
'tests/e2e/**/*.unit.test.ts'
|
||||
]
|
||||
|
||||
export const UNIT_EXCLUDE = [
|
||||
...defaultExclude,
|
||||
'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts',
|
||||
'src/main/daemon/shell-ready.test.ts',
|
||||
'src/main/daemon/node-pty-fd-leak.test.ts',
|
||||
'src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts',
|
||||
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
|
||||
'src/main/pty/omp-shell-wrapper-alias-safety.test.ts',
|
||||
'src/main/pty/omp-shell-wrapper.node-pty.test.ts',
|
||||
'src/main/shell-startup-feature-channel.test.ts',
|
||||
'src/main/terminal-history-fish-session.node-pty.test.ts',
|
||||
'src/main/zsh-scoped-histfile.live-shell.test.ts',
|
||||
'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts',
|
||||
'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts',
|
||||
'src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts',
|
||||
'src/shared/fish-query-reply-child-stdin.node-pty.test.ts',
|
||||
'src/shared/pty-reply-echo-shapes.node-pty.test.ts',
|
||||
'src/shared/startup-shell-portability.live-shell.test.ts',
|
||||
'src/shared/posix-command-path-lookup.test.ts',
|
||||
'tests/e2e/relay-region-compatibility.unit.test.ts',
|
||||
'tests/e2e/relay-region-correction.unit.test.ts',
|
||||
'tests/e2e/cross-version-wire/**'
|
||||
]
|
||||
|
||||
export function discoverUnitFiles(root = process.cwd()) {
|
||||
return globSync(UNIT_INCLUDE, { cwd: root, exclude: UNIT_EXCLUDE })
|
||||
.map((file) => file.replaceAll('\\', '/'))
|
||||
.sort()
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { appendFileSync, readFileSync } from 'node:fs'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { runProcessSync } from './script-child-process.mjs'
|
||||
import { collectUnitDependencyGraph } from './ci-unit-dependency-graph.mjs'
|
||||
import { discoverUnitFiles } from './ci-unit-files.mjs'
|
||||
import { planUnitSelection } from './ci-unit-selection.mjs'
|
||||
import { readTimingBaseline, writeAssignment } from './ci-shard-assignment.mjs'
|
||||
|
||||
export function prepareUnitPlan(env = process.env) {
|
||||
const files = discoverUnitFiles()
|
||||
let plan
|
||||
try {
|
||||
const event = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, 'utf8'))
|
||||
if (env.GITHUB_EVENT_NAME !== 'pull_request') {
|
||||
throw new Error('Full reference run')
|
||||
}
|
||||
const diff = runProcessSync({
|
||||
program: 'git',
|
||||
args: ['diff', '--name-only', '--no-renames', '-z', 'HEAD^1', 'HEAD'],
|
||||
maxOutputBytes: 16 * 1024 * 1024
|
||||
})
|
||||
if (diff.code !== 0 || diff.timedOut) {
|
||||
throw new Error('Changed paths unavailable')
|
||||
}
|
||||
plan = planUnitSelection({
|
||||
files,
|
||||
changed: diff.stdout.split('\0').filter(Boolean),
|
||||
graph: collectUnitDependencyGraph(),
|
||||
timings: readTimingBaseline('unit').timings,
|
||||
event,
|
||||
mode: env.ORCA_UNIT_SELECTION_MODE
|
||||
})
|
||||
} catch (error) {
|
||||
plan = {
|
||||
version: 1,
|
||||
mode: 'shadow',
|
||||
selectionAvailable: false,
|
||||
reason: String(error),
|
||||
files,
|
||||
candidateFiles: files,
|
||||
executionFiles: files,
|
||||
shards: Array.from({ length: 8 }, (_, index) => ({ index: index + 1, count: 8 }))
|
||||
}
|
||||
}
|
||||
writeAssignment('ci-shards/unit-selection.json', plan)
|
||||
if (env.GITHUB_OUTPUT) {
|
||||
appendFileSync(env.GITHUB_OUTPUT, `shards=${JSON.stringify(plan.shards)}\n`)
|
||||
}
|
||||
if (env.GITHUB_STEP_SUMMARY) {
|
||||
appendFileSync(
|
||||
env.GITHUB_STEP_SUMMARY,
|
||||
`Unit selection: **${plan.mode}**; ${plan.candidateFiles.length}/${files.length} candidate files; ${plan.shards.length} execution shards. ${plan.reason}\n`
|
||||
)
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
prepareUnitPlan()
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { expect, it } from 'vitest'
|
||||
import { runProcessSync } from './script-child-process.mjs'
|
||||
|
||||
it.each([true, false])(
|
||||
'plans from a real Git diff, with parent evidence available: %s',
|
||||
(withParent) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-unit-plan-'))
|
||||
const git = (args) => {
|
||||
const result = runProcessSync({ program: 'git', args, cwd: root })
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
return result.stdout.trim()
|
||||
}
|
||||
try {
|
||||
mkdirSync(join(root, 'src'))
|
||||
writeFileSync(join(root, 'src/value.ts'), 'export const value = 1')
|
||||
writeFileSync(join(root, 'src/consumer.test.ts'), "import './value'")
|
||||
writeFileSync(join(root, 'src/unrelated.test.ts'), 'export const unrelated = true')
|
||||
git(['init', '--quiet'])
|
||||
git(['add', 'src'])
|
||||
const commit = [
|
||||
'-c',
|
||||
'user.name=CI Test',
|
||||
'-c',
|
||||
'user.email=ci-test@example.invalid',
|
||||
'-c',
|
||||
'commit.gpgsign=false',
|
||||
'commit',
|
||||
'--quiet',
|
||||
'-m',
|
||||
'fixture'
|
||||
]
|
||||
git(commit)
|
||||
if (withParent) {
|
||||
writeFileSync(join(root, 'src/value.ts'), 'export const value = 2')
|
||||
git(['add', 'src'])
|
||||
git(commit)
|
||||
}
|
||||
const sourceSha = git(['rev-parse', 'HEAD'])
|
||||
const eventPath = join(root, 'event.json')
|
||||
writeFileSync(eventPath, JSON.stringify({ pull_request: { draft: true } }))
|
||||
const result = runProcessSync({
|
||||
program: process.execPath,
|
||||
args: [fileURLToPath(new URL('./ci-unit-plan.mjs', import.meta.url))],
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_BACKGROUND_LAUNCH: '1',
|
||||
ORCA_UNIT_SELECTION_MODE: 'selected',
|
||||
GITHUB_EVENT_NAME: 'pull_request',
|
||||
GITHUB_EVENT_PATH: eventPath,
|
||||
GITHUB_SHA: sourceSha,
|
||||
ORCA_SHARD_SOURCE_SHA: sourceSha,
|
||||
GITHUB_OUTPUT: join(root, 'outputs'),
|
||||
GITHUB_STEP_SUMMARY: join(root, 'summary')
|
||||
}
|
||||
})
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
const plan = JSON.parse(readFileSync(join(root, 'ci-shards/unit-selection.json'), 'utf8'))
|
||||
expect(plan.sourceSha).toBe(sourceSha)
|
||||
expect(plan.mode).toBe(withParent ? 'selected' : 'shadow')
|
||||
expect(plan.executionFiles).toEqual(
|
||||
withParent ? ['src/consumer.test.ts'] : ['src/consumer.test.ts', 'src/unrelated.test.ts']
|
||||
)
|
||||
expect(plan.reason).toBe(
|
||||
withParent
|
||||
? 'Transitive imports plus indirect-input consumers'
|
||||
: 'Error: Changed paths unavailable'
|
||||
)
|
||||
expect(plan.shards).toHaveLength(withParent ? 1 : 8)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
import { appendFileSync, globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
export function reviewUnitSelection(records) {
|
||||
const groups = new Map()
|
||||
for (const { plan, timing } of records) {
|
||||
const key = JSON.stringify([
|
||||
timing.sourceSha,
|
||||
timing.runId,
|
||||
timing.runAttempt,
|
||||
timing.nodeVersion
|
||||
])
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, [])
|
||||
}
|
||||
groups.get(key).push({ plan, timing })
|
||||
}
|
||||
return [...groups.values()].map((group) => {
|
||||
const { plan, timing: first } = group[0]
|
||||
const selected = new Set(plan.candidateFiles)
|
||||
const files = new Set()
|
||||
const shards = new Set()
|
||||
const missedFailures = []
|
||||
let invalid = false
|
||||
let omittedMs = 0
|
||||
let totalMs = 0
|
||||
for (const { plan: other, timing } of group) {
|
||||
invalid ||=
|
||||
JSON.stringify(other) !== JSON.stringify(plan) ||
|
||||
!first.sourceSha ||
|
||||
plan.sourceSha !== first.sourceSha ||
|
||||
timing.unhandledErrors !== 0 ||
|
||||
!['passed', 'failed'].includes(timing.status) ||
|
||||
timing.shard.count !== first.shard.count ||
|
||||
shards.has(timing.shard.index) ||
|
||||
timing.shard.index < 1 ||
|
||||
timing.shard.index > first.shard.count
|
||||
shards.add(timing.shard.index)
|
||||
for (const [file, duration] of Object.entries(timing.timings)) {
|
||||
invalid ||=
|
||||
files.has(file) ||
|
||||
!Number.isFinite(duration) ||
|
||||
duration <= 0 ||
|
||||
!['passed', 'failed', 'skipped'].includes(timing.results?.[file])
|
||||
files.add(file)
|
||||
totalMs += duration
|
||||
if (!selected.has(file)) {
|
||||
omittedMs += duration
|
||||
if (timing.results?.[file] === 'failed') {
|
||||
missedFailures.push(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const complete =
|
||||
!invalid &&
|
||||
shards.size === first.shard.count &&
|
||||
JSON.stringify([...files].sort()) === JSON.stringify([...plan.files].sort())
|
||||
return {
|
||||
sourceSha: first.sourceSha,
|
||||
runId: first.runId,
|
||||
runAttempt: first.runAttempt,
|
||||
nodeVersion: first.nodeVersion,
|
||||
mode: plan.mode,
|
||||
completeFullRun: complete && plan.mode === 'shadow',
|
||||
selectionEvaluated: complete && plan.mode === 'shadow' && plan.selectionAvailable === true,
|
||||
reason: plan.reason,
|
||||
missedFailures,
|
||||
files: files.size,
|
||||
candidateFiles: selected.size,
|
||||
measuredWorkerMs: totalMs,
|
||||
potentiallyOmittedWorkerMs: omittedMs
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const directory = process.argv[2]
|
||||
if (!directory) {
|
||||
throw new Error('Usage: ci-unit-selection-review.mjs ARTIFACT_DIRECTORY')
|
||||
}
|
||||
const records = globSync('**/unit-timings.json', { cwd: directory }).map((file) => {
|
||||
const timing = JSON.parse(readFileSync(join(directory, file), 'utf8'))
|
||||
const plan = JSON.parse(
|
||||
readFileSync(
|
||||
join(directory, file.replace('unit-timings.json', 'unit-selection.json')),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
return { timing, plan }
|
||||
})
|
||||
if (!records.length) {
|
||||
throw new Error('No unit selection evidence')
|
||||
}
|
||||
const review = reviewUnitSelection(records)
|
||||
writeFileSync(join(directory, 'selection-review.json'), `${JSON.stringify(review, null, 2)}\n`)
|
||||
const summary = [
|
||||
'## Unit selection evidence',
|
||||
'',
|
||||
...review.map(
|
||||
(row) =>
|
||||
`- Node ${row.nodeVersion}: ${row.completeFullRun ? 'complete full reference' : 'incomplete or selected evidence'}; ${row.selectionEvaluated ? 'selection evaluated' : 'not selection-validation evidence'}; ${row.candidateFiles}/${row.files} candidate files; ${row.missedFailures.length} failures outside selection; ${(row.potentiallyOmittedWorkerMs / 60_000).toFixed(1)} potentially omitted worker-minutes. ${row.reason ?? ''}`
|
||||
),
|
||||
'',
|
||||
'Worker time overlaps across processes; it is not runner time. Promote only after representative complete references show no missed failures.',
|
||||
''
|
||||
].join('\n')
|
||||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||||
appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary)
|
||||
}
|
||||
console.log(summary)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { reviewUnitSelection } from './ci-unit-selection-review.mjs'
|
||||
|
||||
const plan = {
|
||||
sourceSha: 'sha',
|
||||
mode: 'shadow',
|
||||
selectionAvailable: true,
|
||||
files: ['a', 'b'],
|
||||
candidateFiles: ['a']
|
||||
}
|
||||
|
||||
it('does not present full fallback runs as selection-validation evidence', () => {
|
||||
const records = [record(1, 'a', 'passed'), record(2, 'b', 'passed')].map((row) => ({
|
||||
...row,
|
||||
plan: { ...plan, selectionAvailable: false }
|
||||
}))
|
||||
expect(reviewUnitSelection(records)[0]).toMatchObject({
|
||||
completeFullRun: true,
|
||||
selectionEvaluated: false
|
||||
})
|
||||
})
|
||||
const record = (index, file, state) => ({
|
||||
plan,
|
||||
timing: {
|
||||
sourceSha: 'sha',
|
||||
runId: '1',
|
||||
runAttempt: '1',
|
||||
nodeVersion: '24',
|
||||
status: state,
|
||||
unhandledErrors: 0,
|
||||
shard: { index, count: 2 },
|
||||
timings: { [file]: 100 },
|
||||
results: { [file]: state }
|
||||
}
|
||||
})
|
||||
|
||||
it('finds omitted failures in a complete failing reference run', () => {
|
||||
expect(
|
||||
reviewUnitSelection([record(1, 'a', 'passed'), record(2, 'b', 'failed')])[0]
|
||||
).toMatchObject({ completeFullRun: true, missedFailures: ['b'], potentiallyOmittedWorkerMs: 100 })
|
||||
})
|
||||
|
||||
it('does not call incomplete, duplicate, interrupted or selected evidence a full reference', () => {
|
||||
const a = record(1, 'a', 'passed'),
|
||||
b = record(2, 'b', 'passed')
|
||||
for (const rows of [
|
||||
[a],
|
||||
[a, a],
|
||||
[a, { ...b, timing: { ...b.timing, unhandledErrors: 1 } }],
|
||||
[a, { ...b, timing: { ...b.timing, status: 'interrupted' } }],
|
||||
[a, b].map((row) => ({ ...row, plan: { ...plan, mode: 'selected' } }))
|
||||
]) {
|
||||
expect(reviewUnitSelection(rows).every((row) => !row.completeFullRun)).toBe(true)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { unitConsumers } from './ci-unit-dependency-graph.mjs'
|
||||
import { balanceFiles } from './ci-shard-assignment.mjs'
|
||||
|
||||
export function selectUnitFiles(files, changed, graph) {
|
||||
const full = (reason) => ({ files, reason, full: true })
|
||||
if (!changed.length) {
|
||||
return full('Missing changed-path evidence')
|
||||
}
|
||||
if (changed.some((file) => !file.startsWith('src/') || !graph.files.has(file))) {
|
||||
return full('Global, deleted, renamed or unknown input')
|
||||
}
|
||||
for (const file of changed) {
|
||||
const consumers = unitConsumers([file], graph.reverse)
|
||||
if (!files.some((test) => consumers.has(test))) {
|
||||
return full('No proven test coverage for changed inputs')
|
||||
}
|
||||
}
|
||||
const affected = unitConsumers([...changed, ...graph.opaque], graph.reverse)
|
||||
const selected = files.filter((file) => affected.has(file))
|
||||
if (!selected.length) {
|
||||
return full('No proven test coverage for changed inputs')
|
||||
}
|
||||
return {
|
||||
files: selected,
|
||||
reason: 'Transitive imports plus indirect-input consumers',
|
||||
full: false
|
||||
}
|
||||
}
|
||||
|
||||
export function planUnitSelection({ files, changed, graph, timings, event, mode = 'shadow' }) {
|
||||
const candidate = selectUnitFiles(files, changed, graph)
|
||||
const selected = mode === 'selected' && event?.pull_request?.draft === true && !candidate.full
|
||||
const executionFiles = selected ? candidate.files : files
|
||||
const totalMs = balanceFiles(executionFiles, 1, timings).shards[0].durationMs
|
||||
const count = selected
|
||||
? Math.max(1, Math.min(8, Math.ceil(totalMs / 900_000), executionFiles.length))
|
||||
: 8
|
||||
return {
|
||||
version: 1,
|
||||
mode: selected ? 'selected' : 'shadow',
|
||||
selectionAvailable: !candidate.full,
|
||||
reason: candidate.reason,
|
||||
files,
|
||||
candidateFiles: candidate.files,
|
||||
executionFiles,
|
||||
shards: Array.from({ length: count }, (_, index) => ({ index: index + 1, count }))
|
||||
}
|
||||
}
|
||||
|
||||
export function auditUnitSelection(plan, results) {
|
||||
const candidates = new Set(plan.candidateFiles)
|
||||
const omittedFailures = Object.entries(results)
|
||||
.filter(([file, state]) => state === 'failed' && !candidates.has(file))
|
||||
.map(([file]) => file)
|
||||
return {
|
||||
mode: plan.mode,
|
||||
discovered: plan.files.length,
|
||||
candidate: candidates.size,
|
||||
executed: Object.keys(results).length,
|
||||
omittedFailures
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildUnitDependencyGraph } from './ci-unit-dependency-graph.mjs'
|
||||
import { auditUnitSelection, planUnitSelection, selectUnitFiles } from './ci-unit-selection.mjs'
|
||||
|
||||
const sources = new Map(
|
||||
Object.entries({
|
||||
'src/leaf.ts': 'export const value = 1',
|
||||
'src/forward.ts': `export * from './leaf'`,
|
||||
'src/consumer.test.ts': `import './forward'`,
|
||||
'src/dynamic.test.ts': `import('./leaf')`,
|
||||
'src/require.test.ts': `require('./leaf')`,
|
||||
'src/scan.test.ts': `import { readFileSync } from 'node:fs'; readFileSync('src/leaf.ts')`,
|
||||
'src/indirect.ts': `import(pathFromSettings)`,
|
||||
'src/indirect.test.ts': `import './indirect'`,
|
||||
'src/unrelated.test.ts': 'export const test = 1',
|
||||
'src/renderer/src/view.tsx': 'export const value = 1',
|
||||
'src/view.test.ts': `import '@renderer/view'; import '@/view'`
|
||||
})
|
||||
)
|
||||
const graph = { ...buildUnitDependencyGraph(sources), files: new Set(sources.keys()) }
|
||||
const files = [...sources.keys()].filter((file) => file.endsWith('.test.ts')).sort()
|
||||
|
||||
describe('conservative unit selection', () => {
|
||||
it('follows re-exports, literal dynamic imports and requires, retaining indirect readers', () => {
|
||||
expect(selectUnitFiles(files, ['src/leaf.ts'], graph).files).toEqual([
|
||||
'src/consumer.test.ts',
|
||||
'src/dynamic.test.ts',
|
||||
'src/indirect.test.ts',
|
||||
'src/require.test.ts',
|
||||
'src/scan.test.ts'
|
||||
])
|
||||
})
|
||||
|
||||
it('resolves renderer aliases and changed tests without executing their source', () => {
|
||||
expect(selectUnitFiles(files, ['src/renderer/src/view.tsx'], graph).files).toContain(
|
||||
'src/view.test.ts'
|
||||
)
|
||||
expect(selectUnitFiles(files, ['src/unrelated.test.ts'], graph).files).toContain(
|
||||
'src/unrelated.test.ts'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not mistake unrelated opaque readers for coverage of a new entry point', () => {
|
||||
const uncovered = { ...graph, files: new Set([...graph.files, 'src/entry.ts']) }
|
||||
expect(selectUnitFiles(files, ['src/entry.ts'], uncovered)).toMatchObject({ full: true, files })
|
||||
})
|
||||
|
||||
it.each(
|
||||
[
|
||||
[],
|
||||
['src/deleted.ts'],
|
||||
['src/deleted.ts', 'src/leaf.ts'],
|
||||
['pnpm-lock.yaml'],
|
||||
['config/vitest.config.ts']
|
||||
].map((changed) => ({ changed }))
|
||||
)('runs everything for incomplete/global evidence: $changed', ({ changed }) => {
|
||||
expect(selectUnitFiles(files, changed, graph)).toMatchObject({ files, full: true })
|
||||
})
|
||||
|
||||
it('keeps full coverage by default and on every non-draft commit', () => {
|
||||
const base = { files, changed: ['src/leaf.ts'], graph, timings: {} }
|
||||
for (const event of [
|
||||
{},
|
||||
{ pull_request: { draft: false } },
|
||||
{ pull_request: { draft: true } }
|
||||
]) {
|
||||
expect(planUnitSelection({ ...base, event }).executionFiles).toEqual(files)
|
||||
}
|
||||
expect(
|
||||
planUnitSelection({ ...base, mode: 'selected', event: { pull_request: { draft: false } } })
|
||||
.executionFiles
|
||||
).toEqual(files)
|
||||
const selected = planUnitSelection({
|
||||
...base,
|
||||
mode: 'selected',
|
||||
event: { pull_request: { draft: true } }
|
||||
})
|
||||
expect(selected.executionFiles).not.toContain('src/unrelated.test.ts')
|
||||
expect(selected.shards).toEqual([{ index: 1, count: 1 }])
|
||||
})
|
||||
|
||||
it('records failures that would have been missed while shadow runs remain full', () => {
|
||||
const plan = planUnitSelection({ files, changed: ['src/leaf.ts'], graph, timings: {} })
|
||||
expect(
|
||||
auditUnitSelection(plan, {
|
||||
'src/consumer.test.ts': 'failed',
|
||||
'src/unrelated.test.ts': 'failed',
|
||||
'src/view.test.ts': 'passed'
|
||||
}).omittedFailures
|
||||
).toEqual(['src/unrelated.test.ts'])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { relative } from 'node:path'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { BaseSequencer } from 'vitest/node'
|
||||
import { balanceFiles, readTimingBaseline, writeAssignment } from './ci-shard-assignment.mjs'
|
||||
|
||||
@@ -6,12 +7,46 @@ export default class TimingSequencer extends BaseSequencer {
|
||||
async shard(specs) {
|
||||
const { index, count } = this.ctx.config.shard
|
||||
const key = (spec) => relative(this.ctx.config.root, spec.moduleId).replaceAll('\\', '/')
|
||||
let execution = specs
|
||||
let selectionReason = 'Full suite: no verified selection plan'
|
||||
try {
|
||||
const plan = JSON.parse(
|
||||
readFileSync(
|
||||
process.env.ORCA_UNIT_SELECTION_PLAN ?? 'ci-shards/unit-selection.json',
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
if (
|
||||
plan.version !== 1 ||
|
||||
!plan.sourceSha ||
|
||||
plan.sourceSha !== process.env.ORCA_SHARD_SOURCE_SHA ||
|
||||
JSON.stringify([...plan.files].sort()) !== JSON.stringify(specs.map(key).sort()) ||
|
||||
!Array.isArray(plan.executionFiles) ||
|
||||
plan.executionFiles.some((file) => !plan.files.includes(file))
|
||||
) {
|
||||
throw new Error('Selection provenance or discovery differs')
|
||||
}
|
||||
const allowed = new Set(plan.executionFiles)
|
||||
if (allowed.size === 0) {
|
||||
throw new Error('Empty execution selection')
|
||||
}
|
||||
execution = specs.filter((spec) => allowed.has(key(spec)))
|
||||
selectionReason = plan.reason
|
||||
} catch (error) {
|
||||
console.log(`Running every discovered unit test: ${error.message}`)
|
||||
}
|
||||
const baseline = readTimingBaseline('unit')
|
||||
const assignment = balanceFiles(specs.map(key), count, baseline.timings, baseline.overheadMs)
|
||||
const assignment = balanceFiles(
|
||||
execution.map(key),
|
||||
count,
|
||||
baseline.timings,
|
||||
baseline.overheadMs
|
||||
)
|
||||
writeAssignment(process.env.ORCA_SHARD_MANIFEST ?? 'ci-shards/unit-assignment.json', {
|
||||
...assignment,
|
||||
baselineSha256: baseline.baselineSha256,
|
||||
selectedShard: index
|
||||
selectedShard: index,
|
||||
selectionReason
|
||||
})
|
||||
const selected = new Set(assignment.shards[index - 1].files)
|
||||
return specs.filter((spec) => selected.has(key(spec)))
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import TimingSequencer from './ci-unit-sequencer.mjs'
|
||||
|
||||
let root
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
if (root) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['valid', 'stale', 'missing-file', 'missing-artifact'])(
|
||||
'preserves complete shard coverage with %s planning evidence',
|
||||
async (kind) => {
|
||||
root = mkdtempSync(join(tmpdir(), 'unit-sequencer-'))
|
||||
const files = ['src/a.test.ts', 'src/b.test.ts', 'src/c.test.ts', 'src/d.test.ts']
|
||||
const plan = {
|
||||
version: 1,
|
||||
sourceSha: kind === 'stale' ? 'old' : 'current',
|
||||
files: kind === 'missing-file' ? files.slice(1) : files,
|
||||
executionFiles: files.slice(0, 2)
|
||||
}
|
||||
const planPath = join(root, 'selection.json')
|
||||
if (kind !== 'missing-artifact') {
|
||||
writeFileSync(planPath, JSON.stringify(plan))
|
||||
}
|
||||
vi.stubEnv('ORCA_UNIT_SELECTION_PLAN', planPath)
|
||||
vi.stubEnv('ORCA_SHARD_SOURCE_SHA', 'current')
|
||||
vi.stubEnv('ORCA_SHARD_MANIFEST', join(root, 'assignment.json'))
|
||||
const assigned = []
|
||||
for (const index of [1, 2]) {
|
||||
const sequencer = new TimingSequencer({ config: { root, shard: { index, count: 2 } } })
|
||||
const specs = files.map((file) => ({ moduleId: join(root, file) }))
|
||||
assigned.push(...(await sequencer.shard(specs)).map((spec) => spec.moduleId))
|
||||
}
|
||||
expect(assigned.sort()).toEqual(
|
||||
(kind === 'valid' ? files.slice(0, 2) : files).map((file) => join(root, file)).sort()
|
||||
)
|
||||
expect(new Set(assigned).size).toBe(assigned.length)
|
||||
}
|
||||
)
|
||||
@@ -1,5 +1,7 @@
|
||||
import { relative } from 'node:path'
|
||||
import { appendFileSync, readFileSync } from 'node:fs'
|
||||
import { writeAssignment } from './ci-shard-assignment.mjs'
|
||||
import { auditUnitSelection } from './ci-unit-selection.mjs'
|
||||
|
||||
export function moduleDuration(diagnostic) {
|
||||
return Math.max(
|
||||
@@ -20,12 +22,19 @@ export default class UnitTimingReporter {
|
||||
}
|
||||
|
||||
onTestRunEnd(modules, errors, reason) {
|
||||
const results = Object.fromEntries(
|
||||
modules.map((module) => [
|
||||
relative(this.ctx.config.root, module.moduleId).replaceAll('\\', '/'),
|
||||
module.state?.() ?? 'unknown'
|
||||
])
|
||||
)
|
||||
writeAssignment(process.env.ORCA_UNIT_TIMING_REPORT ?? 'ci-shards/unit-timings.json', {
|
||||
metric: 'module-duration-v1',
|
||||
nodeVersion: process.versions.node,
|
||||
shard: this.ctx.config.shard ?? { index: 1, count: 1 },
|
||||
status: reason,
|
||||
unhandledErrors: errors.length,
|
||||
results,
|
||||
timings: Object.fromEntries(
|
||||
modules.map((module) => [
|
||||
relative(this.ctx.config.root, module.moduleId).replaceAll('\\', '/'),
|
||||
@@ -33,5 +42,23 @@ export default class UnitTimingReporter {
|
||||
])
|
||||
)
|
||||
})
|
||||
try {
|
||||
const plan = JSON.parse(
|
||||
readFileSync(
|
||||
process.env.ORCA_UNIT_SELECTION_PLAN ?? 'ci-shards/unit-selection.json',
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
const audit = auditUnitSelection(plan, results)
|
||||
writeAssignment('ci-shards/unit-selection-audit.json', audit)
|
||||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||||
appendFileSync(
|
||||
process.env.GITHUB_STEP_SUMMARY,
|
||||
`Unit selection (${audit.mode}): ${audit.candidate}/${audit.discovered} candidate files; ${audit.omittedFailures.length} failures outside selection.\n`
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// Timing evidence remains useful when selection planning was unavailable.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +159,9 @@ const CROSS_VERSION_WIRE_PREFIXES = [
|
||||
'src/main/runtime/rpc/methods/session-tabs.ts',
|
||||
'src/main/runtime/rpc/methods/structured-agent-session',
|
||||
'src/main/runtime/rpc/methods/terminal',
|
||||
'src/main/runtime/runtime-worktree-agent-',
|
||||
'src/main/runtime/runtime-worktree-pty-agent-sources',
|
||||
'src/shared/runtime-worktree-contracts',
|
||||
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer'
|
||||
]
|
||||
|
||||
|
||||
@@ -350,6 +350,9 @@ describe('per-job path classification', () => {
|
||||
'src/main/runtime/rpc/methods/structured-agent-session-hold.ts',
|
||||
'src/main/runtime/rpc/methods/structured-agent-session-schemas.ts',
|
||||
'src/main/runtime/rpc/methods/terminal.ts',
|
||||
'src/main/runtime/runtime-worktree-agent-rows.ts',
|
||||
'src/main/runtime/runtime-worktree-pty-agent-sources.ts',
|
||||
'src/shared/runtime-worktree-contracts.ts',
|
||||
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts'
|
||||
]) {
|
||||
expectClassification([file], {
|
||||
@@ -579,12 +582,21 @@ describe('PR Checks skip wiring', () => {
|
||||
|
||||
it('gates each expensive job on its classifier and cache prerequisite', () => {
|
||||
for (const jobName of expensiveJobs.filter((jobName) => jobName !== 'test')) {
|
||||
expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths'])
|
||||
expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(
|
||||
['package', 'package_windows'].includes(jobName)
|
||||
? ['code_paths', 'static_analysis', 'typecheck']
|
||||
: ['code_paths']
|
||||
)
|
||||
expect(prWorkflow.jobs[jobName].if, jobName).toBe(
|
||||
`needs.code_paths.outputs.${jobName} == 'true'`
|
||||
)
|
||||
}
|
||||
expect(prWorkflow.jobs.test.needs).toEqual(['code_paths', 'test_native_cache'])
|
||||
expect(prWorkflow.jobs.test.needs).toEqual([
|
||||
'code_paths',
|
||||
'test_native_cache',
|
||||
'static_analysis',
|
||||
'typecheck'
|
||||
])
|
||||
expect(prWorkflow.jobs.test.if).toContain("needs.code_paths.outputs.test == 'true'")
|
||||
expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'success'")
|
||||
expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'skipped'")
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { appendFileSync, readFileSync } from 'node:fs'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
export function prCheckRunTitle({ number, sourceSha, workflowSha }) {
|
||||
return `PR ${number} | source ${sourceSha} | workflow ${workflowSha}`
|
||||
export function prCheckRunTitle({ number, sourceSha, workflowSha, unitMode = 'full' }) {
|
||||
return `PR ${number} | source ${sourceSha} | workflow ${workflowSha} | unit ${unitMode}`
|
||||
}
|
||||
|
||||
export function reusablePrCheckRun(runs, identity) {
|
||||
|
||||
@@ -45,6 +45,12 @@ describe('ready-for-review required check reuse', () => {
|
||||
it('reuses a completed success only for the identical PR, merge source and workflow', () => {
|
||||
expect(reusablePrCheckRun([passed], identity)).toBe(passed)
|
||||
expect(reusablePrCheckRun([], identity)).toBeUndefined()
|
||||
expect(
|
||||
reusablePrCheckRun(
|
||||
[{ ...passed, display_title: prCheckRunTitle({ ...identity, unitMode: 'selected' }) }],
|
||||
identity
|
||||
)
|
||||
).toBeUndefined()
|
||||
for (const key of ['number', 'sourceSha', 'workflowSha', 'headSha', 'runId']) {
|
||||
const changed = key === 'number' ? 43 : key === 'runId' ? '123' : 'd'.repeat(40)
|
||||
expect(reusablePrCheckRun([passed], { ...identity, [key]: changed }), key).toBeUndefined()
|
||||
@@ -121,7 +127,7 @@ describe('ready-for-review required check reuse', () => {
|
||||
|
||||
it('keeps required skips conditional on proof and leaves advisory routing eligible', () => {
|
||||
expect(workflow['run-name']).toBe(
|
||||
'PR ${{ github.event.pull_request.number }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }}'
|
||||
"PR ${{ github.event.pull_request.number }} | source ${{ github.sha }} | workflow ${{ github.workflow_sha }} | unit ${{ github.event.pull_request.draft && vars.ORCA_UNIT_SELECTION_MODE == 'selected' && 'selected' || 'full' }}"
|
||||
)
|
||||
expect(workflow.on.pull_request.types).toContain('ready_for_review')
|
||||
const detector = workflow.jobs.code_paths
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { existsSync, globSync, readFileSync } from 'node:fs'
|
||||
import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { UNIT_EXCLUDE } from './ci-unit-files.mjs'
|
||||
import { mobileWebCheckArgs } from './run-mobile-web-app-checks.mjs'
|
||||
import { MOBILE_WEB_APP_DEPENDENCIES_REQUIRED_ENV } from './mobile-web-app-bundle-dependencies.mjs'
|
||||
|
||||
@@ -105,15 +106,13 @@ describe('PR workflow parallelism', () => {
|
||||
expect(nodeNextWorkflow.on.schedule).toHaveLength(1)
|
||||
expect(nodeNextWorkflow.on.workflow_dispatch).toBeNull()
|
||||
expect(sharedTest.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}')
|
||||
expect(sharedTest.strategy.matrix.shard).toEqual(
|
||||
Array.from({ length: 8 }, (_, index) => index + 1)
|
||||
)
|
||||
expect(sharedTest.strategy.matrix.shard_total).toEqual([8])
|
||||
expect(sharedTest.strategy.matrix.shard).toBe('${{ fromJSON(needs.plan.outputs.shards) }}')
|
||||
expect(sharedTest.needs).toBe('plan')
|
||||
expect(installStep.with['node-version']).toBe('${{ matrix.node }}')
|
||||
expect(installStep.with['cache-electron-package']).toBe('true')
|
||||
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}')
|
||||
expect(testStep.run).toContain('--shard=${{ matrix.shard.index }}/${{ matrix.shard.count }}')
|
||||
for (const testFile of nativeShellContractFiles) {
|
||||
expect(testStep.run).toContain(`--exclude=${testFile}`)
|
||||
expect(UNIT_EXCLUDE).toContain(testFile)
|
||||
}
|
||||
expect(primerInstall.with['native-runtime']).toBe('node')
|
||||
expect(primerInstall.with['node-version']).toBe('24')
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
function reviewNumber(inputs) {
|
||||
if (/^[1-9]\d*$/.test(inputs.pull_request_number ?? '')) {
|
||||
return Number(inputs.pull_request_number)
|
||||
}
|
||||
const match =
|
||||
/ \| PR ([1-9]\d*)$/.exec(inputs.name ?? '') ??
|
||||
/^Review (?:new commits on )?#([1-9]\d*) \[[\w-]+\]$/.exec(inputs.name ?? '')
|
||||
return match ? Number(match[1]) : null
|
||||
}
|
||||
|
||||
function reviewRunOrder(runs, number, runId) {
|
||||
const related = runs.filter((run) => reviewNumber({ name: run.display_title }) === number)
|
||||
return {
|
||||
superseded: related.some((run) => run.id > runId),
|
||||
older: related
|
||||
.filter(
|
||||
(run) =>
|
||||
run.id < runId &&
|
||||
['queued', 'in_progress', 'waiting', 'pending', 'requested'].includes(run.status)
|
||||
)
|
||||
.map((run) => run.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function reviewScope({ github, context, core }) {
|
||||
const inputs = context.payload.inputs ?? {}
|
||||
const number = reviewNumber(inputs)
|
||||
core.setOutput('current', 'true')
|
||||
if (!number || !Number.isSafeInteger(number)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { data: pr } = await github.rest.pulls.get({ ...context.repo, pull_number: number })
|
||||
if (pr.state !== 'open' || (inputs.head_sha && inputs.head_sha !== pr.head.sha)) {
|
||||
core.setOutput('current', 'false')
|
||||
return
|
||||
}
|
||||
core.setOutput('head', pr.head.sha)
|
||||
core.setOutput('number', String(number))
|
||||
const { data } = await github.rest.actions.listWorkflowRuns({
|
||||
...context.repo,
|
||||
workflow_id: 'pullfrog.yml',
|
||||
event: 'workflow_dispatch',
|
||||
per_page: 100
|
||||
})
|
||||
const order = reviewRunOrder(data.workflow_runs, number, context.runId)
|
||||
if (order.superseded) {
|
||||
core.setOutput('current', 'false')
|
||||
return
|
||||
}
|
||||
// Run IDs, unlike scope-job completion order, cannot let an older review cancel a newer one.
|
||||
for (const runId of order.older) {
|
||||
try {
|
||||
await github.rest.actions.cancelWorkflowRun({ ...context.repo, run_id: runId })
|
||||
} catch (error) {
|
||||
core.warning(`Could not cancel older review ${runId}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(`Review identity unavailable; leaving this task independent: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { reviewNumber, reviewRunOrder, reviewScope }
|
||||
@@ -0,0 +1,109 @@
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import scope from './pullfrog-review-scope.cjs'
|
||||
|
||||
it('groups a current review by PR while leaving other agent tasks independent', async () => {
|
||||
const get = vi.fn(async () => ({ data: { state: 'open', head: { sha: 'current' } } }))
|
||||
const core = { setOutput: vi.fn(), warning: vi.fn() }
|
||||
const context = {
|
||||
repo: { owner: 'stablyai', repo: 'orca' },
|
||||
runId: 7,
|
||||
payload: { inputs: { name: 'Review #42 [abc]' } }
|
||||
}
|
||||
const github = {
|
||||
rest: {
|
||||
pulls: { get },
|
||||
actions: { listWorkflowRuns: async () => ({ data: { workflow_runs: [] } }) }
|
||||
}
|
||||
}
|
||||
await scope.reviewScope({ core, context, github })
|
||||
expect(get).toHaveBeenCalledWith({ owner: 'stablyai', repo: 'orca', pull_number: 42 })
|
||||
expect(core.setOutput).toHaveBeenCalledWith('head', 'current')
|
||||
get.mockClear()
|
||||
await scope.reviewScope({
|
||||
core,
|
||||
github,
|
||||
context: { ...context, payload: { inputs: { name: 'Investigate #42' } } }
|
||||
})
|
||||
expect(get).not.toHaveBeenCalled()
|
||||
expect(core.setOutput).toHaveBeenLastCalledWith('current', 'true')
|
||||
})
|
||||
|
||||
it('coalesces only explicit or recognized PR review identities', () => {
|
||||
expect(scope.reviewNumber({ name: 'Review #23532 [85jpk]' })).toBe(23532)
|
||||
expect(scope.reviewNumber({ name: 'Review new commits on #22727 [24lpq]' })).toBe(22727)
|
||||
expect(scope.reviewNumber({ pull_request_number: '123' })).toBe(123)
|
||||
for (const name of ['Fix #123', 'Review #12; echo test', 'Review #123', '', 'Review #0 [abc]']) {
|
||||
expect(scope.reviewNumber({ name })).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('skips closed or explicitly stale reviews and tolerates lookup failure', async () => {
|
||||
for (const data of [
|
||||
{ state: 'closed', head: { sha: 'a' } },
|
||||
{ state: 'open', head: { sha: 'b' } }
|
||||
]) {
|
||||
const core = { setOutput: vi.fn(), warning: vi.fn() }
|
||||
await scope.reviewScope({
|
||||
core,
|
||||
context: {
|
||||
repo: {},
|
||||
runId: 1,
|
||||
payload: { inputs: { pull_request_number: '1', head_sha: 'a' } }
|
||||
},
|
||||
github: { rest: { pulls: { get: async () => ({ data }) } } }
|
||||
})
|
||||
expect(core.setOutput).toHaveBeenCalledWith('current', 'false')
|
||||
}
|
||||
const core = { setOutput: vi.fn(), warning: vi.fn() }
|
||||
await scope.reviewScope({
|
||||
core,
|
||||
context: { repo: {}, runId: 2, payload: { inputs: { pull_request_number: '1' } } },
|
||||
github: {
|
||||
rest: {
|
||||
pulls: {
|
||||
get: async () => {
|
||||
throw new Error('offline')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(core.setOutput).toHaveBeenCalledWith('current', 'true')
|
||||
expect(core.warning).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a delayed older scope cannot cancel or replace a newer review', async () => {
|
||||
const runs = [
|
||||
{ id: 6, display_title: 'Review #42 [a]', status: 'in_progress' },
|
||||
{ id: 8, display_title: 'Custom review | PR 42', status: 'queued' },
|
||||
{ id: 5, display_title: 'Investigate #42', status: 'in_progress' },
|
||||
{ id: 4, display_title: 'Review #43 [other]', status: 'in_progress' }
|
||||
]
|
||||
for (const runId of [7, 9]) {
|
||||
const cancelWorkflowRun = vi.fn(async () => ({}))
|
||||
const core = { setOutput: vi.fn(), warning: vi.fn() }
|
||||
await scope.reviewScope({
|
||||
context: {
|
||||
repo: { owner: 'stablyai', repo: 'orca' },
|
||||
runId,
|
||||
payload: { inputs: { name: 'Review #42 [current]' } }
|
||||
},
|
||||
core,
|
||||
github: {
|
||||
rest: {
|
||||
pulls: { get: async () => ({ data: { state: 'open', head: { sha: 'head' } } }) },
|
||||
actions: {
|
||||
listWorkflowRuns: async () => ({ data: { workflow_runs: runs } }),
|
||||
cancelWorkflowRun
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
if (runId === 7) {
|
||||
expect(cancelWorkflowRun).not.toHaveBeenCalled()
|
||||
expect(core.setOutput).toHaveBeenCalledWith('current', 'false')
|
||||
} else {
|
||||
expect(cancelWorkflowRun.mock.calls.map(([args]) => args.run_id)).toEqual([6, 8])
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,205 @@
|
||||
// Why sharded: config/oxlint-anti-slop.json turns every native category off and runs its
|
||||
// rules through `jsPlugins`, so oxlint's threaded Rust engine does no work and the pass is
|
||||
// one JS runtime per process. Measured, it does not scale with `--threads` (11.68s at 4 vs
|
||||
// 12.38s at 16). Parallelism has to come from more processes, so this splits the file set
|
||||
// across them. Sharding is sound because every anti-slop rule is a single-file analysis: the
|
||||
// only mutable module state is a WeakMap keyed on each file's own Program node.
|
||||
//
|
||||
// Why directory units and not file paths: a shard holds ~7k files, and passing those as argv
|
||||
// overruns the command-line limit (hard-fails on Windows via CommandLineToArgvW). Whole
|
||||
// directories keep argv to a few dozen entries, so the splitter recurses only until each unit
|
||||
// fits the per-shard target.
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { resolveOxlintInvocation } from './oxlint-cli-invocation.mjs'
|
||||
|
||||
export const CONFIG = 'config/oxlint-anti-slop.json'
|
||||
export const ROOTS = ['src', 'config', 'tests', 'mobile']
|
||||
// Bounds argv growth. Each unit is a path of ~40 chars, so even at this cap a shard stays far
|
||||
// under the ~32k Windows command-line limit, while leaving room to split a lopsided tree.
|
||||
const MAX_UNITS = 4096
|
||||
|
||||
function shardCount() {
|
||||
const requested = Number(process.env.ORCA_ANTI_SLOP_SHARDS)
|
||||
if (Number.isInteger(requested) && requested > 0) {
|
||||
return requested
|
||||
}
|
||||
// CPU-seconds grow with shard count, so on a 4-core runner more shards than cores is a
|
||||
// measured regression (N=8 was slower than N=4 there). Cap keeps a 64-core dev box sane.
|
||||
return Math.max(1, Math.min(os.availableParallelism?.() ?? os.cpus().length, 8))
|
||||
}
|
||||
|
||||
export function listFiles(root = process.cwd()) {
|
||||
const { command, prefixArgs } = resolveOxlintInvocation(root)
|
||||
const result = spawnSync(
|
||||
command,
|
||||
[...prefixArgs, '--config', CONFIG, ...ROOTS, '--debug=files'],
|
||||
{
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 64 * 1024 * 1024
|
||||
}
|
||||
)
|
||||
if (result.status !== 0) {
|
||||
process.stderr.write(result.stderr ?? '')
|
||||
throw new Error(`oxlint --debug=files exited with ${result.status}`)
|
||||
}
|
||||
return result.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim().replace(/^\.\//, ''))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
// Splits the largest unit into its children until every unit fits `target`. A directory's own
|
||||
// direct files become individual units so the parent stays fully covered after a split.
|
||||
export function buildUnits(files, target) {
|
||||
const units = new Map()
|
||||
for (const file of files) {
|
||||
const top = file.split('/')[0]
|
||||
if (!units.has(top)) {
|
||||
units.set(top, [])
|
||||
}
|
||||
units.get(top).push(file)
|
||||
}
|
||||
|
||||
while (units.size < MAX_UNITS) {
|
||||
let biggest
|
||||
for (const [unit, unitFiles] of units) {
|
||||
// A unit that is already a single file cannot be split further.
|
||||
if (unitFiles.length <= target || unitFiles.length <= 1) {
|
||||
continue
|
||||
}
|
||||
if (!biggest || unitFiles.length > units.get(biggest).length) {
|
||||
biggest = unit
|
||||
}
|
||||
}
|
||||
if (!biggest) {
|
||||
break
|
||||
}
|
||||
|
||||
const depth = biggest.split('/').length
|
||||
const children = new Map()
|
||||
for (const file of units.get(biggest)) {
|
||||
const segments = file.split('/')
|
||||
// Files sitting directly in the split directory have no deeper segment to group by.
|
||||
const key = segments.length > depth ? segments.slice(0, depth + 1).join('/') : file
|
||||
if (!children.has(key)) {
|
||||
children.set(key, [])
|
||||
}
|
||||
children.get(key).push(file)
|
||||
}
|
||||
// A single child means every file shares the next segment (src/renderer -> src/renderer/src).
|
||||
// Replacing the unit with it still deepens the path, so the next pass can split further.
|
||||
units.delete(biggest)
|
||||
for (const [key, childFiles] of children) {
|
||||
units.set(key, childFiles)
|
||||
}
|
||||
}
|
||||
|
||||
return [...units.entries()].map(([unit, unitFiles]) => ({ unit, count: unitFiles.length }))
|
||||
}
|
||||
|
||||
// Largest-first into the least-loaded shard: keeps the slowest shard close to the mean, which
|
||||
// is what the wall time is bound by.
|
||||
export function packShards(units, shards) {
|
||||
const bins = Array.from({ length: shards }, () => ({ units: [], count: 0 }))
|
||||
for (const unit of [...units].sort((a, b) => b.count - a.count)) {
|
||||
const lightest = bins.reduce((best, bin) => (bin.count < best.count ? bin : best), bins[0])
|
||||
lightest.units.push(unit.unit)
|
||||
lightest.count += unit.count
|
||||
}
|
||||
return bins.filter((bin) => bin.units.length > 0)
|
||||
}
|
||||
|
||||
function runShard(units, root) {
|
||||
const { command, prefixArgs } = resolveOxlintInvocation(root)
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(
|
||||
command,
|
||||
[
|
||||
...prefixArgs,
|
||||
'--config',
|
||||
CONFIG,
|
||||
'--deny-warnings',
|
||||
...units.map((unit) => path.normalize(unit))
|
||||
],
|
||||
{ cwd: root, stdio: ['ignore', 'pipe', 'pipe'] }
|
||||
)
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk
|
||||
})
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk
|
||||
})
|
||||
child.on('error', (error) =>
|
||||
resolve({ status: 1, stdout, stderr: `${stderr}${error.message}\n` })
|
||||
)
|
||||
child.on('close', (status) => resolve({ status: status ?? 1, stdout, stderr }))
|
||||
})
|
||||
}
|
||||
|
||||
// Keeps a shard's paths well inside the ~32k Windows command-line limit. Splitting finer buys
|
||||
// balance but costs argv, so this is the ceiling the planner degrades against.
|
||||
const ARGV_BUDGET = 16_000
|
||||
|
||||
// Plans the shards for a file list: exported so a test can assert the units stay disjoint and
|
||||
// complete, which is what makes the union of shard findings equal to a single pass.
|
||||
export function planShards(files, shards) {
|
||||
let divisor = shards
|
||||
let plan
|
||||
// A deeper split means more units and a longer argv. If the tree is lopsided enough that the
|
||||
// fine split would overrun the budget, back off to coarser units and accept the imbalance
|
||||
// rather than handing the OS a command line it will reject.
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
const units = buildUnits(files, Math.ceil(files.length / Math.max(divisor, 1)))
|
||||
const bins = packShards(units, shards)
|
||||
plan = { units, bins }
|
||||
const widest = bins.reduce((max, bin) => Math.max(max, bin.units.join(' ').length), 0)
|
||||
if (widest <= ARGV_BUDGET || divisor <= 1) {
|
||||
break
|
||||
}
|
||||
divisor = Math.floor(divisor / 2)
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const root = process.cwd()
|
||||
const files = listFiles(root)
|
||||
const shards = Math.min(shardCount(), files.length || 1)
|
||||
const { units, bins } = planShards(files, shards)
|
||||
|
||||
console.log(
|
||||
`anti-slop: ${files.length} files across ${bins.length} shard(s) (${units.length} units): ${bins
|
||||
.map((bin) => bin.count)
|
||||
.join(', ')}`
|
||||
)
|
||||
|
||||
// Printed in shard order rather than completion order so the log is reproducible.
|
||||
const results = await Promise.all(bins.map((bin) => runShard(bin.units, root)))
|
||||
let failed = false
|
||||
for (const result of results) {
|
||||
if (result.stdout) {
|
||||
process.stdout.write(result.stdout)
|
||||
}
|
||||
if (result.stderr) {
|
||||
process.stderr.write(result.stderr)
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
failed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
await main()
|
||||
}
|
||||
@@ -388,7 +388,9 @@ async function main() {
|
||||
child.kill('SIGTERM')
|
||||
const exited = await Promise.race([
|
||||
new Promise((r) => child.on('exit', () => r(true))),
|
||||
new Promise((r) => setTimeout(() => r(false), SHUTDOWN_TIMEOUT_MS))
|
||||
// unref'd: the loser of this race must not hold the event loop open after the
|
||||
// winner already decided. The timer still bounds the wait.
|
||||
new Promise((r) => setTimeout(() => r(false), SHUTDOWN_TIMEOUT_MS).unref())
|
||||
])
|
||||
if (!exited) {
|
||||
child.kill('SIGKILL')
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
|
||||
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
|
||||
"../src/main/agent-hooks/managed-hook-script-refresh.ts",
|
||||
"../src/main/agent-hooks/managed-hooks-json-events.ts",
|
||||
"../src/main/agent-hooks/posix-hook-command.ts",
|
||||
"../src/main/agent-hooks/runtime-home-hook-command.ts",
|
||||
"../src/main/orca-profiles/profile-storage-paths.ts",
|
||||
@@ -207,6 +208,9 @@
|
||||
"../src/main/in-flight-run-dedupe.ts",
|
||||
"../src/main/kimi/hook-service.ts",
|
||||
"../src/main/kimi/kimi-hook-config-toml.ts",
|
||||
"../src/main/dsh/dsh-home-patch.ts",
|
||||
"../src/main/dsh/hook-service.ts",
|
||||
"../src/main/dsh/hook-settings.ts",
|
||||
"../src/main/muse/hook-config-json.ts",
|
||||
"../src/main/muse/hook-service.ts",
|
||||
"../src/main/muse/hook-settings.ts",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { UNIT_INCLUDE, UNIT_EXCLUDE } from './scripts/ci-unit-files.mjs'
|
||||
import TimingSequencer from './scripts/ci-unit-sequencer.mjs'
|
||||
|
||||
const windowsTestWorkerOptions = process.platform === 'win32' ? { maxWorkers: 4 } : {}
|
||||
@@ -33,14 +34,8 @@ export default defineConfig({
|
||||
resolve('config/scripts/happy-dom-mutation-observer-retention.ts'),
|
||||
resolve('config/scripts/vitest-host-ports-setup.ts')
|
||||
],
|
||||
include: [
|
||||
'src/**/*.test.ts',
|
||||
'src/**/*.test.tsx',
|
||||
'config/scripts/**/*.test.ts',
|
||||
'config/scripts/**/*.test.mjs',
|
||||
'tests/tools/**/*.test.mjs',
|
||||
'tests/e2e/**/*.unit.test.ts'
|
||||
],
|
||||
include: UNIT_INCLUDE,
|
||||
...(process.env.ORCA_BALANCE_UNIT_SHARDS === '1' ? { exclude: UNIT_EXCLUDE } : {}),
|
||||
// Why: the full suite runs heavy TS transforms plus real git/http fixtures;
|
||||
// the Vitest 5s defaults are too tight for the slowest integration cases.
|
||||
hookTimeout: 60_000,
|
||||
|
||||
@@ -179,6 +179,7 @@ Funciona con **cualquier agente CLI** — si corre en una terminal, corre en Orc
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a>
|
||||
<a href="https://deepseek-harness.github.io/deepseek-harness/"><kbd><img src="../../src/shared/agent-icons/dsh.png" alt="DeepSeek Harness logo" width="16" valign="middle" /> DeepSeek Harness</kbd></a>
|
||||
<a href="https://zcode.z.ai/en/docs"><kbd><img src="../../src/shared/agent-icons/zcode.png" alt="ZCode logo" width="16" valign="middle" /> ZCode</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a>
|
||||
|
||||
@@ -183,6 +183,7 @@ Fonctionne avec **n'importe quel agent CLI** — s'il tourne dans un terminal, i
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Logo Cursor" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="Logo GitHub Copilot" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Logo Muse" width="16" valign="middle" /> Muse</kbd></a>
|
||||
<a href="https://deepseek-harness.github.io/deepseek-harness/"><kbd><img src="../../src/shared/agent-icons/dsh.png" alt="DeepSeek Harness logo" width="16" valign="middle" /> DeepSeek Harness</kbd></a>
|
||||
<a href="https://zcode.z.ai/en/docs"><kbd><img src="../../src/shared/agent-icons/zcode.png" alt="Logo ZCode" width="16" valign="middle" /> ZCode</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="Logo OpenCode" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="Logo MiMo Code" width="16" valign="middle" /> MiMo Code</kbd></a>
|
||||
|
||||
@@ -179,6 +179,7 @@ PR、Issue、プロジェクトボードをアプリ内で閲覧 — 任意の
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a>
|
||||
<a href="https://deepseek-harness.github.io/deepseek-harness/"><kbd><img src="../../src/shared/agent-icons/dsh.png" alt="DeepSeek Harness logo" width="16" valign="middle" /> DeepSeek Harness</kbd></a>
|
||||
<a href="https://zcode.z.ai/en/docs"><kbd><img src="../../src/shared/agent-icons/zcode.png" alt="ZCode logo" width="16" valign="middle" /> ZCode</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a>
|
||||
|
||||
@@ -179,6 +179,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a>
|
||||
<a href="https://deepseek-harness.github.io/deepseek-harness/"><kbd><img src="../../src/shared/agent-icons/dsh.png" alt="DeepSeek Harness logo" width="16" valign="middle" /> DeepSeek Harness</kbd></a>
|
||||
<a href="https://zcode.z.ai/en/docs"><kbd><img src="../../src/shared/agent-icons/zcode.png" alt="ZCode logo" width="16" valign="middle" /> ZCode</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="MiMo Code logo" width="16" valign="middle" /> MiMo Code</kbd></a>
|
||||
|
||||
@@ -179,6 +179,7 @@ Funciona com **qualquer agente CLI** — se roda em um terminal, roda no Orca.
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Logotipo do Cursor" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="Logotipo do GitHub Copilot" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Logotipo do Muse" width="16" valign="middle" /> Muse</kbd></a>
|
||||
<a href="https://deepseek-harness.github.io/deepseek-harness/"><kbd><img src="../../src/shared/agent-icons/dsh.png" alt="DeepSeek Harness logo" width="16" valign="middle" /> DeepSeek Harness</kbd></a>
|
||||
<a href="https://zcode.z.ai/en/docs"><kbd><img src="../../src/shared/agent-icons/zcode.png" alt="Logotipo do ZCode" width="16" valign="middle" /> ZCode</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="Logotipo do OpenCode" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="Logotipo do MiMo Code" width="16" valign="middle" /> MiMo Code</kbd></a>
|
||||
|
||||
@@ -179,6 +179,7 @@ VS Code 的编辑器,处处自动保存 — 把文件或图片直接拖入智
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a>
|
||||
<a href="https://deepseek-harness.github.io/deepseek-harness/"><kbd><img src="../../src/shared/agent-icons/dsh.png" alt="DeepSeek Harness logo" width="16" valign="middle" /> DeepSeek Harness</kbd></a>
|
||||
<a href="https://zcode.z.ai/en/docs"><kbd><img src="../../src/shared/agent-icons/zcode.png" alt="ZCode logo" width="16" valign="middle" /> ZCode</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a>
|
||||
|
||||
@@ -105,8 +105,15 @@ ingests the summary into the hook server as a status row:
|
||||
| `structuredHost` | `'owned'` while `summary.hostExecutionOwned` is set, otherwise `'held'`; `worktree ps` derives its row's `structuredHostOwned` from it |
|
||||
| prompt, tool, last message, model, provider session | the summary's fields |
|
||||
|
||||
Sessions with no persisted turn (`status === null`) produce no row, matching
|
||||
what the chat shows. When the host revokes live ownership the row is re-set
|
||||
Sessions with no request (`status === null`) produce no row. A request is a
|
||||
turn record, an assistant message, a user message the provider journaled itself
|
||||
(history, an older host), an accepted or unanswered send, or a send the agent or
|
||||
its start refused; a send that was withdrawn, or left undelivered by a
|
||||
restart or a close, fails nobody and makes nothing listable.
|
||||
`summary.turnOutcome` is the latest request's verdict: its turn's outcome, or
|
||||
`failure` for a send the agent or its start refused (a send that joined a running
|
||||
turn is answered by that turn). The row also publishes `interrupted` from
|
||||
`mainAgent.outcome`, exactly as the hook lanes do. When the host revokes live ownership the row is re-set
|
||||
without the flag; when the host closes or evicts the session the row is
|
||||
dropped. Both already exist as feed events (`revokeLive` and the roster
|
||||
filter in `liveSessionSummaries`); PR 1 turns them into store writes.
|
||||
@@ -218,6 +225,32 @@ sends no hook at all on a cancel and no `is_interrupt` on Stop; that flag on a
|
||||
turn boundary remains a secondary source for builds that send it, and
|
||||
`StopFailure` maps to `failure`.
|
||||
|
||||
Readers decode the verdict through one accessor, `agentMainAgentVerdict`, which
|
||||
reads the main agent's own state, not the combined row's: `mainAgent.outcome`
|
||||
while `mainAgent.state` is `done`, then the legacy `interrupted` flag as a
|
||||
cancellation, which alone needs the combined `done`. So a main agent that
|
||||
failed while its subagents still run has a verdict on a `working` row. Every
|
||||
copy of a row (state-history entries, sleep records, `worktree ps` rows) takes
|
||||
the verdict through `agentVerdictFields`, which carries `interrupted` and the
|
||||
whole `mainAgent` (state, outcome and its own clock) together, so a copy agrees
|
||||
with the row and can date a failure by `mainAgent.stateStartedAt`.
|
||||
|
||||
Display reads the verdict through `agentVerdictDisplayMark`: a failure marks the
|
||||
agent failed whatever the combined state, because it is news the user must see
|
||||
even while subagents run; a stop marks it interrupted only on a `done` row, so
|
||||
a stopped or finished main agent with live child work still reads working.
|
||||
Each subagent keeps its own row and state. Container rollups (worktree card,
|
||||
terminal tab, Cmd+J) rank a pending question first, then a failure, then live
|
||||
work, then a stop, then done. On the worktree card, a failure retained after its
|
||||
agent's pane went away has no expiry, so it ranks below live work and above a
|
||||
stop. Lifecycle waiters keep reading the combined `state`.
|
||||
|
||||
Policy splits the verdict two ways. Clean-finish policy (hibernation, pane
|
||||
ownership, the star-nag value moment) treats a failure like a cancellation
|
||||
(`agentTurnEndedUncleanly`). Attention (completion time, Smart Sort, sticky
|
||||
retention, Cmd+J Recent) demotes only a turn the user stopped
|
||||
(`agentTurnStoppedByUser`); a failure ranks like a completion.
|
||||
|
||||
Admission is one function, `normalizeAgentStatusPayload`, on the relay wire,
|
||||
IPC and disk. A malformed `mainAgent` drops the field and keeps the row. Old hosts
|
||||
send none and readers fall back to `state`. Hook rows persist it inside the
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# CI demand rollout
|
||||
|
||||
This implements the September 28 runner-demand analysis. The baseline inventory
|
||||
covered September 27 04:00–September 28 04:00 UTC: 4,028 workflow runs, with
|
||||
463 stratified job samples. Estimated occupancy was 1,081 runner-hours, dominated
|
||||
by PR unit shards (414 hours) and Bun qualification (262 hours). These are
|
||||
sampled sums of job durations across different runner pools, not billing totals
|
||||
or a guaranteed forecast of savings.
|
||||
|
||||
## What runs now
|
||||
|
||||
| Work | Ordinary draft update | Ready PR / final checks | Main reference |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------- | --------------------------------------- |
|
||||
| Static analysis and types | Immediately | Immediately | Existing workflows |
|
||||
| Unit suite | Full, with shadow selection evidence | Full | Existing daily Node 24/26 x86 suite |
|
||||
| Packages | After successful static analysis and types | Same | Existing release workflows |
|
||||
| Bun persistence | Linux x64 for ordinary runtime changes; all six platforms for sensitive inputs | All six platforms when Bun inputs changed | Full nightly qualification at 11:30 UTC |
|
||||
| Bun glibc/musl qualification | Sensitive inputs only, after persistence succeeds | Both architectures after persistence succeeds | Both architectures |
|
||||
| E2E | Existing targeted routing | Existing targeted routing | One complete run at 17:00 UTC |
|
||||
|
||||
Bun-sensitive inputs include root/toolchain files, configuration, native code,
|
||||
resources, platform-specific paths, persistence, SQLite, orcad, providers,
|
||||
daemon, SSH, relay, and child-process code. Dependency discovery failure, a
|
||||
missing event or an incomplete diff retains full qualification. An unrelated
|
||||
change still skips Bun through the existing dependency classifier. No native
|
||||
artifact is shared across platforms or ABIs. The routine draft reduction is an
|
||||
explicit coverage-placement change; it does not assert identical per-update
|
||||
coverage. Every non-draft synchronize event and the ready-for-review event
|
||||
restores full qualification.
|
||||
|
||||
Expensive PR jobs wait for static/type success. This reduces fan-out for failed
|
||||
or rapidly superseded commits without sleeping on a runner. Successful isolated
|
||||
PRs pay the extra stage latency. Existing per-PR cancellation remains in place.
|
||||
Package assertions, native boundaries, SSH/folder coverage, cache warming and
|
||||
slow-test assertions are retained.
|
||||
|
||||
## Unit selection rollout
|
||||
|
||||
`ci-unit-plan.mjs` discovers the same include/exclude set as Vitest and follows
|
||||
static imports, re-exports, literal dynamic imports, CommonJS requires and the
|
||||
renderer aliases. Consumers of indirect filesystem/process inputs remain in the
|
||||
candidate set, as do script/tool tests. Global configuration changes, deletions,
|
||||
renames involving removed paths, unknown inputs and graph failures run the full
|
||||
suite. A shard verifies the plan's source SHA and complete discovery list before
|
||||
using it. Missing/stale artifacts fall back to full coverage, even if that means
|
||||
running the suite on fewer shards.
|
||||
|
||||
The initial policy is **shadow**, with all eight shards retained. The first
|
||||
local inventory matched Vitest exactly (9,950 files at validation); representative
|
||||
source changes retained roughly 88% of files because of indirect input readers.
|
||||
That is evidence for conservative coverage, not evidence of the analysis's
|
||||
hypothetical 50% unit-work reduction. Improvements to indirect dependency
|
||||
modeling should be demonstrated against full results before expanding selection.
|
||||
|
||||
Every shard uploads `unit-selection.json`, `unit-timings.json` (including module
|
||||
outcomes), and its assignment. The evidence job combines these into
|
||||
`unit-selection-review-attempt-N/selection-review.json`, reporting:
|
||||
|
||||
- Whether every discovered file appeared once across a complete reference run.
|
||||
- Failures outside the candidate set, including failures in otherwise red runs.
|
||||
- Measured worker time that selection would omit; worker times overlap and are
|
||||
not runner occupancy or a prediction of wall-clock savings.
|
||||
|
||||
Missing/duplicate shards, stale plans, interrupted runs and unhandled errors do
|
||||
not count as complete references. Diagnostic upload/report failures do not make
|
||||
tests pass and do not independently fail successful tests.
|
||||
|
||||
After representative complete shadow runs show no missed failures, set repository
|
||||
variable `ORCA_UNIT_SELECTION_MODE=selected` to enable selection **only for draft
|
||||
PRs**. Keep full ready-PR checks and the daily compatibility suite. Inspect at
|
||||
least a week's evidence across renderer, main, shared, SSH and fixture changes
|
||||
before promotion, including red runs rather than only successful examples.
|
||||
Unknown variable values retain shadow mode. Unset the variable or set it to
|
||||
`shadow` to roll back immediately. Selected runs use one to eight timing-balanced
|
||||
shards based on retained work.
|
||||
|
||||
Ready-for-review result reuse includes `unit full` in its source/workflow
|
||||
identity. A green selected draft cannot satisfy the final full check, even if
|
||||
the repository variable changes between runs. An already successful _full_
|
||||
identical-source check can still be reused.
|
||||
|
||||
To inspect downloaded shard artifacts locally:
|
||||
|
||||
```sh
|
||||
node config/scripts/ci-unit-selection-review.mjs ARTIFACT_DIRECTORY
|
||||
```
|
||||
|
||||
## Review automation
|
||||
|
||||
Pullfrog recognizes the existing `Review #N [id]` and
|
||||
`Review new commits on #N [id]` dispatch names. Explicit dispatchers may provide
|
||||
`pull_request_number` and `head_sha`. Explicit PR identities share concurrency at the workflow boundary. Legacy review
|
||||
names use a bounded lookup of the latest 100 dispatches and cancel only lower
|
||||
run IDs for the same PR; a delayed older scope cannot cancel a newer review.
|
||||
Unrecognized tasks are never grouped. The scope job alone has Actions write
|
||||
permission for ordered cancellation. Closed PRs and explicitly stale heads
|
||||
are skipped. A second head check prevents starting an agent after its queued
|
||||
head has changed. Unrecognized agent tasks remain independent; lookup failures
|
||||
also retain an independent task rather than cancelling unrelated work.
|
||||
|
||||
This does not introduce a fixed debounce interval or remove final reviews.
|
||||
Dispatchers should supply `head_sha` for reliable stale-at-dispatch detection;
|
||||
legacy names identify a PR but do not prove which head the prompt describes.
|
||||
|
||||
## E2E signal
|
||||
|
||||
The daily reference still executes all shards and keeps original verdicts. Each
|
||||
shard uploads Playwright JSON and publishes expected, skipped, unexpected, flaky
|
||||
and startup-error counts with the failing test names/messages. Targeted PR and
|
||||
manual coverage remain available. This change does not fix the historically
|
||||
red tests or pretend they pass.
|
||||
|
||||
`config/e2e-failure-tracking.json` can separate an evidenced repeated failure
|
||||
from new failures in the summary. Each entry must have exact `file`, full
|
||||
`title`, `project`, a nonempty stable `message` substring, an `@owner`, a linked
|
||||
repository `issue`, and an ISO `expires` review date. Expired/malformed entries
|
||||
are ignored and reported; changed error signatures appear as untracked. Entries
|
||||
never skip a test or change its exit status. The initial list is empty because
|
||||
the analysis established red workflows but did not establish owners and
|
||||
reproductions for individual failures. Do not blanket-baseline an entire red run.
|
||||
|
||||
## Capacity measurements and acceptance
|
||||
|
||||
`CI runner demand` runs daily at 04:23 UTC and can be dispatched manually. It
|
||||
reads the previous 24 complete hours in hourly pages, samples up to six runs per
|
||||
workflow/outcome stratum, and fetches job pages with bounded concurrency. An
|
||||
hour exceeding the API's 1,000-result search cap fails visibly. The report and
|
||||
raw evidence are retained for 30 days. No extra runner pool is provisioned.
|
||||
|
||||
The report measures the full job durations of runs **created** in the window,
|
||||
not occupancy clipped to the window: earlier runs that overlap it are excluded,
|
||||
and completed sampled jobs may finish after it. This matches the baseline
|
||||
cohort method. Workflow IDs keep ref-qualified paths in one sampling stratum.
|
||||
|
||||
The report shows weighted runner-hours and cancelled-run hours per workflow,
|
||||
runner-minutes per completed PR _run_, and weighted queue/provisioning p95 per
|
||||
runner label. It counts latest attempts only, excludes incomplete jobs, and
|
||||
retains zero-job observations. It does not measure other repositories competing
|
||||
for organization capacity. Compare equivalent traffic windows, not raw totals
|
||||
alone. The collector needs only `contents: read` and `actions: read`.
|
||||
|
||||
After a week, compare runner-minutes per PR run, cancellation occupancy and
|
||||
queue p95 in each affected pool. Count newly added planning/reference overhead.
|
||||
A 25–35% overall reduction remains an experiment target, not an achieved result;
|
||||
selection, coalescing and matrix reductions overlap and cannot simply be added.
|
||||
@@ -1,5 +1,8 @@
|
||||
# CI efficiency and runner capacity
|
||||
|
||||
The [September 28 demand rollout](ci-demand-rollout.md) documents staged checks,
|
||||
unit-selection evidence, Bun qualification, review cancellation and daily occupancy reports.
|
||||
|
||||
## September 27 follow-up
|
||||
|
||||
### Shared E2E CLI output
|
||||
|
||||
@@ -48,6 +48,7 @@ To restore prompts for one agent only, edit that agent's default arguments or en
|
||||
| Codebuff | Auto-setup | [Codebuff](https://www.codebuff.com/docs/help/quick-start) |
|
||||
| Command Code | Auto-setup, status | [Command Code](https://commandcode.ai/docs/quickstart) |
|
||||
| Muse | macOS/Linux; trusts the workspace at launch | [Meta](https://dev.meta.ai/docs/muse-code) |
|
||||
| DeepSeek Harness | Launched through its `dsh-tui` profile; hooks, status, questions, resume | [DeepSeek](https://deepseek-harness.github.io/deepseek-harness/) |
|
||||
| ZCode | Deep integration; needs a `zcode` CLI that ships the TUI (see note below) | [Z.ai](https://zcode.z.ai/en/docs) |
|
||||
| Continue | Auto-setup | [Continue](https://docs.continue.dev/guides/cli) |
|
||||
| Cursor CLI | Deep integration | [Cursor](https://cursor.com/cli) |
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
|
||||
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
|
||||
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
|
||||
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
|
||||
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
|
||||
"scenarioSha256": "c583058382c949e977b7a1287895ed9ccb8cb408b684aef186b629976ff1da4d",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
|
||||
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
|
||||
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
|
||||
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
|
||||
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
|
||||
"scenarioSha256": "651cb070a4b4ba7deddeb37c08886094b83f006e7cbe2bef65d5433ce0430dff",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
|
||||
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
|
||||
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
|
||||
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
|
||||
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
|
||||
"scenarioSha256": "e0ce643010302d304ba3611bd43932c6cdd0fa9290a3ecd4fffb4fc92c758b77",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
|
||||
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
|
||||
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
|
||||
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
|
||||
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
|
||||
"scenarioSha256": "c6b3b4be6b55daf53ee1f5ff755e73a032c4da5379f90f4973736c032eb79414",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
|
||||
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
|
||||
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
|
||||
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
|
||||
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
|
||||
"scenarioSha256": "a71815661dee6129a1b133c398b37ea89a9e4939b45626dada16889acabb2afe",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
|
||||
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
|
||||
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
|
||||
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
|
||||
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
|
||||
"scenarioSha256": "af837de2e273c5f03130b51d6e1a9bff9ff99a4e8d1c9554a53134acb5924c72",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
|
||||
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
|
||||
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
|
||||
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
|
||||
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
|
||||
"scenarioSha256": "7981ac87c4397b9703330835327c70828cb5a76633f1c4803ded1b66520d184d",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
|
||||
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
|
||||
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
|
||||
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
|
||||
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
|
||||
"scenarioSha256": "9f1c39e91a97266b0a550d2b2d061592ae050078401ef23177dd2e00db67bf04",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
|
||||
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
|
||||
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
|
||||
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
|
||||
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
|
||||
"scenarioSha256": "c858cfca59148548146b4c0a775e9518d3ab826ef268ccc9af60b04e52cc9e3d",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
|
||||
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
|
||||
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
|
||||
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
|
||||
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
|
||||
"scenarioSha256": "2f0a6aa8ce100edbce9ccf64c81efe64356fccbdcd2e16f561360e2b11b5700f",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
|
||||
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
|
||||
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
|
||||
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
|
||||
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
|
||||
"scenarioSha256": "7099d7311b2046dde21a2750201718995c0869bc4f17fb7f3ce2b997ce0e6506",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
|
||||
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
|
||||
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
|
||||
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
|
||||
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
|
||||
"scenarioSha256": "8b4a06c1b2918d5ed5f40102c1c7cb411e02aba5e7601e98658d673634784bb0",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
|
||||
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
|
||||
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
|
||||
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
|
||||
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
|
||||
"scenarioSha256": "793e954960d371c6477d4cd3d70a5215c0bd90764684eafdb1e1a2e09790cc86",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"baseline": "f00bebba483d55b483e2d05bd61c92a138a2bed1",
|
||||
"lockfileSha256": "9317f3a98ab047f9f96fe26fd8531b8a632ac5954064b322f41a904148cbf6bb",
|
||||
"recorderSha256": "0317fe2aebe4743ce5e7ae194531aa91b4fe0a84df457640851fe6dfdb9362af",
|
||||
"adapterSha256": "2d72b8e68a66a906394167beb8c78f1c0521ca1e731976fd26963ec3bfa9cca4",
|
||||
"adapterSha256": "86ef3d98ee6c11726bf0991e1a40400b60666b0a2e24d137abe66562fe5876bc",
|
||||
"scenarioSha256": "46b720fed417d29f79dbde4cc7941579f2d6a2f920bae24234537480079e7de5",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { AgentDotState } from '../worktree/agent-row-display'
|
||||
|
||||
// Per-agent state indicator, 1:1 with desktop AgentStateDot
|
||||
// (src/renderer/src/components/AgentStateDot.tsx): yellow spinner for 'working',
|
||||
// emerald for 'done', red for blocked/waiting/interrupted (attention), neutral
|
||||
// emerald for 'done', red for blocked/waiting/interrupted/failed (attention), neutral
|
||||
// for idle. Distinct from the worktree-level AgentSpinner, which collapses the
|
||||
// agent vocabulary into the 5-state rollup the sidebar dot uses.
|
||||
const DOT_COLORS: Record<Exclude<AgentDotState, 'working' | 'monitoring'>, string> = {
|
||||
@@ -13,6 +13,7 @@ const DOT_COLORS: Record<Exclude<AgentDotState, 'working' | 'monitoring'>, strin
|
||||
blocked: '#ef4444',
|
||||
waiting: '#ef4444',
|
||||
interrupted: '#ef4444',
|
||||
failed: '#ef4444',
|
||||
idle: 'rgba(115,115,115,0.4)'
|
||||
}
|
||||
const WORKING_COLOR = '#eab308'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { resolveBottomDrawerMounted } from './bottom-drawer-mount-state'
|
||||
import { MountedBottomDrawer } from './mounted-bottom-drawer'
|
||||
import type { ReactNode } from 'react'
|
||||
import { KeyedBottomDrawer } from './keyed-bottom-drawer'
|
||||
|
||||
type Props = {
|
||||
visible: boolean
|
||||
@@ -19,75 +18,21 @@ type Props = {
|
||||
zIndex?: number
|
||||
}
|
||||
|
||||
export function BottomDrawer({
|
||||
visible,
|
||||
onClose,
|
||||
onAfterClose,
|
||||
children,
|
||||
dragContentToDismiss = true,
|
||||
contentScrollable = true,
|
||||
fillAvailable = false,
|
||||
interactive = true,
|
||||
zIndex
|
||||
}: Props) {
|
||||
const [mounted, setMounted] = useState(visible)
|
||||
const onAfterCloseRef = useRef(onAfterClose)
|
||||
const hiddenHandledRef = useRef(false)
|
||||
const afterClosePendingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
onAfterCloseRef.current = onAfterClose
|
||||
}, [onAfterClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
hiddenHandledRef.current = false
|
||||
afterClosePendingRef.current = false
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
useEffect(() => {
|
||||
if (mounted || !afterClosePendingRef.current) {
|
||||
return
|
||||
}
|
||||
afterClosePendingRef.current = false
|
||||
onAfterCloseRef.current?.()
|
||||
}, [mounted])
|
||||
|
||||
const handleHidden = useCallback(() => {
|
||||
if (hiddenHandledRef.current) {
|
||||
return
|
||||
}
|
||||
hiddenHandledRef.current = true
|
||||
afterClosePendingRef.current = true
|
||||
setMounted(false)
|
||||
}, [])
|
||||
const resolvedMounted = resolveBottomDrawerMounted(visible, mounted)
|
||||
|
||||
// Why: opening drawers should mount before commit; waiting for a passive
|
||||
// Effect adds a null render before every drawer can animate in.
|
||||
if (resolvedMounted !== mounted) {
|
||||
setMounted(resolvedMounted)
|
||||
}
|
||||
|
||||
// Why: hidden drawers are rendered by parent screens even while closed; keep
|
||||
// their Reanimated/Gesture setup out of hot paths like commit-message typing.
|
||||
if (!resolvedMounted) {
|
||||
return null
|
||||
}
|
||||
const SHOWN = 'shown'
|
||||
const sheetKey = () => SHOWN
|
||||
|
||||
export function BottomDrawer({ visible, onClose, onAfterClose, children, ...drawerProps }: Props) {
|
||||
// Why: hidden drawers are rendered by parent screens even while closed; the keyed drawer
|
||||
// renders nothing until shown, which keeps Reanimated/Gesture setup out of hot paths.
|
||||
return (
|
||||
<MountedBottomDrawer
|
||||
visible={visible}
|
||||
<KeyedBottomDrawer
|
||||
{...drawerProps}
|
||||
sheet={visible ? SHOWN : null}
|
||||
sheetKey={sheetKey}
|
||||
onClose={onClose}
|
||||
onHidden={handleHidden}
|
||||
dragContentToDismiss={dragContentToDismiss}
|
||||
contentScrollable={contentScrollable}
|
||||
fillAvailable={fillAvailable}
|
||||
interactive={interactive}
|
||||
zIndex={zIndex}
|
||||
onAfterClose={onAfterClose}
|
||||
>
|
||||
{children}
|
||||
</MountedBottomDrawer>
|
||||
{() => children}
|
||||
</KeyedBottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ import { View, Text, Pressable, StyleSheet } from 'react-native'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
|
||||
type Props = {
|
||||
visible: boolean
|
||||
type ContentProps = {
|
||||
title: string
|
||||
message?: string
|
||||
confirmLabel?: string
|
||||
@@ -13,8 +12,17 @@ type Props = {
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
visible,
|
||||
type Props = ContentProps & { visible: boolean }
|
||||
|
||||
export function ConfirmModal({ visible, ...content }: Props) {
|
||||
return (
|
||||
<BottomDrawer visible={visible} onClose={content.onCancel}>
|
||||
<ConfirmContent {...content} />
|
||||
</BottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
export function ConfirmContent({
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
@@ -22,9 +30,9 @@ export function ConfirmModal({
|
||||
destructive = false,
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: Props) {
|
||||
}: ContentProps) {
|
||||
return (
|
||||
<BottomDrawer visible={visible} onClose={onCancel}>
|
||||
<>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{message ? <Text style={styles.message}>{message}</Text> : null}
|
||||
@@ -52,7 +60,7 @@ export function ConfirmModal({
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</BottomDrawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,15 @@ import type { DiffComment } from '../../../src/shared/diff-comment-types'
|
||||
import { useKeyboardAvoidingPadding } from '../platform/keyboard-occlusion'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import type { ActionSheetAction } from './ActionSheetModal'
|
||||
import { ActionSheetModal } from './ActionSheetModal'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
import { ConfirmModal } from './ConfirmModal'
|
||||
import { mobileReviewCountLabel } from '../session/mobile-diff-review-screen-model'
|
||||
import { ActionSheetContent } from './ActionSheetModal'
|
||||
import { ConfirmContent } from './ConfirmModal'
|
||||
import { KeyedBottomDrawer } from './keyed-bottom-drawer'
|
||||
import {
|
||||
mobileReviewCountLabel,
|
||||
type ComposerState,
|
||||
type SendSheetState
|
||||
} from '../session/mobile-diff-review-screen-model'
|
||||
import { reviewSheetKey, type ReviewSheet } from '../session/mobile-diff-review-sheets'
|
||||
import type { useMobileDiffReviewController } from '../session/use-mobile-diff-review-controller'
|
||||
import { mobileDiffReviewStyles as styles } from './mobile-diff-review-screen-styles'
|
||||
|
||||
@@ -17,59 +22,82 @@ type Props = {
|
||||
}
|
||||
|
||||
export function MobileDiffReviewDrawers({ controller }: Props) {
|
||||
const sendActions = useSendActions(controller)
|
||||
const overflowActions = useOverflowActions(controller)
|
||||
// One drawer for every review sheet: iOS cannot present a sheet while another is still closing.
|
||||
return (
|
||||
<>
|
||||
<ActionSheetModal
|
||||
visible={controller.showOverflow}
|
||||
title="Review Actions"
|
||||
message={
|
||||
controller.reviewedUnstagedCount > 0
|
||||
? `${controller.reviewedUnstagedCount} reviewed unstaged files can be staged`
|
||||
: undefined
|
||||
}
|
||||
actions={overflowActions}
|
||||
onClose={() => controller.setShowOverflow(false)}
|
||||
/>
|
||||
<ActionSheetModal
|
||||
visible={controller.sendSheet !== null}
|
||||
title="Send Notes"
|
||||
message={sendSheetMessage(controller)}
|
||||
actions={sendActions}
|
||||
onClose={() => controller.setSendSheet(null)}
|
||||
/>
|
||||
<ConfirmModal
|
||||
visible={controller.discardTarget !== null}
|
||||
title="Discard File"
|
||||
message={
|
||||
controller.discardTarget
|
||||
? `Discard changes to "${controller.discardTarget.filePath}"? This cannot be undone.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel="Discard"
|
||||
destructive
|
||||
onConfirm={() => {
|
||||
const target = controller.discardTarget
|
||||
controller.setDiscardTarget(null)
|
||||
if (target) {
|
||||
void controller.runGitMutation('git.discard', target)
|
||||
}
|
||||
}}
|
||||
onCancel={() => controller.setDiscardTarget(null)}
|
||||
/>
|
||||
<NoteComposerDrawer controller={controller} />
|
||||
<CompletionDrawer controller={controller} />
|
||||
</>
|
||||
<KeyedBottomDrawer
|
||||
sheet={controller.sheet}
|
||||
sheetKey={reviewSheetKey}
|
||||
onClose={(presented) => controller.closeSheet(presented.kind)}
|
||||
>
|
||||
{(presented) => <ReviewSheetContent controller={controller} sheet={presented} />}
|
||||
</KeyedBottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
function useSendActions(controller: ReturnType<typeof useMobileDiffReviewController>) {
|
||||
function ReviewSheetContent({ controller, sheet }: Props & { sheet: ReviewSheet }) {
|
||||
switch (sheet.kind) {
|
||||
case 'actions':
|
||||
return <ReviewActionsContent controller={controller} />
|
||||
case 'send':
|
||||
return <SendNotesContent controller={controller} load={sheet.load} />
|
||||
case 'discard':
|
||||
return (
|
||||
<ConfirmContent
|
||||
title="Discard File"
|
||||
message={`Discard changes to "${sheet.target.filePath}"? This cannot be undone.`}
|
||||
confirmLabel="Discard"
|
||||
destructive
|
||||
onConfirm={() => {
|
||||
controller.closeSheet('discard')
|
||||
void controller.runGitMutation('git.discard', sheet.target)
|
||||
}}
|
||||
onCancel={() => controller.closeSheet('discard')}
|
||||
/>
|
||||
)
|
||||
case 'composer':
|
||||
return <NoteComposerContent controller={controller} composer={sheet.composer} />
|
||||
case 'completion':
|
||||
return <CompletionContent controller={controller} />
|
||||
}
|
||||
}
|
||||
|
||||
function ReviewActionsContent({ controller }: Props) {
|
||||
const overflowActions = useOverflowActions(controller)
|
||||
return (
|
||||
<ActionSheetContent
|
||||
title="Review Actions"
|
||||
message={
|
||||
controller.reviewedUnstagedCount > 0
|
||||
? `${controller.reviewedUnstagedCount} reviewed unstaged files can be staged`
|
||||
: undefined
|
||||
}
|
||||
actions={overflowActions}
|
||||
onClose={() => controller.closeSheet('actions')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SendNotesContent({ controller, load }: Props & { load: SendSheetState }) {
|
||||
const sendActions = useSendActions(controller, load)
|
||||
return (
|
||||
<ActionSheetContent
|
||||
title="Send Notes"
|
||||
message={sendSheetMessage(controller, load)}
|
||||
actions={sendActions}
|
||||
onClose={() => controller.closeSheet('send')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function useSendActions(
|
||||
controller: ReturnType<typeof useMobileDiffReviewController>,
|
||||
load: SendSheetState
|
||||
) {
|
||||
return useMemo<ActionSheetAction[]>(() => {
|
||||
const comments = controller.unsentComments
|
||||
const terminalActions =
|
||||
controller.sendSheet?.kind === 'ready' || controller.sendSheet?.kind === 'error'
|
||||
? controller.sendSheet.terminals.map((terminal) => ({
|
||||
load.kind === 'ready' || load.kind === 'error'
|
||||
? load.terminals.map((terminal) => ({
|
||||
label: `${terminal.title || 'Terminal'} (${terminal.terminal.slice(0, 6)})`,
|
||||
icon: Send,
|
||||
disabled: comments.length === 0,
|
||||
@@ -94,7 +122,7 @@ function useSendActions(controller: ReturnType<typeof useMobileDiffReviewControl
|
||||
onPress: () => void controller.copyNotes()
|
||||
}
|
||||
]
|
||||
}, [controller])
|
||||
}, [controller, load])
|
||||
}
|
||||
|
||||
function useOverflowActions(controller: ReturnType<typeof useMobileDiffReviewController>) {
|
||||
@@ -111,7 +139,6 @@ function useOverflowActions(controller: ReturnType<typeof useMobileDiffReviewCon
|
||||
label: 'Send Unsent Notes',
|
||||
icon: Send,
|
||||
disabled: controller.unsentComments.length === 0,
|
||||
skipAutoClose: true,
|
||||
onPress: () => void controller.openSendSheet()
|
||||
},
|
||||
{
|
||||
@@ -152,65 +179,61 @@ function useOverflowActions(controller: ReturnType<typeof useMobileDiffReviewCon
|
||||
}
|
||||
|
||||
function sendSheetMessage(
|
||||
controller: ReturnType<typeof useMobileDiffReviewController>
|
||||
controller: ReturnType<typeof useMobileDiffReviewController>,
|
||||
load: SendSheetState
|
||||
): string | undefined {
|
||||
return controller.sendSheet?.kind === 'loading'
|
||||
return load.kind === 'loading'
|
||||
? 'Loading agent sessions...'
|
||||
: controller.sendSheet?.kind === 'error'
|
||||
? controller.sendSheet.message
|
||||
: load.kind === 'error'
|
||||
? load.message
|
||||
: `${controller.unsentComments.length} unsent notes`
|
||||
}
|
||||
|
||||
function NoteComposerDrawer({ controller }: Props) {
|
||||
const composer = controller.composer
|
||||
function NoteComposerContent({ controller, composer }: Props & { composer: ComposerState }) {
|
||||
// Zero on a phone, where `KeyboardAvoidingView` above already moved this; the page's own
|
||||
// keyboard measurement where it cannot, because that view is driven by events RN Web never
|
||||
// sends. Padding rather than a second avoiding view: the drawer owns the position.
|
||||
const keyboardPadding = useKeyboardAvoidingPadding()
|
||||
return (
|
||||
<BottomDrawer visible={composer !== null} onClose={controller.closeComposer}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
style={keyboardPadding > 0 ? { paddingBottom: keyboardPadding } : undefined}
|
||||
>
|
||||
<View style={styles.composerHeader}>
|
||||
<View>
|
||||
<Text style={styles.drawerTitle}>
|
||||
{composer?.mode === 'edit' ? 'Edit Note' : 'Add Note'}
|
||||
</Text>
|
||||
<Text style={styles.drawerSubtitle}>
|
||||
{composer?.mode === 'create' && composer.lineNumber > 0
|
||||
? `Line ${composer.lineNumber}`
|
||||
: 'File note'}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
|
||||
onPress={controller.closeComposer}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Cancel note"
|
||||
>
|
||||
<X size={18} color={colors.textPrimary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
style={keyboardPadding > 0 ? { paddingBottom: keyboardPadding } : undefined}
|
||||
>
|
||||
<View style={styles.composerHeader}>
|
||||
<View>
|
||||
<Text style={styles.drawerTitle}>
|
||||
{composer.mode === 'edit' ? 'Edit Note' : 'Add Note'}
|
||||
</Text>
|
||||
<Text style={styles.drawerSubtitle}>
|
||||
{composer.mode === 'create' && composer.lineNumber > 0
|
||||
? `Line ${composer.lineNumber}`
|
||||
: 'File note'}
|
||||
</Text>
|
||||
</View>
|
||||
<TextInput
|
||||
style={styles.composerInput}
|
||||
value={controller.composerBody}
|
||||
onChangeText={controller.setComposerBody}
|
||||
multiline
|
||||
autoFocus
|
||||
placeholder="Review note"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
accessibilityLabel={composerLabel(composer)}
|
||||
/>
|
||||
<View style={styles.drawerButtonRow}>
|
||||
{composer?.mode === 'edit' ? (
|
||||
<DeleteNoteButton onPress={controller.deleteComment} />
|
||||
) : null}
|
||||
<SaveNoteButton controller={controller} composer={composer} />
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</BottomDrawer>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
|
||||
onPress={controller.closeComposer}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Cancel note"
|
||||
>
|
||||
<X size={18} color={colors.textPrimary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<TextInput
|
||||
style={styles.composerInput}
|
||||
value={controller.composerBody}
|
||||
onChangeText={controller.setComposerBody}
|
||||
multiline
|
||||
autoFocus
|
||||
placeholder="Review note"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
accessibilityLabel={composerLabel(composer)}
|
||||
/>
|
||||
<View style={styles.drawerButtonRow}>
|
||||
{composer.mode === 'edit' ? <DeleteNoteButton onPress={controller.deleteComment} /> : null}
|
||||
<SaveNoteButton controller={controller} composer={composer} />
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -262,14 +285,11 @@ function SaveNoteButton({
|
||||
)
|
||||
}
|
||||
|
||||
function CompletionDrawer({ controller }: Props) {
|
||||
function CompletionContent({ controller }: Props) {
|
||||
const noteCount =
|
||||
controller.screenState.kind === 'ready' ? controller.screenState.comments.length : 0
|
||||
return (
|
||||
<BottomDrawer
|
||||
visible={controller.showCompletion}
|
||||
onClose={() => controller.setShowCompletion(false)}
|
||||
>
|
||||
<>
|
||||
<Text style={styles.drawerTitle}>Review Complete</Text>
|
||||
<Text style={styles.drawerSubtitle}>
|
||||
{mobileReviewCountLabel(controller.queue.length, 'file', 'files')} reviewed,{' '}
|
||||
@@ -297,6 +317,6 @@ function CompletionDrawer({ controller }: Props) {
|
||||
<Text style={styles.primaryButtonText}>Send Notes</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</BottomDrawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export function MobileDiffReviewScreenView({ controller, onBack }: Props) {
|
||||
unsentCount={controller.unsentComments.length}
|
||||
worktreeLabel={controller.worktreeLabel}
|
||||
onBack={onBack}
|
||||
onOpenActions={() => controller.setShowOverflow(true)}
|
||||
onOpenActions={() => controller.openSheet({ kind: 'actions' })}
|
||||
onOpenPRSidebar={controller.openPRSidebar}
|
||||
onSelectFilter={controller.selectFilter}
|
||||
/>
|
||||
@@ -104,7 +104,7 @@ export function MobileDiffReviewScreenView({ controller, onBack }: Props) {
|
||||
busyAction={controller.busyAction}
|
||||
item={controller.currentItem}
|
||||
onAddFileNote={() => controller.openComposer(0)}
|
||||
onDiscard={controller.setDiscardTarget}
|
||||
onDiscard={(target) => controller.openSheet({ kind: 'discard', target })}
|
||||
onGitMutation={(method, item) => void controller.runGitMutation(method, item)}
|
||||
onMarkReviewed={() => void controller.markReviewed()}
|
||||
onMoveFile={controller.moveFile}
|
||||
|
||||
@@ -2,7 +2,12 @@ import { memo } from 'react'
|
||||
import { StyleSheet, Text, View } from 'react-native'
|
||||
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import { agentDisplayLabel, agentDotState, formatTimeAgo } from '../worktree/agent-row-display'
|
||||
import {
|
||||
agentDisplayLabel,
|
||||
agentDotState,
|
||||
agentRowTimeAt,
|
||||
formatTimeAgo
|
||||
} from '../worktree/agent-row-display'
|
||||
import { AgentStateDot } from './AgentStateDot'
|
||||
import { MobileAgentIcon } from './MobileAgentIcon'
|
||||
|
||||
@@ -22,7 +27,7 @@ type Props = {
|
||||
function WorktreeAgentRowComponent({ agent, depth, now, unvisited }: Props) {
|
||||
const dotState = agentDotState(agent, now)
|
||||
const label = agentDisplayLabel(agent, now)
|
||||
const ts = formatTimeAgo(agent.stateStartedAt, now)
|
||||
const ts = formatTimeAgo(agentRowTimeAt(agent), now)
|
||||
|
||||
return (
|
||||
<View style={[styles.row, { paddingLeft: depth * INDENT_PER_DEPTH }]}>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type Timing = { to: number; callback?: (finished: boolean) => void }
|
||||
|
||||
// Models Reanimated's contract: assigning a new animation cancels the running one,
|
||||
// whose callback then gets finished=false.
|
||||
const animations = vi.hoisted((): { running: Timing[] } => ({ running: [] }))
|
||||
|
||||
vi.mock('../navigation/use-back-claim', () => ({ useBackClaim: () => {} }))
|
||||
vi.mock('../platform/keyboard-occlusion', () => ({
|
||||
currentSoftKeyboardHeight: () => 0,
|
||||
subscribeSoftKeyboard: () => () => {}
|
||||
}))
|
||||
vi.mock('react-native', () => ({
|
||||
Keyboard: { dismiss: () => {} },
|
||||
Modal: 'Modal',
|
||||
Platform: { OS: 'android', select: (options: { android?: unknown }) => options.android },
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: <T,>(styles: T) => styles, absoluteFillObject: {} },
|
||||
View: 'View',
|
||||
useWindowDimensions: () => ({ width: 412, height: 900 })
|
||||
}))
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ top: 24, bottom: 0, left: 0, right: 0 })
|
||||
}))
|
||||
vi.mock('react-native-gesture-handler', () => {
|
||||
const chain: Record<string, unknown> = {}
|
||||
for (const method of [
|
||||
'activeOffsetY',
|
||||
'simultaneousWithExternalGesture',
|
||||
'onBegin',
|
||||
'onUpdate',
|
||||
'onEnd'
|
||||
]) {
|
||||
chain[method] = () => chain
|
||||
}
|
||||
return {
|
||||
Gesture: { Pan: () => chain, Native: () => chain },
|
||||
GestureDetector: 'GestureDetector',
|
||||
GestureHandlerRootView: 'GestureHandlerRootView'
|
||||
}
|
||||
})
|
||||
vi.mock('react-native-reanimated', () => {
|
||||
function isTiming(value: unknown): value is Timing {
|
||||
return typeof value === 'object' && value !== null && 'to' in value
|
||||
}
|
||||
return {
|
||||
default: { View: 'AnimatedView', ScrollView: 'AnimatedScrollView' },
|
||||
useSharedValue: (initial: number) => {
|
||||
let value = initial
|
||||
let current: Timing | null = null
|
||||
return {
|
||||
get value() {
|
||||
return value
|
||||
},
|
||||
set value(next: unknown) {
|
||||
if (current) {
|
||||
const cancelled = current
|
||||
current = null
|
||||
animations.running = animations.running.filter((entry) => entry !== cancelled)
|
||||
cancelled.callback?.(false)
|
||||
}
|
||||
if (isTiming(next)) {
|
||||
current = next
|
||||
animations.running.push(next)
|
||||
value = next.to
|
||||
} else if (typeof next === 'number') {
|
||||
value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
useAnimatedStyle: () => ({}),
|
||||
useAnimatedScrollHandler: () => () => {},
|
||||
withSpring: (to: number) => to,
|
||||
withTiming: (to: number, _config?: unknown, callback?: (finished: boolean) => void) => ({
|
||||
to,
|
||||
callback
|
||||
}),
|
||||
runOnJS: (fn: () => void) => fn,
|
||||
interpolate: () => 0,
|
||||
Extrapolation: { CLAMP: 'clamp' }
|
||||
}
|
||||
})
|
||||
|
||||
import { MountedBottomDrawer } from './mounted-bottom-drawer'
|
||||
|
||||
function finishAnimations(): void {
|
||||
const finishing = animations.running
|
||||
animations.running = []
|
||||
act(() => {
|
||||
for (const animation of finishing) {
|
||||
animation.callback?.(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function drawer(visible: boolean, onClose: () => void, onHidden: () => void) {
|
||||
return (
|
||||
<MountedBottomDrawer visible={visible} onClose={onClose} onHidden={onHidden}>
|
||||
{null}
|
||||
</MountedBottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
function pressAndroidBack(renderer: ReactTestRenderer): void {
|
||||
act(() => renderer.root.find((node) => String(node.type) === 'Modal').props.onRequestClose())
|
||||
}
|
||||
|
||||
describe('bottom drawer close request while hiding', () => {
|
||||
afterEach(() => {
|
||||
animations.running = []
|
||||
})
|
||||
|
||||
it('still closes an open drawer on Android Back', () => {
|
||||
const onClose = vi.fn()
|
||||
let renderer!: ReactTestRenderer
|
||||
act(() => {
|
||||
renderer = create(drawer(true, onClose, vi.fn()))
|
||||
})
|
||||
finishAnimations()
|
||||
|
||||
pressAndroidBack(renderer)
|
||||
finishAnimations()
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
// A Back press inside the hide animation used to restart it, so onHidden never fired
|
||||
// and the drawer's invisible Modal stayed up, swallowing every tap on the screen.
|
||||
it('lets the hide finish when Back is pressed mid-close', () => {
|
||||
const onClose = vi.fn()
|
||||
const onHidden = vi.fn()
|
||||
let renderer!: ReactTestRenderer
|
||||
act(() => {
|
||||
renderer = create(drawer(true, onClose, onHidden))
|
||||
})
|
||||
finishAnimations()
|
||||
act(() => renderer.update(drawer(false, onClose, onHidden)))
|
||||
|
||||
pressAndroidBack(renderer)
|
||||
finishAnimations()
|
||||
|
||||
expect(onHidden).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
})
|
||||
@@ -98,4 +98,23 @@ describe('BottomDrawer close lifecycle', () => {
|
||||
expect(latestAfterClose).toHaveBeenCalledTimes(1)
|
||||
expect(renderer.toJSON()).toBeNull()
|
||||
})
|
||||
|
||||
// The hide finished and the drawer reopened before the scheduled JS callback ran; that late
|
||||
// callback used to latch, so the next close never unmounted and its invisible Modal ate taps.
|
||||
it('a hide that lands after a reopen does not swallow the next close', () => {
|
||||
const onAfterClose = vi.fn()
|
||||
const renderer = renderDrawer(true, vi.fn(), onAfterClose)
|
||||
const lateOnHidden = mountedDrawer(renderer).props.onHidden
|
||||
updateDrawer(renderer, false, vi.fn(), onAfterClose)
|
||||
updateDrawer(renderer, true, vi.fn(), onAfterClose)
|
||||
|
||||
act(() => lateOnHidden())
|
||||
expect(mountedDrawer(renderer).props.visible).toBe(true)
|
||||
expect(onAfterClose).not.toHaveBeenCalled()
|
||||
|
||||
updateDrawer(renderer, false, vi.fn(), onAfterClose)
|
||||
act(() => mountedDrawer(renderer).props.onHidden())
|
||||
expect(renderer.toJSON()).toBeNull()
|
||||
expect(onAfterClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { createElement, Profiler, useLayoutEffect, useState, type ReactElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
|
||||
// iOS cannot present a native Modal while another is still presented, even mid-close. Each mounted
|
||||
// MountedBottomDrawer is one native Modal; this mock records when each mounts and unmounts, in which
|
||||
// commit, and which sheets it ever showed, so the tests can check the drawer's lifecycle directly.
|
||||
|
||||
type Drawer = {
|
||||
id: number
|
||||
keys: Set<string>
|
||||
visible: boolean
|
||||
onHidden: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
type Event = { type: 'mount' | 'unmount'; id: number; commit: number }
|
||||
|
||||
type Modals = {
|
||||
commit: number
|
||||
nextId: number
|
||||
all: Drawer[]
|
||||
live: Map<number, Drawer>
|
||||
events: Event[]
|
||||
}
|
||||
|
||||
const modals = vi.hoisted((): Modals => ({
|
||||
commit: 0,
|
||||
nextId: 0,
|
||||
all: [],
|
||||
live: new Map(),
|
||||
events: []
|
||||
}))
|
||||
|
||||
vi.mock('./mounted-bottom-drawer', () => ({
|
||||
MountedBottomDrawer: function MockMountedBottomDrawer(props: {
|
||||
visible: boolean
|
||||
onHidden: () => void
|
||||
onClose: () => void
|
||||
children: ReactElement<{ name: string }>
|
||||
}) {
|
||||
const [drawer] = useState((): Drawer => ({ id: modals.nextId++, keys: new Set(), ...props }))
|
||||
useLayoutEffect(() => {
|
||||
drawer.visible = props.visible
|
||||
drawer.onHidden = props.onHidden
|
||||
drawer.onClose = props.onClose
|
||||
drawer.keys.add(props.children.props.name)
|
||||
})
|
||||
useLayoutEffect(() => {
|
||||
modals.all.push(drawer)
|
||||
modals.live.set(drawer.id, drawer)
|
||||
modals.events.push({ type: 'mount', id: drawer.id, commit: modals.commit })
|
||||
return () => {
|
||||
modals.live.delete(drawer.id)
|
||||
modals.events.push({ type: 'unmount', id: drawer.id, commit: modals.commit })
|
||||
}
|
||||
}, [])
|
||||
return props.children
|
||||
}
|
||||
}))
|
||||
|
||||
const { KeyedBottomDrawer } = await import('./keyed-bottom-drawer')
|
||||
|
||||
type Sheet = { name: string; version: number }
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let onAfterClose: Mock<(closed: Sheet) => void>
|
||||
|
||||
function tree(sheet: Sheet | null, onClose: (presented: Sheet) => void = () => {}): ReactElement {
|
||||
return (
|
||||
<Profiler
|
||||
id="drawer"
|
||||
// Why: runs once per commit after its subtree's layout effects, so it numbers commits.
|
||||
onRender={() => {
|
||||
modals.commit++
|
||||
}}
|
||||
>
|
||||
<KeyedBottomDrawer<Sheet>
|
||||
sheet={sheet}
|
||||
sheetKey={(s) => s.name}
|
||||
onClose={onClose}
|
||||
onAfterClose={onAfterClose}
|
||||
>
|
||||
{(presented) =>
|
||||
createElement('SheetContent', { name: presented.name, version: presented.version })
|
||||
}
|
||||
</KeyedBottomDrawer>
|
||||
</Profiler>
|
||||
)
|
||||
}
|
||||
|
||||
function request(sheet: Sheet | null): void {
|
||||
act(() => {
|
||||
if (renderer) {
|
||||
renderer.update(tree(sheet))
|
||||
} else {
|
||||
renderer = create(tree(sheet))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function only(): Drawer | null {
|
||||
expect(modals.live.size).toBeLessThanOrEqual(1)
|
||||
return [...modals.live.values()][0] ?? null
|
||||
}
|
||||
|
||||
function presented(): { name: string; version: number; visible: boolean } | null {
|
||||
const drawer = only()
|
||||
if (!drawer) {
|
||||
return null
|
||||
}
|
||||
const content = renderer!.root.find((node) => String(node.type) === 'SheetContent')
|
||||
return { name: content.props.name, version: content.props.version, visible: drawer.visible }
|
||||
}
|
||||
|
||||
/** The native hide animation of the mounted drawer finished. */
|
||||
function finishHide(drawer = only()): void {
|
||||
act(() => drawer?.onHidden())
|
||||
}
|
||||
|
||||
function mounts(): Event[] {
|
||||
return modals.events.filter((event) => event.type === 'mount')
|
||||
}
|
||||
|
||||
const A = { name: 'a', version: 1 }
|
||||
const B = { name: 'b', version: 1 }
|
||||
const C = { name: 'c', version: 1 }
|
||||
|
||||
beforeEach(() => {
|
||||
modals.commit = 0
|
||||
modals.nextId = 0
|
||||
modals.all = []
|
||||
modals.live.clear()
|
||||
modals.events = []
|
||||
onAfterClose = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
describe('KeyedBottomDrawer', () => {
|
||||
it('presents a request at once when nothing is presented', () => {
|
||||
request(null)
|
||||
expect(presented()).toBeNull()
|
||||
request(A)
|
||||
expect(presented()).toEqual({ name: 'a', version: 1, visible: true })
|
||||
})
|
||||
|
||||
it('hides the presented sheet and shows the next only after its Modal has unmounted', () => {
|
||||
request(A)
|
||||
request(B)
|
||||
// A keeps its content through the close; B is not mounted yet.
|
||||
expect(presented()).toEqual({ name: 'a', version: 1, visible: false })
|
||||
|
||||
finishHide()
|
||||
expect(presented()).toEqual({ name: 'b', version: 1, visible: true })
|
||||
expect(onAfterClose).toHaveBeenCalledExactlyOnceWith(A)
|
||||
|
||||
const unmountA = modals.events.find((event) => event.type === 'unmount')
|
||||
const mountB = mounts()[1]!
|
||||
// A commit with no Modal at all lands between A leaving and B arriving.
|
||||
expect(mountB.commit).toBeGreaterThan(unmountA!.commit)
|
||||
})
|
||||
|
||||
it('presents the latest request after a close, never one replaced before it was shown', () => {
|
||||
request(A)
|
||||
request(B)
|
||||
request(C)
|
||||
finishHide()
|
||||
expect(presented()?.name).toBe('c')
|
||||
expect(modals.all.map((drawer) => [...drawer.keys])).toEqual([['a'], ['c']])
|
||||
})
|
||||
|
||||
it('a request cleared before the close finishes presents nothing afterwards', () => {
|
||||
request(A)
|
||||
request(B)
|
||||
request(null)
|
||||
finishHide()
|
||||
expect(presented()).toBeNull()
|
||||
expect(mounts()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reopening the same sheet mid-close re-shows the same Modal with the new content', () => {
|
||||
request(A)
|
||||
const drawer = only()
|
||||
request(null)
|
||||
expect(presented()?.visible).toBe(false)
|
||||
request({ name: 'a', version: 2 })
|
||||
expect(presented()).toEqual({ name: 'a', version: 2, visible: true })
|
||||
expect(only()).toBe(drawer)
|
||||
expect(mounts()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('switching away and back to the presented sheet mid-close re-shows it', () => {
|
||||
request(A)
|
||||
request(B)
|
||||
request(A)
|
||||
expect(presented()).toEqual({ name: 'a', version: 1, visible: true })
|
||||
finishHide()
|
||||
expect(presented()?.visible).toBe(true)
|
||||
expect(mounts()).toHaveLength(1)
|
||||
})
|
||||
|
||||
// The hide animation finishes, the sheet reopens before the scheduled JS callback runs, and then
|
||||
// the callback lands: it must not unmount the shown sheet or swallow the next close.
|
||||
it('ignores a hide that lands after a reopen and still honours the next close', () => {
|
||||
request(A)
|
||||
const drawer = only()!
|
||||
request(null)
|
||||
request(A)
|
||||
finishHide(drawer)
|
||||
expect(presented()).toEqual({ name: 'a', version: 1, visible: true })
|
||||
expect(onAfterClose).not.toHaveBeenCalled()
|
||||
|
||||
request(B)
|
||||
finishHide()
|
||||
expect(presented()).toEqual({ name: 'b', version: 1, visible: true })
|
||||
expect(onAfterClose).toHaveBeenCalledExactlyOnceWith(A)
|
||||
})
|
||||
|
||||
it('a hide from an earlier Modal cannot close a later one', () => {
|
||||
request(A)
|
||||
const first = only()!
|
||||
request(null)
|
||||
finishHide()
|
||||
request(B)
|
||||
request(null)
|
||||
act(() => first.onHidden())
|
||||
expect(presented()).toEqual({ name: 'b', version: 1, visible: false })
|
||||
finishHide()
|
||||
expect(presented()).toBeNull()
|
||||
})
|
||||
|
||||
it('reports the close with the sheet that was presented', () => {
|
||||
const onClose = vi.fn()
|
||||
act(() => {
|
||||
renderer = create(tree({ name: 'a', version: 3 }, onClose))
|
||||
})
|
||||
act(() => only()?.onClose())
|
||||
expect(onClose).toHaveBeenCalledExactlyOnceWith({ name: 'a', version: 3 })
|
||||
})
|
||||
|
||||
it('keeps one Modal, never swaps its sheet, and always settles on the latest request', () => {
|
||||
const names = ['a', 'b', 'c']
|
||||
let seed = 11
|
||||
const random = (n: number) => {
|
||||
seed = (seed * 48271) % 2147483647
|
||||
return seed % n
|
||||
}
|
||||
let latestName: string | null = null
|
||||
let version = 0
|
||||
for (let step = 0; step < 600; step++) {
|
||||
const move = random(5)
|
||||
if (move === 2) {
|
||||
finishHide()
|
||||
} else if (move === 3) {
|
||||
// A stale or early hide from whichever Modal is up.
|
||||
act(() => only()?.onHidden())
|
||||
} else {
|
||||
// A new request, or the same sheet again with fresh content.
|
||||
const pick = random(names.length + 1)
|
||||
latestName = move === 4 ? latestName : (names[pick] ?? null)
|
||||
request(latestName === null ? null : { name: latestName, version: ++version })
|
||||
}
|
||||
const shown = presented()
|
||||
if (shown?.visible) {
|
||||
expect(shown.name).toBe(latestName)
|
||||
}
|
||||
}
|
||||
// Settle: finishing every pending hide lands on the latest request.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (presented()?.visible === false) {
|
||||
finishHide()
|
||||
}
|
||||
}
|
||||
expect(presented()?.name ?? null).toBe(latestName)
|
||||
|
||||
expect(modals.all.length).toBeGreaterThan(20)
|
||||
for (const drawer of modals.all) {
|
||||
expect(drawer.keys.size).toBe(1)
|
||||
}
|
||||
// Each Modal mounted in a later commit than the one before it left.
|
||||
const ordered = modals.events
|
||||
for (let i = 1; i < ordered.length; i++) {
|
||||
const event = ordered[i]!
|
||||
if (event.type === 'mount') {
|
||||
const previous = ordered[i - 1]!
|
||||
expect(previous.type).toBe('unmount')
|
||||
expect(event.commit).toBeGreaterThan(previous.commit)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { MountedBottomDrawer, type MountedBottomDrawerProps } from './mounted-bottom-drawer'
|
||||
|
||||
// iOS cannot present a native Modal while another is still presented, even mid-close, so one
|
||||
// drawer owns what is on screen and swaps sheets only after the previous Modal has unmounted.
|
||||
|
||||
type Props<T> = Omit<MountedBottomDrawerProps, 'visible' | 'onClose' | 'onHidden' | 'children'> & {
|
||||
/** The sheet the screen wants shown, or null for none. */
|
||||
sheet: T | null
|
||||
/** Sheets with the same key are one presentation: new content refreshes it in place. */
|
||||
sheetKey: (sheet: T) => string
|
||||
onClose: (presented: T) => void
|
||||
/** Runs once a sheet's Modal has unmounted, in the commit after it left the tree. */
|
||||
onAfterClose?: (closed: T) => void
|
||||
children: (presented: T) => ReactNode
|
||||
}
|
||||
|
||||
type Presentation<T> = {
|
||||
presented: T | null
|
||||
/** The presented sheet is animating closed. */
|
||||
closing: boolean
|
||||
/** Just unmounted; nothing may present until a commit without its Modal has landed. */
|
||||
released: T | null
|
||||
/** Identifies one mounted Modal so a hide from an earlier one cannot close a later one. */
|
||||
epoch: number
|
||||
}
|
||||
|
||||
export function KeyedBottomDrawer<T>({
|
||||
sheet,
|
||||
sheetKey,
|
||||
onClose,
|
||||
onAfterClose,
|
||||
children,
|
||||
...drawerProps
|
||||
}: Props<T>) {
|
||||
const [state, setState] = useState<Presentation<T>>(() => ({
|
||||
presented: sheet,
|
||||
closing: false,
|
||||
released: null,
|
||||
epoch: 0
|
||||
}))
|
||||
const onAfterCloseRef = useRef(onAfterClose)
|
||||
useEffect(() => {
|
||||
onAfterCloseRef.current = onAfterClose
|
||||
}, [onAfterClose])
|
||||
|
||||
const next = followRequest(state, sheet, sheetKey)
|
||||
// Why: present in the same render as the request so opening does not add a blank commit.
|
||||
if (next !== state) {
|
||||
setState(next)
|
||||
}
|
||||
|
||||
const { epoch, released } = next
|
||||
const handleHidden = useCallback(() => {
|
||||
// Why: a hide that finished before a reopen re-showed this sheet must not unmount it.
|
||||
setState((current) =>
|
||||
current.epoch === epoch && current.closing
|
||||
? { presented: null, closing: false, released: current.presented, epoch: epoch + 1 }
|
||||
: current
|
||||
)
|
||||
}, [epoch])
|
||||
|
||||
useEffect(() => {
|
||||
if (released === null) {
|
||||
return
|
||||
}
|
||||
onAfterCloseRef.current?.(released)
|
||||
setState((current) =>
|
||||
current.released === released ? { ...current, released: null } : current
|
||||
)
|
||||
}, [released])
|
||||
|
||||
const presented = next.presented
|
||||
if (presented === null) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<MountedBottomDrawer
|
||||
{...drawerProps}
|
||||
visible={!next.closing}
|
||||
onClose={() => onClose(presented)}
|
||||
onHidden={handleHidden}
|
||||
>
|
||||
{children(presented)}
|
||||
</MountedBottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
function followRequest<T>(
|
||||
state: Presentation<T>,
|
||||
sheet: T | null,
|
||||
sheetKey: (sheet: T) => string
|
||||
): Presentation<T> {
|
||||
const { presented } = state
|
||||
if (presented === null) {
|
||||
return sheet !== null && state.released === null
|
||||
? { ...state, presented: sheet, closing: false }
|
||||
: state
|
||||
}
|
||||
if (sheet !== null && sheetKey(sheet) === sheetKey(presented)) {
|
||||
return sheet !== presented || state.closing
|
||||
? { ...state, presented: sheet, closing: false }
|
||||
: state
|
||||
}
|
||||
return state.closing ? state : { ...state, closing: true }
|
||||
}
|
||||
@@ -40,6 +40,7 @@ export const MOBILE_AGENT_ICON_ASSETS: Partial<Record<TuiAgent, ImageSourcePropT
|
||||
hermes: require('../../../src/shared/agent-icons/hermes.png'),
|
||||
devin: require('../../../src/shared/agent-icons/devin.png'),
|
||||
muse: require('../../../src/shared/agent-icons/muse.png'),
|
||||
dsh: require('../../../src/shared/agent-icons/dsh.png'),
|
||||
zcode: require('../../../src/shared/agent-icons/zcode.png'),
|
||||
openclaw: require('../../../src/shared/agent-icons/openclaw.png')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { DiffComment } from '../../../src/shared/diff-comment-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ReviewScreenState } from '../session/mobile-diff-review-screen-model'
|
||||
|
||||
// iOS cannot present a review sheet while another is still on screen (even mid-close): the second
|
||||
// presentation silently fails and every later tap on the screen is swallowed. These drive the real
|
||||
// controller and keyed drawer; only the native drawer is mocked, and its finished hide animation is
|
||||
// played by calling its `onHidden`.
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
KeyboardAvoidingView: 'KeyboardAvoidingView',
|
||||
Platform: { OS: 'ios' },
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: <T>(styles: T) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
TextInput: 'TextInput',
|
||||
View: 'View'
|
||||
}))
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
Check: 'Check',
|
||||
Copy: 'Copy',
|
||||
Edit3: 'Edit3',
|
||||
FileText: 'FileText',
|
||||
Plus: 'Plus',
|
||||
Send: 'Send',
|
||||
Trash2: 'Trash2',
|
||||
X: 'X'
|
||||
}))
|
||||
vi.mock('expo-haptics', () => ({
|
||||
impactAsync: vi.fn(async () => {}),
|
||||
notificationAsync: vi.fn(async () => {}),
|
||||
selectionAsync: vi.fn(async () => {}),
|
||||
performAndroidHapticsAsync: vi.fn(async () => {}),
|
||||
AndroidHaptics: {},
|
||||
ImpactFeedbackStyle: {},
|
||||
NotificationFeedbackType: {}
|
||||
}))
|
||||
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
|
||||
vi.mock('../platform/keyboard-occlusion', () => ({ useKeyboardAvoidingPadding: () => 0 }))
|
||||
vi.mock('./mobile-diff-review-screen-styles', () => ({
|
||||
mobileDiffReviewStyles: new Proxy({}, { get: () => ({}) })
|
||||
}))
|
||||
vi.mock('./mounted-bottom-drawer', () => ({ MountedBottomDrawer: 'MountedBottomDrawer' }))
|
||||
const loadSnapshot = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../session/mobile-diff-review-loaders', () => ({
|
||||
loadMobileDiffReviewSnapshot: loadSnapshot,
|
||||
loadMobileDiffReviewDiff: vi.fn().mockResolvedValue({ kind: 'idle' })
|
||||
}))
|
||||
vi.mock('../session/use-mobile-pr-sidebar-controller', () => ({
|
||||
useMobilePrSidebarController: () => ({})
|
||||
}))
|
||||
|
||||
const { MobileDiffReviewDrawers } = await import('./MobileDiffReviewDrawers')
|
||||
const { useMobileDiffReviewController } =
|
||||
await import('../session/use-mobile-diff-review-controller')
|
||||
|
||||
type Controller = ReturnType<typeof useMobileDiffReviewController>
|
||||
type Deferred = { promise: Promise<unknown>; resolve: (value: unknown) => void }
|
||||
|
||||
function deferred(): Deferred {
|
||||
let resolve: (value: unknown) => void = () => {}
|
||||
const promise = new Promise<unknown>((settle) => {
|
||||
resolve = settle
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
const NOTE: DiffComment = {
|
||||
id: 'note-1',
|
||||
worktreeId: 'wt-1',
|
||||
filePath: 'src/a.ts',
|
||||
lineNumber: 3,
|
||||
body: 'rename this',
|
||||
createdAt: 1,
|
||||
side: 'modified'
|
||||
}
|
||||
|
||||
const SNAPSHOT: ReviewScreenState = {
|
||||
kind: 'ready',
|
||||
status: {
|
||||
entries: [{ path: 'src/a.ts', status: 'modified', area: 'unstaged' }],
|
||||
conflictOperation: undefined,
|
||||
upstreamStatus: undefined,
|
||||
branch: 'feature',
|
||||
head: 'abc123'
|
||||
},
|
||||
branchCompare: null,
|
||||
comments: [NOTE],
|
||||
reviewState: { version: 1, files: {} }
|
||||
}
|
||||
|
||||
const TABS_REPLY = {
|
||||
id: 'tabs',
|
||||
ok: true,
|
||||
result: { tabs: [{ type: 'terminal', id: 'tab-1', terminal: 'terminal-1', title: 'codex' }] },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
}
|
||||
const SAVE_REPLY = { id: 'save', ok: true, result: {}, _meta: { runtimeId: 'runtime' } }
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let controller: Controller
|
||||
let replies: Map<string, Deferred>
|
||||
|
||||
function Screen({ client }: { client: RpcClient }) {
|
||||
controller = useMobileDiffReviewController({
|
||||
client,
|
||||
connState: 'connected',
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
name: 'review',
|
||||
initialFilter: 'all',
|
||||
initialTarget: null,
|
||||
onOpenSession: () => {},
|
||||
onReconnect: null
|
||||
})
|
||||
return createElement(MobileDiffReviewDrawers, { controller })
|
||||
}
|
||||
|
||||
/** Every RPC waits until the test answers it, so each case controls when a load or save lands. */
|
||||
async function mountScreen(): Promise<void> {
|
||||
replies = new Map()
|
||||
const sendRequest = vi.fn((method: string) => {
|
||||
const reply = deferred()
|
||||
replies.set(method, reply)
|
||||
return reply.promise
|
||||
})
|
||||
loadSnapshot.mockResolvedValue(SNAPSHOT)
|
||||
await act(async () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the operations under test only call `sendRequest`.
|
||||
renderer = create(createElement(Screen, { client: { sendRequest } as unknown as RpcClient }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(controller.currentItem).not.toBeNull()
|
||||
}
|
||||
|
||||
async function answer(method: string, reply: unknown): Promise<void> {
|
||||
const pending = replies.get(method)
|
||||
expect(pending, `${method} was never requested`).toBeDefined()
|
||||
await act(async () => {
|
||||
pending?.resolve(reply)
|
||||
await new Promise((settle) => setTimeout(settle, 0))
|
||||
})
|
||||
}
|
||||
|
||||
function textOf(node: ReactTestInstance): string[] {
|
||||
return node
|
||||
.findAll((child) => String(child.type) === 'Text')
|
||||
.flatMap((text) => text.children.filter((child) => typeof child === 'string'))
|
||||
}
|
||||
|
||||
/** Every mounted native drawer: each is its own Modal, so there must never be two. */
|
||||
function mountedDrawers(): ReactTestInstance[] {
|
||||
return renderer!.root.findAll((node) => String(node.type) === 'MountedBottomDrawer')
|
||||
}
|
||||
|
||||
/** The mounted sheet, which must carry this title (the first text it renders). */
|
||||
function drawer(title: string): ReactTestInstance {
|
||||
const [mounted, ...others] = mountedDrawers()
|
||||
expect(others).toEqual([])
|
||||
expect(mounted && textOf(mounted)[0]).toBe(title)
|
||||
return mounted!
|
||||
}
|
||||
|
||||
function shownSheets(): string[] {
|
||||
const mounted = mountedDrawers()
|
||||
expect(mounted.length).toBeLessThanOrEqual(1)
|
||||
return mounted.filter((node) => node.props.visible === true).map((node) => textOf(node)[0]!)
|
||||
}
|
||||
|
||||
function press(within: ReactTestInstance, label: string): void {
|
||||
const target = within.find(
|
||||
(node) =>
|
||||
String(node.type) === 'Pressable' &&
|
||||
(node.props.accessibilityLabel === label || textOf(node).includes(label))
|
||||
)
|
||||
act(() => target.props.onPress())
|
||||
}
|
||||
|
||||
/** The drawer's native hide animation finished. */
|
||||
function finishClosing(title: string): void {
|
||||
const closing = drawer(title)
|
||||
expect(closing.props.visible).toBe(false)
|
||||
act(() => closing.props.onHidden())
|
||||
}
|
||||
|
||||
/** Marks the only file reviewed; the save stays in flight until `answer('worktree.set')`. */
|
||||
function startMarkReviewed(): Promise<void> {
|
||||
let marking: Promise<void> = Promise.resolve()
|
||||
act(() => {
|
||||
marking = controller.markReviewed()
|
||||
})
|
||||
return marking
|
||||
}
|
||||
|
||||
async function completeReview(): Promise<void> {
|
||||
const marking = startMarkReviewed()
|
||||
await answer('worktree.set', SAVE_REPLY)
|
||||
await act(() => marking)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
loadSnapshot.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
describe('review screen sheets never stack', () => {
|
||||
it('Send Unsent Notes shows Send Notes only after Review Actions has closed', async () => {
|
||||
await mountScreen()
|
||||
act(() => controller.openSheet({ kind: 'actions' }))
|
||||
expect(shownSheets()).toEqual(['Review Actions'])
|
||||
|
||||
press(drawer('Review Actions'), 'Send Unsent Notes')
|
||||
expect(shownSheets()).toEqual([])
|
||||
|
||||
await answer('session.tabs.list', TABS_REPLY)
|
||||
expect(shownSheets()).toEqual([])
|
||||
|
||||
finishClosing('Review Actions')
|
||||
expect(shownSheets()).toEqual(['Send Notes'])
|
||||
expect(textOf(drawer('Send Notes'))).toContain('codex (termin)')
|
||||
})
|
||||
|
||||
it('Review Complete → Send shows Send Notes only after Review Complete has closed', async () => {
|
||||
await mountScreen()
|
||||
await completeReview()
|
||||
expect(shownSheets()).toEqual(['Review Complete'])
|
||||
|
||||
press(drawer('Review Complete'), 'Send notes to agent')
|
||||
expect(shownSheets()).toEqual([])
|
||||
|
||||
finishClosing('Review Complete')
|
||||
expect(shownSheets()).toEqual(['Send Notes'])
|
||||
})
|
||||
|
||||
it('does not open Send Notes when Review Complete closes for another reason', async () => {
|
||||
await mountScreen()
|
||||
await completeReview()
|
||||
|
||||
act(() => drawer('Review Complete').props.onClose())
|
||||
finishClosing('Review Complete')
|
||||
|
||||
expect(shownSheets()).toEqual([])
|
||||
})
|
||||
|
||||
it('Review Complete arriving while another sheet is open waits for that sheet to close', async () => {
|
||||
await mountScreen()
|
||||
const marking = startMarkReviewed()
|
||||
// The user opens Review Actions while the save is still in flight.
|
||||
act(() => controller.openSheet({ kind: 'actions' }))
|
||||
await answer('worktree.set', SAVE_REPLY)
|
||||
await act(() => marking)
|
||||
expect(shownSheets()).toEqual(['Review Actions'])
|
||||
|
||||
act(() => drawer('Review Actions').props.onClose())
|
||||
expect(shownSheets()).toEqual([])
|
||||
|
||||
finishClosing('Review Actions')
|
||||
expect(shownSheets()).toEqual(['Review Complete'])
|
||||
})
|
||||
|
||||
it('a sheet replaced before it was ever shown is never mounted', async () => {
|
||||
await mountScreen()
|
||||
act(() => {
|
||||
controller.openSheet({ kind: 'completion' })
|
||||
controller.openSheet({ kind: 'actions' })
|
||||
})
|
||||
expect(shownSheets()).toEqual(['Review Actions'])
|
||||
|
||||
act(() => {
|
||||
controller.openSheet({ kind: 'completion' })
|
||||
controller.closeSheet('completion')
|
||||
})
|
||||
expect(shownSheets()).toEqual([])
|
||||
finishClosing('Review Actions')
|
||||
expect(mountedDrawers()).toEqual([])
|
||||
})
|
||||
|
||||
it('a send list that lands after Send Notes was dismissed does not bring it back', async () => {
|
||||
await mountScreen()
|
||||
act(() => void controller.openSendSheet())
|
||||
expect(shownSheets()).toEqual(['Send Notes'])
|
||||
|
||||
act(() => drawer('Send Notes').props.onClose())
|
||||
await answer('session.tabs.list', TABS_REPLY)
|
||||
finishClosing('Send Notes')
|
||||
expect(mountedDrawers()).toEqual([])
|
||||
|
||||
act(() => controller.openSheet({ kind: 'actions' }))
|
||||
expect(shownSheets()).toEqual(['Review Actions'])
|
||||
})
|
||||
|
||||
it('Discard keeps its file through the close and discards the file it showed', async () => {
|
||||
await mountScreen()
|
||||
const target = controller.currentItem!
|
||||
act(() => controller.openSheet({ kind: 'discard', target }))
|
||||
press(drawer('Discard File'), 'Discard')
|
||||
expect(shownSheets()).toEqual([])
|
||||
expect(textOf(drawer('Discard File')).join(' ')).toContain(target.filePath)
|
||||
expect(replies.has('git.discard')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -185,13 +185,18 @@ export function MountedBottomDrawer({
|
||||
}, [visible, interactive, insets.bottom, fillAvailable])
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
// Why: restarting the hide animation cancels it, so onHidden never fires and the
|
||||
// invisible Modal stays up swallowing taps (Android Back lands here mid-close).
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
Keyboard.dismiss()
|
||||
progress.value = withTiming(0, { duration: BOTTOM_DRAWER_HIDE_DURATION_MS }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(onClose)()
|
||||
}
|
||||
})
|
||||
}, [onClose, progress])
|
||||
}, [onClose, progress, visible])
|
||||
|
||||
// One seam, both platforms: natively this is the hardware key, and inside the shell's page it is
|
||||
// a claim the shell hands one press over on. Every session sheet renders through this component,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { MobileDiffReviewQueueItem } from './mobile-diff-review-queue'
|
||||
import type { SendSheetState } from './mobile-diff-review-screen-model'
|
||||
import {
|
||||
NO_REVIEW_SHEETS,
|
||||
reduceReviewSheets,
|
||||
reviewComposer,
|
||||
type ReviewSheet,
|
||||
type ReviewSheetsAction,
|
||||
type ReviewSheetsState
|
||||
} from './mobile-diff-review-sheets'
|
||||
|
||||
const ACTIONS: ReviewSheet = { kind: 'actions' }
|
||||
const COMPLETION: ReviewSheet = { kind: 'completion' }
|
||||
const SEND_LOADING: ReviewSheet = { kind: 'send', load: { kind: 'loading' } }
|
||||
const COMPOSER: ReviewSheet = { kind: 'composer', composer: { mode: 'create', lineNumber: 4 } }
|
||||
const READY: SendSheetState = { kind: 'ready', terminals: [] }
|
||||
const DISCARD_TARGET: MobileDiffReviewQueueItem = {
|
||||
key: 'unstaged:src/a.ts',
|
||||
scope: 'unstaged',
|
||||
area: 'unstaged',
|
||||
filePath: 'src/a.ts',
|
||||
status: 'modified',
|
||||
title: 'a.ts',
|
||||
subtitle: 'src',
|
||||
canStage: true,
|
||||
canUnstage: false,
|
||||
canDiscard: true,
|
||||
isGeneratedOrLockFile: false,
|
||||
diffIdentity: 'identity-1',
|
||||
noteCount: 0,
|
||||
unsentNoteCount: 0,
|
||||
staleNoteCount: 0,
|
||||
isReviewed: false,
|
||||
changedSinceReview: false
|
||||
}
|
||||
|
||||
function run(...actions: ReviewSheetsAction[]): ReviewSheetsState {
|
||||
return actions.reduce(reduceReviewSheets, NO_REVIEW_SHEETS)
|
||||
}
|
||||
|
||||
const open = (sheet: ReviewSheet): ReviewSheetsAction => ({ type: 'open', sheet })
|
||||
const openWhenIdle = (sheet: ReviewSheet): ReviewSheetsAction => ({ type: 'openWhenIdle', sheet })
|
||||
const close = (kind: ReviewSheet['kind']): ReviewSheetsAction => ({ type: 'close', kind })
|
||||
const updateSend = (load: SendSheetState): ReviewSheetsAction => ({ type: 'updateSend', load })
|
||||
const DISCARD: ReviewSheet = { kind: 'discard', target: DISCARD_TARGET }
|
||||
|
||||
describe('review screen sheet requests', () => {
|
||||
it('the newest request wins', () => {
|
||||
expect(run(open(ACTIONS))).toEqual({ requested: ACTIONS, deferred: null })
|
||||
expect(run(open(ACTIONS), open(SEND_LOADING), open(COMPOSER))).toEqual({
|
||||
requested: COMPOSER,
|
||||
deferred: null
|
||||
})
|
||||
})
|
||||
|
||||
it('closing a sheet clears the request only for that sheet', () => {
|
||||
expect(run(open(DISCARD), close('discard'))).toEqual(NO_REVIEW_SHEETS)
|
||||
const state = run(open(ACTIONS))
|
||||
expect(reduceReviewSheets(state, close('send'))).toBe(state)
|
||||
})
|
||||
|
||||
it('exposes the requested composer only', () => {
|
||||
expect(reviewComposer(run(open(COMPOSER)))).toEqual({ mode: 'create', lineNumber: 4 })
|
||||
expect(reviewComposer(run(open(COMPOSER), close('composer')))).toBeNull()
|
||||
})
|
||||
|
||||
it('openWhenIdle opens at once when nothing is requested', () => {
|
||||
expect(run(openWhenIdle(COMPLETION))).toEqual({ requested: COMPLETION, deferred: null })
|
||||
})
|
||||
|
||||
it('openWhenIdle waits behind the user sheet and opens when it closes', () => {
|
||||
const waiting = run(open(ACTIONS), openWhenIdle(COMPLETION))
|
||||
expect(waiting).toEqual({ requested: ACTIONS, deferred: COMPLETION })
|
||||
expect(reduceReviewSheets(waiting, close('actions'))).toEqual({
|
||||
requested: COMPLETION,
|
||||
deferred: null
|
||||
})
|
||||
})
|
||||
|
||||
// Review Complete → Send Notes, then a second Mark Reviewed save lands while Review Complete is
|
||||
// still closing: Send Notes must stay the request.
|
||||
it('a late Review Complete never displaces a sheet the user asked for', () => {
|
||||
const state = run(open(COMPLETION), open(SEND_LOADING), openWhenIdle(COMPLETION))
|
||||
expect(state.requested).toEqual(SEND_LOADING)
|
||||
expect(run(open(ACTIONS), open(SEND_LOADING), openWhenIdle(COMPLETION)).requested).toEqual(
|
||||
SEND_LOADING
|
||||
)
|
||||
})
|
||||
|
||||
it('openWhenIdle does not repeat a sheet that is already requested or waiting', () => {
|
||||
const shown = run(openWhenIdle(COMPLETION))
|
||||
expect(reduceReviewSheets(shown, openWhenIdle(COMPLETION))).toBe(shown)
|
||||
const waiting = run(open(ACTIONS), openWhenIdle(COMPLETION))
|
||||
expect(reduceReviewSheets(waiting, openWhenIdle(COMPLETION))).toBe(waiting)
|
||||
})
|
||||
|
||||
it('moving to another sheet drops a waiting background sheet', () => {
|
||||
expect(run(open(ACTIONS), openWhenIdle(COMPLETION), open(SEND_LOADING))).toEqual({
|
||||
requested: SEND_LOADING,
|
||||
deferred: null
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshing the requested sheet keeps a waiting background sheet', () => {
|
||||
const edit: ReviewSheet = { kind: 'composer', composer: { mode: 'create', lineNumber: 9 } }
|
||||
expect(run(open(COMPOSER), openWhenIdle(COMPLETION), open(edit))).toEqual({
|
||||
requested: edit,
|
||||
deferred: COMPLETION
|
||||
})
|
||||
})
|
||||
|
||||
it('closing a waiting background sheet leaves the user sheet alone', () => {
|
||||
expect(run(open(ACTIONS), openWhenIdle(COMPLETION), close('completion'))).toEqual({
|
||||
requested: ACTIONS,
|
||||
deferred: null
|
||||
})
|
||||
})
|
||||
|
||||
it('updateSend fills a requested Send Notes', () => {
|
||||
expect(run(open(SEND_LOADING), updateSend(READY)).requested).toEqual({
|
||||
kind: 'send',
|
||||
load: READY
|
||||
})
|
||||
})
|
||||
|
||||
it('a send list that lands after Send Notes was dismissed does not bring it back', () => {
|
||||
const dismissed = run(open(SEND_LOADING), close('send'))
|
||||
expect(reduceReviewSheets(dismissed, updateSend(READY))).toBe(dismissed)
|
||||
const moved = run(open(SEND_LOADING), open(ACTIONS))
|
||||
expect(reduceReviewSheets(moved, updateSend(READY))).toBe(moved)
|
||||
})
|
||||
|
||||
it('never waits a sheet behind nothing or behind its own kind', () => {
|
||||
const sheets = [ACTIONS, COMPLETION, SEND_LOADING, COMPOSER, DISCARD]
|
||||
const actions: ReviewSheetsAction[] = [updateSend(READY)]
|
||||
for (const sheet of sheets) {
|
||||
actions.push(open(sheet), openWhenIdle(sheet), close(sheet.kind))
|
||||
}
|
||||
let seed = 7
|
||||
let state = NO_REVIEW_SHEETS
|
||||
for (let step = 0; step < 2000; step++) {
|
||||
seed = (seed * 48271) % 2147483647
|
||||
state = reduceReviewSheets(state, actions[seed % actions.length]!)
|
||||
if (state.deferred !== null) {
|
||||
expect(state.requested).not.toBeNull()
|
||||
expect(state.deferred.kind).not.toBe(state.requested?.kind)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { MobileDiffReviewQueueItem } from './mobile-diff-review-queue'
|
||||
import type { ComposerState, SendSheetState } from './mobile-diff-review-screen-model'
|
||||
|
||||
// Which review sheet the user wants; the screen's one keyed drawer decides when it can be shown.
|
||||
|
||||
export type ReviewSheet =
|
||||
| { kind: 'actions' }
|
||||
| { kind: 'send'; load: SendSheetState }
|
||||
| { kind: 'discard'; target: MobileDiffReviewQueueItem }
|
||||
| { kind: 'composer'; composer: ComposerState }
|
||||
| { kind: 'completion' }
|
||||
|
||||
export type ReviewSheetKind = ReviewSheet['kind']
|
||||
|
||||
export type ReviewSheetsState = {
|
||||
requested: ReviewSheet | null
|
||||
/** A background sheet waiting for `requested` to close; never of the same kind. */
|
||||
deferred: ReviewSheet | null
|
||||
}
|
||||
|
||||
export type ReviewSheetsAction =
|
||||
| { type: 'open'; sheet: ReviewSheet }
|
||||
| { type: 'openWhenIdle'; sheet: ReviewSheet }
|
||||
| { type: 'close'; kind: ReviewSheetKind }
|
||||
| { type: 'updateSend'; load: SendSheetState }
|
||||
|
||||
export const NO_REVIEW_SHEETS: ReviewSheetsState = { requested: null, deferred: null }
|
||||
|
||||
export function reduceReviewSheets(
|
||||
state: ReviewSheetsState,
|
||||
action: ReviewSheetsAction
|
||||
): ReviewSheetsState {
|
||||
const { requested, deferred } = state
|
||||
switch (action.type) {
|
||||
case 'open':
|
||||
// Why: the newest request wins; a background sheet only survives a refresh of the same sheet.
|
||||
return {
|
||||
requested: action.sheet,
|
||||
deferred: requested?.kind === action.sheet.kind ? deferred : null
|
||||
}
|
||||
case 'openWhenIdle':
|
||||
if (!requested) {
|
||||
return { requested: action.sheet, deferred: null }
|
||||
}
|
||||
// Why: a background opener waits behind the user's sheet and never displaces it.
|
||||
if (deferred || requested.kind === action.sheet.kind) {
|
||||
return state
|
||||
}
|
||||
return { requested, deferred: action.sheet }
|
||||
case 'close':
|
||||
if (requested?.kind === action.kind) {
|
||||
return { requested: deferred, deferred: null }
|
||||
}
|
||||
if (deferred?.kind === action.kind) {
|
||||
return { requested, deferred: null }
|
||||
}
|
||||
return state
|
||||
case 'updateSend':
|
||||
// Why: a list that resolves after Send Notes was dismissed must not bring it back.
|
||||
if (requested?.kind !== 'send') {
|
||||
return state
|
||||
}
|
||||
return { requested: { kind: 'send', load: action.load }, deferred }
|
||||
}
|
||||
}
|
||||
|
||||
export function reviewComposer(state: ReviewSheetsState): ComposerState | null {
|
||||
return state.requested?.kind === 'composer' ? state.requested.composer : null
|
||||
}
|
||||
|
||||
/** The only ways callers change the review screen's sheets. */
|
||||
export function reviewSheetIntents(dispatch: (action: ReviewSheetsAction) => void) {
|
||||
return {
|
||||
openSheet: (sheet: ReviewSheet) => dispatch({ type: 'open', sheet }),
|
||||
/** For async openers: waits for the user's sheet to close instead of closing it. */
|
||||
openSheetWhenIdle: (sheet: ReviewSheet) => dispatch({ type: 'openWhenIdle', sheet }),
|
||||
closeSheet: (kind: ReviewSheetKind) => dispatch({ type: 'close', kind }),
|
||||
updateSendSheet: (load: SendSheetState) => dispatch({ type: 'updateSend', load })
|
||||
}
|
||||
}
|
||||
|
||||
export type ReviewSheetIntents = ReturnType<typeof reviewSheetIntents>
|
||||
|
||||
export function reviewSheetKey(sheet: ReviewSheet): ReviewSheetKind {
|
||||
return sheet.kind
|
||||
}
|
||||
@@ -110,13 +110,14 @@ function mountSendSites(client: ReturnType<typeof clientFixture>, handle = 'term
|
||||
onSuccess: vi.fn(),
|
||||
refreshCanPaste: vi.fn()
|
||||
} as never)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: untyped vi.fn() stubs stand in for callbacks whose returns the send path never reads.
|
||||
diff = useMobileDiffReviewSendActions({
|
||||
client: client as unknown as RpcClient,
|
||||
connState: 'connected',
|
||||
worktreeId: 'workspace',
|
||||
screenState: { kind: 'loading' },
|
||||
setActionError: vi.fn(),
|
||||
setSendSheet: vi.fn(),
|
||||
sheets: { openSheet: vi.fn(), closeSheet: vi.fn(), updateSendSheet: vi.fn() },
|
||||
saveCommentsAndReviewState: vi.fn()
|
||||
} as never)
|
||||
return null
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
nextReviewIndexAfterMarkReviewed,
|
||||
reviewDescriptorFromItem
|
||||
} from './mobile-diff-review-screen-model'
|
||||
import type { ReviewSheetIntents } from './mobile-diff-review-sheets'
|
||||
|
||||
type CommentActionsInput = {
|
||||
client: RpcClient | null
|
||||
@@ -36,10 +37,9 @@ type CommentActionsInput = {
|
||||
composerBody: string
|
||||
setScreenState: Dispatch<SetStateAction<ReviewScreenState>>
|
||||
setCurrentIndex: Dispatch<SetStateAction<number>>
|
||||
setComposer: Dispatch<SetStateAction<ComposerState | null>>
|
||||
setComposerBody: Dispatch<SetStateAction<string>>
|
||||
setActionError: Dispatch<SetStateAction<string | null>>
|
||||
setShowCompletion: Dispatch<SetStateAction<boolean>>
|
||||
sheets: Pick<ReviewSheetIntents, 'openSheet' | 'openSheetWhenIdle' | 'closeSheet'>
|
||||
}
|
||||
|
||||
export function useMobileDiffReviewCommentActions(input: CommentActionsInput) {
|
||||
@@ -57,11 +57,11 @@ export function useMobileDiffReviewCommentActions(input: CommentActionsInput) {
|
||||
composerBody,
|
||||
setScreenState,
|
||||
setCurrentIndex,
|
||||
setComposer,
|
||||
setComposerBody,
|
||||
setActionError,
|
||||
setShowCompletion
|
||||
sheets
|
||||
} = input
|
||||
const { openSheet, openSheetWhenIdle, closeSheet } = sheets
|
||||
|
||||
const persistMetadata = useCallback(
|
||||
async (comments: readonly DiffComment[], reviewState: MobileDiffReviewState) => {
|
||||
@@ -109,24 +109,24 @@ export function useMobileDiffReviewCommentActions(input: CommentActionsInput) {
|
||||
|
||||
const openComposer = useCallback(
|
||||
(lineNumber: number) => {
|
||||
setComposer({ mode: 'create', lineNumber })
|
||||
openSheet({ kind: 'composer', composer: { mode: 'create', lineNumber } })
|
||||
setComposerBody('')
|
||||
},
|
||||
[setComposer, setComposerBody]
|
||||
[openSheet, setComposerBody]
|
||||
)
|
||||
|
||||
const openEditComposer = useCallback(
|
||||
(comment: DiffComment) => {
|
||||
setComposer({ mode: 'edit', comment })
|
||||
openSheet({ kind: 'composer', composer: { mode: 'edit', comment } })
|
||||
setComposerBody(comment.body)
|
||||
},
|
||||
[setComposer, setComposerBody]
|
||||
[openSheet, setComposerBody]
|
||||
)
|
||||
|
||||
const closeComposer = useCallback(() => {
|
||||
setComposer(null)
|
||||
closeSheet('composer')
|
||||
setComposerBody('')
|
||||
}, [setComposer, setComposerBody])
|
||||
}, [closeSheet, setComposerBody])
|
||||
|
||||
const saveComposer = useCallback(async () => {
|
||||
if (!composer || !currentItem || screenState.kind !== 'ready') {
|
||||
@@ -201,18 +201,19 @@ export function useMobileDiffReviewCommentActions(input: CommentActionsInput) {
|
||||
if (nextIndex !== null) {
|
||||
setCurrentIndex(nextIndex)
|
||||
} else {
|
||||
setShowCompletion(true)
|
||||
// Why: the save can outlast a sheet the user opened meanwhile; never stack on or close it.
|
||||
openSheetWhenIdle({ kind: 'completion' })
|
||||
}
|
||||
}, [
|
||||
currentIndex,
|
||||
currentItem,
|
||||
filter,
|
||||
filteredQueue,
|
||||
openSheetWhenIdle,
|
||||
queue,
|
||||
saveCommentsAndReviewState,
|
||||
screenState,
|
||||
setCurrentIndex,
|
||||
setShowCompletion
|
||||
setCurrentIndex
|
||||
])
|
||||
|
||||
const markUnreviewed = useCallback(async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
|
||||
import type { FlatList } from 'react-native'
|
||||
import type { DiffComment } from '../../../src/shared/diff-comment-types'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
filterMobileDiffReviewQueue,
|
||||
mobileDiffReviewCommentMatchesItem,
|
||||
summarizeMobileDiffReviewQueue,
|
||||
type MobileDiffReviewQueueFilter,
|
||||
type MobileDiffReviewQueueItem
|
||||
type MobileDiffReviewQueueFilter
|
||||
} from './mobile-diff-review-queue'
|
||||
import {
|
||||
findMobileDiffReviewInitialIndex,
|
||||
@@ -20,12 +19,13 @@ import {
|
||||
import { loadMobileDiffReviewSnapshot } from './mobile-diff-review-loaders'
|
||||
import { useMobileDiffReviewDiffLoading } from './use-mobile-diff-review-diff-loading'
|
||||
import { canOpenMobileBranchCompareDiff } from '../source-control/mobile-branch-compare'
|
||||
import type {
|
||||
ComposerState,
|
||||
ReviewDiffLine,
|
||||
ReviewScreenState,
|
||||
SendSheetState
|
||||
} from './mobile-diff-review-screen-model'
|
||||
import type { ReviewDiffLine, ReviewScreenState } from './mobile-diff-review-screen-model'
|
||||
import {
|
||||
NO_REVIEW_SHEETS,
|
||||
reduceReviewSheets,
|
||||
reviewComposer,
|
||||
reviewSheetIntents
|
||||
} from './mobile-diff-review-sheets'
|
||||
import { useMobileDiffReviewInteractions } from './use-mobile-diff-review-interactions'
|
||||
import { useMobilePrSidebarController } from './use-mobile-pr-sidebar-controller'
|
||||
|
||||
@@ -62,14 +62,12 @@ export function useMobileDiffReviewController(input: ControllerInput) {
|
||||
const [filter, setFilter] = useState<MobileDiffReviewQueueFilter>(initialFilter)
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [activeHunkIndex, setActiveHunkIndex] = useState<number | null>(null)
|
||||
const [composer, setComposer] = useState<ComposerState | null>(null)
|
||||
const [sheets, dispatchSheets] = useReducer(reduceReviewSheets, NO_REVIEW_SHEETS)
|
||||
const sheetIntents = useMemo(() => reviewSheetIntents(dispatchSheets), [])
|
||||
const composer = reviewComposer(sheets)
|
||||
const [composerBody, setComposerBody] = useState('')
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
const [busyAction, setBusyAction] = useState<string | null>(null)
|
||||
const [discardTarget, setDiscardTarget] = useState<MobileDiffReviewQueueItem | null>(null)
|
||||
const [showOverflow, setShowOverflow] = useState(false)
|
||||
const [sendSheet, setSendSheet] = useState<SendSheetState | null>(null)
|
||||
const [showCompletion, setShowCompletion] = useState(false)
|
||||
const worktreeLabel = getWorktreeLabel(name, worktreeId)
|
||||
|
||||
const loadReviewData = useCallback(async () => {
|
||||
@@ -244,12 +242,10 @@ export function useMobileDiffReviewController(input: ControllerInput) {
|
||||
setFilter,
|
||||
setCurrentIndex,
|
||||
setActiveHunkIndex,
|
||||
setComposer,
|
||||
setComposerBody,
|
||||
setActionError,
|
||||
setBusyAction,
|
||||
setSendSheet,
|
||||
setShowCompletion,
|
||||
sheets: sheetIntents,
|
||||
loadReviewData,
|
||||
onOpenSession,
|
||||
onReconnect
|
||||
@@ -258,6 +254,7 @@ export function useMobileDiffReviewController(input: ControllerInput) {
|
||||
return {
|
||||
...interactions,
|
||||
...prSidebar,
|
||||
...sheetIntents,
|
||||
// Exposed so the screen can thread the RPC client + worktree into the PR
|
||||
// sidebar's lazy check-detail fetches (U5) and mutation actions (U6).
|
||||
client,
|
||||
@@ -274,7 +271,6 @@ export function useMobileDiffReviewController(input: ControllerInput) {
|
||||
currentIndex,
|
||||
currentItem,
|
||||
diffState,
|
||||
discardTarget,
|
||||
fileNotes: commentsByLine.get(0) ?? [],
|
||||
filter,
|
||||
filteredQueue,
|
||||
@@ -283,14 +279,8 @@ export function useMobileDiffReviewController(input: ControllerInput) {
|
||||
reviewedCount,
|
||||
reviewedUnstagedCount,
|
||||
screenState,
|
||||
sendSheet,
|
||||
setComposerBody,
|
||||
setDiscardTarget,
|
||||
setSendSheet,
|
||||
setShowCompletion,
|
||||
setShowOverflow,
|
||||
showCompletion,
|
||||
showOverflow,
|
||||
sheet: sheets.requested,
|
||||
staleCommentIds,
|
||||
unsentComments,
|
||||
worktreeLabel
|
||||
|
||||
@@ -12,9 +12,9 @@ import type {
|
||||
ComposerState,
|
||||
ReviewDiffLine,
|
||||
ReviewDiffState,
|
||||
ReviewScreenState,
|
||||
SendSheetState
|
||||
ReviewScreenState
|
||||
} from './mobile-diff-review-screen-model'
|
||||
import type { ReviewSheetIntents } from './mobile-diff-review-sheets'
|
||||
import { sourceFileDiffOpenRun } from '../source-control/mobile-source-file-open-operations'
|
||||
import { refusedRpcMessageOrFallback } from '../transport/rpc-refusal-message'
|
||||
import { useMobileDiffReviewCommentActions } from './use-mobile-diff-review-comment-actions'
|
||||
@@ -42,12 +42,10 @@ type InteractionInput = {
|
||||
setFilter: Dispatch<SetStateAction<MobileDiffReviewQueueFilter>>
|
||||
setCurrentIndex: Dispatch<SetStateAction<number>>
|
||||
setActiveHunkIndex: Dispatch<SetStateAction<number | null>>
|
||||
setComposer: Dispatch<SetStateAction<ComposerState | null>>
|
||||
setComposerBody: Dispatch<SetStateAction<string>>
|
||||
setActionError: Dispatch<SetStateAction<string | null>>
|
||||
setBusyAction: Dispatch<SetStateAction<string | null>>
|
||||
setSendSheet: Dispatch<SetStateAction<SendSheetState | null>>
|
||||
setShowCompletion: Dispatch<SetStateAction<boolean>>
|
||||
sheets: ReviewSheetIntents
|
||||
loadReviewData: () => Promise<void>
|
||||
onOpenSession: () => void
|
||||
onReconnect: ((hostId: string) => void | Promise<void>) | null
|
||||
@@ -74,12 +72,10 @@ export function useMobileDiffReviewInteractions(input: InteractionInput) {
|
||||
setFilter,
|
||||
setCurrentIndex,
|
||||
setActiveHunkIndex,
|
||||
setComposer,
|
||||
setComposerBody,
|
||||
setActionError,
|
||||
setBusyAction,
|
||||
setSendSheet,
|
||||
setShowCompletion,
|
||||
sheets,
|
||||
loadReviewData,
|
||||
onOpenSession,
|
||||
onReconnect
|
||||
@@ -108,10 +104,9 @@ export function useMobileDiffReviewInteractions(input: InteractionInput) {
|
||||
composerBody,
|
||||
setScreenState,
|
||||
setCurrentIndex,
|
||||
setComposer,
|
||||
setComposerBody,
|
||||
setActionError,
|
||||
setShowCompletion
|
||||
sheets
|
||||
})
|
||||
|
||||
const { runGitMutation, stageReviewedFiles } = useMobileDiffReviewGitActions({
|
||||
@@ -131,7 +126,7 @@ export function useMobileDiffReviewInteractions(input: InteractionInput) {
|
||||
worktreeId,
|
||||
screenState,
|
||||
setActionError,
|
||||
setSendSheet,
|
||||
sheets,
|
||||
saveCommentsAndReviewState
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import type { DiffComment } from '../../../src/shared/diff-comment-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ReviewScreenState } from './mobile-diff-review-screen-model'
|
||||
import type { ReviewSheetIntents } from './mobile-diff-review-sheets'
|
||||
import {
|
||||
isMobileNativeChatInputStale,
|
||||
markMobileNativeChatInputStale,
|
||||
@@ -51,14 +52,18 @@ describe('useMobileDiffReviewSendActions', () => {
|
||||
let actions: SendActions | null = null
|
||||
let mountedClient: RpcClient | null = null
|
||||
let setActionError: ReturnType<typeof vi.fn>
|
||||
let setSendSheet: ReturnType<typeof vi.fn>
|
||||
let sheets: {
|
||||
openSheet: Mock<ReviewSheetIntents['openSheet']>
|
||||
closeSheet: Mock<ReviewSheetIntents['closeSheet']>
|
||||
updateSendSheet: Mock<ReviewSheetIntents['updateSendSheet']>
|
||||
}
|
||||
let saveCommentsAndReviewState: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
clipboardMock.setStringAsync.mockReset().mockResolvedValue(true)
|
||||
resetMobileNativeChatStaleInputForTests()
|
||||
setActionError = vi.fn()
|
||||
setSendSheet = vi.fn()
|
||||
sheets = { openSheet: vi.fn(), closeSheet: vi.fn(), updateSendSheet: vi.fn() }
|
||||
saveCommentsAndReviewState = vi.fn().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
@@ -76,7 +81,7 @@ describe('useMobileDiffReviewSendActions', () => {
|
||||
worktreeId: 'wt-1',
|
||||
screenState: READY,
|
||||
setActionError,
|
||||
setSendSheet,
|
||||
sheets,
|
||||
saveCommentsAndReviewState
|
||||
})
|
||||
return null
|
||||
@@ -158,7 +163,7 @@ describe('useMobileDiffReviewSendActions', () => {
|
||||
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '\x15', enter: false })
|
||||
expect(saveCommentsAndReviewState).not.toHaveBeenCalled()
|
||||
expect(setActionError).not.toHaveBeenCalled()
|
||||
expect(setSendSheet).not.toHaveBeenCalled()
|
||||
expect(sheets.closeSheet).not.toHaveBeenCalled()
|
||||
// Marker survives for the next attempt.
|
||||
expect(isMobileNativeChatInputStale('terminal-1')).toBe(true)
|
||||
})
|
||||
@@ -192,7 +197,7 @@ describe('useMobileDiffReviewSendActions', () => {
|
||||
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ terminal: 'terminal-1', enter: true })
|
||||
expect(saveCommentsAndReviewState).toHaveBeenCalledTimes(1)
|
||||
expect(setActionError).toHaveBeenCalledWith('Review notes sent')
|
||||
expect(setSendSheet).toHaveBeenCalledWith(null)
|
||||
expect(sheets.closeSheet).toHaveBeenCalledWith('send')
|
||||
})
|
||||
|
||||
it('only heals the terminal that was marked', async () => {
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
} from './mobile-review-terminal-operations'
|
||||
import { interpretOrThrowRefusalMessage } from '../transport/rpc-refusal-message'
|
||||
import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input'
|
||||
import type { ReviewScreenState, SendSheetState } from './mobile-diff-review-screen-model'
|
||||
import type { ReviewScreenState } from './mobile-diff-review-screen-model'
|
||||
import type { ReviewSheetIntents } from './mobile-diff-review-sheets'
|
||||
|
||||
type SendActionsInput = {
|
||||
client: RpcClient | null
|
||||
@@ -21,7 +22,7 @@ type SendActionsInput = {
|
||||
worktreeId: string
|
||||
screenState: ReviewScreenState
|
||||
setActionError: Dispatch<SetStateAction<string | null>>
|
||||
setSendSheet: Dispatch<SetStateAction<SendSheetState | null>>
|
||||
sheets: Pick<ReviewSheetIntents, 'openSheet' | 'closeSheet' | 'updateSendSheet'>
|
||||
saveCommentsAndReviewState: (
|
||||
comments: DiffComment[],
|
||||
reviewState: MobileDiffReviewState
|
||||
@@ -38,9 +39,10 @@ export function useMobileDiffReviewSendActions(input: SendActionsInput) {
|
||||
worktreeId,
|
||||
screenState,
|
||||
setActionError,
|
||||
setSendSheet,
|
||||
sheets,
|
||||
saveCommentsAndReviewState
|
||||
} = input
|
||||
const { openSheet, closeSheet, updateSendSheet } = sheets
|
||||
|
||||
const copyNotes = useCallback(async () => {
|
||||
if (screenState.kind !== 'ready' || screenState.comments.length === 0) {
|
||||
@@ -107,9 +109,9 @@ export function useMobileDiffReviewSendActions(input: SendActionsInput) {
|
||||
await markNotesSent(comments)
|
||||
triggerSuccess()
|
||||
setActionError('Review notes sent')
|
||||
setSendSheet(null)
|
||||
closeSheet('send')
|
||||
},
|
||||
[client, connState, markNotesSent, setActionError, setSendSheet]
|
||||
[client, connState, closeSheet, markNotesSent, setActionError]
|
||||
)
|
||||
|
||||
const createTerminalAndSend = useCallback(
|
||||
@@ -138,7 +140,7 @@ export function useMobileDiffReviewSendActions(input: SendActionsInput) {
|
||||
setActionError('Waiting for desktop...')
|
||||
return
|
||||
}
|
||||
setSendSheet({ kind: 'loading' })
|
||||
openSheet({ kind: 'send', load: { kind: 'loading' } })
|
||||
try {
|
||||
const response = await reviewTerminalListRead.request(client, {
|
||||
worktree: `id:${worktreeId}`
|
||||
@@ -148,15 +150,15 @@ export function useMobileDiffReviewSendActions(input: SendActionsInput) {
|
||||
() => reviewTerminalListRead.interpret(response),
|
||||
'Unable to load agent sessions'
|
||||
)
|
||||
setSendSheet({ kind: 'ready', terminals })
|
||||
updateSendSheet({ kind: 'ready', terminals })
|
||||
} catch (err) {
|
||||
setSendSheet({
|
||||
updateSendSheet({
|
||||
kind: 'error',
|
||||
message: err instanceof Error ? err.message : 'Unable to load agent sessions',
|
||||
terminals: []
|
||||
})
|
||||
}
|
||||
}, [client, connState, setActionError, setSendSheet, worktreeId])
|
||||
}, [client, connState, openSheet, setActionError, updateSendSheet, worktreeId])
|
||||
|
||||
return {
|
||||
clearSentNotes,
|
||||
|
||||
@@ -22,6 +22,7 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial<Record<TuiAgent, string>>
|
||||
ante: 'antigma.ai',
|
||||
trae: 'www.trae.cn',
|
||||
muse: 'dev.meta.ai',
|
||||
dsh: 'deepseek.com',
|
||||
zcode: 'zcode.z.ai',
|
||||
omp: 'omp.sh',
|
||||
'prime-agent': 'primeintellect.ai',
|
||||
|
||||
+11
-7
@@ -31,6 +31,9 @@ export function diffReviewActionMountAdapters(
|
||||
const useInteractions = modules.load<
|
||||
typeof import('../../../session/use-mobile-diff-review-interactions')
|
||||
>('mobile/src/session/use-mobile-diff-review-interactions.ts').useMobileDiffReviewInteractions
|
||||
const reviewSheets = modules.load<
|
||||
typeof import('../../../session/mobile-diff-review-sheets')
|
||||
>('mobile/src/session/mobile-diff-review-sheets.ts')
|
||||
const staleInput = modules.load<
|
||||
typeof import('../../../session/mobile-native-chat-stale-input')
|
||||
>('mobile/src/session/mobile-native-chat-stale-input.ts')
|
||||
@@ -75,7 +78,12 @@ export function diffReviewActionMountAdapters(
|
||||
}
|
||||
let actionError: string | null = null
|
||||
let busyAction: string | null = null
|
||||
let sendSheet: SendSheetState | null = null
|
||||
let sheets = reviewSheets.NO_REVIEW_SHEETS
|
||||
const sheetIntents = reviewSheets.reviewSheetIntents((action) => {
|
||||
sheets = reviewSheets.reduceReviewSheets(sheets, action)
|
||||
})
|
||||
const sendSheet = (): SendSheetState | null =>
|
||||
sheets.requested?.kind === 'send' ? sheets.requested.load : null
|
||||
let interactions: ReturnType<typeof useInteractions>
|
||||
const hook = hookMount(() => {
|
||||
interactions = useInteractions(
|
||||
@@ -101,7 +109,6 @@ export function diffReviewActionMountAdapters(
|
||||
setFilter: () => {},
|
||||
setCurrentIndex: () => {},
|
||||
setActiveHunkIndex: () => {},
|
||||
setComposer: () => {},
|
||||
setComposerBody: () => {},
|
||||
setActionError: (update) => {
|
||||
actionError = typeof update === 'function' ? update(actionError) : update
|
||||
@@ -109,10 +116,7 @@ export function diffReviewActionMountAdapters(
|
||||
setBusyAction: (update) => {
|
||||
busyAction = typeof update === 'function' ? update(busyAction) : update
|
||||
},
|
||||
setSendSheet: (update) => {
|
||||
sendSheet = typeof update === 'function' ? update(sendSheet) : update
|
||||
},
|
||||
setShowCompletion: () => {},
|
||||
sheets: sheetIntents,
|
||||
loadReviewData: () => {
|
||||
effect('load-review-data', {})
|
||||
return Promise.resolve()
|
||||
@@ -163,7 +167,7 @@ export function diffReviewActionMountAdapters(
|
||||
throw new Error(`Unknown review action: ${name}${String(args.unused ?? '')}`)
|
||||
})
|
||||
},
|
||||
state: () => ({ screenState, actionError, busyAction, sendSheet }),
|
||||
state: () => ({ screenState, actionError, busyAction, sendSheet: sendSheet() }),
|
||||
dispose: () => {
|
||||
staleInput.resetMobileNativeChatStaleInputForTests()
|
||||
hook.unmount()
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
|
||||
import {
|
||||
agentMainAgentVerdict,
|
||||
agentVerdictDisplayMark
|
||||
} from '../../../src/shared/agent-main-agent-verdict'
|
||||
import { AGENT_JOURNAL_TURN_OUTCOMES } from '../../../src/shared/agent-turn-outcome'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
agentDisplayLabel,
|
||||
agentDotState,
|
||||
agentIdentityLabel,
|
||||
agentRowTimeAt,
|
||||
agentRowVerdict,
|
||||
agentRowVerdictMark,
|
||||
formatTimeAgo
|
||||
} from './agent-row-display'
|
||||
|
||||
type Outcome = (typeof AGENT_JOURNAL_TURN_OUTCOMES)[number]
|
||||
const mainAgentDone = (outcome: Outcome, stateStartedAt = 0) => ({
|
||||
mainAgent: { state: 'done' as const, outcome, stateStartedAt }
|
||||
})
|
||||
|
||||
function row(overrides: Partial<RuntimeWorktreeAgentRow> = {}): RuntimeWorktreeAgentRow {
|
||||
return {
|
||||
paneKey: 'p',
|
||||
@@ -37,8 +50,54 @@ describe('agentDotState', () => {
|
||||
expect(agentDotState(row({ state: 'unknown-state' as never }), 0)).toBe('idle')
|
||||
})
|
||||
|
||||
it('reports interrupted regardless of state', () => {
|
||||
it('reports the verdict of a done row: failed, interrupted, or an old host legacy flag', () => {
|
||||
expect(agentDotState(row({ state: 'done', interrupted: true }), 0)).toBe('interrupted')
|
||||
expect(agentDotState(row({ state: 'done', ...mainAgentDone('failure') }), 0)).toBe('failed')
|
||||
expect(
|
||||
agentDotState(row({ state: 'done', ...mainAgentDone('cancellation'), interrupted: true }), 0)
|
||||
).toBe('interrupted')
|
||||
expect(agentDotState(row({ state: 'done', ...mainAgentDone('success') }), 0)).toBe('done')
|
||||
})
|
||||
|
||||
it('shows a main agent that failed while its subagents still run as failed', () => {
|
||||
expect(agentDotState(row({ state: 'working', ...mainAgentDone('failure') }), 0)).toBe('failed')
|
||||
expect(agentDotState(row({ state: 'waiting', ...mainAgentDone('failure') }), 0)).toBe('failed')
|
||||
// Only a failure outranks live work; a success or a stop with live subagents reads working.
|
||||
expect(agentDotState(row({ state: 'working', ...mainAgentDone('success') }), 0)).toBe('working')
|
||||
expect(
|
||||
agentDotState(
|
||||
row({ state: 'working', ...mainAgentDone('cancellation'), interrupted: true }),
|
||||
0
|
||||
)
|
||||
).toBe('working')
|
||||
})
|
||||
|
||||
// The shared accessor cannot be imported by app code here, so this mirror must not drift from it.
|
||||
it('agrees with the desktop verdict accessor on every row', () => {
|
||||
const states = ['working', 'blocked', 'waiting', 'done'] as const
|
||||
const mainAgents = [
|
||||
undefined,
|
||||
...states.flatMap((state) =>
|
||||
[undefined, ...AGENT_JOURNAL_TURN_OUTCOMES].map((outcome) => ({
|
||||
state,
|
||||
...(outcome ? { outcome } : {}),
|
||||
stateStartedAt: 0
|
||||
}))
|
||||
)
|
||||
]
|
||||
for (const state of states) {
|
||||
for (const mainAgent of mainAgents) {
|
||||
for (const interrupted of [false, true]) {
|
||||
const agentRow = { state, interrupted, ...(mainAgent ? { mainAgent } : {}) }
|
||||
expect(agentRowVerdict(agentRow), JSON.stringify(agentRow)).toBe(
|
||||
agentMainAgentVerdict(agentRow)
|
||||
)
|
||||
expect(agentRowVerdictMark(agentRow), JSON.stringify(agentRow)).toBe(
|
||||
agentVerdictDisplayMark(agentRow)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('decays a stale active state to idle, matching desktop', () => {
|
||||
@@ -51,14 +110,36 @@ describe('agentDotState', () => {
|
||||
expect(
|
||||
agentDotState(row({ state: 'working', updatedAt: 0 }), AGENT_STATUS_STALE_AFTER_MS)
|
||||
).toBe('working')
|
||||
// 'done' never decays; interrupted still wins.
|
||||
// 'done' never decays, and neither does its verdict.
|
||||
expect(agentDotState(row({ state: 'done', updatedAt: 0 }), stale)).toBe('done')
|
||||
expect(agentDotState(row({ state: 'working', updatedAt: 0, interrupted: true }), stale)).toBe(
|
||||
expect(agentDotState(row({ state: 'done', updatedAt: 0, interrupted: true }), stale)).toBe(
|
||||
'interrupted'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentRowTimeAt', () => {
|
||||
it('dates a main agent that failed while its subagents run by its own failure', () => {
|
||||
expect(
|
||||
agentRowTimeAt(
|
||||
row({ state: 'working', stateStartedAt: 100, ...mainAgentDone('failure', 900) })
|
||||
)
|
||||
).toBe(900)
|
||||
})
|
||||
|
||||
it('dates every other row by when its state began', () => {
|
||||
expect(
|
||||
agentRowTimeAt(
|
||||
row({ state: 'working', stateStartedAt: 100, ...mainAgentDone('success', 900) })
|
||||
)
|
||||
).toBe(100)
|
||||
expect(
|
||||
agentRowTimeAt(row({ state: 'done', stateStartedAt: 100, ...mainAgentDone('failure', 900) }))
|
||||
).toBe(100)
|
||||
expect(agentRowTimeAt(row({ state: 'working', stateStartedAt: 100 }))).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentDisplayLabel', () => {
|
||||
it('prefers last message, then prompt, then state label', () => {
|
||||
expect(agentDisplayLabel(row({ lastAssistantMessage: 'hello there' }), 0)).toBe('hello there')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
|
||||
import type { AgentJournalTurnOutcome } from '../../../src/shared/agent-turn-outcome'
|
||||
|
||||
// Mirrors the desktop AGENT_STATUS_STALE_AFTER_MS (src/shared/agent-status-types.ts:
|
||||
// 30 min). Defined locally rather than imported because a runtime-value import
|
||||
@@ -17,13 +18,39 @@ export type AgentDotState =
|
||||
| 'done'
|
||||
| 'idle'
|
||||
| 'interrupted'
|
||||
| 'failed'
|
||||
|
||||
type AgentRowVerdictSource = Pick<RuntimeWorktreeAgentRow, 'state' | 'interrupted' | 'mainAgent'>
|
||||
|
||||
// Mirrors desktop agentMainAgentVerdict and agentVerdictDisplayMark
|
||||
// (src/shared/agent-main-agent-verdict.ts); a parity test runs both over one table. `mainAgent` is
|
||||
// the main agent's own status, sent also while subagents hold the row working; an old host sends none.
|
||||
export function agentRowVerdict(row: AgentRowVerdictSource): AgentJournalTurnOutcome | null {
|
||||
if (row.mainAgent && row.mainAgent.state !== 'done') {
|
||||
return null
|
||||
}
|
||||
return row.mainAgent?.outcome ?? (row.state === 'done' && row.interrupted ? 'cancellation' : null)
|
||||
}
|
||||
|
||||
// A failure outranks every state; a stop marks only a row that is itself done.
|
||||
export function agentRowVerdictMark(row: AgentRowVerdictSource): 'failed' | 'interrupted' | null {
|
||||
const verdict = agentRowVerdict(row)
|
||||
if (verdict === 'failure') {
|
||||
return 'failed'
|
||||
}
|
||||
return verdict === 'cancellation' && row.state === 'done' ? 'interrupted' : null
|
||||
}
|
||||
|
||||
export function agentDotState(
|
||||
row: Pick<RuntimeWorktreeAgentRow, 'state' | 'workingMode' | 'interrupted' | 'updatedAt'>,
|
||||
row: Pick<
|
||||
RuntimeWorktreeAgentRow,
|
||||
'state' | 'workingMode' | 'interrupted' | 'mainAgent' | 'updatedAt'
|
||||
>,
|
||||
now: number
|
||||
): AgentDotState {
|
||||
if (row.interrupted) {
|
||||
return 'interrupted'
|
||||
const mark = agentRowVerdictMark(row)
|
||||
if (mark) {
|
||||
return mark
|
||||
}
|
||||
switch (row.state) {
|
||||
case 'blocked':
|
||||
@@ -56,6 +83,8 @@ export function agentStateLabel(state: AgentDotState): string {
|
||||
return 'Waiting for input'
|
||||
case 'interrupted':
|
||||
return 'Interrupted'
|
||||
case 'failed':
|
||||
return 'Failed'
|
||||
case 'done':
|
||||
return 'Done'
|
||||
case 'idle':
|
||||
@@ -99,6 +128,17 @@ export function agentIdentityLabel(agentType: string | null): string {
|
||||
return known[normalized] ?? normalized.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
// When the row's state began, except that a main agent that failed while its subagents run is
|
||||
// dated by its own failure. Mirrors desktop lastEnteredDoneAt (agent-finished-timestamp.ts).
|
||||
export function agentRowTimeAt(
|
||||
row: Pick<RuntimeWorktreeAgentRow, 'state' | 'interrupted' | 'mainAgent' | 'stateStartedAt'>
|
||||
): number {
|
||||
if (row.state !== 'done' && row.mainAgent && agentRowVerdictMark(row) === 'failed') {
|
||||
return row.mainAgent.stateStartedAt
|
||||
}
|
||||
return row.stateStartedAt
|
||||
}
|
||||
|
||||
// Relative time, matching desktop formatTimeAgo thresholds (just now / Xm / Xh / Xd).
|
||||
export function formatTimeAgo(ts: number, now: number): string {
|
||||
const delta = now - ts
|
||||
|
||||
@@ -21,6 +21,13 @@ function agent(overrides: Partial<RuntimeWorktreeAgentRow> = {}): RuntimeWorktre
|
||||
}
|
||||
}
|
||||
|
||||
function done(
|
||||
outcome: 'success' | 'failure',
|
||||
stateStartedAt = 1
|
||||
): NonNullable<RuntimeWorktreeAgentRow['mainAgent']> {
|
||||
return { state: 'done', outcome, stateStartedAt }
|
||||
}
|
||||
|
||||
function worktree(overrides: Partial<Worktree> = {}): Worktree {
|
||||
const worktreePath = join('/tmp', 'orca', 'worktrees', 'manta')
|
||||
return {
|
||||
@@ -160,6 +167,31 @@ describe('areWorktreeListsEqual', () => {
|
||||
expect(areWorktreeListsEqual(first, second)).toBe(false)
|
||||
})
|
||||
|
||||
it('detects a verdict change that leaves the interrupted flag as it was', () => {
|
||||
const first = [worktree({ agents: [agent({ state: 'done', mainAgent: done('success') })] })]
|
||||
const second = [worktree({ agents: [agent({ state: 'done', mainAgent: done('failure') })] })]
|
||||
|
||||
expect(areWorktreeListsEqual(first, second)).toBe(false)
|
||||
})
|
||||
|
||||
it('detects a main agent failing while its subagents keep the row working', () => {
|
||||
const first = [worktree({ agents: [agent({ state: 'working' })] })]
|
||||
const second = [worktree({ agents: [agent({ state: 'working', mainAgent: done('failure') })] })]
|
||||
|
||||
expect(areWorktreeListsEqual(first, second)).toBe(false)
|
||||
})
|
||||
|
||||
it('detects the main agent clock moving, which dates a failure', () => {
|
||||
const at = (stateStartedAt: number) => [
|
||||
worktree({
|
||||
agents: [agent({ state: 'working', mainAgent: done('failure', stateStartedAt) })]
|
||||
})
|
||||
]
|
||||
|
||||
expect(areWorktreeListsEqual(at(1), at(2))).toBe(false)
|
||||
expect(areWorktreeListsEqual(at(1), at(1))).toBe(true)
|
||||
})
|
||||
|
||||
it('detects monitoring mode changes within working', () => {
|
||||
const first = [worktree({ agents: [agent({ state: 'working' })] })]
|
||||
const second = [worktree({ agents: [agent({ state: 'working', workingMode: 'monitoring' })] })]
|
||||
|
||||
@@ -110,6 +110,7 @@ function areAgentRowsEqual(
|
||||
a.toolName !== b.toolName ||
|
||||
a.toolInput !== b.toolInput ||
|
||||
a.interrupted !== b.interrupted ||
|
||||
!areMainAgentsEqual(a.mainAgent, b.mainAgent) ||
|
||||
a.stateStartedAt !== b.stateStartedAt ||
|
||||
a.updatedAt !== b.updatedAt
|
||||
) {
|
||||
@@ -118,3 +119,20 @@ function areAgentRowsEqual(
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function areMainAgentsEqual(
|
||||
left: RuntimeWorktreeAgentRow['mainAgent'],
|
||||
right: RuntimeWorktreeAgentRow['mainAgent']
|
||||
): boolean {
|
||||
if (left === right) {
|
||||
return true
|
||||
}
|
||||
if (!left || !right) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
left.state === right.state &&
|
||||
left.outcome === right.outcome &&
|
||||
left.stateStartedAt === right.stateStartedAt
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -170,7 +170,7 @@
|
||||
"test:e2e:ssh-docker-bulk-open-freeze": "node config/scripts/run-ssh-docker-bulk-open-freeze-e2e.mjs",
|
||||
"repro:live-remote-bulk-open-freeze": "node config/scripts/live-remote-bulk-open-freeze-repro.mjs",
|
||||
"repro:live-remote-realistic-freeze": "node config/scripts/live-remote-realistic-freeze-repro.mjs",
|
||||
"audit:anti-slop": "node config/scripts/sync-anti-slop-plugin.mjs && oxlint --config config/oxlint-anti-slop.json src config tests mobile --deny-warnings",
|
||||
"audit:anti-slop": "node config/scripts/sync-anti-slop-plugin.mjs && node config/scripts/run-anti-slop-shards.mjs",
|
||||
"sync:anti-slop-plugin": "node config/scripts/sync-anti-slop-plugin.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { commandCodeHookService } from '../command-code/hook-service'
|
||||
import { copilotHookService } from '../copilot/hook-service'
|
||||
import { cursorHookService } from '../cursor/hook-service'
|
||||
import { devinHookService } from '../devin/hook-service'
|
||||
import { dshHookService } from '../dsh/hook-service'
|
||||
import { droidHookService } from '../droid/hook-service'
|
||||
import { geminiHookService } from '../gemini/hook-service'
|
||||
import { grokHookService } from '../grok/hook-service'
|
||||
@@ -54,7 +55,8 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[]
|
||||
['devin', () => devinHookService.install()],
|
||||
['kimi', () => kimiHookService.install()],
|
||||
['muse', () => museHookService.install()],
|
||||
['zcode', () => zcodeHookService.install()]
|
||||
['zcode', () => zcodeHookService.install()],
|
||||
['dsh', () => dshHookService.install()]
|
||||
]
|
||||
|
||||
// Why: covers the shared launcher/statusline scripts under ~/.orca/agent-hooks — the files a
|
||||
@@ -77,7 +79,8 @@ export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScri
|
||||
['devin', () => devinHookService.refreshManagedScripts()],
|
||||
['kimi', () => kimiHookService.refreshManagedScripts()],
|
||||
['muse', () => museHookService.refreshManagedScripts()],
|
||||
['zcode', () => zcodeHookService.refreshManagedScripts()]
|
||||
['zcode', () => zcodeHookService.refreshManagedScripts()],
|
||||
['dsh', () => dshHookService.refreshManagedScripts()]
|
||||
]
|
||||
|
||||
export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [
|
||||
@@ -96,7 +99,8 @@ export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [
|
||||
['devin', () => devinHookService.remove()],
|
||||
['kimi', () => kimiHookService.remove()],
|
||||
['muse', () => museHookService.remove()],
|
||||
['zcode', () => zcodeHookService.remove()]
|
||||
['zcode', () => zcodeHookService.remove()],
|
||||
['dsh', () => dshHookService.remove()]
|
||||
]
|
||||
|
||||
export const MANAGED_AGENT_HOOK_ASYNC_REMOVERS: readonly ManagedAgentHookAsyncRemover[] = [
|
||||
@@ -119,5 +123,6 @@ export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusR
|
||||
['devin', () => devinHookService.getStatus()],
|
||||
['kimi', () => kimiHookService.getStatus()],
|
||||
['muse', () => museHookService.getStatus()],
|
||||
['zcode', () => zcodeHookService.getStatus()]
|
||||
['zcode', () => zcodeHookService.getStatus()],
|
||||
['dsh', () => dshHookService.getStatus()]
|
||||
]
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { getDevinManagedCommand, getDevinRemoteManagedCommand } from '../devin/hook-settings'
|
||||
import { getGrokManagedCommand } from '../grok/grok-hook-script'
|
||||
import { getMuseManagedCommand, getMuseRemoteManagedCommand } from '../muse/hook-settings'
|
||||
import { getDshManagedCommand, getDshRemoteManagedCommand } from '../dsh/hook-settings'
|
||||
import { getZCodeManagedCommand, getZCodeRemoteManagedCommand } from '../zcode/hook-settings'
|
||||
import {
|
||||
wrapPosixHookCommand,
|
||||
@@ -151,6 +152,13 @@ const buildersByAgent = new Map<string, CommandBuilders>([
|
||||
remote: (path) => [getMuseRemoteManagedCommand(path)]
|
||||
}
|
||||
],
|
||||
[
|
||||
'dsh',
|
||||
{
|
||||
local: (path) => [getDshManagedCommand(path)],
|
||||
remote: (path) => [getDshRemoteManagedCommand(path)]
|
||||
}
|
||||
],
|
||||
[
|
||||
'zcode',
|
||||
{
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { isPlainObject } from './installer-utils'
|
||||
|
||||
/**
|
||||
* Which of `events` an Orca-owned `{ hooks: { <event>: [{ hooks: [{ command }] }] } }` file
|
||||
* still registers under a managed command.
|
||||
*
|
||||
* Shared by every agent whose managed hooks file Orca generates wholesale (Muse, DSH), so
|
||||
* "installed", "partial" and "not_installed" cannot drift between them.
|
||||
*
|
||||
* Why every lookup is guarded: the file is on disk and hand-editable, so any node can be
|
||||
* null, a scalar, or the wrong container. A malformed node reads as "event absent" — status
|
||||
* calculation must report a broken install, never throw on it.
|
||||
*/
|
||||
export function readManagedHookEventsFromJson(
|
||||
parsed: unknown,
|
||||
events: readonly string[],
|
||||
isManagedCommand: (command: string | undefined) => boolean
|
||||
): Set<string> {
|
||||
const hooks = isPlainObject(parsed) && isPlainObject(parsed.hooks) ? parsed.hooks : {}
|
||||
return new Set(
|
||||
events.filter((event) =>
|
||||
asArray(hooks[event]).some((definition) =>
|
||||
asArray(isPlainObject(definition) ? definition.hooks : null).some((hook) =>
|
||||
isManagedCommand(readCommand(hook))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function asArray(value: unknown): readonly unknown[] {
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
function readCommand(hook: unknown): string | undefined {
|
||||
const command = isPlainObject(hook) ? hook.command : undefined
|
||||
return typeof command === 'string' ? command : undefined
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { geminiHookService } from '../gemini/hook-service'
|
||||
import { grokHookService } from '../grok/hook-service'
|
||||
import { hermesHookService } from '../hermes/hook-service'
|
||||
import { kimiHookService } from '../kimi/hook-service'
|
||||
import { dshHookService } from '../dsh/hook-service'
|
||||
import { museHookService } from '../muse/hook-service'
|
||||
import { openClaudeHookService } from '../openclaude/hook-service'
|
||||
import { zcodeHookService } from '../zcode/hook-service'
|
||||
@@ -50,7 +51,8 @@ describe('remote hook service registry coverage', () => {
|
||||
['devin', devinHookService],
|
||||
['kimi', kimiHookService],
|
||||
['muse', museHookService],
|
||||
['zcode', zcodeHookService]
|
||||
['zcode', zcodeHookService],
|
||||
['dsh', dshHookService]
|
||||
])
|
||||
|
||||
// Guard against a service silently missing from the map above as new agents land.
|
||||
|
||||
@@ -13,6 +13,7 @@ import { droidHookService } from '../droid/hook-service'
|
||||
import { grokHookService } from '../grok/hook-service'
|
||||
import { hermesHookService } from '../hermes/hook-service'
|
||||
import { kimiHookService } from '../kimi/hook-service'
|
||||
import { dshHookService } from '../dsh/hook-service'
|
||||
import { museHookService } from '../muse/hook-service'
|
||||
import { zcodeHookService } from '../zcode/hook-service'
|
||||
import { openClaudeHookService } from '../openclaude/hook-service'
|
||||
@@ -76,7 +77,8 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [
|
||||
['devin', (sftp, remoteHome) => devinHookService.installRemote(sftp, remoteHome)],
|
||||
['kimi', (sftp, remoteHome) => kimiHookService.installRemote(sftp, remoteHome)],
|
||||
['muse', (sftp, remoteHome) => museHookService.installRemote(sftp, remoteHome)],
|
||||
['zcode', (sftp, remoteHome) => zcodeHookService.installRemote(sftp, remoteHome)]
|
||||
['zcode', (sftp, remoteHome) => zcodeHookService.installRemote(sftp, remoteHome)],
|
||||
['dsh', (sftp, remoteHome) => dshHookService.installRemote(sftp, remoteHome)]
|
||||
]
|
||||
|
||||
/** Agents wired into the remote (SSH) hook installer. Exported so an invariant
|
||||
|
||||
@@ -42,7 +42,8 @@ const NEW_TURN_EVENT: Record<AgentHookSource, string | null> = {
|
||||
'mimo-code': null,
|
||||
'command-code': null,
|
||||
muse: 'UserPromptSubmit',
|
||||
zcode: 'SessionStart'
|
||||
zcode: 'SessionStart',
|
||||
dsh: 'SessionStart'
|
||||
}
|
||||
|
||||
function reviveRetiredPane(source: unknown, hookEventName: string): boolean {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user