mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/fix-permissions-query-status
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
Fixes #
|
||||
|
||||
## Visual Proof
|
||||
|
||||
<!-- REQUIRED for UI / behavior changes. Please attach a BEFORE and AFTER that can easily tabbed/switched. Use videos for when appropriate over screenshots -->
|
||||
<!-- If there is truly no visual or interaction change, write exactly: `N/A` and briefly say why. -->
|
||||
<!-- For attachments NEVER add directly to the PR files (do not commit to files), use `gh image` extension or drag + drop (works for any attachment) -->
|
||||
@@ -28,13 +29,19 @@ Fixes #
|
||||
- [ ] I manually tested these changes locally
|
||||
- [ ] Automated tests added/updated, or explained why not below
|
||||
|
||||
|
||||
## AI Disclosure
|
||||
|
||||
<!-- DO NOT FILL IN IF YOU ARE STABLYAI TEAM MEMBER (INTERNAL CONTRIBUTOR), IGNORE SECTION: -->
|
||||
<!-- Which AI model if anyone was used, please state the details -->
|
||||
|
||||
## Review
|
||||
|
||||
## Agent skill upstream boundary
|
||||
|
||||
- [ ] Not applicable, or this change follows `docs/reference/agent-skill-sharing-upstream-boundary.md` and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.
|
||||
|
||||
## Notes
|
||||
|
||||
Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance
|
||||
|
||||
## Checklist
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { appendFileSync, readFileSync } from 'node:fs'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { mergeLocBlock, renderLocBlock, sumChangedFiles } from './pr-test-loc-table.mjs'
|
||||
|
||||
export const PR_FILES_PAGE_LIMIT = 3000
|
||||
|
||||
export function nextLink(linkHeader) {
|
||||
if (linkHeader == null || linkHeader.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (const part of linkHeader.split(',')) {
|
||||
const match = part.match(/<([^>]+)>\s*;\s*rel="next"/)
|
||||
if (match != null) {
|
||||
return match[1]
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function githubHeaders(token) {
|
||||
return {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
'User-Agent': 'orca-pr-test-loc',
|
||||
'X-GitHub-Api-Version': '2022-11-28'
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPullFiles({ owner, repo, pullNumber, token, fetchImpl = fetch }) {
|
||||
const files = []
|
||||
let url = `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/files?per_page=100`
|
||||
|
||||
while (url != null) {
|
||||
const response = await fetchImpl(url, { headers: githubHeaders(token) })
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to list PR #${pullNumber} files: ${response.status} ${response.statusText}`
|
||||
)
|
||||
}
|
||||
const page = await response.json()
|
||||
if (!Array.isArray(page)) {
|
||||
throw new Error(`Unexpected PR files payload for #${pullNumber}`)
|
||||
}
|
||||
files.push(...page)
|
||||
if (files.length >= PR_FILES_PAGE_LIMIT) {
|
||||
console.log(
|
||||
`PR #${pullNumber} file list hit GitHub's ${PR_FILES_PAGE_LIMIT}-file cap; totals may be short.`
|
||||
)
|
||||
return files.slice(0, PR_FILES_PAGE_LIMIT)
|
||||
}
|
||||
url = nextLink(response.headers.get('link'))
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
function writeGithubOutput(totals) {
|
||||
const block = `${renderLocBlock(totals)}\n`
|
||||
const outputPath = process.env.GITHUB_OUTPUT
|
||||
if (outputPath != null) {
|
||||
appendFileSync(outputPath, `summary<<ORCA_PR_LOC_EOF\n${block}ORCA_PR_LOC_EOF\n`)
|
||||
}
|
||||
const summaryPath = process.env.GITHUB_STEP_SUMMARY
|
||||
if (summaryPath != null) {
|
||||
appendFileSync(summaryPath, `${block}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePullRequest({ owner, repo, pullNumber, token, totals, fetchImpl = fetch }) {
|
||||
const headers = githubHeaders(token)
|
||||
const url = `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}`
|
||||
const response = await fetchImpl(url, { headers })
|
||||
if (response.status === 403) {
|
||||
console.log('Skipping PR body update: token cannot write (likely a fork PR).')
|
||||
return 0
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to read PR #${pullNumber}: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const pull = await response.json()
|
||||
const nextBody = mergeLocBlock(pull.body, totals)
|
||||
if (nextBody === (pull.body ?? '')) {
|
||||
console.log(`PR #${pullNumber} LoC header already current.`)
|
||||
return 0
|
||||
}
|
||||
|
||||
const update = await fetchImpl(url, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ body: nextBody })
|
||||
})
|
||||
if (update.status === 403) {
|
||||
console.log('Skipping PR body update: token cannot write (likely a fork PR).')
|
||||
return 0
|
||||
}
|
||||
if (!update.ok) {
|
||||
throw new Error(`Failed to update PR #${pullNumber}: ${update.status} ${update.statusText}`)
|
||||
}
|
||||
|
||||
console.log(`Updated LoC header on PR #${pullNumber}.`)
|
||||
return 0
|
||||
}
|
||||
|
||||
function resolveRepository() {
|
||||
const repository = process.env.GITHUB_REPOSITORY
|
||||
if (repository == null || !repository.includes('/')) {
|
||||
return undefined
|
||||
}
|
||||
const slash = repository.indexOf('/')
|
||||
return { owner: repository.slice(0, slash), repo: repository.slice(slash + 1) }
|
||||
}
|
||||
|
||||
function resolveToken() {
|
||||
return process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.error(
|
||||
`Usage: ${process.argv[1]} --from-pr <number> | --update-pr <number> [--files-json <file>] [--merge-body <file>]`
|
||||
)
|
||||
}
|
||||
|
||||
async function main(argv) {
|
||||
let filesJsonPath
|
||||
let mergeBodyPath
|
||||
let fromPrNumber
|
||||
let updatePrNumber
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i]
|
||||
if (arg === '--files-json') {
|
||||
filesJsonPath = argv[i + 1]
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if (arg === '--merge-body') {
|
||||
mergeBodyPath = argv[i + 1]
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if (arg === '--from-pr') {
|
||||
fromPrNumber = argv[i + 1]
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
if (arg === '--update-pr') {
|
||||
updatePrNumber = argv[i + 1]
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
printUsage()
|
||||
return 2
|
||||
}
|
||||
|
||||
const pullNumber = updatePrNumber ?? fromPrNumber
|
||||
if (
|
||||
(argv.includes('--files-json') && filesJsonPath == null) ||
|
||||
(argv.includes('--merge-body') && mergeBodyPath == null) ||
|
||||
(argv.includes('--from-pr') && fromPrNumber == null) ||
|
||||
(argv.includes('--update-pr') && updatePrNumber == null) ||
|
||||
(filesJsonPath == null && pullNumber == null)
|
||||
) {
|
||||
printUsage()
|
||||
return 2
|
||||
}
|
||||
|
||||
let files
|
||||
if (filesJsonPath != null) {
|
||||
files = JSON.parse(readFileSync(filesJsonPath, 'utf8'))
|
||||
} else {
|
||||
const repository = resolveRepository()
|
||||
const token = resolveToken()
|
||||
if (repository == null || token == null) {
|
||||
console.error('GITHUB_REPOSITORY and GITHUB_TOKEN are required to read a pull request.')
|
||||
return 2
|
||||
}
|
||||
files = await listPullFiles({
|
||||
...repository,
|
||||
pullNumber: Number(pullNumber),
|
||||
token
|
||||
})
|
||||
}
|
||||
|
||||
const totals = sumChangedFiles(files)
|
||||
writeGithubOutput(totals)
|
||||
|
||||
if (mergeBodyPath != null) {
|
||||
process.stdout.write(mergeLocBlock(readFileSync(mergeBodyPath, 'utf8'), totals))
|
||||
return 0
|
||||
}
|
||||
|
||||
console.log(renderLocBlock(totals))
|
||||
|
||||
if (updatePrNumber == null) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const repository = resolveRepository()
|
||||
const token = resolveToken()
|
||||
if (repository == null || token == null) {
|
||||
console.error('GITHUB_REPOSITORY and GITHUB_TOKEN are required with --update-pr.')
|
||||
return 2
|
||||
}
|
||||
|
||||
return updatePullRequest({
|
||||
...repository,
|
||||
pullNumber: Number(updatePrNumber),
|
||||
token,
|
||||
totals
|
||||
})
|
||||
}
|
||||
|
||||
if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main(process.argv.slice(2))
|
||||
.then((code) => {
|
||||
process.exitCode = code
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
export const LOC_BLOCK_START = '<!-- orca-pr-loc -->'
|
||||
export const LOC_BLOCK_END = '<!-- /orca-pr-loc -->'
|
||||
export const LOC_HANDS_OFF_COMMENT =
|
||||
'<!-- Programmatic LoC summary. Do not edit by hand; rewritten on every commit. -->'
|
||||
|
||||
const TEST_DIR_SEGMENT = /(?:^|\/)(?:__tests__|e2e|tests)(?:\/|$)/i
|
||||
const TEST_FILENAME = /\.(?:test|spec|e2e)\.[^/]+$/i
|
||||
|
||||
export function isTestPath(path) {
|
||||
const normalized = path.replaceAll('\\', '/')
|
||||
return TEST_DIR_SEGMENT.test(normalized) || TEST_FILENAME.test(normalized)
|
||||
}
|
||||
|
||||
export function emptyLocTotals() {
|
||||
return {
|
||||
test: { files: 0, added: 0, deleted: 0 },
|
||||
nonTest: { files: 0, added: 0, deleted: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
export function sumChangedFiles(files) {
|
||||
const totals = emptyLocTotals()
|
||||
for (const file of files) {
|
||||
const path = file.filename
|
||||
if (path == null) {
|
||||
continue
|
||||
}
|
||||
const bucket = isTestPath(path) ? totals.test : totals.nonTest
|
||||
bucket.files += 1
|
||||
bucket.added += Number(file.additions ?? 0)
|
||||
bucket.deleted += Number(file.deletions ?? 0)
|
||||
}
|
||||
return totals
|
||||
}
|
||||
|
||||
const COLOR_ADDED = '#1a7f37'
|
||||
const COLOR_DELETED = '#cf222e'
|
||||
|
||||
function diffCell(count) {
|
||||
if (count === 0) {
|
||||
return '0'
|
||||
}
|
||||
if (count > 0) {
|
||||
return `$\\color{${COLOR_ADDED}}{\\Huge{\\mathbf{+}}}$\u200b${count}`
|
||||
}
|
||||
return `$\\color{${COLOR_DELETED}}{\\Huge{\\mathbf{−}}}$\u200b${Math.abs(count)}`
|
||||
}
|
||||
|
||||
function locTableRow(label, bucket) {
|
||||
const added = bucket.added ?? 0
|
||||
const deleted = bucket.deleted ?? 0
|
||||
return `| ${label} | ${bucket.files ?? 0} | ${diffCell(added)} | ${diffCell(-deleted)} | ${diffCell(added - deleted)} |`
|
||||
}
|
||||
|
||||
export function formatLocTable({ test, nonTest }) {
|
||||
return [
|
||||
'| | Files | Added | Deleted | Net |',
|
||||
'| :--- | ---: | ---: | ---: | ---: |',
|
||||
locTableRow('Test', test),
|
||||
locTableRow('Prod', nonTest)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function renderLocBlock(totals) {
|
||||
return [
|
||||
LOC_BLOCK_START,
|
||||
LOC_HANDS_OFF_COMMENT,
|
||||
'',
|
||||
formatLocTable(totals),
|
||||
'',
|
||||
LOC_BLOCK_END
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function mergeLocBlock(body, totals) {
|
||||
const block = renderLocBlock(totals)
|
||||
const current = body ?? ''
|
||||
const start = current.indexOf(LOC_BLOCK_START)
|
||||
const end = current.indexOf(LOC_BLOCK_END)
|
||||
|
||||
if (start !== -1 && end !== -1 && end > start) {
|
||||
const rest = current.slice(end + LOC_BLOCK_END.length).replace(/^\r?\n/, '')
|
||||
if (rest.trim().length === 0) {
|
||||
return `${current.slice(0, start)}${block}\n`
|
||||
}
|
||||
return `${current.slice(0, start)}${block}\n\n${rest.replace(/^\r?\n+/, '')}`
|
||||
}
|
||||
|
||||
if (current.trim().length === 0) {
|
||||
return `${block}\n`
|
||||
}
|
||||
|
||||
return `${block}\n\n${current.replace(/^\r?\n+/, '')}`
|
||||
}
|
||||
@@ -5,13 +5,15 @@ name: Daily macOS Dev Build
|
||||
# hourly.
|
||||
#
|
||||
# Schedule is a single UTC cron (GH Actions has no timezone-aware schedules).
|
||||
# 14:15 UTC is early morning Pacific year-round (6:15am PST / 7:15am PDT). Minute
|
||||
# 18:15 UTC is late morning Pacific year-round (10:15am PST / 11:15am PDT). Minute
|
||||
# 15 avoids stacking with hourly-mac-build, which fires at minute 0 every hour.
|
||||
#
|
||||
# Deliberately narrow scope:
|
||||
# - macOS only. Other platforms keep using RC/stable.
|
||||
# - No tests, no lint, no e2e. This channel trades safety for latency; PR CI
|
||||
# and release-cut remain the gates that matter.
|
||||
# - No tests or lint on the build job. After a live publish we fire-and-forget
|
||||
# the full E2E workflow at the cut SHA (same detached dispatch as
|
||||
# release-cut). A red suite must not fail or delay the daily.
|
||||
# - PR CI and release-cut remain the gates that matter.
|
||||
# - Signed AND notarized, exactly like a release. The notary round trip is the
|
||||
# one slow step kept: macOS anchors a notarized app's TCC grants on identifier
|
||||
# + team rather than on its cdhash, so those grants survive an update. Without
|
||||
@@ -45,8 +47,8 @@ name: Daily macOS Dev Build
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Once a day, early morning Pacific. Single cron — no DST twin, no clock gate.
|
||||
- cron: '15 14 * * *'
|
||||
# Once a day, late morning Pacific. Single cron — no DST twin, no clock gate.
|
||||
- cron: '15 18 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
@@ -70,6 +72,9 @@ env:
|
||||
jobs:
|
||||
build-daily-mac:
|
||||
if: github.repository == 'stablyai/orca'
|
||||
outputs:
|
||||
head_sha: ${{ steps.freshness.outputs.head_sha }}
|
||||
published: ${{ steps.publish_live.outcome == 'success' && 'true' || 'false' }}
|
||||
runs-on: blacksmith-6vcpu-macos-15
|
||||
# Why 150: it must exceed the worst case the retry budgets below can produce
|
||||
# (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or
|
||||
@@ -469,3 +474,34 @@ jobs:
|
||||
gh release delete "$tag" --repo "$DAILY_REPO" --yes --cleanup-tag || \
|
||||
echo "::warning::Could not prune $tag"
|
||||
done <<<"$stale"
|
||||
|
||||
# Why detached dispatch, not a reusable workflow in this graph: the full suite
|
||||
# is currently red on main. Inlining it here would fail the daily after the
|
||||
# signed build already published. Same pattern as release-cut's post-release-e2e.
|
||||
#
|
||||
# Why --ref main plus the SHA input: daily tags live on orca-daily, not this
|
||||
# repo, so there is no cut tag for `gh workflow run --ref`. The workflow file
|
||||
# comes from main; checkout uses the exact commit this daily built.
|
||||
post-daily-e2e:
|
||||
needs: build-daily-mac
|
||||
if: ${{ needs.build-daily-mac.outputs.published == 'true' && needs.build-daily-mac.outputs.head_sha != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Dispatch cut-scoped E2E
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SHA: ${{ needs.build-daily-mac.outputs.head_sha }}
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
if gh workflow run e2e.yml \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--ref main \
|
||||
--raw-field "ref=$SHA"; then
|
||||
echo "Dispatched post-daily E2E for $SHA."
|
||||
exit 0
|
||||
fi
|
||||
[[ "$attempt" -eq 3 ]] || sleep "$((attempt * 5))"
|
||||
done
|
||||
echo "::warning::Failed to dispatch post-daily E2E for $SHA after 3 attempts."
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
name: PR test LoC
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
|
||||
concurrency:
|
||||
group: pr-test-loc-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
loc:
|
||||
name: test vs non-test LoC
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
# Why no checkout: the Files API already has per-file additions/deletions.
|
||||
- name: Count test vs non-test LoC
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
for script in pr-test-loc-table.mjs pr-test-loc-summary.mjs; do
|
||||
gh api "repos/${GITHUB_REPOSITORY}/contents/.github/scripts/${script}?ref=pull/${{ github.event.pull_request.number }}/head" \
|
||||
--jq .content | base64 --decode > "$RUNNER_TEMP/${script}"
|
||||
done
|
||||
node "$RUNNER_TEMP/pr-test-loc-summary.mjs" --update-pr "${{ github.event.pull_request.number }}"
|
||||
@@ -264,7 +264,10 @@ jobs:
|
||||
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.test.ts \
|
||||
src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
|
||||
src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts \
|
||||
src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts \
|
||||
src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \
|
||||
src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
@@ -300,7 +303,10 @@ jobs:
|
||||
--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.test.ts \
|
||||
--exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
|
||||
--exclude=src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts \
|
||||
--exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts \
|
||||
--exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \
|
||||
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
|
||||
@@ -923,6 +923,87 @@ jobs:
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
skill-sharing-release-gate:
|
||||
needs: cut
|
||||
if: needs.cut.outputs.should_release == 'true'
|
||||
name: skill sharing release gate ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-15
|
||||
platform: mac
|
||||
- os: windows-2022
|
||||
platform: windows
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
- name: Install Electron package binary for tests
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
|
||||
- name: Run skill package, transaction, and compatibility suites
|
||||
env:
|
||||
ORCA_REAL_PROCESS_SKILL_TEST: '1'
|
||||
ORCA_REAL_WINDOWS_SKILL_TEST: ${{ runner.os == 'Windows' && '1' || '0' }}
|
||||
run: pnpm test:skill-sharing:release --reporter=json --outputFile=skill-sharing-release-results.json
|
||||
|
||||
- name: Archive bounded skill-sharing results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: skill-sharing-release-${{ matrix.platform }}
|
||||
path: skill-sharing-release-results.json
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
skill-sharing-linux-floor-release-gate:
|
||||
needs: cut
|
||||
if: needs.cut.outputs.should_release == 'true'
|
||||
name: skill sharing release gate linux-glibc-2.31
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
container: ubuntu:20.04
|
||||
|
||||
steps:
|
||||
- name: Install Ubuntu 20.04 prerequisites
|
||||
run: apt-get update && apt-get install -y build-essential ca-certificates git python3
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
- name: Install Electron package binary for tests
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
|
||||
- name: Run skill package, transaction, and compatibility suites
|
||||
env:
|
||||
ORCA_REAL_PROCESS_SKILL_TEST: '1'
|
||||
run: pnpm test:skill-sharing:release --reporter=json --outputFile=skill-sharing-release-results.json
|
||||
|
||||
- name: Archive bounded skill-sharing results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: skill-sharing-release-linux-glibc-2.31
|
||||
path: skill-sharing-release-results.json
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
|
||||
# Why: these broader terminal rendering repros are useful release evidence,
|
||||
# but they include heavier app-like flows and must not block publishing.
|
||||
terminal-rendering-release-evidence:
|
||||
@@ -1026,12 +1107,14 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
platform: linux-x64
|
||||
release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --x64 --publish always
|
||||
unpacked_dir: dist/linux-unpacked
|
||||
eb_cache_path: |
|
||||
~/.cache/electron
|
||||
~/.cache/electron-builder
|
||||
- os: ubuntu-24.04-arm
|
||||
platform: linux-arm64
|
||||
release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_LINUX_ARM64_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --arm64 --publish always
|
||||
unpacked_dir: dist/linux-arm64-unpacked
|
||||
eb_cache_path: |
|
||||
~/.cache/electron
|
||||
~/.cache/electron-builder
|
||||
@@ -1209,6 +1292,17 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Load packaged node-pty on the Linux floor
|
||||
if: matrix.platform == 'linux-x64' || matrix.platform == 'linux-arm64'
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: >-
|
||||
node config/scripts/run-linux-packaged-node-pty-floor-smoke.mjs
|
||||
--app-dir ${{ matrix.unpacked_dir }}
|
||||
|
||||
# Why: SignPath signs GitHub workflow artifacts, so Windows builds must
|
||||
# upload only after the production-signed installer has been returned.
|
||||
- name: Build Windows release artifacts
|
||||
@@ -1896,6 +1990,8 @@ jobs:
|
||||
- cut
|
||||
- build
|
||||
- build-mac
|
||||
- skill-sharing-linux-floor-release-gate
|
||||
- skill-sharing-release-gate
|
||||
- terminal-rendering-golden
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
@@ -94,6 +94,8 @@ docs/**
|
||||
!docs/readme/
|
||||
!docs/readme/**
|
||||
!docs/STYLEGUIDE.md
|
||||
!docs/agent-skill-sharing-implementation-checklist.md
|
||||
!docs/agent-skill-sharing-installation-plan.md
|
||||
!docs/ai-vault-process-isolation-plan.md
|
||||
!docs/mobile-terminal-shortcut-bar.md
|
||||
!docs/reference/
|
||||
|
||||
@@ -22,8 +22,7 @@ const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.
|
||||
const isMacHourly = process.env.ORCA_MAC_HOURLY === '1'
|
||||
const isMacDaily = process.env.ORCA_MAC_DAILY === '1'
|
||||
const isMacAdhoc = process.env.ORCA_MAC_ADHOC === '1'
|
||||
const isMacRelease =
|
||||
process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacDaily || isMacAdhoc
|
||||
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' || isMacHourly || isMacDaily || isMacAdhoc
|
||||
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
|
||||
const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION
|
||||
const devChannelBuildVersion = isMacHourly
|
||||
@@ -92,6 +91,7 @@ const winSpeechNativeResource = {
|
||||
module.exports = {
|
||||
appId,
|
||||
productName: 'Orca',
|
||||
protocols: [{ name: 'Orca', schemes: ['orca'] }],
|
||||
...(devChannelBuildVersion
|
||||
? { extraMetadata: { version: devChannelBuildVersion } }
|
||||
: localBuildVersion
|
||||
@@ -284,8 +284,14 @@ module.exports = {
|
||||
}
|
||||
if (context.electronPlatformName === 'darwin') {
|
||||
await signMacComputerUseHelper(join(resourcesDir, 'Orca Computer Use.app'), context.packager)
|
||||
await signMacNotificationStatusHelper(
|
||||
await signMacStandaloneHelper(
|
||||
join(resourcesDir, '..', 'MacOS', 'orca-notification-status'),
|
||||
'orca-notification-status',
|
||||
context.packager
|
||||
)
|
||||
await signMacStandaloneHelper(
|
||||
join(resourcesDir, '..', 'MacOS', 'orca-keyboard-layout'),
|
||||
'orca-keyboard-layout',
|
||||
context.packager
|
||||
)
|
||||
}
|
||||
@@ -399,6 +405,10 @@ module.exports = {
|
||||
{
|
||||
from: 'native/notification-status-macos/.build/release/orca-notification-status',
|
||||
to: 'MacOS/orca-notification-status'
|
||||
},
|
||||
{
|
||||
from: 'native/keyboard-layout-macos/.build/release/orca-keyboard-layout',
|
||||
to: 'MacOS/orca-keyboard-layout'
|
||||
}
|
||||
],
|
||||
target: [
|
||||
@@ -571,10 +581,10 @@ async function signMacComputerUseHelper(helperAppPath, packager) {
|
||||
})
|
||||
}
|
||||
|
||||
async function signMacNotificationStatusHelper(helperPath, packager) {
|
||||
async function signMacStandaloneHelper(helperPath, helperName, packager) {
|
||||
if (!existsSync(helperPath)) {
|
||||
if (isMacRelease) {
|
||||
throw new Error(`Missing orca-notification-status helper at ${helperPath}`)
|
||||
throw new Error(`Missing ${helperName} helper at ${helperPath}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -587,12 +597,9 @@ async function signMacNotificationStatusHelper(helperPath, packager) {
|
||||
findInstalledMacSigningIdentity(codeSigningInfo?.keychainFile) ??
|
||||
(isMacRelease ? null : '-')
|
||||
if (!identity) {
|
||||
throw new Error('Missing signing identity for orca-notification-status helper')
|
||||
throw new Error(`Missing signing identity for ${helperName} helper`)
|
||||
}
|
||||
// Why: macOS keys notification records to the code-signing identifier; the
|
||||
// binary embeds the app's CFBundleIdentifier in __TEXT,__info_plist so this
|
||||
// (and any later) `codesign --force` derives the correct identifier. Sign
|
||||
// before the outer Orca.app is sealed, like the computer-use helper.
|
||||
// Why: nested executables must be signed before the outer app bundle is sealed.
|
||||
const args = ['--force', '--sign', identity]
|
||||
if (isMacRelease) {
|
||||
args.push('--options', 'runtime', '--timestamp')
|
||||
|
||||
@@ -5,180 +5,89 @@
|
||||
inline src/cli/handlers/automations.ts
|
||||
inline src/cli/handlers/orchestration.ts
|
||||
inline src/cli/help.ts
|
||||
inline src/cli/index.test.ts
|
||||
inline src/main/agent-hooks/server.test.ts
|
||||
inline src/main/agent-hooks/server.ts
|
||||
inline src/main/amp/hook-service.ts
|
||||
inline src/main/antigravity/hook-service.ts
|
||||
inline src/main/automations/external-manager.ts
|
||||
inline src/main/automations/hermes-cron-output.ts
|
||||
inline src/main/browser/agent-browser-bridge.test.ts
|
||||
inline src/main/browser/agent-browser-bridge.ts
|
||||
inline src/main/browser/browser-cookie-import.ts
|
||||
inline src/main/browser/browser-guest-ui.ts
|
||||
inline src/main/browser/browser-manager-grab.test.ts
|
||||
inline src/main/browser/browser-manager.test.ts
|
||||
inline src/main/browser/browser-manager.ts
|
||||
inline src/main/browser/browser-screencast-stream.ts
|
||||
inline src/main/browser/browser-session-registry.ts
|
||||
inline src/main/browser/cdp-bridge.ts
|
||||
inline src/main/browser/cdp-ws-proxy.ts
|
||||
inline src/main/browser/grab-guest-script.ts
|
||||
inline src/main/browser/snapshot-engine.ts
|
||||
inline src/main/claude-accounts/runtime-auth-service.test.ts
|
||||
inline src/main/claude-accounts/runtime-auth-service.ts
|
||||
inline src/main/claude-accounts/service.test.ts
|
||||
inline src/main/claude-accounts/service.ts
|
||||
inline src/main/claude-usage/scanner.ts
|
||||
inline src/main/claude-usage/store.ts
|
||||
inline src/main/cli/cli-installer.test.ts
|
||||
inline src/main/cli/cli-installer.ts
|
||||
inline src/main/cli/wsl-cli-installer.ts
|
||||
inline src/main/codex-accounts/runtime-home-service.test.ts
|
||||
inline src/main/codex-accounts/runtime-home-service.ts
|
||||
inline src/main/codex-accounts/service.test.ts
|
||||
inline src/main/codex-accounts/service.ts
|
||||
inline src/main/codex-usage/scanner.ts
|
||||
inline src/main/codex-usage/store.test.ts
|
||||
inline src/main/codex-usage/store.ts
|
||||
inline src/main/codex/config-toml-trust.test.ts
|
||||
inline src/main/codex/config-toml-trust.ts
|
||||
inline src/main/codex/hook-service.test.ts
|
||||
inline src/main/codex/hook-service.ts
|
||||
inline src/main/copilot/hook-service.ts
|
||||
inline src/main/daemon/client.ts
|
||||
inline src/main/daemon/daemon-health.ts
|
||||
inline src/main/daemon/daemon-init.test.ts
|
||||
inline src/main/daemon/daemon-init.ts
|
||||
inline src/main/daemon/daemon-pty-adapter.test.ts
|
||||
inline src/main/daemon/daemon-pty-adapter.ts
|
||||
inline src/main/daemon/daemon-server.ts
|
||||
inline src/main/daemon/pty-subprocess.test.ts
|
||||
inline src/main/daemon/pty-subprocess.ts
|
||||
inline src/main/daemon/session.ts
|
||||
inline src/main/daemon/shell-ready.ts
|
||||
inline src/main/git/remove-worktree.test.ts
|
||||
inline src/main/git/repo.ts
|
||||
inline src/main/git/runner.ts
|
||||
inline src/main/git/status.test.ts
|
||||
inline src/main/git/status.ts
|
||||
inline src/main/git/worktree.test.ts
|
||||
inline src/main/git/worktree.ts
|
||||
inline src/main/github/client.test.ts
|
||||
inline src/main/github/client.ts
|
||||
inline src/main/github/issues.ts
|
||||
inline src/main/github/pr-refresh-coordinator.test.ts
|
||||
inline src/main/github/pr-refresh-coordinator.ts
|
||||
inline src/main/github/project-view.ts
|
||||
inline src/main/github/project-view/mutations.ts
|
||||
inline src/main/github/work-item-details.ts
|
||||
inline src/main/gitlab/client-mr.test.ts
|
||||
inline src/main/gitlab/client.ts
|
||||
inline src/main/gitlab/issues.ts
|
||||
inline src/main/gitlab/work-item-details.ts
|
||||
inline src/main/hermes/hook-service.ts
|
||||
inline src/main/hooks.test.ts
|
||||
inline src/main/hooks.ts
|
||||
inline src/main/index.ts
|
||||
inline src/main/ipc/browser.ts
|
||||
inline src/main/ipc/crash-reporting.ts
|
||||
inline src/main/ipc/filesystem-auth.ts
|
||||
inline src/main/ipc/filesystem-mutations.ts
|
||||
inline src/main/ipc/filesystem-watcher.ts
|
||||
inline src/main/ipc/filesystem.test.ts
|
||||
inline src/main/ipc/filesystem.ts
|
||||
inline src/main/ipc/github.test.ts
|
||||
inline src/main/ipc/github.ts
|
||||
inline src/main/ipc/gitlab.ts
|
||||
inline src/main/ipc/linear.ts
|
||||
inline src/main/ipc/notifications.test.ts
|
||||
inline src/main/ipc/notifications.ts
|
||||
inline src/main/ipc/pet.ts
|
||||
inline src/main/ipc/preflight.test.ts
|
||||
inline src/main/ipc/pty.test.ts
|
||||
inline src/main/ipc/pty.ts
|
||||
inline src/main/ipc/remote-workspace.ts
|
||||
inline src/main/ipc/repos-remote.test.ts
|
||||
inline src/main/ipc/repos.ts
|
||||
inline src/main/ipc/runtime-environments.test.ts
|
||||
inline src/main/ipc/ssh.test.ts
|
||||
inline src/main/ipc/ssh.ts
|
||||
inline src/main/ipc/worktree-remote.ts
|
||||
inline src/main/ipc/worktrees.test.ts
|
||||
inline src/main/ipc/worktrees.ts
|
||||
inline src/main/jira/client.ts
|
||||
inline src/main/jira/issues.ts
|
||||
inline src/main/keybindings/keybinding-file.ts
|
||||
inline src/main/linear/client.ts
|
||||
inline src/main/linear/issues.ts
|
||||
inline src/main/linear/projects.ts
|
||||
inline src/main/memory/collector.ts
|
||||
inline src/main/opencode-usage/scanner.ts
|
||||
inline src/main/opencode-usage/store.ts
|
||||
inline src/main/opencode/hook-service.ts
|
||||
inline src/main/persistence.test.ts
|
||||
inline src/main/persistence.ts
|
||||
inline src/main/persistence/loading-store/store.ts
|
||||
inline src/main/ports/advertised-url-watcher.ts
|
||||
inline src/main/ports/local-workspace-port-scanner.ts
|
||||
inline src/main/project-groups/nested-repo-discovery.ts
|
||||
inline src/main/providers/local-pty-provider.test.ts
|
||||
inline src/main/providers/local-pty-provider.ts
|
||||
inline src/main/providers/local-pty-shell-ready.test.ts
|
||||
inline src/main/providers/local-pty-shell-ready.ts
|
||||
inline src/main/providers/ssh-git-provider.test.ts
|
||||
inline src/main/providers/ssh-git-provider.ts
|
||||
inline src/main/rate-limits/claude-fetcher.test.ts
|
||||
inline src/main/rate-limits/claude-fetcher.ts
|
||||
inline src/main/rate-limits/claude-pty.ts
|
||||
inline src/main/rate-limits/codex-fetcher.ts
|
||||
inline src/main/rate-limits/service.test.ts
|
||||
inline src/main/rate-limits/service.ts
|
||||
inline src/main/runtime/orca-runtime-browser.ts
|
||||
inline src/main/runtime/orca-runtime-files.test.ts
|
||||
inline src/main/runtime/orca-runtime-files.ts
|
||||
inline src/main/runtime/orca-runtime-git.ts
|
||||
inline src/main/runtime/orca-runtime.test.ts
|
||||
inline src/main/runtime/orca-runtime.ts
|
||||
inline src/main/runtime/orchestration/coordinator.ts
|
||||
inline src/main/runtime/orchestration/db.ts
|
||||
inline src/main/runtime/rpc/methods/files.ts
|
||||
inline src/main/runtime/rpc/methods/git.ts
|
||||
inline src/main/runtime/rpc/methods/github.ts
|
||||
inline src/main/runtime/rpc/methods/orchestration.test.ts
|
||||
inline src/main/runtime/rpc/methods/orchestration.ts
|
||||
inline src/main/runtime/rpc/methods/terminal.ts
|
||||
inline src/main/runtime/rpc/terminal-multiplex.test.ts
|
||||
inline src/main/runtime/runtime-rpc.test.ts
|
||||
inline src/main/runtime/runtime-rpc.ts
|
||||
inline src/main/source-control/hosted-review-creation.ts
|
||||
inline src/main/speech/model-manager.ts
|
||||
inline src/main/speech/stt-service.ts
|
||||
inline src/main/ssh/ssh-channel-multiplexer.ts
|
||||
inline src/main/ssh/ssh-connection.test.ts
|
||||
inline src/main/ssh/ssh-connection.ts
|
||||
inline src/main/ssh/ssh-relay-deploy.ts
|
||||
inline src/main/ssh/ssh-relay-session.ts
|
||||
inline src/main/star-nag/service.test.ts
|
||||
inline src/main/text-generation/commit-message-text-generation.test.ts
|
||||
inline src/main/text-generation/commit-message-text-generation.ts
|
||||
inline src/main/updater.test.ts
|
||||
inline src/main/updater.ts
|
||||
inline src/main/window/attach-main-window-services.ts
|
||||
inline src/main/window/createMainWindow.test.ts
|
||||
inline src/main/window/createMainWindow.ts
|
||||
inline src/main/workspace-space-analysis.ts
|
||||
inline src/preload/index.ts
|
||||
inline src/relay/agent-hook-server.ts
|
||||
inline src/relay/dispatcher.ts
|
||||
inline src/relay/external-automations-handler.ts
|
||||
inline src/relay/fs-handler.ts
|
||||
inline src/relay/git-handler.test.ts
|
||||
inline src/relay/git-handler.ts
|
||||
inline src/relay/pty-handler.test.ts
|
||||
inline src/relay/pty-handler.ts
|
||||
inline src/relay/relay.ts
|
||||
inline src/relay/workspace-space-scan.ts
|
||||
inline src/renderer/src/components/GitHubItemDialog.tsx
|
||||
inline src/renderer/src/components/GitLabItemDialog.tsx
|
||||
inline src/renderer/src/components/JiraIssueWorkspace.tsx
|
||||
inline src/renderer/src/components/LinearIssueWorkspace.tsx
|
||||
@@ -189,23 +98,16 @@ inline src/renderer/src/components/TaskPage.tsx
|
||||
inline src/renderer/src/components/Terminal.tsx
|
||||
inline src/renderer/src/components/UpdateCard.tsx
|
||||
inline src/renderer/src/components/WorktreeJumpPalette.tsx
|
||||
inline src/renderer/src/components/activity/ActivityPrototypePage.test.ts
|
||||
inline src/renderer/src/components/activity/ActivityPrototypePage.tsx
|
||||
inline src/renderer/src/components/automations/AutomationsPage.tsx
|
||||
inline src/renderer/src/components/browser-pane/BrowserPane.tsx
|
||||
inline src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx
|
||||
inline src/renderer/src/components/editor/CombinedDiffViewer.tsx
|
||||
inline src/renderer/src/components/editor/EditorContent.tsx
|
||||
inline src/renderer/src/components/editor/IpynbViewer.tsx
|
||||
inline src/renderer/src/components/editor/MarkdownPreview.tsx
|
||||
inline src/renderer/src/components/editor/MonacoEditor.tsx
|
||||
inline src/renderer/src/components/editor/editor-autosave-controller.ts
|
||||
inline src/renderer/src/components/editor/ipynb-parse.ts
|
||||
inline src/renderer/src/components/editor/useEditorPanelContentState.ts
|
||||
inline src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx
|
||||
inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx
|
||||
inline src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx
|
||||
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx
|
||||
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
|
||||
inline src/renderer/src/components/github-project/ProjectCell.tsx
|
||||
inline src/renderer/src/components/github-project/ProjectPicker.tsx
|
||||
@@ -214,107 +116,58 @@ inline src/renderer/src/components/linear-project-view-surfaces.tsx
|
||||
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
|
||||
inline src/renderer/src/components/onboarding/use-onboarding-flow.ts
|
||||
inline src/renderer/src/components/right-sidebar/ChecksPanel.tsx
|
||||
inline src/renderer/src/components/right-sidebar/FileExplorer.test.tsx
|
||||
inline src/renderer/src/components/right-sidebar/FileExplorer.tsx
|
||||
inline src/renderer/src/components/right-sidebar/FileExplorerRow.tsx
|
||||
inline src/renderer/src/components/right-sidebar/PortsPanel.tsx
|
||||
inline src/renderer/src/components/right-sidebar/SourceControl.tsx
|
||||
inline src/renderer/src/components/right-sidebar/checks-panel-content.tsx
|
||||
inline src/renderer/src/components/right-sidebar/index.tsx
|
||||
inline src/renderer/src/components/right-sidebar/source-control-dropdown-items.ts
|
||||
inline src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts
|
||||
inline src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts
|
||||
inline src/renderer/src/components/settings/AccountsPane.tsx
|
||||
inline src/renderer/src/components/settings/AgentsPane.tsx
|
||||
inline src/renderer/src/components/settings/RepositoryHooksSection.tsx
|
||||
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
|
||||
inline src/renderer/src/components/settings/Settings.tsx
|
||||
inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
|
||||
inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
|
||||
inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
|
||||
inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
|
||||
inline src/renderer/src/components/sidebar/worktree-list-groups.test.ts
|
||||
inline src/renderer/src/components/sidebar/worktree-list-groups.ts
|
||||
inline src/renderer/src/components/stats/usage-overview-model.ts
|
||||
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
|
||||
inline src/renderer/src/components/status-bar/StatusBar.tsx
|
||||
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
|
||||
inline src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts
|
||||
inline src/renderer/src/components/tab-group/useTabDragSplit.ts
|
||||
inline src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
|
||||
inline src/renderer/src/components/terminal-pane/TerminalPane.tsx
|
||||
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts
|
||||
inline src/renderer/src/components/terminal-pane/keyboard-handlers.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-connection.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-connection.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-transport.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/pty-transport.ts
|
||||
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
|
||||
inline src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts
|
||||
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
|
||||
inline src/renderer/src/hooks/useAutomationDispatchEvents.ts
|
||||
inline src/renderer/src/hooks/useComposerState.ts
|
||||
inline src/renderer/src/hooks/useEditorExternalWatch.ts
|
||||
inline src/renderer/src/hooks/useIpcEvents.test.ts
|
||||
inline src/renderer/src/hooks/useIpcEvents.ts
|
||||
inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts
|
||||
inline src/renderer/src/lib/active-agent-note-send.test.ts
|
||||
inline src/renderer/src/lib/file-type-icons.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-manager.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
|
||||
inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
|
||||
inline src/renderer/src/lib/worktree-activation.test.ts
|
||||
inline src/renderer/src/lib/worktree-activation.ts
|
||||
inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
|
||||
inline src/renderer/src/runtime/runtime-file-client.test.ts
|
||||
inline src/renderer/src/runtime/runtime-file-client.ts
|
||||
inline src/renderer/src/runtime/runtime-git-client.ts
|
||||
inline src/renderer/src/runtime/runtime-linear-client.ts
|
||||
inline src/renderer/src/runtime/sync-runtime-graph.test.ts
|
||||
inline src/renderer/src/runtime/sync-runtime-graph.ts
|
||||
inline src/renderer/src/runtime/web-runtime-session.test.ts
|
||||
inline src/renderer/src/runtime/web-runtime-session.ts
|
||||
inline src/renderer/src/runtime/web-session-tabs-sync.test.ts
|
||||
inline src/renderer/src/runtime/web-session-tabs-sync.ts
|
||||
inline src/renderer/src/store/slices/agent-status.test.ts
|
||||
inline src/renderer/src/store/slices/agent-status.ts
|
||||
inline src/renderer/src/store/slices/browser.test.ts
|
||||
inline src/renderer/src/store/slices/browser.ts
|
||||
inline src/renderer/src/store/slices/diffComments.ts
|
||||
inline src/renderer/src/store/slices/editor.test.ts
|
||||
inline src/renderer/src/store/slices/editor.ts
|
||||
inline src/renderer/src/store/slices/github.test.ts
|
||||
inline src/renderer/src/store/slices/github.ts
|
||||
inline src/renderer/src/store/slices/hosted-review.ts
|
||||
inline src/renderer/src/store/slices/jira.ts
|
||||
inline src/renderer/src/store/slices/linear.test.ts
|
||||
inline src/renderer/src/store/slices/linear.ts
|
||||
inline src/renderer/src/store/slices/repos.ts
|
||||
inline src/renderer/src/store/slices/store-cascades.test.ts
|
||||
inline src/renderer/src/store/slices/store-session-cascades.test.ts
|
||||
inline src/renderer/src/store/slices/tabs.test.ts
|
||||
inline src/renderer/src/store/slices/tabs.ts
|
||||
inline src/renderer/src/store/slices/terminals.ts
|
||||
inline src/renderer/src/store/slices/ui.test.ts
|
||||
inline src/renderer/src/store/slices/ui.ts
|
||||
inline src/renderer/src/store/slices/workspace-cleanup.ts
|
||||
inline src/renderer/src/store/slices/worktrees.test.ts
|
||||
inline src/renderer/src/web/web-preload-api.test.ts
|
||||
inline src/renderer/src/web/web-preload-api.ts
|
||||
inline src/renderer/src/web/web-runtime-client.ts
|
||||
inline src/shared/agent-hook-listener.test.ts
|
||||
inline src/shared/agent-hook-listener.ts
|
||||
inline src/shared/automation-schedules.ts
|
||||
inline src/shared/commit-message-agent-spec.ts
|
||||
inline src/shared/constants.ts
|
||||
inline src/shared/github/project-types.ts
|
||||
inline src/shared/keybindings.test.ts
|
||||
inline src/shared/keybindings.ts
|
||||
inline src/shared/marine-creatures.ts
|
||||
inline src/shared/remote-runtime-client.ts
|
||||
@@ -323,7 +176,6 @@ inline src/shared/source-control-ai.ts
|
||||
inline src/shared/telemetry-events.ts
|
||||
inline src/shared/text-search.ts
|
||||
inline tests/e2e/helpers/terminal.ts
|
||||
inline tests/e2e/terminal-panes.spec.ts
|
||||
mobile-config app/h/*/files/*.tsx
|
||||
mobile-config app/h/*/index.tsx
|
||||
mobile-config app/h/*/session/*.tsx
|
||||
|
||||
File diff suppressed because one or more lines are too long
+990
-355
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '../..')
|
||||
const sourcePath = path.join(repoRoot, 'native', 'keyboard-layout-macos', 'main.swift')
|
||||
const defaultOutputPath = path.join(
|
||||
repoRoot,
|
||||
'native',
|
||||
'keyboard-layout-macos',
|
||||
'.build',
|
||||
'release',
|
||||
'orca-keyboard-layout'
|
||||
)
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const outputPath = readArg('--output') ?? defaultOutputPath
|
||||
const singleArch = args.includes('--single-arch')
|
||||
const workDir = mkdtempSync(path.join(tmpdir(), 'orca-keyboard-layout-'))
|
||||
|
||||
try {
|
||||
const triples = singleArch
|
||||
? [process.arch === 'arm64' ? 'arm64-apple-macosx' : 'x86_64-apple-macosx']
|
||||
: ['arm64-apple-macosx', 'x86_64-apple-macosx']
|
||||
const builtBinaries = triples.map((triple) => {
|
||||
const output = path.join(workDir, `orca-keyboard-layout-${triple}`)
|
||||
execFileSync(
|
||||
'swiftc',
|
||||
[
|
||||
'-O',
|
||||
sourcePath,
|
||||
'-target',
|
||||
triple.replace('-apple-macosx', '-apple-macosx11.0'),
|
||||
'-o',
|
||||
output
|
||||
],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
return output
|
||||
})
|
||||
mkdirSync(path.dirname(outputPath), { recursive: true })
|
||||
if (builtBinaries.length === 1) {
|
||||
execFileSync('cp', [builtBinaries[0], outputPath])
|
||||
} else {
|
||||
execFileSync('lipo', ['-create', ...builtBinaries, '-output', outputPath])
|
||||
}
|
||||
chmodSync(outputPath, 0o755)
|
||||
} finally {
|
||||
rmSync(workDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
function readArg(name) {
|
||||
const index = args.indexOf(name)
|
||||
return index === -1 ? undefined : args[index + 1]
|
||||
}
|
||||
@@ -13,6 +13,7 @@ if (process.platform !== 'darwin') {
|
||||
}
|
||||
|
||||
runPnpmScript('build:computer-macos')
|
||||
runPnpmScript('build:keyboard-layout-macos')
|
||||
runPnpmScript('build:notification-status-macos')
|
||||
process.exit(0)
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
|
||||
function source(path) {
|
||||
return readFileSync(join(projectDir, path), 'utf8')
|
||||
}
|
||||
|
||||
function sourceBetween(contents, startMarker, endMarker) {
|
||||
const start = contents.indexOf(startMarker)
|
||||
const end = contents.indexOf(endMarker, start + startMarker.length)
|
||||
if (start === -1 || end === -1) {
|
||||
throw new Error(`Missing source boundary: ${startMarker} → ${endMarker}`)
|
||||
}
|
||||
return contents.slice(start, end)
|
||||
}
|
||||
|
||||
describe('computer-use mouse button routing', () => {
|
||||
it('maps the macOS middle button onto the otherMouse event family', () => {
|
||||
const macOS = source('native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift')
|
||||
const mapping = sourceBetween(
|
||||
macOS,
|
||||
'extension MouseButtonSelection {',
|
||||
'private func mouseButton('
|
||||
)
|
||||
|
||||
expect(mapping).toContain('return .center')
|
||||
expect(mapping).toContain('return .otherMouseDown')
|
||||
expect(mapping).toContain('return .otherMouseUp')
|
||||
// A middle press posted as a left event type would silently left-click.
|
||||
expect(mapping).not.toContain('case .middle:\n return .leftMouseDown')
|
||||
})
|
||||
|
||||
it('validates the macOS mouse button before any accessibility shortcut runs', () => {
|
||||
const macOS = source('native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift')
|
||||
const click = sourceBetween(
|
||||
macOS,
|
||||
'private func click(params:',
|
||||
'private func performClickAction('
|
||||
)
|
||||
|
||||
expect(click).toContain('let button = try mouseButton(params["mouseButton"]?.string)')
|
||||
expect(click).toContain('button.hasAccessibilityAction')
|
||||
// An unvalidated raw string reaches AXPress and reports a left click as success.
|
||||
expect(click).not.toContain('params["mouseButton"]?.string ?? "left"')
|
||||
})
|
||||
|
||||
it('keeps every platform from resolving a middle click through its accessibility path', () => {
|
||||
const windows = source('native/computer-use-windows/runtime.ps1')
|
||||
const windowsClick = sourceBetween(
|
||||
windows,
|
||||
'$handledByPattern = $false',
|
||||
'if (-not $handledByPattern)'
|
||||
)
|
||||
|
||||
expect(windowsClick).toContain('$Operation.mouse_button -ne "middle"')
|
||||
|
||||
const linux = source('native/computer-use-linux/runtime.py')
|
||||
const linuxClick = sourceBetween(linux, 'has_modifiers = bool(', 'if not handled:')
|
||||
|
||||
expect(linuxClick).toContain('operation.get("mouse_button", "left") == "left"')
|
||||
})
|
||||
})
|
||||
@@ -60,7 +60,7 @@ export function nextDailyBuildNumber(baseVersion, releaseNames = []) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `1.4.163 • 01 • Aug 9, 6:15AM • e698241` — the human-facing release title,
|
||||
* `1.4.163 • 01 • Aug 9, 10:15AM • e698241` — the human-facing release title,
|
||||
* shown verbatim in both the GitHub releases list and the in-app build picker.
|
||||
*/
|
||||
export function formatDailyReleaseName(version, buildNumber, commit, date) {
|
||||
|
||||
@@ -45,27 +45,27 @@ describe('formatDailyReleaseName', () => {
|
||||
formatDailyReleaseName('1.4.163-daily.x', buildNumber, commit, new Date(iso))
|
||||
|
||||
it('renders version, number, Pacific timestamp, and short sha', () => {
|
||||
// 13:15 UTC is 6:15AM PDT in July (the daily cut time).
|
||||
expect(name('2026-07-28T13:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 6:15AM • e698241')
|
||||
// 17:15 UTC is 10:15AM PDT in July (the daily cut time).
|
||||
expect(name('2026-07-28T17:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 10:15AM • e698241')
|
||||
})
|
||||
|
||||
// Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so
|
||||
// the offset between them is not a constant. A test pinned to one season would
|
||||
// pass all summer and start failing in November.
|
||||
it('follows the Pacific offset across DST', () => {
|
||||
expect(name('2026-01-15T14:15:00Z')).toBe('1.4.163 • 01 • Jan 15, 6:15AM • e698241')
|
||||
expect(name('2026-07-28T13:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 6:15AM • e698241')
|
||||
expect(name('2026-01-15T18:15:00Z')).toBe('1.4.163 • 01 • Jan 15, 10:15AM • e698241')
|
||||
expect(name('2026-07-28T17:15:00Z')).toBe('1.4.163 • 01 • Jul 28, 10:15AM • e698241')
|
||||
})
|
||||
|
||||
it('pads to two digits and grows past them', () => {
|
||||
expect(name('2026-07-28T13:15:00Z', 9)).toContain(' • 09 • ')
|
||||
expect(name('2026-07-28T13:15:00Z', 42)).toContain(' • 42 • ')
|
||||
expect(name('2026-07-28T17:15:00Z', 9)).toContain(' • 09 • ')
|
||||
expect(name('2026-07-28T17:15:00Z', 42)).toContain(' • 42 • ')
|
||||
})
|
||||
|
||||
it('rejects a build number that is not a positive integer', () => {
|
||||
expect(() => name('2026-07-28T13:15:00Z', 0)).toThrow(/positive integer/)
|
||||
expect(() => name('2026-07-28T13:15:00Z', -1)).toThrow(/positive integer/)
|
||||
expect(() => name('2026-07-28T13:15:00Z', 1.5)).toThrow(/positive integer/)
|
||||
expect(() => name('2026-07-28T17:15:00Z', 0)).toThrow(/positive integer/)
|
||||
expect(() => name('2026-07-28T17:15:00Z', -1)).toThrow(/positive integer/)
|
||||
expect(() => name('2026-07-28T17:15:00Z', 1.5)).toThrow(/positive integer/)
|
||||
})
|
||||
|
||||
it('rejects an invalid timestamp', () => {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const dailyWorkflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/daily-mac-build.yml'), 'utf8')
|
||||
)
|
||||
const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
|
||||
|
||||
describe('daily E2E dispatch contract', () => {
|
||||
it('dispatches SHA-scoped full E2E only after a live daily publish', () => {
|
||||
const buildJob = dailyWorkflow.jobs['build-daily-mac']
|
||||
const dispatchJob = dailyWorkflow.jobs['post-daily-e2e']
|
||||
const dispatchStep = dispatchJob.steps.find((step) => step.name === 'Dispatch cut-scoped E2E')
|
||||
|
||||
expect(dailyWorkflow.jobs.e2e).toBeUndefined()
|
||||
expect(buildJob.outputs.head_sha).toBe('${{ steps.freshness.outputs.head_sha }}')
|
||||
expect(buildJob.outputs.published).toBe(
|
||||
"${{ steps.publish_live.outcome == 'success' && 'true' || 'false' }}"
|
||||
)
|
||||
expect(dispatchJob.needs).toBe('build-daily-mac')
|
||||
expect(dispatchJob.if).toBe(
|
||||
"${{ needs.build-daily-mac.outputs.published == 'true' && needs.build-daily-mac.outputs.head_sha != '' }}"
|
||||
)
|
||||
expect(dispatchJob.permissions.actions).toBe('write')
|
||||
expect(dispatchStep.env.SHA).toBe('${{ needs.build-daily-mac.outputs.head_sha }}')
|
||||
expect(dispatchStep.run).toContain('gh workflow run e2e.yml')
|
||||
expect(dispatchStep.run).toContain('--ref main')
|
||||
expect(dispatchStep.run).toContain('--raw-field "ref=$SHA"')
|
||||
expect(dispatchStep.run).toContain('for attempt in 1 2 3')
|
||||
expect(dispatchStep.run).toContain('[[ "$attempt" -eq 3 ]] || sleep')
|
||||
expect(dispatchStep.run).toContain('::warning::Failed to dispatch post-daily E2E')
|
||||
})
|
||||
|
||||
it('leaves test_files unset so the dispatched run takes the full suite', () => {
|
||||
const dispatchJob = dailyWorkflow.jobs['post-daily-e2e']
|
||||
const dispatchStep = dispatchJob.steps.find((step) => step.name === 'Dispatch cut-scoped E2E')
|
||||
|
||||
expect(dispatchStep.run).not.toContain('test_files')
|
||||
expect(e2eWorkflow.on.workflow_call.inputs.test_files.required).toBe(false)
|
||||
expect(e2eWorkflow.jobs.e2e.if).toBe("inputs.test_files == ''")
|
||||
})
|
||||
})
|
||||
@@ -155,6 +155,20 @@ describe('electron-builder config', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('ships the mac keyboard-layout helper in Contents/MacOS, not Resources', () => {
|
||||
expect(electronBuilderConfig.mac.extraFiles).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
from: 'native/keyboard-layout-macos/.build/release/orca-keyboard-layout',
|
||||
to: 'MacOS/orca-keyboard-layout'
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(electronBuilderConfig.mac.extraResources).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ to: 'orca-keyboard-layout' })])
|
||||
)
|
||||
})
|
||||
|
||||
it('unpacks the compiled CommonJS boundary with CLI runtime files', () => {
|
||||
expect(electronBuilderConfig.asarUnpack).toEqual(
|
||||
expect.arrayContaining([
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
const { join } = require('node:path')
|
||||
|
||||
const resourcesDirectory = process.argv[2]
|
||||
if (!resourcesDirectory) {
|
||||
throw new Error('linux-packaged-node-pty-floor-resources-required')
|
||||
}
|
||||
|
||||
const pty = require(join(resourcesDirectory, 'node_modules', 'node-pty'))
|
||||
const expected = 'orca-node-pty-floor-ok'
|
||||
let output = ''
|
||||
let settled = false
|
||||
const child = pty.spawn('/bin/sh', ['-c', `printf ${expected}; sleep 0.1`], {
|
||||
name: 'xterm-color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/tmp',
|
||||
env: { PATH: process.env.PATH ?? '/usr/bin:/bin' }
|
||||
})
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
child.kill()
|
||||
process.stderr.write('packaged node-pty floor smoke timed out\n')
|
||||
process.exitCode = 1
|
||||
}, 5_000)
|
||||
|
||||
child.onData((data) => {
|
||||
output = `${output}${data}`.slice(-256)
|
||||
})
|
||||
child.onExit(({ exitCode }) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timeout)
|
||||
if (exitCode !== 0 || output !== expected) {
|
||||
process.stderr.write(
|
||||
`packaged node-pty floor smoke failed: exit=${exitCode} bytes=${Buffer.byteLength(output)}\n`
|
||||
)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
process.stdout.write(`${expected}\n`)
|
||||
})
|
||||
@@ -90,17 +90,21 @@ describe('Electron runtime package contract', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps Windows and Linux package builds off the macOS native helper build', () => {
|
||||
it('keeps Windows and Linux package builds off macOS native helper builds', () => {
|
||||
const scripts = packageJson.scripts
|
||||
|
||||
expect(scripts['build:desktop']).not.toContain('build:computer-macos')
|
||||
expect(scripts['build:desktop']).not.toContain('build:keyboard-layout-macos')
|
||||
expect(scripts['build:win']).toContain('pnpm run build:desktop')
|
||||
expect(scripts['build:win']).not.toContain('pnpm run build ')
|
||||
expect(scripts['build:win']).not.toContain('build:computer-macos')
|
||||
expect(scripts['build:win']).not.toContain('build:keyboard-layout-macos')
|
||||
expect(scripts['build:linux']).toContain('pnpm run build:desktop')
|
||||
expect(scripts['build:linux']).not.toContain('pnpm run build ')
|
||||
expect(scripts['build:linux']).not.toContain('build:computer-macos')
|
||||
expect(scripts['build:linux']).not.toContain('build:keyboard-layout-macos')
|
||||
expect(scripts['build:mac']).toContain('pnpm run build:computer-macos')
|
||||
expect(scripts['build:mac']).toContain('pnpm run build:keyboard-layout-macos')
|
||||
expect(scripts['build:release']).toContain('pnpm run build:native')
|
||||
expect(scripts['build:release']).not.toContain('build:computer-macos')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { listPullFiles, nextLink } from '../../.github/scripts/pr-test-loc-summary.mjs'
|
||||
import {
|
||||
LOC_HANDS_OFF_COMMENT,
|
||||
isTestPath,
|
||||
mergeLocBlock,
|
||||
renderLocBlock,
|
||||
sumChangedFiles
|
||||
} from '../../.github/scripts/pr-test-loc-table.mjs'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const locScript = join(projectDir, '.github/scripts/pr-test-loc-summary.mjs')
|
||||
const tempDirs = []
|
||||
|
||||
function runLoc(args, { env } = {}) {
|
||||
return spawnSync(process.execPath, [locScript, ...args], {
|
||||
cwd: projectDir,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...env }
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
rmSync(tempDirs.pop(), { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('PR test LoC summary', () => {
|
||||
it('classifies colocated tests and e2e/tests directories', () => {
|
||||
expect(isTestPath('src/main/foo.test.ts')).toBe(true)
|
||||
expect(isTestPath('src/main/foo.spec.tsx')).toBe(true)
|
||||
expect(isTestPath('src/e2e/login.ts')).toBe(true)
|
||||
expect(isTestPath('tests/tools/probe.mjs')).toBe(true)
|
||||
expect(isTestPath('src/main/foo-test-setup.ts')).toBe(false)
|
||||
expect(isTestPath('src/main/foo.ts')).toBe(false)
|
||||
})
|
||||
|
||||
it('sums GitHub pull-file additions and deletions', () => {
|
||||
const totals = sumChangedFiles([
|
||||
{ filename: 'src/app.ts', additions: 4, deletions: 1 },
|
||||
{ filename: 'src/app.test.ts', additions: 12, deletions: 3 },
|
||||
{ filename: 'icon.png', additions: 0, deletions: 0 }
|
||||
])
|
||||
|
||||
expect(totals).toEqual({
|
||||
test: { files: 1, added: 12, deleted: 3 },
|
||||
nonTest: { files: 2, added: 4, deleted: 1 }
|
||||
})
|
||||
})
|
||||
|
||||
it('reads the next page from a GitHub Link header', () => {
|
||||
expect(
|
||||
nextLink(
|
||||
'<https://api.github.com/repos/stablyai/orca/pulls/1/files?page=2>; rel="next", <https://api.github.com/repos/stablyai/orca/pulls/1/files?page=3>; rel="last"'
|
||||
)
|
||||
).toBe('https://api.github.com/repos/stablyai/orca/pulls/1/files?page=2')
|
||||
expect(
|
||||
nextLink('<https://api.github.com/repos/stablyai/orca/pulls/1/files?page=1>; rel="prev"')
|
||||
).toBe(undefined)
|
||||
})
|
||||
|
||||
it('paginates pull files until Link rel=next is gone', async () => {
|
||||
const pages = {
|
||||
'https://api.github.com/repos/stablyai/orca/pulls/9/files?per_page=100': {
|
||||
body: [{ filename: 'src/app.ts', additions: 2, deletions: 0 }],
|
||||
link: '<https://api.github.com/repos/stablyai/orca/pulls/9/files?page=2>; rel="next"'
|
||||
},
|
||||
'https://api.github.com/repos/stablyai/orca/pulls/9/files?page=2': {
|
||||
body: [{ filename: 'src/app.test.ts', additions: 5, deletions: 1 }],
|
||||
link: null
|
||||
}
|
||||
}
|
||||
|
||||
const files = await listPullFiles({
|
||||
owner: 'stablyai',
|
||||
repo: 'orca',
|
||||
pullNumber: 9,
|
||||
token: 'test-token',
|
||||
fetchImpl: async (url) => {
|
||||
const page = pages[url]
|
||||
if (page == null) {
|
||||
throw new Error(`unexpected url ${String(url)}`)
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => page.body,
|
||||
headers: { get: (name) => (name === 'link' ? page.link : null) }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(sumChangedFiles(files)).toEqual({
|
||||
test: { files: 1, added: 5, deleted: 1 },
|
||||
nonTest: { files: 1, added: 2, deleted: 0 }
|
||||
})
|
||||
})
|
||||
|
||||
it('colors added cells green and deleted or negative-net cells red', () => {
|
||||
const block = renderLocBlock({
|
||||
test: { files: 1, added: 2, deleted: 5 },
|
||||
nonTest: { files: 1, added: 0, deleted: 4 }
|
||||
})
|
||||
|
||||
expect(block).toContain(
|
||||
'| Test | 1 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b2 | $\\color{#cf222e}{\\Huge{\\mathbf{−}}}$\u200b5 | $\\color{#cf222e}{\\Huge{\\mathbf{−}}}$\u200b3 |'
|
||||
)
|
||||
expect(block).toContain(
|
||||
'| Prod | 1 | 0 | $\\color{#cf222e}{\\Huge{\\mathbf{−}}}$\u200b4 | $\\color{#cf222e}{\\Huge{\\mathbf{−}}}$\u200b4 |'
|
||||
)
|
||||
})
|
||||
|
||||
it('replaces an existing header and prepends when missing', () => {
|
||||
const totals = {
|
||||
test: { files: 1, added: 2, deleted: 1 },
|
||||
nonTest: { files: 1, added: 4, deleted: 0 }
|
||||
}
|
||||
const block = renderLocBlock(totals)
|
||||
|
||||
expect(block).toContain(LOC_HANDS_OFF_COMMENT)
|
||||
expect(block).toContain(
|
||||
'| Test | 1 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b2 | $\\color{#cf222e}{\\Huge{\\mathbf{−}}}$\u200b1 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b1 |'
|
||||
)
|
||||
expect(block).toContain(
|
||||
'| Prod | 1 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b4 | 0 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b4 |'
|
||||
)
|
||||
expect(block).not.toContain('| Total |')
|
||||
expect(mergeLocBlock('## ELI5\n\nHello\n', totals)).toBe(`${block}\n\n## ELI5\n\nHello\n`)
|
||||
expect(mergeLocBlock(`${block}\n\n## ELI5\n`, totals)).toBe(`${block}\n\n## ELI5\n`)
|
||||
expect(
|
||||
mergeLocBlock(
|
||||
'<!-- orca-pr-loc -->\n**LoC** · test **+1 / −0**\n<!-- /orca-pr-loc -->\n\n## ELI5\n',
|
||||
totals
|
||||
)
|
||||
).toBe(`${block}\n\n## ELI5\n`)
|
||||
})
|
||||
|
||||
it('counts and merges from a files JSON fixture', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-pr-test-loc-'))
|
||||
tempDirs.push(root)
|
||||
const filesPath = join(root, 'files.json')
|
||||
const bodyPath = join(root, 'body.md')
|
||||
writeFileSync(
|
||||
filesPath,
|
||||
JSON.stringify([
|
||||
{ filename: 'src/app.ts', additions: 2, deletions: 0 },
|
||||
{ filename: 'src/app.test.ts', additions: 6, deletions: 1 }
|
||||
])
|
||||
)
|
||||
writeFileSync(bodyPath, '## ELI5\n\nHello\n')
|
||||
|
||||
const result = runLoc(['--files-json', filesPath, '--merge-body', bodyPath])
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout.startsWith('<!-- orca-pr-loc -->')).toBe(true)
|
||||
expect(result.stdout).toContain(LOC_HANDS_OFF_COMMENT)
|
||||
expect(result.stdout).toContain(
|
||||
'| Test | 1 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b6 | $\\color{#cf222e}{\\Huge{\\mathbf{−}}}$\u200b1 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b5 |'
|
||||
)
|
||||
expect(result.stdout).toContain(
|
||||
'| Prod | 1 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b2 | 0 | $\\color{#1a7f37}{\\Huge{\\mathbf{+}}}$\u200b2 |'
|
||||
)
|
||||
expect(result.stdout).not.toContain('| Total |')
|
||||
expect(result.stdout).toContain('## ELI5\n\nHello\n')
|
||||
expect(result.stdout.match(/<!-- orca-pr-loc -->/g)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('exits 2 with usage when no PR or files JSON is supplied', () => {
|
||||
const result = runLoc([])
|
||||
|
||||
expect(result.status).toBe(2)
|
||||
expect(result.stderr).toContain('--from-pr')
|
||||
})
|
||||
|
||||
it('is a no-checkout GitHub-hosted PR workflow', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/pr-test-loc.yml'), 'utf8')
|
||||
)
|
||||
const locJob = workflow.jobs.loc
|
||||
const serialized = JSON.stringify(workflow)
|
||||
|
||||
expect(locJob['runs-on']).toBe('ubuntu-latest')
|
||||
expect(locJob.steps).toHaveLength(1)
|
||||
expect(locJob.steps[0].run).toContain('gh api')
|
||||
expect(locJob.steps[0].run).toContain('pr-test-loc-table.mjs')
|
||||
expect(locJob.steps[0].run).toContain('pr-test-loc-summary.mjs')
|
||||
expect(locJob.steps[0].run).toContain('--update-pr')
|
||||
expect(workflow.permissions['pull-requests']).toBe('write')
|
||||
expect(serialized).not.toContain('actions/checkout')
|
||||
expect(serialized).not.toContain('self-hosted')
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,10 @@ const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
|
||||
const shellContractFiles = [
|
||||
'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts',
|
||||
'src/main/daemon/shell-ready.test.ts',
|
||||
'src/main/providers/local-pty-shell-ready.test.ts',
|
||||
'src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts',
|
||||
'src/main/providers/local-pty-shell-ready-zsh-startup-file-behavior.test.ts',
|
||||
'src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts',
|
||||
'src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts',
|
||||
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
|
||||
'src/shared/posix-command-path-lookup.test.ts'
|
||||
]
|
||||
|
||||
@@ -128,8 +128,9 @@ function prepareMacDevElectronApp() {
|
||||
|
||||
const title = process.env.ORCA_DEV_DOCK_TITLE || 'Orca: dev'
|
||||
const identityKey = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot
|
||||
// v7: give the terminal daemon helper an Orca-specific TCC identity.
|
||||
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v7'
|
||||
// v10: add the keyboard-layout helper. A stale copy only emits extra fields
|
||||
// the parser ignores, so narrowing its schema needs no bump.
|
||||
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v10'
|
||||
const hash = createHash('sha1')
|
||||
.update(
|
||||
`${sourceAppPath}\0${electronVersion ?? ''}\0${title}\0${identityKey}\0${bundleLayoutVersion}`
|
||||
@@ -169,7 +170,8 @@ function prepareMacDevElectronApp() {
|
||||
'Electron Framework.framework',
|
||||
'Resources',
|
||||
'icudtl.dat'
|
||||
)
|
||||
),
|
||||
path.join(appPath, 'Contents', 'MacOS', 'orca-keyboard-layout')
|
||||
]
|
||||
|
||||
function copiedAppIsUsable() {
|
||||
@@ -242,6 +244,23 @@ function prepareMacDevElectronApp() {
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(repoRoot, 'config', 'scripts', 'build-keyboard-layout-macos.mjs'),
|
||||
'--single-arch',
|
||||
'--output',
|
||||
path.join(appPath, 'Contents', 'MacOS', 'orca-keyboard-layout')
|
||||
],
|
||||
{ stdio: 'inherit' }
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[orca-dev] keyboard-layout helper build failed (shifted Option composition stays conservative): ${error?.message ?? error}`
|
||||
)
|
||||
}
|
||||
|
||||
// Why: the plist edits above (and the copy itself) break the bundle's
|
||||
// ad-hoc seal, and macOS refuses Notification Center registration for
|
||||
// invalidly-signed apps — every dev notification fails with UNErrorDomain
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const FLOOR_IMAGE = 'ubuntu:20.04'
|
||||
const LINUX_EXECUTABLE = 'orca-ide'
|
||||
const FLOOR_PACKAGES = [
|
||||
'ca-certificates',
|
||||
'libasound2',
|
||||
'libatspi2.0-0',
|
||||
'libdrm2',
|
||||
'libgbm1',
|
||||
'libgtk-3-0',
|
||||
'libnss3',
|
||||
'libx11-xcb1',
|
||||
'libxkbcommon0',
|
||||
'libxss1'
|
||||
]
|
||||
|
||||
function workspaceRelativePath(workspaceDirectory, inputPath) {
|
||||
const absolutePath = resolve(workspaceDirectory, inputPath)
|
||||
const relativePath = relative(workspaceDirectory, absolutePath)
|
||||
if (!relativePath || relativePath === '..' || relativePath.startsWith(`..${sep}`)) {
|
||||
throw new Error('linux-packaged-node-pty-floor-app-directory-invalid')
|
||||
}
|
||||
return relativePath.split(sep).join('/')
|
||||
}
|
||||
|
||||
export function packagedNodePtyFloorDockerArgs({ workspaceDirectory, appDirectory }) {
|
||||
const relativeAppDirectory = workspaceRelativePath(workspaceDirectory, appDirectory)
|
||||
const containerAppDirectory = `/workspace/${relativeAppDirectory}`
|
||||
const command = [
|
||||
'export DEBIAN_FRONTEND=noninteractive',
|
||||
'apt-get update -qq',
|
||||
`apt-get install -y -qq ${FLOOR_PACKAGES.join(' ')} >/dev/null`,
|
||||
`ELECTRON_RUN_AS_NODE=1 ${containerAppDirectory}/${LINUX_EXECUTABLE} ` +
|
||||
'/workspace/config/scripts/linux-packaged-node-pty-floor-child.cjs ' +
|
||||
`${containerAppDirectory}/resources`
|
||||
].join(' && ')
|
||||
return [
|
||||
'run',
|
||||
'--rm',
|
||||
'--mount',
|
||||
`type=bind,src=${workspaceDirectory},dst=/workspace,readonly`,
|
||||
'--workdir',
|
||||
'/workspace',
|
||||
FLOOR_IMAGE,
|
||||
'/bin/bash',
|
||||
'-lc',
|
||||
command
|
||||
]
|
||||
}
|
||||
|
||||
function parseAppDirectory(argv) {
|
||||
const index = argv.indexOf('--app-dir')
|
||||
const value = index !== -1 ? argv[index + 1] : undefined
|
||||
if (!value || isAbsolute(value) || value.startsWith('-')) {
|
||||
throw new Error('Usage: run-linux-packaged-node-pty-floor-smoke.mjs --app-dir <relative-path>')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function runPackagedNodePtyFloorSmoke({
|
||||
workspaceDirectory = process.cwd(),
|
||||
appDirectory,
|
||||
spawn = spawnSync
|
||||
}) {
|
||||
if (process.platform !== 'linux') {
|
||||
throw new Error('linux-packaged-node-pty-floor-smoke-requires-linux')
|
||||
}
|
||||
const absoluteAppDirectory = resolve(workspaceDirectory, appDirectory)
|
||||
if (!existsSync(resolve(absoluteAppDirectory, LINUX_EXECUTABLE))) {
|
||||
throw new Error(`linux-packaged-node-pty-floor-executable-missing: ${absoluteAppDirectory}`)
|
||||
}
|
||||
const result = spawn(
|
||||
'docker',
|
||||
packagedNodePtyFloorDockerArgs({ workspaceDirectory, appDirectory }),
|
||||
{ encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }
|
||||
)
|
||||
if (result.error || result.signal || result.status !== 0) {
|
||||
const detail =
|
||||
result.error?.message ||
|
||||
result.stderr?.trim() ||
|
||||
`status-${result.status}-signal-${result.signal ?? 'none'}`
|
||||
throw new Error(`linux-packaged-node-pty-floor-smoke-failed: ${detail}`)
|
||||
}
|
||||
process.stdout.write(result.stdout)
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url
|
||||
if (isMain) {
|
||||
runPackagedNodePtyFloorSmoke({ appDirectory: parseAppDirectory(process.argv.slice(2)) })
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { packagedNodePtyFloorDockerArgs } from './run-linux-packaged-node-pty-floor-smoke.mjs'
|
||||
|
||||
describe('packaged node-pty Linux floor smoke', () => {
|
||||
it('mounts the exact package read-only in an Ubuntu 20.04 container', () => {
|
||||
const args = packagedNodePtyFloorDockerArgs({
|
||||
workspaceDirectory: '/repo',
|
||||
appDirectory: 'dist/linux-arm64-unpacked'
|
||||
})
|
||||
|
||||
expect(args).toContain('type=bind,src=/repo,dst=/workspace,readonly')
|
||||
expect(args).toContain('ubuntu:20.04')
|
||||
expect(args.at(-1)).toContain('/workspace/dist/linux-arm64-unpacked/orca-ide')
|
||||
expect(args.at(-1)).toContain('ELECTRON_RUN_AS_NODE=1')
|
||||
})
|
||||
|
||||
it('rejects an app directory outside the workspace', () => {
|
||||
expect(() =>
|
||||
packagedNodePtyFloorDockerArgs({
|
||||
workspaceDirectory: '/repo',
|
||||
appDirectory: '../outside'
|
||||
})
|
||||
).toThrow('linux-packaged-node-pty-floor-app-directory-invalid')
|
||||
})
|
||||
|
||||
it('loads node-pty from packaged runtime resources', () => {
|
||||
const childSource = readFileSync(
|
||||
new URL('./linux-packaged-node-pty-floor-child.cjs', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(childSource).toContain("join(resourcesDirectory, 'node_modules', 'node-pty')")
|
||||
expect(childSource).not.toContain("join(resourcesDirectory, 'app.asar'")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const workflow = parse(readFileSync('.github/workflows/release-cut.yml', 'utf8'))
|
||||
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
|
||||
|
||||
function stepNamed(job, name) {
|
||||
return job.steps.find((step) => step.name === name)
|
||||
}
|
||||
|
||||
describe('skill-sharing release workflow', () => {
|
||||
it('blocks publication on native Windows, macOS, and the Linux floor', () => {
|
||||
const platform = workflow.jobs['skill-sharing-release-gate']
|
||||
const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
|
||||
const publishNeeds = workflow.jobs['publish-release'].needs
|
||||
|
||||
expect(platform.strategy.matrix.include).toEqual([
|
||||
{ os: 'macos-15', platform: 'mac' },
|
||||
{ os: 'windows-2022', platform: 'windows' }
|
||||
])
|
||||
expect(linux.container).toBe('ubuntu:20.04')
|
||||
expect(publishNeeds).toContain('skill-sharing-release-gate')
|
||||
expect(publishNeeds).toContain('skill-sharing-linux-floor-release-gate')
|
||||
})
|
||||
|
||||
it('runs the focused contract and transaction suite with real Windows coverage', () => {
|
||||
const platform = workflow.jobs['skill-sharing-release-gate']
|
||||
const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
|
||||
const platformTest = stepNamed(
|
||||
platform,
|
||||
'Run skill package, transaction, and compatibility suites'
|
||||
)
|
||||
const linuxTest = stepNamed(linux, 'Run skill package, transaction, and compatibility suites')
|
||||
const command = packageJson.scripts['test:skill-sharing:release']
|
||||
|
||||
expect(platformTest.env.ORCA_REAL_WINDOWS_SKILL_TEST).toContain("runner.os == 'Windows'")
|
||||
expect(platformTest.env.ORCA_REAL_PROCESS_SKILL_TEST).toBe('1')
|
||||
expect(linuxTest.env.ORCA_REAL_PROCESS_SKILL_TEST).toBe('1')
|
||||
expect(platformTest.run).toContain('pnpm test:skill-sharing:release')
|
||||
expect(linuxTest.run).toContain('pnpm test:skill-sharing:release')
|
||||
expect(command).toContain('src/main/skills')
|
||||
expect(command).toContain('src/relay/skill-install-handler.test.ts')
|
||||
expect(command).toContain('src/shared/skill-bundle-install-contract.test.ts')
|
||||
})
|
||||
|
||||
it('archives bounded machine-readable evidence from every platform', () => {
|
||||
for (const jobName of [
|
||||
'skill-sharing-release-gate',
|
||||
'skill-sharing-linux-floor-release-gate'
|
||||
]) {
|
||||
const job = workflow.jobs[jobName]
|
||||
const test = stepNamed(job, 'Run skill package, transaction, and compatibility suites')
|
||||
const archive = stepNamed(job, 'Archive bounded skill-sharing results')
|
||||
|
||||
expect(test.run).toContain('--reporter=json')
|
||||
expect(test.run).toContain('--outputFile=skill-sharing-release-results.json')
|
||||
expect(archive.if).toBe('always()')
|
||||
expect(archive.with['retention-days']).toBe(14)
|
||||
expect(archive.with['if-no-files-found']).toBe('error')
|
||||
}
|
||||
})
|
||||
|
||||
it('loads each exact Linux package on the glibc 2.31 floor', () => {
|
||||
const build = workflow.jobs.build
|
||||
const smoke = stepNamed(build, 'Load packaged node-pty on the Linux floor')
|
||||
const linuxEntries = build.strategy.matrix.include.filter(({ platform }) =>
|
||||
platform.startsWith('linux-')
|
||||
)
|
||||
|
||||
expect(linuxEntries).toEqual([
|
||||
expect.objectContaining({ platform: 'linux-x64', unpacked_dir: 'dist/linux-unpacked' }),
|
||||
expect.objectContaining({
|
||||
platform: 'linux-arm64',
|
||||
unpacked_dir: 'dist/linux-arm64-unpacked'
|
||||
})
|
||||
])
|
||||
expect(smoke.if).toContain("matrix.platform == 'linux-x64'")
|
||||
expect(smoke.with.command).toContain('run-linux-packaged-node-pty-floor-smoke.mjs')
|
||||
expect(smoke.with.command).toContain('${{ matrix.unpacked_dir }}')
|
||||
})
|
||||
})
|
||||
@@ -14,8 +14,13 @@
|
||||
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
|
||||
"../src/main/agent-hooks/managed-hook-script-refresh.ts",
|
||||
"../src/main/agent-hooks/runtime-home-hook-command.ts",
|
||||
"../src/main/amp/agent-status-plugin-source.ts",
|
||||
"../src/main/amp/hook-service.ts",
|
||||
"../src/main/amp/managed-plugin-install-status.ts",
|
||||
"../src/main/antigravity/hook-events.ts",
|
||||
"../src/main/antigravity/hook-script.ts",
|
||||
"../src/main/antigravity/hook-service.ts",
|
||||
"../src/main/antigravity/hooks-json-bundle.ts",
|
||||
"../src/main/claude/hook-settings.ts",
|
||||
"../src/main/claude/hook-service.ts",
|
||||
"../src/main/claude/statusline-script.ts",
|
||||
@@ -62,6 +67,9 @@
|
||||
"../src/main/codex-cli/command.ts",
|
||||
"../src/main/command-code/command-code-managed-script.ts",
|
||||
"../src/main/command-code/hook-service.ts",
|
||||
"../src/main/copilot/copilot-managed-hook-definitions.ts",
|
||||
"../src/main/copilot/copilot-managed-script.ts",
|
||||
"../src/main/copilot/copilot-remote-hook-install.ts",
|
||||
"../src/main/copilot/hook-service.ts",
|
||||
"../src/main/cursor/hook-service.ts",
|
||||
"../src/main/droid/hook-service.ts",
|
||||
@@ -71,6 +79,9 @@
|
||||
"../src/main/devin/hook-settings.ts",
|
||||
"../src/main/devin/hook-service.ts",
|
||||
"../src/main/devin/hook-config-json.ts",
|
||||
"../src/main/hermes/hermes-config-yaml.ts",
|
||||
"../src/main/hermes/hermes-home-filesystem.ts",
|
||||
"../src/main/hermes/hermes-managed-plugin-source.ts",
|
||||
"../src/main/hermes/hook-service.ts",
|
||||
"../src/main/kimi/hook-service.ts",
|
||||
"../src/main/kimi/kimi-hook-config-toml.ts",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 20m">
|
||||
<title>downloads: 20m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 21m">
|
||||
<title>downloads: 21m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">20m</text>
|
||||
<text x="90" y="14">20m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">21m</text>
|
||||
<text x="90" y="14">21m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
Binary file not shown.
|
Before Width: | Height: | Size: 371 KiB After Width: | Height: | Size: 388 KiB |
@@ -0,0 +1,97 @@
|
||||
# Administer agent skill sharing
|
||||
|
||||
This guide describes the first-release access, lifecycle, retention, and recovery contract for
|
||||
Orca skill sharing. The operator runbook remains the source of truth for incident commands and
|
||||
environment-specific procedures.
|
||||
|
||||
## Access model
|
||||
|
||||
- Shared bundles are unlisted bearer resources. Orca provides no public browse, search, recipient
|
||||
inventory, or package index.
|
||||
- Anyone with an active, unexpired link can inspect the package and request a short-lived download
|
||||
grant without signing in.
|
||||
- Publishing, package/version management, owned-link inventory, revocation, and deletion require
|
||||
an authenticated package owner with current organization access.
|
||||
- Missing, expired, revoked, deleted, and unauthorized resources return the same non-disclosing
|
||||
response.
|
||||
- Desktop and remote runtimes receive no GCP identity or long-lived storage credential.
|
||||
|
||||
The durable share ID is a credential. Do not put it in tickets, logs, analytics, or support
|
||||
bundles. Use revocation if a link may have reached an unintended recipient.
|
||||
|
||||
## Revocation and deletion
|
||||
|
||||
Revoking a share immediately blocks new resolution and download grants. A generation-bound grant
|
||||
issued before revocation can work until its five-minute expiry. Already installed skills remain on
|
||||
recipient machines.
|
||||
|
||||
Package deletion follows this order:
|
||||
|
||||
1. Mark the package deleted and revoke its active shares.
|
||||
2. Dereference retained versions transactionally.
|
||||
3. Delete only an object generation that no retained version references.
|
||||
4. Reconcile bounded pending deletions after partial database or GCS failures.
|
||||
|
||||
A version cannot be deleted while an active pinned share references it. Deletion uses the exact
|
||||
recorded GCS generation and never overwrites an immutable published key.
|
||||
|
||||
## User and organization departure
|
||||
|
||||
Packages belong to an owner tenant and record the publishing user. In an organization tenant,
|
||||
another current member can manage the package after its publisher leaves; Orca does not rewrite
|
||||
the recorded creator. Removing a user does not automatically revoke the organization's links,
|
||||
delete packages, or remove installed copies.
|
||||
|
||||
Before deleting an organization tenant:
|
||||
|
||||
1. Disable new grants for the tenant.
|
||||
2. Have an authorized operator inventory and revoke active shares.
|
||||
3. Decide whether packages transfer to another authorized owner, remain retained, or are deleted.
|
||||
4. Resolve legal hold, erasure, and audit-retention requirements.
|
||||
5. Apply the coordinated metadata and object lifecycle; do not bypass reference checks.
|
||||
|
||||
The product does not yet encode a universal ownership-transfer or legal-retention policy. Privacy,
|
||||
security, and the organization owner must approve the applicable policy before external rollout.
|
||||
Until that decision is recorded, preserve metadata and soft-deleted generations rather than
|
||||
guessing.
|
||||
|
||||
## Retention contract
|
||||
|
||||
| Data | Default behavior |
|
||||
| -------------------------------------- | --------------------------------------------------------- |
|
||||
| Upload policy and pending upload row | Expires after 15 minutes |
|
||||
| Abandoned `uploads/` quarantine object | Deleted by GCS after one day |
|
||||
| Published immutable package object | No age-based deletion; retained while referenced |
|
||||
| Issued download grant | Expires after five minutes |
|
||||
| Deleted package object | Recoverable through GCS soft delete for seven days |
|
||||
| PostgreSQL metadata | Covered by backups and seven-day point-in-time recovery |
|
||||
| Installed recipient copy | Independent local data; Cloud deletion does not remove it |
|
||||
| Audit event | Follows the approved audit-retention policy |
|
||||
|
||||
Organization retention, legal hold, and erasure requirements take precedence over product rollback
|
||||
retention. Product deletion is not a legal-hold mechanism.
|
||||
|
||||
## Audit and privacy
|
||||
|
||||
Audit records may include package/version IDs, actor IDs, event category, outcome, and timestamp.
|
||||
They must not include skill contents, filenames, manifests, organization membership lists, local
|
||||
paths, durable share URLs, upload policies, download grants, or credentials. Anonymous abuse
|
||||
controls must not persist raw requester IP addresses.
|
||||
|
||||
Normal Cloud logs are limited to route, method, status, duration, and bounded failure categories.
|
||||
Use seeded privacy canaries when validating staging logs and diagnostic exports.
|
||||
|
||||
## Recovery and incident controls
|
||||
|
||||
Upload, download, and remote-install operations have independent kill switches. Disable the
|
||||
narrowest affected operation; existing local discovery and installs continue to work.
|
||||
|
||||
Coordinate PostgreSQL point-in-time recovery with GCS generation recovery. Restore metadata into
|
||||
an isolated database, identify exact referenced generations, restore only matching soft-deleted
|
||||
objects, verify archive and package identities, then transactionally repoint metadata. Keep grants
|
||||
disabled until bearer preview and a generation-bound download pass.
|
||||
|
||||
See the Orca Cloud `docs/skill-sharing-runbook.md` for deployment controls, reconciliation,
|
||||
saturation, signing failures, database outages, and the guarded restore workflow. Security
|
||||
invariants and unresolved release gates are recorded in
|
||||
[Agent skill sharing threat model](./agent-skill-sharing-threat-model.md).
|
||||
@@ -0,0 +1,38 @@
|
||||
# Agent skill provider paths
|
||||
|
||||
Last verified: 2026-08-11.
|
||||
|
||||
V1 supports only providers whose paths are independently established by official documentation.
|
||||
The registry is deliberately small; it is not copied or synchronized from a community path table.
|
||||
|
||||
| Provider | Detection | Global canonical support | Workspace support | Orca placement |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Codex | `codex` CLI found through Orca's host-owned PATH detection | Reads `$HOME/.agents/skills` directly | Reads `.agents/skills` from the current directory through the repository root | Canonical copy only |
|
||||
| Claude Code | `claude` CLI found through Orca's host-owned PATH detection | Reads `$HOME/.claude/skills` | Reads `.claude/skills` from the launch directory through the repository root, plus nested directories as files are accessed | Relative directory symlink on POSIX, directory junction on Windows, or verified independent-copy fallback |
|
||||
|
||||
Codex locations and symlink behavior are documented in the official OpenAI documentation:
|
||||
[Build skills](https://learn.chatgpt.com/docs/build-skills#where-codex-loads-local-skills).
|
||||
|
||||
Claude Code locations, precedence, parent traversal, live detection, and symlink behavior are
|
||||
documented in the official Anthropic documentation:
|
||||
[Extend Claude with skills](https://code.claude.com/docs/en/skills#where-skills-live).
|
||||
|
||||
Codex therefore needs no provider-specific placement. Claude Code does not document
|
||||
`.agents/skills` as a discovery root, so Orca reconciles its documented `.claude/skills` path back
|
||||
to the canonical copy. Orca never replaces a path it does not own. If alias creation is unavailable,
|
||||
the verified copy fallback is tracked in the install receipt so update and removal can detect drift.
|
||||
|
||||
## Registry change process
|
||||
|
||||
Every registry change requires normal code review and all of the following evidence:
|
||||
|
||||
1. Link current official provider documentation for global and workspace paths.
|
||||
2. Record whether the provider reads `.agents/skills` directly and its documented link behavior.
|
||||
3. Verify global and folder-workspace discovery on macOS, Linux, native Windows, and WSL where the
|
||||
provider supports those platforms.
|
||||
4. Exercise local, paired-runtime, and SSH host-owned path resolution.
|
||||
5. Test alias denial, broken owned aliases, independent-copy drift, update, rollback, and removal.
|
||||
6. Update mixed-version capability evidence if the placement contract changes.
|
||||
|
||||
Do not add automated upstream path-table synchronization. A provider release that changes discovery
|
||||
semantics must enter through this review process.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Agent skill sharing threat model
|
||||
|
||||
Status: implementation baseline for security and privacy review. This document does not constitute
|
||||
security approval.
|
||||
|
||||
## Scope and trust model
|
||||
|
||||
This model covers private skill packaging, Orca Cloud publication and authorization, durable share
|
||||
resolution, local and remote installation, provider placement, update, rollback, removal, and
|
||||
operator recovery. It applies to macOS, Linux, native Windows, WSL, paired Orca runtimes, and SSH
|
||||
targets.
|
||||
|
||||
Private means unlisted: an unpredictable active share ID is a bearer credential, while publication
|
||||
and management remain access-controlled to authenticated Orca users and organizations. V1 is not
|
||||
end-to-end encrypted from Orca Cloud operators. A skill is code from its author: `SKILL.md` can
|
||||
change agent behavior and packaged scripts may be executed later by a user or agent, although the
|
||||
installer itself never executes package content.
|
||||
|
||||
## Protected assets
|
||||
|
||||
- Skill contents, filenames, manifests, package metadata, release notes, and author identity.
|
||||
- Organization membership, selected-user ACLs, durable share identifiers, and package existence.
|
||||
- Signed upload policies, download grants, authentication tokens, database credentials, and GCP
|
||||
service identities.
|
||||
- Existing local skills, provider configuration, user modifications, provenance, and transaction
|
||||
recovery state.
|
||||
- Cloud package metadata, immutable object generations, audit records, and deletion state.
|
||||
- Availability and cost of Cloud Run, Cloud SQL, GCS, connected runtimes, and local filesystems.
|
||||
|
||||
## Actors and boundaries
|
||||
|
||||
Actors include an authorized publisher, an authorized recipient, an authenticated but unauthorized
|
||||
Orca user, a malicious skill author, a compromised renderer, an untrusted remote RPC caller, an
|
||||
attacker controlling a network endpoint, and an operator with GCP or database access.
|
||||
|
||||
Trust boundaries are:
|
||||
|
||||
1. Source skill directory to the owner-private package staging directory.
|
||||
2. Desktop renderer to the main process and its authenticated Cloud client.
|
||||
3. Orca client to a paired runtime or SSH host across independently versioned protocols.
|
||||
4. Orca Cloud API authorization to short-lived GCS access.
|
||||
5. GCS quarantine to validated immutable publication and PostgreSQL metadata.
|
||||
6. Extracted staging to canonical destination and provider placements.
|
||||
7. Local diagnostic records to a user-reviewed support-bundle upload.
|
||||
8. Terraform and deployment identities to staging and production resources.
|
||||
|
||||
## Security invariants
|
||||
|
||||
- Package identity is deterministic and binds normalized paths, exact bytes, executable state, and
|
||||
immutable package/version IDs.
|
||||
- Publication and installation accept only the `manifest.json` plus `skill/` envelope and never
|
||||
execute archive content.
|
||||
- Archive validation completes before destination mutation.
|
||||
- Destination paths are resolved by the runtime that owns the host or workspace.
|
||||
- Unowned or modified local content is never silently replaced or deleted.
|
||||
- Final GCS objects are immutable, generation-fenced, and reachable only through fresh ACL checks
|
||||
followed by short-lived grants.
|
||||
- No remote caller can turn a desktop-local path into remote filesystem authority.
|
||||
- Interrupted transactions converge to a verified old or requested version.
|
||||
- Logs and support bundles exclude package contents and private authorization or filesystem data.
|
||||
- Cloud sharing, downloading, and remote installation have independent kill switches.
|
||||
|
||||
## Threat register
|
||||
|
||||
| ID | Threat | Required controls and current evidence | Residual release gate |
|
||||
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| TM-01 | Source changes after preview or a link swaps bytes during packaging. | Observe the specific source directory, copy without following links, re-observe staged bytes, compare every identity, and bind preview to the final digest. Source-drift and link/special-file tests cover rejection and cleanup. | Repeat race and permission tests on every supported filesystem. |
|
||||
| TM-02 | Archive traversal, drive paths, links, devices, duplicate paths, Unicode/case collisions, or decompression/resource exhaustion escape staging. | Streaming parser rejects unsafe entry classes and normalized collisions; compressed, extracted, entry, file, depth, and per-file limits are enforced during parsing and extraction. Boundary, malformed, checksum, and fuzz tests run before destination mutation. | Keep package safety suites required in release CI. |
|
||||
| TM-03 | A forged manifest lies about names, bytes, executable state, or package identity. | Parse the manifest before trusting entries, require `skill/SKILL.md`, hash extracted bytes independently, recompute package identity, and compare package/version IDs and digest. | Cross-platform identical-byte digest evidence remains required. |
|
||||
| TM-04 | A client chooses another home, workspace, WSL distro, SSH path, or escapes a destination root. | The executing runtime resolves home and workspace identities, realpath-checks directories, uses platform-native joins, and rejects client-supplied remote paths. `local-file` ingress is trusted in-process only. | Real SSH and mixed-version topology tests remain required. |
|
||||
| TM-05 | Installation overwrites or deletion removes unowned or locally modified content. | Planner distinguishes missing, unchanged, clean, modified, unowned, external-link, broken-link, and collision states. Provenance lives outside installed content. Replacement requires an explicit conflict decision; removal revalidates ownership and digest. | Complete the remaining real junction, external-link, copy-drift, and permission matrix. |
|
||||
| TM-06 | A crash between renames or receipt publication loses both old and new versions. | Same-filesystem staging, durable journals, backups, flushed receipt replacement, bounded recovery, and ownership tokens protect every commit boundary. Failure injection covers every journal transition. | Real process termination and disk/antivirus contention tests remain required. |
|
||||
| TM-07 | A grant leaks through redirects, userinfo, an insecure scheme, DNS/host confusion, or an oversized stream. | Cloud requests reject redirects. Package downloads require configured origins and HTTPS, reject URL credentials and cross-origin redirects, cap redirect count, recheck expiry, stream exact expected bytes, and verify archive/package digests. | Validate approved production origins and exercise malicious network cases in staging. |
|
||||
| TM-08 | Reuse of an upload ID, wrong tenant, wrong key, stale generation, or quarantine object publishes attacker-selected bytes. | Random tenant-bound upload rows, signed POST conditions, expiry, exact metadata/key/type/size validation, generation-fenced reads, streamed validation, and idempotent finalization fail closed. | Staging GCS integration must cover stale generations and lifecycle deletion. |
|
||||
| TM-09 | IDOR, stale organization membership, or a guessed identifier exposes package management data or bearer-protected content. | Management requests authenticate and re-evaluate current ownership. Recipient preview and grants require the exact unpredictable, active bearer share ID and ignore legacy ACL rows. Not-found responses hide unauthorized existence. | Human authorization review and organization-departure policy approval remain required. |
|
||||
| TM-10 | Content-addressed deduplication discloses another tenant's package through response shape or timing. | Tenant-scoped APIs never expose GCS keys, generations, or whether an object already existed; existing-object reuse verifies both archive and logical package identity. Cross-tenant tests prove identical archives use separate tenant-hashed objects and response shapes disclose no reuse. | Keep tenant-isolation and response-disclosure coverage required in release CI. |
|
||||
| TM-11 | A compromised renderer, old client, or arbitrary RPC caller sends credentials, local paths, unknown opcodes, or unsupported operations to a host. | Main owns auth tokens and grants; remote requests use strict schemas and capabilities; package transfer is separate from install; no stream opcode was added; mixed versions fail with update-required results. | Real client-newer/server-newer and SSH parity gates remain required. |
|
||||
| TM-12 | Transfer replay, overlap, disconnect, or abandoned staging consumes disk or commits different bytes. | Session count, idle time, total bytes, and chunk size are bounded. Offsets are monotonic, identical retry is idempotent, changed replay fails, commit hashes the exact staged file, and cancellation/disconnect cleanup is bounded. | Exercise offline-GCS and real disconnects at every transfer boundary. |
|
||||
| TM-13 | Provider aliases or junctions escape canonical storage or point at external content. | POSIX aliases are relative from real parents, Windows junctions use absolute canonical targets, targets are revalidated, and unowned/broken/external links are conflicts. Copy fallback is independently hashed and receipt-owned. Real Windows and WSL coverage includes existing, broken, external, denied, and drifted placement behavior. | Preserve the physical placement matrix in release coverage. |
|
||||
| TM-14 | Skill instructions or scripts are mistaken for trusted Orca code or executed during install. | Share/install previews identify author, organization, scripts, executable files, digest, and version. Installation never runs scripts. Trust copy says the package is code from its author. | Security and design must approve the final trust wording and accessibility behavior. |
|
||||
| TM-15 | Telemetry, logs, or support bundles leak instructions, filenames, paths, ACLs, grants, policies, or credentials. | Desktop install diagnostics map values to bounded categories before tracing. Deployed staging logs contain route templates and bounded request metadata only, and the logging exclusion removes bearer URLs. Support-bundle tests inject private canaries and prove they are absent from collected output. | Preserve deployed-log and support-bundle privacy checks for future changes. |
|
||||
| TM-16 | Permissive staging permissions expose package bytes to another local user. | Package archives, downloads, extraction, relayed uploads, locks, journals, and receipts use owner-private modes on POSIX; Windows uses owner-profile paths and inherited ACLs. Existing POSIX download roots are tightened before use. Real Windows, WSL, Ubuntu-floor, and SSH validation passed. | Preserve owner-private staging checks across supported hosts. |
|
||||
| TM-17 | Deletion, revocation, retention, or user departure leaves unauthorized grants or unrecoverable metadata/blob divergence. | Revocation blocks new grants immediately; existing grants expire within five minutes. Database references govern final deletion, quarantine lifecycle cleans abandonment, and GCS soft delete supplies recovery. Local installs remain independent. | Approve departure/legal-retention policy and exercise coordinated database/GCS recovery. |
|
||||
| TM-18 | Broad IAM, public bucket access, service-account keys, or a compromised deployment identity bypasses application authorization. | Uniform bucket access, public-access prevention, bucket-scoped object access, service-account-scoped signing, skill-secret-only access, Cloud SQL client role, no desktop IAM, and no long-lived keys are Terraform-defined. | Review the staging and production plans and verify live IAM before rollout. |
|
||||
| TM-19 | Unbounded validation or request concurrency causes memory, CPU, database, storage, or egress denial of service. | Fixed streaming buffers, package limits, per-instance finalization semaphore, rate/quota limits, bounded transfer sessions, Cloud Run instance limits, lifecycle cleanup, and independent kill switches constrain work. | Complete load testing, dashboards, alerts, and budget thresholds. |
|
||||
| TM-20 | Operator recovery, diagnostics, or legal workflows bypass tenant isolation or leak content. | Runbooks require generation-specific recovery, coordinated PostgreSQL/GCS restoration, audited lifecycle actions, and no package contents in normal logs. | Security/privacy approval and restricted break-glass procedure remain required. |
|
||||
|
||||
## Required review evidence
|
||||
|
||||
Security and privacy approval must not rely on this document alone. Reviewers need:
|
||||
|
||||
- Package/admission schemas and stable failure categories.
|
||||
- Archive parser, extraction containment, transaction, provenance, and removal tests.
|
||||
- Cloud authorization, object-generation, tenant-isolation, deletion, and recovery tests.
|
||||
- Terraform plans plus live staging IAM, bucket, Cloud Run, Secret Manager, and Cloud SQL evidence.
|
||||
- Real macOS, Linux-floor, Windows, WSL, paired-runtime, mixed-version, and SSH results.
|
||||
- Captured staging logs, metrics, traces, and support bundles with seeded private canaries absent.
|
||||
- Load-test results and independently tested upload, download, and remote-install kill switches.
|
||||
|
||||
Approval owners record findings and accepted residual risks outside this implementation document.
|
||||
The external rollout gate remains closed until those findings are resolved or explicitly accepted.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Agent skill sharing upstream boundary
|
||||
|
||||
Status: proposed for formal engineering and legal review.
|
||||
|
||||
Date: 2026-08-11.
|
||||
|
||||
## Context
|
||||
|
||||
Orca needs private, durable, cross-machine skill sharing with bounded archive ingestion,
|
||||
host-owned destination resolution, crash-safe transactions, provenance, and mixed-version remote
|
||||
support. `vercel-labs/skills` exposes a CLI and does not provide the Cloud authorization or local
|
||||
transaction contract Orca requires.
|
||||
|
||||
The behavioral assessment used upstream commit
|
||||
`c6f69c631292444cc541ac6d91e2226b0ff247da`.
|
||||
|
||||
## Decision
|
||||
|
||||
Orca implements its package, Cloud, installation, provider-placement, and recovery behavior
|
||||
independently. The upstream project is a behavioral reference only.
|
||||
|
||||
Do not copy or mechanically translate upstream source, tests, fixtures, registry entries, provider
|
||||
path tables, comments, or documentation. Derive provider paths from official provider
|
||||
documentation and verify them with real installations. Orca does not depend on the upstream CLI,
|
||||
npm package, or an unsupported programmatic API.
|
||||
|
||||
If a future change proposes incorporating upstream material, stop and review the exact material,
|
||||
license, attribution, notices, and maintenance implications before implementation or merge.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Orca owns stability, security, compatibility, and maintenance of this narrower installer.
|
||||
- There is no automatic upstream synchronization job.
|
||||
- Similar behavior is acceptable when independently derived from requirements and official
|
||||
provider contracts; textual or structural copying is not.
|
||||
- Provider registry changes require normal code review plus official-documentation and real-host
|
||||
evidence.
|
||||
- The pull request template requires reviewers to confirm this boundary for relevant changes.
|
||||
|
||||
## Review record
|
||||
|
||||
Product chose the reference-only approach during planning. Formal engineering and legal reviewers
|
||||
remain to be named before external rollout.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Relay regional placement
|
||||
|
||||
Orca selects a Relay region in the Electron main process before requesting a new assignment. The
|
||||
director publishes an allowlisted region catalog containing only HTTPS cell subdomains of that
|
||||
director; Orca takes three bounded `/health` latency samples per region and caches the stable choice
|
||||
for 24 hours. A cached region changes only when the alternative is materially faster.
|
||||
|
||||
The assignment request sends only `preferredRegion`. It does not send latency, IP address, country,
|
||||
pairing data, or credentials. Catalog, probe, and cache failures fall back to an assignment without
|
||||
a region preference. A rolled-back director that rejects the new field is retried once without
|
||||
only that field while preserving reconnect behavior.
|
||||
|
||||
The selection measures the desktop network path. Folder workspaces and SSH workspaces share the
|
||||
same local broker and do not run probes on remote hosts. The phone continues to connect to the
|
||||
exact cell URL in the desktop pairing payload, so its location is not measured independently and
|
||||
no mobile protocol update is required.
|
||||
|
||||
For deterministic local diagnostics, set `ORCA_RELAY_REGION_OVERRIDE` to `us-central1` or
|
||||
`asia-east2` before launching Orca. The override is not an end-user setting and is not written to
|
||||
the preference cache.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Share and install agent skills
|
||||
|
||||
Orca can put one skill or a bundle of skills behind one unlisted, revocable link. Shared bundles
|
||||
do not appear in search, a catalog, or a public index. Anyone who has an active link can inspect
|
||||
and install its contents without signing in, so treat the link like a credential.
|
||||
|
||||
## Share skills
|
||||
|
||||
Publishing and link management require an Orca account in the desktop app.
|
||||
|
||||
1. Open **Skills** and choose **Share skills**.
|
||||
2. Select one or more skills. One link can contain a large collection, such as 30 skills.
|
||||
3. Review the bundle name, included skills and files, scripts, executable files, digest, account,
|
||||
and optional release notes.
|
||||
4. Choose **Publish skill**, **Publish bundle**, or **Publish new version**, then copy the link.
|
||||
|
||||
Orca publishes an immutable version. Later changes do not silently alter a link's current bytes;
|
||||
publish a new version to update the Cloud package.
|
||||
|
||||
Use **Settings → Share Skills** to copy or revoke active links. Revocation blocks new previews and
|
||||
download grants. A grant issued immediately before revocation can remain usable for up to five
|
||||
minutes, and revocation does not remove copies that recipients already installed.
|
||||
|
||||
## Install from a link
|
||||
|
||||
Opening an Orca skill link shows a preview before changing any files. You can also open **Skills**,
|
||||
choose **Install from link**, and paste the URL.
|
||||
|
||||
1. Verify the author and organization.
|
||||
2. Review the version, release notes, included skills, scripts, executable files, and digest.
|
||||
3. Select all skills or only the ones you want.
|
||||
4. Choose the destination machine and either global or workspace scope.
|
||||
5. Review new, unchanged, updated, and conflicting skills, then choose **Install N skills**.
|
||||
|
||||
Supported destinations include the local machine, paired Orca runtimes, WSL, and SSH hosts. The
|
||||
destination runtime resolves its own home and workspace paths, so folder workspaces and remote
|
||||
filesystems do not borrow paths from the client machine.
|
||||
|
||||
Orca keeps one canonical installed copy and places it where supported agents can discover it.
|
||||
Current provider coverage is documented in
|
||||
[Agent skill provider paths](./agent-skill-provider-paths.md).
|
||||
|
||||
## Conflicts, updates, and rollback
|
||||
|
||||
**Keep local** is the default when an existing skill differs. Orca replaces modified content only
|
||||
after you explicitly choose to discard it.
|
||||
|
||||
Open **Skills → Manage installs** to inspect managed skills and their immutable version history.
|
||||
Installing the latest version performs an update; selecting an older retained version performs a
|
||||
rollback. Both use the same protected install transaction. If a bundle changes between versions,
|
||||
Orca updates only the selected skills that still exist in that version.
|
||||
|
||||
An interrupted install is recovered on restart. If Orca reports a conflict or partial result,
|
||||
review the named skill and retry; completed skills do not need to be installed again.
|
||||
|
||||
## Remove an installed skill
|
||||
|
||||
Use **Skills → Manage installs → Remove**. Orca removes only copies and provider placements that it
|
||||
owns and can verify. Modified or unowned files are preserved and reported. Discarding modified
|
||||
content requires a separate explicit confirmation.
|
||||
|
||||
Removing a local install does not revoke its share or delete its Cloud package. Likewise,
|
||||
revoking or deleting Cloud data does not reach into recipients' machines.
|
||||
|
||||
## Retention and deletion
|
||||
|
||||
- Upload grants expire after 15 minutes.
|
||||
- Abandoned upload bytes are removed from quarantine after one day.
|
||||
- Published versions have no automatic age-based deletion.
|
||||
- Deleting a package revokes its links before unreferenced objects are deleted.
|
||||
- Deleted GCS objects remain operator-recoverable through a seven-day soft-delete window.
|
||||
- Installed copies remain until someone removes them on each destination machine.
|
||||
|
||||
Organization legal or retention requirements can override normal rollback and deletion timing.
|
||||
|
||||
## Trust and privacy
|
||||
|
||||
A skill is code from its author. `SKILL.md` can change agent behavior, and included scripts or
|
||||
executables may run later when a person or agent uses the skill. Orca validates the package and
|
||||
never executes its contents during installation, but you should install only from people you trust
|
||||
and review unexpected scripts or executable files.
|
||||
|
||||
Orca records bounded operational identifiers and outcomes. Normal logs, telemetry, and support
|
||||
bundles exclude skill contents, filenames, manifests, local paths, share URLs, upload policies,
|
||||
download grants, credentials, and access lists.
|
||||
|
||||
If a link no longer works, ask its owner for an active link. Missing, expired, revoked, and deleted
|
||||
links intentionally show the same response so Orca does not disclose private package existence.
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Orca",
|
||||
"slug": "orca-mobile",
|
||||
"version": "0.0.43",
|
||||
"version": "0.0.44",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
@@ -75,7 +75,7 @@
|
||||
"allowBackup": false,
|
||||
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
|
||||
"package": "com.stably.orca.mobile",
|
||||
"versionCode": 12
|
||||
"versionCode": 13
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useWorktreeResync } from '../../../src/transport/use-worktree-resync'
|
||||
import { startHostWorktreeRefresh } from '../../../src/worktree/host-worktree-refresh'
|
||||
import {
|
||||
useLastConnectedAt,
|
||||
usePendingConnectionPath,
|
||||
useReconnectAttempt
|
||||
} from '../../../src/transport/client-context-connection-metrics'
|
||||
import {
|
||||
@@ -138,6 +139,7 @@ export function HostScreen({
|
||||
const { client, state: connState } = useHostClient(hostId)
|
||||
const reconnectAttempts = useReconnectAttempt(hostId)
|
||||
const lastConnectedAt = useLastConnectedAt(hostId)
|
||||
const pendingConnectionPath = usePendingConnectionPath(hostId)
|
||||
const clientRef = useRef<RpcClient | null>(null)
|
||||
const fetchWorktreesInFlightRef = useRef(false)
|
||||
// Why: useRef, not useMemo — React may discard memoized values, which would silently
|
||||
@@ -817,7 +819,8 @@ export function HostScreen({
|
||||
const headerVerdict = classifyConnection({
|
||||
state: connState,
|
||||
reconnectAttempts,
|
||||
lastConnectedAt
|
||||
lastConnectedAt,
|
||||
pendingPath: pendingConnectionPath
|
||||
})
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context'
|
||||
import {
|
||||
useLastConnectedAt,
|
||||
usePendingConnectionPath,
|
||||
useReconnectAttempt
|
||||
} from '../../../../src/transport/client-context-connection-metrics'
|
||||
import {
|
||||
@@ -207,7 +208,6 @@ import {
|
||||
confirmsMirroredTabSelection,
|
||||
type AppliedSnapshotMarker
|
||||
} from '../../../../src/session/session-tab-snapshot-gate'
|
||||
import { hasPendingTerminalHandleRecoveryNeed } from '../../../../src/session/pending-terminal-handle-recovery'
|
||||
import {
|
||||
createInitialSessionAutoCreateState,
|
||||
useInitialSessionTerminalAutoCreate,
|
||||
@@ -254,6 +254,7 @@ import type {
|
||||
} from '../../../../src/session/mobile-session-tabs-stream-health'
|
||||
import { useMobileSessionTabsFetchReporting } from '../../../../src/session/use-mobile-session-tabs-fetch-reporting'
|
||||
import { useMobileSessionTabsReconciliation } from '../../../../src/session/use-mobile-session-tabs-reconciliation'
|
||||
import { PendingTerminalHandleRecoveryContextCache } from '../../../../src/session/pending-terminal-handle-recovery'
|
||||
import {
|
||||
getRepoIdFromMobileWorktreeId,
|
||||
getActiveTabIdForHandle,
|
||||
@@ -736,6 +737,7 @@ export default function SessionScreen() {
|
||||
const { client, state: connState } = useHostClient(hostId)
|
||||
const reconnectAttempts = useReconnectAttempt(hostId)
|
||||
const lastConnectedAt = useLastConnectedAt(hostId)
|
||||
const pendingConnectionPath = usePendingConnectionPath(hostId)
|
||||
const forceReconnectHost = useForceReconnect()
|
||||
const { name: worktreeName, resolution: worktreeResolution } = useLiveWorktreeName({
|
||||
client,
|
||||
@@ -936,6 +938,9 @@ export default function SessionScreen() {
|
||||
const pendingBrowserFocusPageIdRef = useRef<string | null>(null)
|
||||
const switchSessionTabRef = useRef<((tab: MobileSessionTab) => void) | null>(null)
|
||||
const pendingTerminalActivationAttemptRef = useRef<string | null>(null)
|
||||
const [parkedPendingTerminalContext, setParkedPendingTerminalContext] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
// Why: route the terminal URL tap through a ref so it runs the current handleCreateBrowser closure (the memoized one may hold a null-client render).
|
||||
const handleCreateBrowserRef = useRef<((rawUrl?: string) => Promise<boolean>) | null>(null)
|
||||
|
||||
@@ -2284,16 +2289,25 @@ export default function SessionScreen() {
|
||||
() =>
|
||||
closedTabTombstonesRef.current.size > 0 ||
|
||||
pendingBrowserFocusPageIdRef.current !== null ||
|
||||
// Why: a pending-handle terminal only turns ready in a fresh snapshot. A live
|
||||
// stream otherwise parks the poll, and a host that mints the handle without
|
||||
// republishing strands the pane on its spinner forever (STA-4256).
|
||||
hasPendingTerminalHandleRecoveryNeed(sessionTabsRef.current, activeSessionTabIdRef.current) ||
|
||||
// Why: a chat-covered handle that ran out of rearms and left `terminal.list`
|
||||
// was reminted by a desktop graph reload. Only a fresh tab snapshot carries
|
||||
// the replacement handle, so force one instead of holding the composer locked.
|
||||
nativeChatStream.hasTabsRecoveryNeed(),
|
||||
[nativeChatStream]
|
||||
)
|
||||
const pendingTerminalRecoveryContextCache = useMemo(
|
||||
() => new PendingTerminalHandleRecoveryContextCache(),
|
||||
[]
|
||||
)
|
||||
const getPendingTerminalRecoveryContextKey = useCallback(
|
||||
() =>
|
||||
pendingTerminalRecoveryContextCache.read(
|
||||
sessionTabsRef.current,
|
||||
activeSessionTabIdRef.current
|
||||
),
|
||||
[pendingTerminalRecoveryContextCache]
|
||||
)
|
||||
const pendingTerminalRecoveryContextKey = getPendingTerminalRecoveryContextKey()
|
||||
const getSessionTabsApplicationRevision = useCallback(
|
||||
() => appliedSessionTabsRevisionRef.current,
|
||||
[]
|
||||
@@ -2302,18 +2316,25 @@ export default function SessionScreen() {
|
||||
worktreeId,
|
||||
diagnosticsRef: terminalDiagnosticsRef
|
||||
})
|
||||
const { fetchSessionTabs, ensureSessionTabs, fetchPendingBrowserSessionTabs } =
|
||||
useMobileSessionTabsReconciliation<SessionTabsResult, MobileSessionTab>({
|
||||
client,
|
||||
connState,
|
||||
worktreeId,
|
||||
applySessionTabs,
|
||||
consumeAcceptedSessionTabs,
|
||||
fetchTerminals,
|
||||
hasRecoveryNeed: hasSessionTabsRecoveryNeed,
|
||||
getApplicationRevision: getSessionTabsApplicationRevision,
|
||||
...sessionTabsFetchReporting
|
||||
})
|
||||
const {
|
||||
fetchSessionTabs,
|
||||
ensureSessionTabs,
|
||||
fetchPendingBrowserSessionTabs,
|
||||
retryPendingTerminalRecovery
|
||||
} = useMobileSessionTabsReconciliation<SessionTabsResult, MobileSessionTab>({
|
||||
client,
|
||||
connState,
|
||||
worktreeId,
|
||||
applySessionTabs,
|
||||
consumeAcceptedSessionTabs,
|
||||
fetchTerminals,
|
||||
hasRecoveryNeed: hasSessionTabsRecoveryNeed,
|
||||
pendingTerminalRecoveryContextKey,
|
||||
getPendingTerminalRecoveryContextKey,
|
||||
onPendingTerminalRecoveryParked: setParkedPendingTerminalContext,
|
||||
getApplicationRevision: getSessionTabsApplicationRevision,
|
||||
...sessionTabsFetchReporting
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (connState === 'connected') {
|
||||
@@ -4075,6 +4096,9 @@ export default function SessionScreen() {
|
||||
activeSessionTab?.type === 'terminal' && typeof activeSessionTab.terminal !== 'string'
|
||||
? activeSessionTab
|
||||
: null
|
||||
const isPendingTerminalRecoveryParked =
|
||||
pendingTerminalRecoveryContextKey !== null &&
|
||||
pendingTerminalRecoveryContextKey === parkedPendingTerminalContext
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || connState !== 'connected' || !activePendingTerminalTab) {
|
||||
@@ -4149,7 +4173,8 @@ export default function SessionScreen() {
|
||||
state: connState,
|
||||
reconnectAttempts,
|
||||
lastConnectedAt,
|
||||
endpoint: hostEndpoint
|
||||
endpoint: hostEndpoint,
|
||||
pendingPath: pendingConnectionPath
|
||||
})
|
||||
const showConnectionRetry =
|
||||
connectionVerdict.kind === 'warning' || connectionVerdict.kind === 'unreachable'
|
||||
@@ -4593,10 +4618,27 @@ export default function SessionScreen() {
|
||||
</View>
|
||||
) : activePendingTerminalTab ? (
|
||||
<View style={styles.emptyState}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
{!isPendingTerminalRecoveryParked && (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
)}
|
||||
<Text style={styles.emptyText}>
|
||||
{activePendingTerminalTab.title || 'Loading terminal'}
|
||||
{isPendingTerminalRecoveryParked
|
||||
? 'Terminal is taking longer than expected'
|
||||
: activePendingTerminalTab.title || 'Loading terminal'}
|
||||
</Text>
|
||||
{isPendingTerminalRecoveryParked && (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Retry loading terminal"
|
||||
style={({ pressed }) => [
|
||||
styles.createButton,
|
||||
pressed && styles.newTerminalButtonPressed
|
||||
]}
|
||||
onPress={() => void retryPendingTerminalRecovery()}
|
||||
>
|
||||
<Text style={styles.createButtonText}>Retry</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
|
||||
@@ -37,6 +37,7 @@ import type { RpcSuccess } from '../../../src/transport/types'
|
||||
import { useHostClient } from '../../../src/transport/client-context'
|
||||
import {
|
||||
useLastConnectedAt,
|
||||
usePendingConnectionPath,
|
||||
useReconnectAttempt
|
||||
} from '../../../src/transport/client-context-connection-metrics'
|
||||
import { classifyConnection } from '../../../src/transport/connection-health'
|
||||
@@ -2073,6 +2074,7 @@ export default function MobileTasksScreen() {
|
||||
const { client, state: connState } = useHostClient(hostId)
|
||||
const reconnectAttempts = useReconnectAttempt(hostId)
|
||||
const lastConnectedAt = useLastConnectedAt(hostId)
|
||||
const pendingConnectionPath = usePendingConnectionPath(hostId)
|
||||
const clientRef = useRef<RpcClient | null>(null)
|
||||
const loadGenerationRef = useRef(0)
|
||||
const taskResumeRef = useRef<TaskResumeState>({})
|
||||
@@ -8673,7 +8675,8 @@ export default function MobileTasksScreen() {
|
||||
const headerVerdict = classifyConnection({
|
||||
state: connState,
|
||||
reconnectAttempts,
|
||||
lastConnectedAt
|
||||
lastConnectedAt,
|
||||
pendingPath: pendingConnectionPath
|
||||
})
|
||||
const emptyLabel =
|
||||
connState !== 'connected'
|
||||
|
||||
@@ -259,6 +259,10 @@ export default function HomeScreen() {
|
||||
() => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])),
|
||||
[allClients]
|
||||
)
|
||||
const hostPendingPaths = useMemo(
|
||||
() => Object.fromEntries(allClients.map(({ hostId, pendingPath }) => [hostId, pendingPath])),
|
||||
[allClients]
|
||||
)
|
||||
const disconnectHostClient = useDisconnectHostClient()
|
||||
const forgetHostClient = useForgetHostClient()
|
||||
const forceReconnectHost = useForceReconnect()
|
||||
@@ -765,7 +769,8 @@ export default function HomeScreen() {
|
||||
state,
|
||||
reconnectAttempts: attempts,
|
||||
lastConnectedAt,
|
||||
endpoint: item.endpoint
|
||||
endpoint: item.endpoint,
|
||||
pendingPath: hostPendingPaths[item.id] ?? null
|
||||
})
|
||||
return (
|
||||
<MobileHostCard
|
||||
|
||||
Generated
+8
-8
@@ -5606,8 +5606,8 @@ packages:
|
||||
multitars@1.0.0:
|
||||
resolution: {integrity: sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg==}
|
||||
|
||||
nanoid@3.3.17:
|
||||
resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==}
|
||||
nanoid@3.3.18:
|
||||
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
@@ -9906,7 +9906,7 @@ snapshots:
|
||||
'@react-navigation/routers': 7.5.3
|
||||
escape-string-regexp: 4.0.0
|
||||
fast-deep-equal: 3.1.3
|
||||
nanoid: 3.3.17
|
||||
nanoid: 3.3.18
|
||||
query-string: 7.1.3
|
||||
react: 19.2.6
|
||||
react-is: 19.2.6
|
||||
@@ -9942,14 +9942,14 @@ snapshots:
|
||||
'@react-navigation/core': 7.17.2(react@19.2.6)
|
||||
escape-string-regexp: 4.0.0
|
||||
fast-deep-equal: 3.1.3
|
||||
nanoid: 3.3.17
|
||||
nanoid: 3.3.18
|
||||
react: 19.2.6
|
||||
react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)
|
||||
use-latest-callback: 0.2.6(react@19.2.6)
|
||||
|
||||
'@react-navigation/routers@7.5.3':
|
||||
dependencies:
|
||||
nanoid: 3.3.17
|
||||
nanoid: 3.3.18
|
||||
|
||||
'@rolldown/binding-android-arm64@1.1.3':
|
||||
optional: true
|
||||
@@ -12166,7 +12166,7 @@ snapshots:
|
||||
expo-symbols: 55.0.8(expo-font@55.0.8)(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
|
||||
fast-deep-equal: 3.1.3
|
||||
invariant: 2.2.4
|
||||
nanoid: 3.3.17
|
||||
nanoid: 3.3.18
|
||||
query-string: 7.1.3
|
||||
react: 19.2.6
|
||||
react-fast-compare: 3.2.2
|
||||
@@ -13866,7 +13866,7 @@ snapshots:
|
||||
|
||||
multitars@1.0.0: {}
|
||||
|
||||
nanoid@3.3.17: {}
|
||||
nanoid@3.3.18: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
@@ -14162,7 +14162,7 @@ snapshots:
|
||||
|
||||
postcss@8.5.25:
|
||||
dependencies:
|
||||
nanoid: 3.3.17
|
||||
nanoid: 3.3.18
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
|
||||
@@ -94,22 +94,23 @@ describe('MobileHostCard', () => {
|
||||
it('names the relay while the dial is still in flight', async () => {
|
||||
const lines = await renderCard(undefined, {
|
||||
state: 'connecting',
|
||||
verdict: { kind: 'normal', label: 'Connecting…' },
|
||||
verdict: { kind: 'normal', label: 'Connecting via Relay…' },
|
||||
path: 'relay'
|
||||
})
|
||||
|
||||
expect(lines).toContain('Connecting…')
|
||||
expect(lines).toContain(' · Orca Relay')
|
||||
expect(lines).toContain('Connecting via Relay…')
|
||||
expect(lines).not.toContain(' · Orca Relay')
|
||||
})
|
||||
|
||||
it('names the relay while a failed direct dial is still retrying', async () => {
|
||||
const lines = await renderCard(undefined, {
|
||||
state: 'reconnecting',
|
||||
verdict: { kind: 'normal', label: 'Reconnecting…' },
|
||||
verdict: { kind: 'normal', label: 'Connecting via Relay…' },
|
||||
path: 'relay'
|
||||
})
|
||||
|
||||
expect(lines).toContain(' · Orca Relay')
|
||||
expect(lines).toContain('Connecting via Relay…')
|
||||
expect(lines).not.toContain(' · Orca Relay')
|
||||
})
|
||||
|
||||
it('leaves an idle disconnected host unlabelled', async () => {
|
||||
|
||||
@@ -25,11 +25,6 @@ export function MobileHostCard(props: {
|
||||
const credentialUnavailable = props.credentialStatus === 'temporarily-unavailable'
|
||||
const credentialMissing = props.credentialStatus === 'missing'
|
||||
const connected = props.state === 'connected' && !credentialUnavailable && !credentialMissing
|
||||
// Why: a relay dial can run for seconds behind "Connecting…"/"Reconnecting…"; naming the
|
||||
// path mid-wait tells the user the phone is off-LAN rather than hung (F5). Only 'relay' is
|
||||
// named — 'lan' doubles as the unknown-path default, so it would be a guess before connect.
|
||||
const dialingPath =
|
||||
['connecting', 'handshaking', 'reconnecting'].includes(props.state) && props.path === 'relay'
|
||||
const isError =
|
||||
credentialMissing || ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind)
|
||||
const statusLabel = credentialMissing
|
||||
@@ -44,7 +39,7 @@ export function MobileHostCard(props: {
|
||||
: props.verdict
|
||||
const worktreeSummary = homeHostWorktreeSummary(props.worktreeInfo)
|
||||
const connectionPathLabel =
|
||||
!credentialMissing && !credentialUnavailable && (connected || dialingPath)
|
||||
!credentialMissing && !credentialUnavailable && connected
|
||||
? mobileConnectionPathLabel(props.path)
|
||||
: null
|
||||
const discoveryHint =
|
||||
|
||||
@@ -187,16 +187,38 @@ describe('mobile session startup', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('counts a pending-handle active terminal as a tabs recovery need (STA-4256)', () => {
|
||||
const recoveryNeed = sliceBetween(
|
||||
'const hasSessionTabsRecoveryNeed = useCallback(',
|
||||
'const getSessionTabsApplicationRevision ='
|
||||
it('wires pending-handle recovery through its bounded context (STA-4256)', () => {
|
||||
const applySessionTabs = sliceBetween(
|
||||
'const applySessionTabs = useCallback(',
|
||||
'const consumeAcceptedSessionTabs = useCallback('
|
||||
)
|
||||
const recoveryContext = sliceBetween(
|
||||
'const pendingTerminalRecoveryContextCache = useMemo(',
|
||||
'const getSessionTabsApplicationRevision'
|
||||
)
|
||||
|
||||
expect(recoveryNeed).toContain('hasPendingTerminalHandleRecoveryNeed(')
|
||||
expect(recoveryNeed).toContain('sessionTabsRef.current')
|
||||
expect(recoveryNeed).toContain('activeSessionTabIdRef.current')
|
||||
// The predicate only reaches the poll loop through this hook's option.
|
||||
const tabsRefWrite = 'sessionTabsRef.current = nextTabs'
|
||||
const tabsStateWrite = 'setSessionTabs((prev)'
|
||||
const activeRefWrite = 'activeSessionTabIdRef.current = active?.id ?? null'
|
||||
const activeStateWrite = 'setActiveSessionTabId(active?.id ?? null)'
|
||||
for (const write of [tabsRefWrite, tabsStateWrite, activeRefWrite, activeStateWrite]) {
|
||||
expect(applySessionTabs).toContain(write)
|
||||
}
|
||||
expect(applySessionTabs.indexOf(tabsRefWrite)).toBeLessThan(
|
||||
applySessionTabs.indexOf(tabsStateWrite)
|
||||
)
|
||||
expect(applySessionTabs.indexOf(activeRefWrite)).toBeLessThan(
|
||||
applySessionTabs.indexOf(activeStateWrite)
|
||||
)
|
||||
expect(recoveryContext).toContain('() => new PendingTerminalHandleRecoveryContextCache()')
|
||||
expect(recoveryContext).toContain('sessionTabsRef.current,')
|
||||
expect(recoveryContext).toContain('activeSessionTabIdRef.current')
|
||||
expect(recoveryContext).toContain(
|
||||
'const pendingTerminalRecoveryContextKey = getPendingTerminalRecoveryContextKey()'
|
||||
)
|
||||
expect(source).toContain('hasRecoveryNeed: hasSessionTabsRecoveryNeed')
|
||||
expect(source).toContain('getPendingTerminalRecoveryContextKey,')
|
||||
expect(source).toContain('onPendingTerminalRecoveryParked: setParkedPendingTerminalContext')
|
||||
expect(source).toContain('retryPendingTerminalRecovery()')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -134,6 +134,29 @@ describe('MobileSessionTabsStreamHealth', () => {
|
||||
expect(harness.apply).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('coalesces repeated retries and releases the retry cohort after failure', async () => {
|
||||
const harness = makeHarness()
|
||||
harness.controller.setReconciliationActive(true)
|
||||
|
||||
const halfOpen = harness.controller.requestReconciliation()
|
||||
const retry = harness.controller.retryReconciliation()
|
||||
const repeatedRetry = harness.controller.retryReconciliation()
|
||||
expect(harness.sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(repeatedRetry).toBe(retry)
|
||||
|
||||
harness.requests[1]!.resolve(failure())
|
||||
await retry
|
||||
const nextRetry = harness.controller.retryReconciliation()
|
||||
expect(harness.sendRequest).toHaveBeenCalledTimes(3)
|
||||
expect(nextRetry).not.toBe(retry)
|
||||
|
||||
harness.requests[2]!.resolve(success(result(3)))
|
||||
await nextRetry
|
||||
harness.requests[0]!.resolve(success(result(1)))
|
||||
await halfOpen
|
||||
expect(harness.apply).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('starts distinct pre- and post-snapshot lists and discards the stale barrier', async () => {
|
||||
const harness = makeHarness()
|
||||
harness.controller.setReconciliationActive(true)
|
||||
|
||||
@@ -19,6 +19,7 @@ type RequestOwner = {
|
||||
type RequestCohort = {
|
||||
promise: Promise<void>
|
||||
resolve: () => void
|
||||
retry: boolean
|
||||
}
|
||||
|
||||
type ControllerOptions<Result, Tab> = {
|
||||
@@ -31,6 +32,7 @@ type ControllerOptions<Result, Tab> = {
|
||||
source: SessionTabsStreamSource
|
||||
) => void
|
||||
hasRecoveryNeed: () => boolean
|
||||
allowRecoveryPoll?: () => boolean
|
||||
getApplicationRevision?: () => number
|
||||
onFetchStarted?: () => void
|
||||
onFetchSucceeded?: (result: Result) => void
|
||||
@@ -84,16 +86,35 @@ export class MobileSessionTabsStreamHealth<Result, Tab> {
|
||||
return this.requestReconciliation()
|
||||
}
|
||||
|
||||
retryReconciliation(): Promise<void> {
|
||||
this.syncGeneration()
|
||||
const currentRequest = this.inFlight.get(`${this.generation}:${this.barrier}`)
|
||||
if (currentRequest?.retry) {
|
||||
return currentRequest.promise
|
||||
}
|
||||
this.requirementRevision += 1
|
||||
this.barrier += currentRequest ? 1 : 0
|
||||
return this.startCurrentRequest(true)
|
||||
}
|
||||
|
||||
poll(): Promise<void> | null {
|
||||
this.syncGeneration()
|
||||
if (
|
||||
!this.reconciliationActive ||
|
||||
(this.health === 'live' &&
|
||||
!this.options.hasRecoveryNeed() &&
|
||||
this.requirementRevision <= this.satisfiedRevision)
|
||||
) {
|
||||
if (!this.reconciliationActive) {
|
||||
return null
|
||||
}
|
||||
const recoveryNeeded = this.options.hasRecoveryNeed()
|
||||
if (this.health === 'live') {
|
||||
if (!recoveryNeeded && this.requirementRevision <= this.satisfiedRevision) {
|
||||
return null
|
||||
}
|
||||
const currentRequest = this.inFlight.get(`${this.generation}:${this.barrier}`)
|
||||
if (currentRequest) {
|
||||
return currentRequest.promise
|
||||
}
|
||||
if (recoveryNeeded && this.options.allowRecoveryPoll?.() === false) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return this.ensureReconciliation()
|
||||
}
|
||||
|
||||
@@ -186,7 +207,7 @@ export class MobileSessionTabsStreamHealth<Result, Tab> {
|
||||
this.requirementRevision += 1
|
||||
}
|
||||
|
||||
private startCurrentRequest(): Promise<void> {
|
||||
private startCurrentRequest(retry = false): Promise<void> {
|
||||
if (this.disposed || !this.reconciliationActive) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
@@ -199,7 +220,7 @@ export class MobileSessionTabsStreamHealth<Result, Tab> {
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
resolveRequest = resolve
|
||||
})
|
||||
const cohort = { promise, resolve: resolveRequest }
|
||||
const cohort = { promise, resolve: resolveRequest, retry }
|
||||
this.inFlight.set(key, cohort)
|
||||
this.runCohortRequest(key, cohort)
|
||||
return promise
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { MobileSessionTab } from './mobile-session-route-types'
|
||||
import { PendingTerminalHandleRecoveryContextCache } from './pending-terminal-handle-recovery'
|
||||
|
||||
function terminalTab(id: string, terminal: string | null): MobileSessionTab {
|
||||
return {
|
||||
type: 'terminal',
|
||||
id,
|
||||
title: 'zsh',
|
||||
parentTabId: 'parent',
|
||||
leafId: 'leaf',
|
||||
status: terminal === null ? 'pending-handle' : 'ready',
|
||||
terminal,
|
||||
isActive: true
|
||||
}
|
||||
}
|
||||
|
||||
describe('PendingTerminalHandleRecoveryContextCache', () => {
|
||||
it('reads authoritative tab refs before the next render', () => {
|
||||
const cache = new PendingTerminalHandleRecoveryContextCache()
|
||||
const pendingTabs = [terminalTab('terminal-a', null)]
|
||||
const readyTabs = [terminalTab('terminal-a', 'pty-a')]
|
||||
|
||||
expect(cache.read(pendingTabs, 'terminal-a')).not.toBeNull()
|
||||
expect(cache.read(readyTabs, 'terminal-a')).toBeNull()
|
||||
expect(cache.read(pendingTabs, null)).toBeNull()
|
||||
expect(cache.read(pendingTabs, 'terminal-a')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('recomputes when the pending terminal identity changes', () => {
|
||||
const cache = new PendingTerminalHandleRecoveryContextCache()
|
||||
const first = terminalTab('terminal-a', null)
|
||||
const parentChanged = { ...first, parentTabId: 'other-parent' }
|
||||
const leafChanged = { ...first, leafId: 'other-leaf' }
|
||||
const collisionLeft = { ...first, parentTabId: 'ab', leafId: 'c' }
|
||||
const collisionRight = { ...first, parentTabId: 'a', leafId: 'bc' }
|
||||
const firstKey = cache.read([first], first.id)
|
||||
|
||||
expect(cache.read([parentChanged], parentChanged.id)).not.toBe(firstKey)
|
||||
expect(cache.read([leafChanged], leafChanged.id)).not.toBe(firstKey)
|
||||
const collisionLeftKey = cache.read([collisionLeft], collisionLeft.id)
|
||||
expect(cache.read([collisionRight], collisionRight.id)).not.toBe(collisionLeftKey)
|
||||
})
|
||||
|
||||
it('does not rescan an unchanged context on the polling hot path', () => {
|
||||
let skippedTabIdReads = 0
|
||||
const skippedTab = terminalTab('terminal-b', 'pty-b')
|
||||
Object.defineProperty(skippedTab, 'id', {
|
||||
get: () => {
|
||||
skippedTabIdReads += 1
|
||||
return 'terminal-b'
|
||||
}
|
||||
})
|
||||
const tabs = [skippedTab, terminalTab('terminal-a', null)]
|
||||
const cache = new PendingTerminalHandleRecoveryContextCache()
|
||||
|
||||
const contextKey = cache.read(tabs, 'terminal-a')
|
||||
const readsAfterFirstLookup = skippedTabIdReads
|
||||
expect(readsAfterFirstLookup).toBeGreaterThan(0)
|
||||
expect(cache.read(tabs, 'terminal-a')).toBe(contextKey)
|
||||
expect(skippedTabIdReads).toBe(readsAfterFirstLookup)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,371 @@
|
||||
import { createElement, Suspense } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import type { SessionTabsApplyOutcome } from './mobile-session-tabs-stream-health'
|
||||
import { PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS } from './pending-terminal-handle-recovery'
|
||||
import { useMobileSessionTabsReconciliation } from './use-mobile-session-tabs-reconciliation'
|
||||
|
||||
type TestResult = {
|
||||
type?: 'snapshot' | 'updated'
|
||||
snapshotVersion: number
|
||||
tabs: string[]
|
||||
}
|
||||
|
||||
type HarnessProps = {
|
||||
onParked?: (key: string | null) => void
|
||||
suspend?: Promise<never>
|
||||
}
|
||||
|
||||
const lifecycle = vi.hoisted(() => ({
|
||||
appState: 'active',
|
||||
focused: true,
|
||||
listeners: new Set<(state: string) => void>()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
AppState: {
|
||||
get currentState() {
|
||||
return lifecycle.appState
|
||||
},
|
||||
addEventListener(_event: string, listener: (state: string) => void) {
|
||||
lifecycle.listeners.add(listener)
|
||||
return { remove: () => lifecycle.listeners.delete(listener) }
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('expo-router', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
useFocusEffect(effect: () => void | (() => void)): void {
|
||||
React.useEffect(() => (lifecycle.focused ? effect() : undefined), [effect, lifecycle.focused])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function result(type?: TestResult['type']): TestResult {
|
||||
return { snapshotVersion: 1, tabs: ['pending-terminal'], ...(type ? { type } : {}) }
|
||||
}
|
||||
|
||||
function makeHarness() {
|
||||
const requestTimes: number[] = []
|
||||
const parkedContexts: (string | null)[] = []
|
||||
let contextKey: string | null = 'terminal-a'
|
||||
let otherRecoveryNeeded = false
|
||||
let connState: 'connected' | 'disconnected' = 'connected'
|
||||
let streamListener: ((payload: unknown) => void) | null = null
|
||||
let actions: ReturnType<typeof useMobileSessionTabsReconciliation<TestResult, string>> | null =
|
||||
null
|
||||
const sendRequest = vi.fn(async (): Promise<RpcSuccess> => {
|
||||
requestTimes.push(Date.now())
|
||||
return {
|
||||
id: `list-${requestTimes.length}`,
|
||||
ok: true,
|
||||
result: result(),
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
})
|
||||
const subscribe = vi.fn(
|
||||
(_method: string, _params: unknown, listener: (payload: unknown) => void) => {
|
||||
streamListener = listener
|
||||
return () => {}
|
||||
}
|
||||
)
|
||||
const client = { sendRequest, subscribe } as unknown as RpcClient
|
||||
const consumeAcceptedSessionTabs = () => {}
|
||||
const fetchTerminals = async () => {}
|
||||
const getPendingTerminalRecoveryContextKey = () => contextKey
|
||||
const hasRecoveryNeed = () => otherRecoveryNeeded
|
||||
const onPendingTerminalRecoveryParked = (key: string | null) => parkedContexts.push(key)
|
||||
const applySessionTabs = (value: TestResult): SessionTabsApplyOutcome<string> => ({
|
||||
accepted: true,
|
||||
effectiveTabs: value.tabs
|
||||
})
|
||||
|
||||
function Harness({ onParked = onPendingTerminalRecoveryParked, suspend }: HarnessProps): null {
|
||||
actions = useMobileSessionTabsReconciliation<TestResult, string>({
|
||||
client,
|
||||
connState,
|
||||
worktreeId: 'repo::worktree',
|
||||
applySessionTabs,
|
||||
consumeAcceptedSessionTabs,
|
||||
fetchTerminals,
|
||||
hasRecoveryNeed,
|
||||
pendingTerminalRecoveryContextKey: contextKey,
|
||||
getPendingTerminalRecoveryContextKey,
|
||||
onPendingTerminalRecoveryParked: onParked
|
||||
})
|
||||
if (suspend) {
|
||||
throw suspend
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
Harness,
|
||||
actions: () => actions,
|
||||
emit(payload: TestResult) {
|
||||
streamListener?.(payload)
|
||||
},
|
||||
parkedContexts,
|
||||
requestTimes,
|
||||
sendRequest,
|
||||
setConnectionState(value: typeof connState) {
|
||||
connState = value
|
||||
},
|
||||
setContextKey(value: string | null) {
|
||||
contextKey = value
|
||||
},
|
||||
setOtherRecoveryNeeded(value: boolean) {
|
||||
otherRecoveryNeeded = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
for (let turn = 0; turn < 8; turn += 1) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
// react-test-renderer's types do not resolve in the type-aware lint project, so both
|
||||
// ReactTestRenderer and ReturnType<typeof create> degrade to `any` and poison this union.
|
||||
// Name the two methods this suite actually uses instead.
|
||||
type MountedHarness = {
|
||||
unmount: () => void
|
||||
update: (element: ReturnType<typeof createElement>) => void
|
||||
}
|
||||
|
||||
describe('bounded pending-handle reconciliation cadence', () => {
|
||||
let renderer: MountedHarness | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
lifecycle.appState = 'active'
|
||||
lifecycle.focused = true
|
||||
lifecycle.listeners.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function mount(harness: ReturnType<typeof makeHarness>): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(harness.Harness))
|
||||
await flush()
|
||||
})
|
||||
await act(async () => {
|
||||
harness.emit(result('snapshot'))
|
||||
await flush()
|
||||
harness.emit(result('updated'))
|
||||
await flush()
|
||||
})
|
||||
harness.sendRequest.mockClear()
|
||||
harness.requestTimes.length = 0
|
||||
harness.parkedContexts.length = 0
|
||||
}
|
||||
|
||||
async function advance(milliseconds: number): Promise<void> {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(milliseconds)
|
||||
})
|
||||
}
|
||||
|
||||
async function setAppState(state: string): Promise<void> {
|
||||
lifecycle.appState = state
|
||||
await act(async () => {
|
||||
for (const listener of lifecycle.listeners) {
|
||||
listener(state)
|
||||
}
|
||||
await flush()
|
||||
})
|
||||
}
|
||||
|
||||
it('drops the main baseline from 1800 lists per hour to five, then parks', async () => {
|
||||
const harness = makeHarness()
|
||||
await mount(harness)
|
||||
|
||||
await advance(3_600_000)
|
||||
|
||||
expect(harness.requestTimes).toEqual([2000, 4000, 6000, 8000, 10_000])
|
||||
expect(harness.parkedContexts).toContain('terminal-a')
|
||||
})
|
||||
|
||||
it('resets for active-tab and pending-terminal context changes, but not ordinary ticks', async () => {
|
||||
const harness = makeHarness()
|
||||
await mount(harness)
|
||||
await advance(12_000)
|
||||
expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS)
|
||||
|
||||
harness.setContextKey(null)
|
||||
await advance(2000)
|
||||
const afterActiveTabChange = harness.requestTimes.length
|
||||
harness.setContextKey('terminal-a')
|
||||
await advance(2000)
|
||||
expect(harness.requestTimes).toHaveLength(afterActiveTabChange + 1)
|
||||
|
||||
harness.setContextKey('terminal-b')
|
||||
await advance(2000)
|
||||
expect(harness.requestTimes).toHaveLength(afterActiveTabChange + 2)
|
||||
})
|
||||
|
||||
it('resets when the active tab changes away and back between poll ticks', async () => {
|
||||
const harness = makeHarness()
|
||||
await mount(harness)
|
||||
await advance(12_000)
|
||||
|
||||
harness.setContextKey(null)
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(harness.Harness))
|
||||
await flush()
|
||||
})
|
||||
expect(harness.parkedContexts.at(-1)).toBeNull()
|
||||
harness.setContextKey('terminal-a')
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(harness.Harness))
|
||||
await flush()
|
||||
})
|
||||
expect(harness.parkedContexts.at(-1)).toBeNull()
|
||||
await advance(2000)
|
||||
|
||||
expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 1)
|
||||
})
|
||||
|
||||
it('keeps the parked state across unrelated rerenders', async () => {
|
||||
const harness = makeHarness()
|
||||
await mount(harness)
|
||||
await advance(12_000)
|
||||
expect(harness.parkedContexts.at(-1)).toBe('terminal-a')
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(harness.Harness))
|
||||
await flush()
|
||||
})
|
||||
|
||||
expect(harness.parkedContexts.at(-1)).toBe('terminal-a')
|
||||
await advance(2000)
|
||||
expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS)
|
||||
})
|
||||
|
||||
it('does not publish a parked callback from a suspended render', async () => {
|
||||
const harness = makeHarness()
|
||||
const committedCallback = vi.fn()
|
||||
const suspendedCallback = vi.fn()
|
||||
const neverCommits = new Promise<never>(() => {})
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(
|
||||
Suspense,
|
||||
{ fallback: null },
|
||||
createElement(harness.Harness, { onParked: committedCallback })
|
||||
)
|
||||
)
|
||||
await flush()
|
||||
})
|
||||
const committedActions = harness.actions()
|
||||
committedCallback.mockClear()
|
||||
|
||||
await act(async () => {
|
||||
renderer?.update(
|
||||
createElement(
|
||||
Suspense,
|
||||
{ fallback: null },
|
||||
createElement(harness.Harness, {
|
||||
onParked: suspendedCallback,
|
||||
suspend: neverCommits
|
||||
})
|
||||
)
|
||||
)
|
||||
await flush()
|
||||
})
|
||||
await act(async () => {
|
||||
await committedActions?.retryPendingTerminalRecovery()
|
||||
})
|
||||
|
||||
expect(committedCallback).toHaveBeenCalledWith(null)
|
||||
expect(suspendedCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('publishes the committed parked callback before a same-commit context reset', async () => {
|
||||
const harness = makeHarness()
|
||||
const previousCallback = vi.fn()
|
||||
const committedCallback = vi.fn()
|
||||
await act(async () => {
|
||||
renderer = create(createElement(harness.Harness, { onParked: previousCallback }))
|
||||
await flush()
|
||||
})
|
||||
previousCallback.mockClear()
|
||||
|
||||
harness.setContextKey('terminal-b')
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(harness.Harness, { onParked: committedCallback }))
|
||||
await flush()
|
||||
})
|
||||
|
||||
expect(committedCallback).toHaveBeenCalledWith(null)
|
||||
expect(previousCallback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps other recovery sources live while the pending-terminal budget is parked', async () => {
|
||||
const harness = makeHarness()
|
||||
await mount(harness)
|
||||
await advance(12_000)
|
||||
expect(harness.requestTimes).toEqual([2000, 4000, 6000, 8000, 10_000])
|
||||
|
||||
harness.setOtherRecoveryNeeded(true)
|
||||
await advance(14_000)
|
||||
expect(harness.requestTimes).toEqual([
|
||||
2000, 4000, 6000, 8000, 10_000, 14_000, 16_000, 18_000, 20_000, 22_000, 24_000, 26_000
|
||||
])
|
||||
|
||||
harness.setOtherRecoveryNeeded(false)
|
||||
await advance(2000)
|
||||
expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 7)
|
||||
})
|
||||
|
||||
it('resets after foregrounding, reconnecting, and explicit retry', async () => {
|
||||
const harness = makeHarness()
|
||||
await mount(harness)
|
||||
await advance(12_000)
|
||||
|
||||
await setAppState('background')
|
||||
await setAppState('active')
|
||||
expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 1)
|
||||
await advance(2000)
|
||||
expect(harness.requestTimes).toHaveLength(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 2)
|
||||
await advance(10_000)
|
||||
|
||||
harness.setConnectionState('disconnected')
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(harness.Harness))
|
||||
await flush()
|
||||
})
|
||||
harness.setConnectionState('connected')
|
||||
await act(async () => {
|
||||
renderer?.update(createElement(harness.Harness))
|
||||
await flush()
|
||||
})
|
||||
await act(async () => {
|
||||
harness.emit(result('updated'))
|
||||
await flush()
|
||||
})
|
||||
const afterReconnect = harness.requestTimes.length
|
||||
await advance(2000)
|
||||
expect(harness.requestTimes).toHaveLength(afterReconnect + 1)
|
||||
|
||||
await advance(10_000)
|
||||
const beforeRetry = harness.requestTimes.length
|
||||
await act(async () => {
|
||||
await harness.actions()?.retryPendingTerminalRecovery()
|
||||
})
|
||||
expect(harness.requestTimes).toHaveLength(beforeRetry + 1)
|
||||
await advance(2000)
|
||||
expect(harness.requestTimes).toHaveLength(beforeRetry + 2)
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,12 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import type { MobileSessionTab, SessionTabsResult } from './mobile-session-route-types'
|
||||
import { hasPendingTerminalHandleRecoveryNeed } from './pending-terminal-handle-recovery'
|
||||
import {
|
||||
getPendingTerminalHandleRecoveryContextKey,
|
||||
hasPendingTerminalHandleRecoveryNeed,
|
||||
PendingTerminalHandleRecoveryBudget,
|
||||
PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS
|
||||
} from './pending-terminal-handle-recovery'
|
||||
import {
|
||||
MobileSessionTabsStreamHealth,
|
||||
type SessionTabsApplyOutcome
|
||||
@@ -62,6 +67,38 @@ describe('hasPendingTerminalHandleRecoveryNeed', () => {
|
||||
expect(hasPendingTerminalHandleRecoveryNeed([terminalTab(null)], null)).toBe(false)
|
||||
expect(hasPendingTerminalHandleRecoveryNeed([terminalTab(null)], 'gone')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps recovery contexts distinct when tab identifiers contain separators', () => {
|
||||
const first = { ...terminalTab(null), id: 'a:b', parentTabId: 'c' }
|
||||
const second = { ...terminalTab(null), id: 'a', parentTabId: 'b:c' }
|
||||
|
||||
expect(getPendingTerminalHandleRecoveryContextKey([first], first.id)).not.toBe(
|
||||
getPendingTerminalHandleRecoveryContextKey([second], second.id)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PendingTerminalHandleRecoveryBudget', () => {
|
||||
it('parks after the bounded attempt window and resets for a new terminal', () => {
|
||||
const budget = new PendingTerminalHandleRecoveryBudget()
|
||||
for (let attempt = 1; attempt <= PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS; attempt += 1) {
|
||||
expect(budget.take('terminal-a')).toEqual({
|
||||
allowed: true,
|
||||
parked: false
|
||||
})
|
||||
}
|
||||
expect(budget.take('terminal-a')).toEqual({ allowed: false, parked: true })
|
||||
expect(budget.take('terminal-b')).toEqual({ allowed: true, parked: false })
|
||||
})
|
||||
|
||||
it('resets the current terminal budget explicitly', () => {
|
||||
const budget = new PendingTerminalHandleRecoveryBudget()
|
||||
for (let attempt = 0; attempt < PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS; attempt += 1) {
|
||||
budget.take('terminal-a')
|
||||
}
|
||||
budget.reset()
|
||||
expect(budget.take('terminal-a')).toEqual({ allowed: true, parked: false })
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -82,6 +119,7 @@ describe('pending-handle recovery through MobileSessionTabsStreamHealth', () =>
|
||||
const client = { sendRequest, getGeneration: () => 1 } as unknown as RpcClient
|
||||
let tabs: MobileSessionTab[] = []
|
||||
let activeTabId: string | null = null
|
||||
const recoveryBudget = new PendingTerminalHandleRecoveryBudget()
|
||||
const controller = new MobileSessionTabsStreamHealth<SessionTabsResult, MobileSessionTab>({
|
||||
client,
|
||||
scope: 'id:repo::worktree',
|
||||
@@ -91,7 +129,9 @@ describe('pending-handle recovery through MobileSessionTabsStreamHealth', () =>
|
||||
return { accepted: true, effectiveTabs: result.tabs }
|
||||
},
|
||||
consumeAccepted: () => {},
|
||||
hasRecoveryNeed: () => hasPendingTerminalHandleRecoveryNeed(tabs, activeTabId)
|
||||
hasRecoveryNeed: () => hasPendingTerminalHandleRecoveryNeed(tabs, activeTabId),
|
||||
allowRecoveryPoll: () =>
|
||||
recoveryBudget.take(getPendingTerminalHandleRecoveryContextKey(tabs, activeTabId)).allowed
|
||||
})
|
||||
return {
|
||||
controller,
|
||||
@@ -139,6 +179,88 @@ describe('pending-handle recovery through MobileSessionTabsStreamHealth', () =>
|
||||
})
|
||||
})
|
||||
|
||||
it('parks the live poll after five pending-handle lists', async () => {
|
||||
const harness = makeHarness()
|
||||
await driveToLiveStream(harness, null)
|
||||
|
||||
for (let attempt = 0; attempt < PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS; attempt += 1) {
|
||||
const request = harness.controller.poll()
|
||||
expect(request).not.toBeNull()
|
||||
await settle()
|
||||
harness.resolveNext(snapshot(null))
|
||||
await request
|
||||
}
|
||||
|
||||
expect(harness.sendRequest).toHaveBeenCalledTimes(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS)
|
||||
expect(harness.controller.poll()).toBeNull()
|
||||
await settle()
|
||||
expect(harness.sendRequest).toHaveBeenCalledTimes(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS)
|
||||
})
|
||||
|
||||
it('does not spend live fallback attempts while a slow request is coalesced', async () => {
|
||||
const harness = makeHarness()
|
||||
await driveToLiveStream(harness, null)
|
||||
|
||||
for (let attempt = 0; attempt < PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS; attempt += 1) {
|
||||
const request = harness.controller.poll()
|
||||
expect(request).not.toBeNull()
|
||||
for (let joinedTick = 0; joinedTick < 5; joinedTick += 1) {
|
||||
expect(harness.controller.poll()).toBe(request)
|
||||
}
|
||||
expect(harness.sendRequest).toHaveBeenCalledTimes(attempt + 1)
|
||||
harness.resolveNext(snapshot(null))
|
||||
await request
|
||||
}
|
||||
|
||||
expect(harness.controller.poll()).toBeNull()
|
||||
expect(harness.sendRequest).toHaveBeenCalledTimes(PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS)
|
||||
})
|
||||
|
||||
it.each(['probing', 'degraded'] as const)(
|
||||
'keeps polling a pending handle while stream health is %s',
|
||||
async (health) => {
|
||||
const harness = makeHarness()
|
||||
harness.controller.setReconciliationActive(true)
|
||||
const subscription = harness.controller.beginSubscription()
|
||||
if (health === 'degraded') {
|
||||
subscription.listener({ ...snapshot(null), type: 'end' })
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 2; attempt += 1) {
|
||||
const request = harness.controller.poll()
|
||||
expect(request).not.toBeNull()
|
||||
await settle()
|
||||
harness.resolveNext(snapshot(null))
|
||||
await request
|
||||
}
|
||||
|
||||
expect(harness.sendRequest).toHaveBeenCalledTimes(
|
||||
PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS + 2
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('starts a fresh retry and fences the half-open request result', async () => {
|
||||
const harness = makeHarness()
|
||||
await driveToLiveStream(harness, null)
|
||||
|
||||
const halfOpen = harness.controller.poll()
|
||||
expect(harness.sendRequest).toHaveBeenCalledTimes(1)
|
||||
const retry = harness.controller.retryReconciliation()
|
||||
expect(retry).not.toBe(halfOpen)
|
||||
expect(harness.sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(harness.controller.retryReconciliation()).toBe(retry)
|
||||
expect(harness.sendRequest).toHaveBeenCalledTimes(2)
|
||||
|
||||
harness.resolveNext(snapshot('stale-handle'))
|
||||
await halfOpen
|
||||
expect(harness.readTabs()[0]).toMatchObject({ terminal: null })
|
||||
|
||||
harness.resolveNext(snapshot('fresh-handle'))
|
||||
await retry
|
||||
expect(harness.readTabs()[0]).toMatchObject({ terminal: 'fresh-handle' })
|
||||
})
|
||||
|
||||
it('stops polling once a fresh snapshot carries the handle', async () => {
|
||||
const harness = makeHarness()
|
||||
await driveToLiveStream(harness, null)
|
||||
|
||||
@@ -1,5 +1,74 @@
|
||||
import type { MobileSessionTab } from './mobile-session-route-types'
|
||||
|
||||
export const PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS = 5
|
||||
|
||||
export type PendingTerminalHandleRecoveryAttempt = {
|
||||
allowed: boolean
|
||||
parked: boolean
|
||||
}
|
||||
|
||||
export class PendingTerminalHandleRecoveryBudget {
|
||||
private contextKey: string | null = null
|
||||
private remaining = PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS
|
||||
|
||||
observeContext(contextKey: string | null): void {
|
||||
if (contextKey === this.contextKey) {
|
||||
return
|
||||
}
|
||||
this.contextKey = contextKey
|
||||
this.remaining = PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS
|
||||
}
|
||||
|
||||
take(contextKey: string | null): PendingTerminalHandleRecoveryAttempt {
|
||||
this.observeContext(contextKey)
|
||||
if (contextKey === null) {
|
||||
return { allowed: false, parked: false }
|
||||
}
|
||||
if (this.remaining === 0) {
|
||||
return { allowed: false, parked: true }
|
||||
}
|
||||
this.remaining -= 1
|
||||
return { allowed: true, parked: false }
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.contextKey = null
|
||||
this.remaining = PENDING_TERMINAL_HANDLE_RECOVERY_ATTEMPTS
|
||||
}
|
||||
}
|
||||
|
||||
export class PendingTerminalHandleRecoveryContextCache {
|
||||
private activeTabId: string | null = null
|
||||
private contextKey: string | null = null
|
||||
private initialized = false
|
||||
private tabs: readonly MobileSessionTab[] | null = null
|
||||
|
||||
read(tabs: readonly MobileSessionTab[], activeTabId: string | null): string | null {
|
||||
if (this.initialized && tabs === this.tabs && activeTabId === this.activeTabId) {
|
||||
return this.contextKey
|
||||
}
|
||||
this.initialized = true
|
||||
this.tabs = tabs
|
||||
this.activeTabId = activeTabId
|
||||
this.contextKey = getPendingTerminalHandleRecoveryContextKey(tabs, activeTabId)
|
||||
return this.contextKey
|
||||
}
|
||||
}
|
||||
|
||||
export function getPendingTerminalHandleRecoveryContextKey(
|
||||
tabs: readonly MobileSessionTab[],
|
||||
activeTabId: string | null
|
||||
): string | null {
|
||||
if (activeTabId === null) {
|
||||
return null
|
||||
}
|
||||
const active = tabs.find((tab) => tab.id === activeTabId)
|
||||
if (active?.type !== 'terminal' || typeof active.terminal === 'string') {
|
||||
return null
|
||||
}
|
||||
return JSON.stringify([active.id, active.parentTabId, active.leafId ?? null])
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the tab the user is looking at is a terminal the host published
|
||||
* without a PTY handle (`status: 'pending-handle'`). The session screen renders
|
||||
@@ -16,9 +85,5 @@ export function hasPendingTerminalHandleRecoveryNeed(
|
||||
tabs: readonly MobileSessionTab[],
|
||||
activeTabId: string | null
|
||||
): boolean {
|
||||
if (activeTabId === null) {
|
||||
return false
|
||||
}
|
||||
const active = tabs.find((tab) => tab.id === activeTabId)
|
||||
return active?.type === 'terminal' && typeof active.terminal !== 'string'
|
||||
return getPendingTerminalHandleRecoveryContextKey(tabs, activeTabId) !== null
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { AppState } from 'react-native'
|
||||
import { useFocusEffect } from 'expo-router'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type SessionTabsApplyOutcome,
|
||||
type SessionTabsStreamSource
|
||||
} from './mobile-session-tabs-stream-health'
|
||||
import { PendingTerminalHandleRecoveryBudget } from './pending-terminal-handle-recovery'
|
||||
|
||||
type Params<Result, Tab> = {
|
||||
client: RpcClient | null
|
||||
@@ -21,6 +22,9 @@ type Params<Result, Tab> = {
|
||||
) => void
|
||||
fetchTerminals: () => Promise<void>
|
||||
hasRecoveryNeed: () => boolean
|
||||
pendingTerminalRecoveryContextKey?: string | null
|
||||
getPendingTerminalRecoveryContextKey?: () => string | null
|
||||
onPendingTerminalRecoveryParked?: (contextKey: string | null) => void
|
||||
getApplicationRevision?: () => number
|
||||
onFetchStarted?: () => void
|
||||
onFetchSucceeded?: (result: Result) => void
|
||||
@@ -32,6 +36,7 @@ type ResultActions = {
|
||||
fetchSessionTabs: () => Promise<void>
|
||||
ensureSessionTabs: () => Promise<void>
|
||||
fetchPendingBrowserSessionTabs: () => Promise<void>
|
||||
retryPendingTerminalRecovery: () => Promise<void>
|
||||
}
|
||||
|
||||
const resolved = Promise.resolve()
|
||||
@@ -44,12 +49,46 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
|
||||
consumeAcceptedSessionTabs,
|
||||
fetchTerminals,
|
||||
hasRecoveryNeed,
|
||||
pendingTerminalRecoveryContextKey,
|
||||
getPendingTerminalRecoveryContextKey,
|
||||
onPendingTerminalRecoveryParked,
|
||||
getApplicationRevision,
|
||||
onFetchStarted,
|
||||
onFetchSucceeded,
|
||||
onFetchFailed,
|
||||
onFetchErrored
|
||||
}: Params<Result, Tab>): ResultActions {
|
||||
const pendingTerminalRecoveryBudget = useMemo(() => new PendingTerminalHandleRecoveryBudget(), [])
|
||||
const onPendingTerminalRecoveryParkedRef = useRef(onPendingTerminalRecoveryParked)
|
||||
// Why: only poll/reset callbacks read this, and they run after commit — so writing it in
|
||||
// render would let a discarded render leak a callback that never mounted.
|
||||
useEffect(() => {
|
||||
onPendingTerminalRecoveryParkedRef.current = onPendingTerminalRecoveryParked
|
||||
})
|
||||
const combinedHasRecoveryNeed = useCallback(() => {
|
||||
const contextKey = getPendingTerminalRecoveryContextKey?.() ?? null
|
||||
pendingTerminalRecoveryBudget.observeContext(contextKey)
|
||||
return hasRecoveryNeed() || contextKey !== null
|
||||
}, [getPendingTerminalRecoveryContextKey, hasRecoveryNeed, pendingTerminalRecoveryBudget])
|
||||
const allowRecoveryPoll = useCallback(() => {
|
||||
if (hasRecoveryNeed()) {
|
||||
return true
|
||||
}
|
||||
const contextKey = getPendingTerminalRecoveryContextKey?.() ?? null
|
||||
const attempt = pendingTerminalRecoveryBudget.take(contextKey)
|
||||
if (attempt.parked) {
|
||||
onPendingTerminalRecoveryParkedRef.current?.(contextKey)
|
||||
}
|
||||
return attempt.allowed
|
||||
}, [getPendingTerminalRecoveryContextKey, hasRecoveryNeed, pendingTerminalRecoveryBudget])
|
||||
const resetPendingTerminalRecovery = useCallback(() => {
|
||||
pendingTerminalRecoveryBudget.reset()
|
||||
onPendingTerminalRecoveryParkedRef.current?.(null)
|
||||
}, [pendingTerminalRecoveryBudget])
|
||||
useEffect(() => {
|
||||
pendingTerminalRecoveryBudget.observeContext(pendingTerminalRecoveryContextKey ?? null)
|
||||
onPendingTerminalRecoveryParkedRef.current?.(null)
|
||||
}, [pendingTerminalRecoveryBudget, pendingTerminalRecoveryContextKey])
|
||||
const controller = useMemo(
|
||||
() =>
|
||||
client
|
||||
@@ -58,7 +97,8 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
|
||||
scope: `id:${worktreeId}`,
|
||||
apply: applySessionTabs,
|
||||
consumeAccepted: consumeAcceptedSessionTabs,
|
||||
hasRecoveryNeed,
|
||||
hasRecoveryNeed: combinedHasRecoveryNeed,
|
||||
allowRecoveryPoll: getPendingTerminalRecoveryContextKey ? allowRecoveryPoll : undefined,
|
||||
getApplicationRevision,
|
||||
onFetchStarted,
|
||||
onFetchSucceeded,
|
||||
@@ -71,7 +111,9 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
|
||||
client,
|
||||
consumeAcceptedSessionTabs,
|
||||
getApplicationRevision,
|
||||
hasRecoveryNeed,
|
||||
allowRecoveryPoll,
|
||||
combinedHasRecoveryNeed,
|
||||
getPendingTerminalRecoveryContextKey,
|
||||
onFetchErrored,
|
||||
onFetchFailed,
|
||||
onFetchStarted,
|
||||
@@ -91,6 +133,7 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
|
||||
if (!client || !controller || connState !== 'connected') {
|
||||
return
|
||||
}
|
||||
resetPendingTerminalRecovery()
|
||||
const subscription = controller.beginSubscription()
|
||||
const unsubscribe = client.subscribe(
|
||||
'session.tabs.subscribe',
|
||||
@@ -101,7 +144,7 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
|
||||
subscription.cancel()
|
||||
unsubscribe()
|
||||
}
|
||||
}, [client, connState, controller, worktreeId])
|
||||
}, [client, connState, controller, resetPendingTerminalRecovery, worktreeId])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
@@ -123,19 +166,21 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
|
||||
}
|
||||
const appStateSubscription = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') {
|
||||
resetPendingTerminalRecovery()
|
||||
refresh(true)
|
||||
} else {
|
||||
controller.setReconciliationActive(false)
|
||||
}
|
||||
})
|
||||
const interval = setInterval(() => refresh(false), 2000)
|
||||
resetPendingTerminalRecovery()
|
||||
refresh(true)
|
||||
return () => {
|
||||
controller.setReconciliationActive(false)
|
||||
clearInterval(interval)
|
||||
appStateSubscription.remove()
|
||||
}
|
||||
}, [connState, controller, fetchTerminals])
|
||||
}, [connState, controller, fetchTerminals, resetPendingTerminalRecovery])
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -150,6 +195,10 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
|
||||
fetchPendingBrowserSessionTabs: useCallback(
|
||||
() => controller?.requestPendingRecovery() ?? resolved,
|
||||
[controller]
|
||||
)
|
||||
),
|
||||
retryPendingTerminalRecovery: useCallback(() => {
|
||||
resetPendingTerminalRecovery()
|
||||
return controller?.retryReconciliation() ?? resolved
|
||||
}, [controller, resetPendingTerminalRecovery])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -752,7 +752,8 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
|
||||
attachWebglAddon(true);
|
||||
if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {}
|
||||
if (typeof replayData === 'string' && replayData.length > 0) {
|
||||
enqueueWrite(replayData);
|
||||
// Why no trailing reset: the snapshot pen belongs to the live host TUI receiving later output.
|
||||
enqueueWrite(ESC + '[0m' + replayData);
|
||||
}
|
||||
|
||||
// Why: reset eviction tracking + attach observers for the new term.
|
||||
|
||||
@@ -26,7 +26,7 @@ type RegisteredWindowListener = {
|
||||
type: string
|
||||
}
|
||||
|
||||
function makeTerminal(writeCallbacks: Array<() => void>) {
|
||||
function makeTerminal(writeCallbacks: Array<() => void>, writes: string[]) {
|
||||
const terminal = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
@@ -45,7 +45,8 @@ function makeTerminal(writeCallbacks: Array<() => void>) {
|
||||
getLine: () => null
|
||||
}
|
||||
},
|
||||
write(_data: string, callback?: () => void) {
|
||||
write(data: string, callback?: () => void) {
|
||||
writes.push(data)
|
||||
if (callback) {
|
||||
writeCallbacks.push(callback)
|
||||
}
|
||||
@@ -93,6 +94,7 @@ describe('terminal WebView init surface replacement', () => {
|
||||
let terminalOptions: TerminalOptions[]
|
||||
let terminals: TerminalStub[]
|
||||
let writeCallbacks: Array<() => void>
|
||||
let writes: string[]
|
||||
|
||||
beforeEach(() => {
|
||||
animationFrames = []
|
||||
@@ -100,6 +102,7 @@ describe('terminal WebView init surface replacement', () => {
|
||||
terminalOptions = []
|
||||
terminals = []
|
||||
writeCallbacks = []
|
||||
writes = []
|
||||
const addWindowEventListener = window.addEventListener.bind(window)
|
||||
vi.spyOn(window, 'addEventListener').mockImplementation(((
|
||||
type: string,
|
||||
@@ -121,7 +124,7 @@ describe('terminal WebView init surface replacement', () => {
|
||||
}
|
||||
webWindow.Terminal = function (options: TerminalOptions) {
|
||||
terminalOptions.push(options)
|
||||
const terminal = makeTerminal(writeCallbacks)
|
||||
const terminal = makeTerminal(writeCallbacks, writes)
|
||||
terminals.push(terminal)
|
||||
return terminal
|
||||
} as unknown as new (options: TerminalOptions) => TerminalStub
|
||||
@@ -153,6 +156,13 @@ describe('terminal WebView init surface replacement', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('grounds the initial replay without clearing the host live pen', () => {
|
||||
dispatchInit(80, '\x1b[1mBOLD-RUN-LEFT-OPEN')
|
||||
animationFrames.shift()?.()
|
||||
|
||||
expect(writes).toEqual(['\x1b[0m\x1b[1mBOLD-RUN-LEFT-OPEN'])
|
||||
})
|
||||
|
||||
it('commits only the newest surface when phone-fit init calls overlap', () => {
|
||||
// Why: restored terminals can receive desktop scrollback, a phone resize,
|
||||
// and phone scrollback before any xterm replay callback has completed.
|
||||
|
||||
@@ -144,7 +144,7 @@ describe('TerminalWebView text zoom', () => {
|
||||
expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon')
|
||||
const open = terminalHtmlSource.indexOf('term.open(surface)')
|
||||
const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'")
|
||||
const replay = terminalHtmlSource.indexOf('enqueueWrite(replayData)')
|
||||
const replay = terminalHtmlSource.indexOf("enqueueWrite(ESC + '[0m' + replayData)")
|
||||
expect(open).toBeGreaterThanOrEqual(0)
|
||||
expect(unicode).toBeGreaterThan(open)
|
||||
expect(replay).toBeGreaterThan(unicode)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRpcClientContext, type RpcClientContextValue } from './client-context'
|
||||
import type { MobileConnectionPath } from './stable-logical-rpc-client'
|
||||
|
||||
export function useReconnectAttempt(hostId: string | undefined): number {
|
||||
return useHostMetric(hostId, (context, id) => context.getReconnectAttempt(id), 0)
|
||||
@@ -9,6 +10,10 @@ export function useLastConnectedAt(hostId: string | undefined): number | null {
|
||||
return useHostMetric(hostId, (context, id) => context.getLastConnectedAt(id), null)
|
||||
}
|
||||
|
||||
export function usePendingConnectionPath(hostId: string | undefined): MobileConnectionPath | null {
|
||||
return useHostMetric(hostId, (context, id) => context.getPendingPath(id), null)
|
||||
}
|
||||
|
||||
function useHostMetric<T>(
|
||||
hostId: string | undefined,
|
||||
read: (context: RpcClientContextValue, hostId: string) => T,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ConnectionState } from './types'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { MobileConnectionPath } from './stable-logical-rpc-client'
|
||||
|
||||
const connectMock = vi.fn()
|
||||
const loadHostsMock = vi.fn()
|
||||
@@ -31,12 +32,15 @@ import { selectHomeAutoConnectHostIds } from './home-host-auto-connect'
|
||||
|
||||
type FakeClient = RpcClient & {
|
||||
emitState: (state: ConnectionState) => void
|
||||
emitPendingPath: (path: MobileConnectionPath | null) => void
|
||||
closeMock: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function makeFakeClient(initialState: ConnectionState): FakeClient {
|
||||
let state = initialState
|
||||
let pendingPath: MobileConnectionPath | null = null
|
||||
const listeners = new Set<(state: ConnectionState) => void>()
|
||||
const pathListeners = new Set<() => void>()
|
||||
const closeMock = vi.fn()
|
||||
return {
|
||||
sendRequest: vi.fn(),
|
||||
@@ -45,6 +49,12 @@ function makeFakeClient(initialState: ConnectionState): FakeClient {
|
||||
getState: () => state,
|
||||
getReconnectAttempt: () => 0,
|
||||
getLastConnectedAt: () => null,
|
||||
getActivePath: () => 'tailscale',
|
||||
getPendingPath: () => pendingPath,
|
||||
onConnectionPathChange: (listener: () => void) => {
|
||||
pathListeners.add(listener)
|
||||
return () => pathListeners.delete(listener)
|
||||
},
|
||||
onStateChange: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
@@ -57,6 +67,12 @@ function makeFakeClient(initialState: ConnectionState): FakeClient {
|
||||
for (const listener of listeners) {
|
||||
listener(next)
|
||||
}
|
||||
},
|
||||
emitPendingPath: (next) => {
|
||||
pendingPath = next
|
||||
for (const listener of pathListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
} as FakeClient
|
||||
}
|
||||
@@ -393,6 +409,30 @@ describe('useHostClient', () => {
|
||||
})
|
||||
|
||||
describe('useAllHostClients', () => {
|
||||
it('rerenders when Relay becomes pending without a transport-state change', async () => {
|
||||
const client = makeFakeClient('reconnecting')
|
||||
connectMock.mockReturnValue(client)
|
||||
loadHostsMock.mockResolvedValue([HOST])
|
||||
let pendingPath: MobileConnectionPath | null | undefined
|
||||
let renderer!: ReturnType<typeof create>
|
||||
|
||||
function Probe(): null {
|
||||
pendingPath = useAllHostClients([HOST.id])[0]?.pendingPath
|
||||
return null
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(pendingPath).toBeNull()
|
||||
|
||||
act(() => client.emitPendingPath('relay'))
|
||||
expect(pendingPath).toBe('relay')
|
||||
|
||||
act(() => renderer.unmount())
|
||||
})
|
||||
|
||||
it('only opens the requested startup subset', async () => {
|
||||
const host2 = { ...HOST, id: 'host-2', name: 'Host 2' }
|
||||
connectMock.mockReturnValue(makeFakeClient('connected'))
|
||||
|
||||
@@ -76,6 +76,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
primedHostsRef.current.delete(hostId)
|
||||
}
|
||||
entry?.unsubState()
|
||||
entry?.unsubConnectionPath()
|
||||
storeRef.current.delete(hostId)
|
||||
entry?.client.close()
|
||||
notifyHostState(hostId, 'disconnected')
|
||||
@@ -242,6 +243,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
manualDemandRef.current.add(hostId)
|
||||
if (entry) {
|
||||
entry.unsubState()
|
||||
entry.unsubConnectionPath()
|
||||
entry.client.close()
|
||||
storeRef.current.delete(hostId)
|
||||
}
|
||||
@@ -260,8 +262,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
[openEntry]
|
||||
)
|
||||
|
||||
const { getKnownState, getState, getReconnectAttempt, getLastConnectedAt, getActivePath } =
|
||||
useMemo(() => createHostClientSelectors(storeRef.current, pendingOpensRef.current), [])
|
||||
const selectors = useMemo(
|
||||
() => createHostClientSelectors(storeRef.current, pendingOpensRef.current),
|
||||
[]
|
||||
)
|
||||
|
||||
const subscribeHostState = useCallback(
|
||||
(hostId: string, listener: (state: ConnectionState) => void) =>
|
||||
@@ -318,11 +322,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
refreshHostClient,
|
||||
forgetHostClient,
|
||||
disconnectHostClient,
|
||||
getState,
|
||||
getKnownState,
|
||||
getReconnectAttempt,
|
||||
getLastConnectedAt,
|
||||
getActivePath,
|
||||
...selectors,
|
||||
subscribeHostState,
|
||||
getAllClients,
|
||||
subscribeAllHosts,
|
||||
@@ -337,11 +337,7 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
|
||||
refreshHostClient,
|
||||
forgetHostClient,
|
||||
disconnectHostClient,
|
||||
getState,
|
||||
getKnownState,
|
||||
getReconnectAttempt,
|
||||
getLastConnectedAt,
|
||||
getActivePath,
|
||||
selectors,
|
||||
subscribeHostState,
|
||||
getAllClients,
|
||||
subscribeAllHosts,
|
||||
|
||||
@@ -25,7 +25,8 @@ describe('classifyConnection Tailscale hint', () => {
|
||||
const verdict = classifyConnection({
|
||||
...base,
|
||||
reconnectAttempts: 3,
|
||||
endpoint: 'ws://100.65.9.106:6768'
|
||||
endpoint: 'ws://100.65.9.106:6768',
|
||||
pendingPath: null
|
||||
})
|
||||
expect(verdict).toMatchObject({ kind: 'warning', hint: 'check Tailscale' })
|
||||
})
|
||||
@@ -69,6 +70,37 @@ describe('classifyConnection Tailscale hint', () => {
|
||||
})
|
||||
expect(verdict).toEqual({ kind: 'normal', label: 'Connected' })
|
||||
})
|
||||
|
||||
it('presents an in-flight relay fallback instead of the failed Tailscale path', () => {
|
||||
const incident = {
|
||||
...base,
|
||||
reconnectAttempts: 5,
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
pendingPath: 'relay' as const
|
||||
}
|
||||
|
||||
for (const state of ['connecting', 'handshaking', 'reconnecting', 'disconnected'] as const) {
|
||||
const verdict = classifyConnection({ ...incident, state })
|
||||
expect(verdict).toEqual({ kind: 'normal', label: 'Connecting via Relay…' })
|
||||
expect(verdictDisplayLabel(verdict)).not.toContain('Tailscale')
|
||||
}
|
||||
})
|
||||
|
||||
it('escalates a prolonged relay recovery without reviving the Tailscale hint', () => {
|
||||
const verdict = classifyConnection({
|
||||
...base,
|
||||
reconnectAttempts: 12,
|
||||
endpoint: 'ws://100.88.90.25:6768',
|
||||
pendingPath: 'relay'
|
||||
})
|
||||
|
||||
expect(verdict).toEqual({
|
||||
kind: 'unreachable',
|
||||
label: "Can't connect via Relay",
|
||||
reason: 'never-connected'
|
||||
})
|
||||
expect(verdictDisplayLabel(verdict)).not.toContain('Tailscale')
|
||||
})
|
||||
})
|
||||
|
||||
// Issue #10119: every redial re-enters 'connecting', which used to revert an
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
|
||||
import type { MobileConnectionPath } from './stable-logical-rpc-client'
|
||||
import type { ConnectionState } from './types'
|
||||
|
||||
// Why: thresholds for escalating connection UX from neutral
|
||||
@@ -49,6 +50,7 @@ export function classifyConnection(args: {
|
||||
// Optional pinned host endpoint — enables the Tailscale hint on
|
||||
// warning/unreachable verdicts. Callers without it get plain labels.
|
||||
endpoint?: string | null
|
||||
pendingPath?: MobileConnectionPath | null
|
||||
nowMs?: number
|
||||
}): ConnectionVerdict {
|
||||
const { state, reconnectAttempts, lastConnectedAt } = args
|
||||
@@ -65,6 +67,18 @@ export function classifyConnection(args: {
|
||||
return { kind: 'normal', label: 'Connected' }
|
||||
}
|
||||
|
||||
if (args.pendingPath === 'relay') {
|
||||
if (reconnectAttempts >= UNREACHABLE_ATTEMPTS) {
|
||||
if (lastConnectedAt == null) {
|
||||
return { kind: 'unreachable', label: "Can't connect via Relay", reason: 'never-connected' }
|
||||
}
|
||||
if (now - lastConnectedAt >= STALE_SINCE_LAST_CONNECT_MS) {
|
||||
return { kind: 'unreachable', label: "Can't connect via Relay", reason: 'stale' }
|
||||
}
|
||||
}
|
||||
return { kind: 'normal', label: 'Connecting via Relay…' }
|
||||
}
|
||||
|
||||
if (state === 'disconnected') {
|
||||
return { kind: 'normal', label: 'Disconnected' }
|
||||
}
|
||||
|
||||
@@ -84,7 +84,9 @@ export function createHostClientSelectors(
|
||||
getLastConnectedAt: (hostId: string): number | null =>
|
||||
entries.get(hostId)?.client.getLastConnectedAt() ?? null,
|
||||
getActivePath: (hostId: string): MobileConnectionPath =>
|
||||
clientActivePath(entries.get(hostId)?.client)
|
||||
clientActivePath(entries.get(hostId)?.client),
|
||||
getPendingPath: (hostId: string): MobileConnectionPath | null =>
|
||||
clientPendingPath(entries.get(hostId)?.client)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,3 +98,8 @@ export function clientActivePath(client: RpcClient | undefined): MobileConnectio
|
||||
// Why: during migration the pending path is what the user is waiting on.
|
||||
return logical.getPendingPath?.() ?? logical.getActivePath()
|
||||
}
|
||||
|
||||
export function clientPendingPath(client: RpcClient | undefined): MobileConnectionPath | null {
|
||||
const logical = client as Partial<StableLogicalRpcClient> | undefined
|
||||
return logical?.getPendingPath?.() ?? null
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { openHostLogicalClient } from './host-logical-client'
|
||||
import type { HostClientOpenRegistry } from './host-client-open-registry'
|
||||
import type { HostOpenRetryScheduler } from './host-open-retry-scheduler'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
|
||||
import type { ConnectionState, HostProfile } from './types'
|
||||
|
||||
export type HostClientStoreEntry = {
|
||||
@@ -11,6 +12,7 @@ export type HostClientStoreEntry = {
|
||||
state: ConnectionState
|
||||
refCount: number
|
||||
unsubState: () => void
|
||||
unsubConnectionPath: () => void
|
||||
}
|
||||
|
||||
type HostEntryOpenerState = {
|
||||
@@ -113,11 +115,20 @@ export async function openHostClientEntry(
|
||||
current.state = next
|
||||
state.notifyHostState(hostId, next)
|
||||
})
|
||||
const logical = client as Partial<StableLogicalRpcClient>
|
||||
const unsubConnectionPath =
|
||||
logical.onConnectionPathChange?.(() => {
|
||||
const current = state.store.get(hostId)
|
||||
if (current) {
|
||||
state.notifyHostState(hostId, current.state)
|
||||
}
|
||||
}) ?? (() => {})
|
||||
const entry: HostClientStoreEntry = {
|
||||
client,
|
||||
state: client.getState(),
|
||||
refCount: state.pendingAcquisitions.get(hostId) ?? 0,
|
||||
unsubState
|
||||
unsubState,
|
||||
unsubConnectionPath
|
||||
}
|
||||
state.pendingAcquisitions.delete(hostId)
|
||||
state.store.set(hostId, entry)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { MobileConnectionPath } from './stable-logical-rpc-client'
|
||||
|
||||
export class LogicalClientConnectionPath {
|
||||
private migration: MobileConnectionPath | null = null
|
||||
private recovery: MobileConnectionPath | null = null
|
||||
private readonly listeners = new Set<() => void>()
|
||||
|
||||
constructor(private readonly isConnected: () => boolean) {}
|
||||
|
||||
pending(): MobileConnectionPath | null {
|
||||
return this.isConnected() ? null : (this.migration ?? this.recovery)
|
||||
}
|
||||
|
||||
setMigration(path: MobileConnectionPath | null): void {
|
||||
this.update(() => {
|
||||
this.migration = path
|
||||
})
|
||||
}
|
||||
|
||||
clearAfterConnected(): void {
|
||||
this.migration = null
|
||||
this.recovery = null
|
||||
}
|
||||
|
||||
setRecovery(path: MobileConnectionPath | null): void {
|
||||
this.update(() => {
|
||||
this.recovery = path
|
||||
})
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
}
|
||||
|
||||
private update(apply: () => void): void {
|
||||
const previous = this.pending()
|
||||
apply()
|
||||
if (previous === this.pending()) {
|
||||
return
|
||||
}
|
||||
for (const listener of this.listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,9 @@ export class FakeRelaySession extends FakeSession implements MobileRelayRpcSessi
|
||||
|
||||
export class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
|
||||
private path: MobileConnectionPath
|
||||
private recoveryPath: MobileConnectionPath | null = null
|
||||
private generation = 1
|
||||
private readonly pathListeners = new Set<() => void>()
|
||||
|
||||
constructor(state: ConnectionState, path: MobileConnectionPath) {
|
||||
super(state)
|
||||
@@ -89,14 +91,28 @@ export class FakeLogicalClient extends FakeSession implements StableLogicalRpcCl
|
||||
throw new Error('migration superseded')
|
||||
}
|
||||
this.path = path
|
||||
this.recoveryPath = null
|
||||
this.generation += 1
|
||||
// Connected-state publication carries the migration cleanup.
|
||||
this.publishState('connected')
|
||||
}
|
||||
)
|
||||
suspendActiveSession = vi.fn(() => this.publishState('disconnected'))
|
||||
getActivePath = () => this.path
|
||||
// This fake migrates instantly, so no dial is ever in flight to name.
|
||||
getPendingPath = () => null
|
||||
getPendingPath = () => (this.getState() === 'connected' ? null : this.recoveryPath)
|
||||
setRecoveryPath = vi.fn((path: MobileConnectionPath | null) => {
|
||||
const previous = this.getPendingPath()
|
||||
this.recoveryPath = path
|
||||
if (previous !== this.getPendingPath()) {
|
||||
for (const listener of this.pathListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
})
|
||||
onConnectionPathChange = vi.fn((listener: () => void) => {
|
||||
this.pathListeners.add(listener)
|
||||
return () => this.pathListeners.delete(listener)
|
||||
})
|
||||
getGeneration = () => this.generation
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,63 @@ describe('mobile endpoint supervisor', () => {
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('does not announce Relay when credential selection finishes after backgrounding', async () => {
|
||||
const logical = new FakeLogicalClient('reconnecting', 'lan')
|
||||
let finishCredentialRead: ((value: MobileRelayCredentialBundle) => void) | undefined
|
||||
const credentialReadPending = new Promise<MobileRelayCredentialBundle>((resolve) => {
|
||||
finishCredentialRead = resolve
|
||||
})
|
||||
const readBundle = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockReturnValueOnce(credentialReadPending)
|
||||
const deps = dependencies({ readBundle })
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
const starting = supervisor.start()
|
||||
await vi.waitFor(() => expect(readBundle).toHaveBeenCalledTimes(2))
|
||||
supervisor.setForeground(false)
|
||||
finishCredentialRead?.(bundle)
|
||||
await starting
|
||||
|
||||
expect(deps.openRelay).not.toHaveBeenCalled()
|
||||
expect(logical.setRecoveryPath).not.toHaveBeenCalledWith('relay')
|
||||
expect(logical.getPendingPath()).toBeNull()
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('does not announce Relay without a dialable credential', async () => {
|
||||
const logical = new FakeLogicalClient('reconnecting', 'lan')
|
||||
const expired = { ...bundle, current: { ...bundle.current, expiresAt: Date.now() - 1 } }
|
||||
const deps = dependencies({ readBundle: vi.fn(async () => expired) })
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
|
||||
expect(deps.openRelay).not.toHaveBeenCalled()
|
||||
expect(logical.setRecoveryPath).not.toHaveBeenCalledWith('relay')
|
||||
expect(logical.getPendingPath()).toBeNull()
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('keeps Relay pending through a failed dial cooldown and clears it on stop', async () => {
|
||||
const logical = new FakeLogicalClient('reconnecting', 'lan')
|
||||
const deps = dependencies({
|
||||
openRelay: vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4408))),
|
||||
randomBytes: () => new Uint8Array([128, 0])
|
||||
})
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
|
||||
await supervisor.start()
|
||||
|
||||
expect(logical.getPendingPath()).toBe('relay')
|
||||
await vi.advanceTimersByTimeAsync(249)
|
||||
expect(logical.getPendingPath()).toBe('relay')
|
||||
|
||||
supervisor.stop()
|
||||
expect(logical.getPendingPath()).toBeNull()
|
||||
})
|
||||
|
||||
it('does not spend a queued relay retry while direct authentication is progressing', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const openRelay = vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4408)))
|
||||
@@ -187,6 +244,7 @@ describe('mobile endpoint supervisor', () => {
|
||||
logical.publishState('disconnected')
|
||||
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
expect(logical.getPendingPath()).toBe('relay')
|
||||
await vi.advanceTimersByTimeAsync(249)
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
@@ -194,6 +252,22 @@ describe('mobile endpoint supervisor', () => {
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('does not announce recovery when an active Relay drops after credential expiry', async () => {
|
||||
const logical = new FakeLogicalClient('connected', 'relay')
|
||||
const expired = { ...bundle, current: { ...bundle.current, expiresAt: Date.now() - 1 } }
|
||||
const deps = dependencies({ readBundle: vi.fn(async () => expired) })
|
||||
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
|
||||
await supervisor.start()
|
||||
|
||||
logical.publishState('disconnected')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(deps.openRelay).not.toHaveBeenCalled()
|
||||
expect(logical.setRecoveryPath).not.toHaveBeenCalledWith('relay')
|
||||
expect(logical.getPendingPath()).toBeNull()
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
it('escalates backoff when relay sessions connect and then drop repeatedly', async () => {
|
||||
const logical = new FakeLogicalClient('disconnected', 'lan')
|
||||
const openRelay = vi.fn(() => new FakeRelaySession('connected', new RelayOuterError(4408)))
|
||||
@@ -432,6 +506,7 @@ describe('mobile endpoint supervisor', () => {
|
||||
|
||||
expect(openRelay).toHaveBeenCalledOnce()
|
||||
expect(deps.writeBundle).not.toHaveBeenCalled()
|
||||
expect(logical.getPendingPath()).toBeNull()
|
||||
|
||||
logical.publishState('connected')
|
||||
await vi.waitFor(() => expect(deps.writeBundle).toHaveBeenCalledOnce())
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bund
|
||||
import { MobileEndpointNudgeRouter } from './mobile-endpoint-nudge-router'
|
||||
import { MobileRelayDirectGraceTimer } from './mobile-relay-direct-grace-timer'
|
||||
import { MobileRelaySessionEstablisher } from './mobile-relay-session-establisher'
|
||||
import * as recoveryPresentation from './mobile-relay-recovery-presentation'
|
||||
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
|
||||
import type { ForegroundNudgeReason, HostProfile } from './types'
|
||||
|
||||
@@ -154,6 +155,7 @@ export class MobileEndpointSupervisor {
|
||||
} else {
|
||||
// Why: the direct client enters reconnecting after its first failed
|
||||
// dial and may never publish disconnected while its retry loop lives.
|
||||
recoveryPresentation.onActiveFailure(this.logical, this.relayReconnect, state, this.bundle)
|
||||
this.relayReconnect.handleStateFailure(this.logical, state)
|
||||
}
|
||||
})
|
||||
@@ -181,12 +183,11 @@ export class MobileEndpointSupervisor {
|
||||
this.relayReconnect.clear()
|
||||
this.leaseRotation.clear()
|
||||
this.directGrace.clear()
|
||||
this.logical.setRecoveryPath(null)
|
||||
}
|
||||
}
|
||||
|
||||
nudge(reason: ForegroundNudgeReason): void {
|
||||
this.nudgeRouter.nudge(reason)
|
||||
}
|
||||
nudge = (reason: ForegroundNudgeReason): void => this.nudgeRouter.nudge(reason)
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true
|
||||
@@ -196,6 +197,7 @@ export class MobileEndpointSupervisor {
|
||||
this.relayReconnect.clear()
|
||||
this.leaseRotation.clear()
|
||||
this.directGrace.clear()
|
||||
this.logical.setRecoveryPath(null)
|
||||
}
|
||||
|
||||
// forceReplacement: dial past the "direct still looks live" guard — a lease
|
||||
@@ -235,7 +237,6 @@ export class MobileEndpointSupervisor {
|
||||
return
|
||||
}
|
||||
this.operationInFlight = true
|
||||
let lastError: Error | null = null
|
||||
let retryAfterOperation = false
|
||||
try {
|
||||
const selection = await selectDialableRelayCredentials({
|
||||
@@ -246,6 +247,7 @@ export class MobileEndpointSupervisor {
|
||||
})
|
||||
this.bundle = selection.bundle
|
||||
if (selection.credentials.length === 0) {
|
||||
this.logical.setRecoveryPath(null)
|
||||
// Why: "expired" vs "missing" separates a sleep-past-expiry phone
|
||||
// (needs re-pair or LAN) from a Keychain failure in field reports.
|
||||
this.logRelay(
|
||||
@@ -261,6 +263,12 @@ export class MobileEndpointSupervisor {
|
||||
}
|
||||
return
|
||||
}
|
||||
const recoveryNeeded =
|
||||
forceReplacement || this.relayReconnect.needsRecovery(this.logical.getState())
|
||||
if (this.stopped || !this.foreground || !recoveryNeeded) {
|
||||
return
|
||||
}
|
||||
this.logical.setRecoveryPath('relay')
|
||||
const dialed = await this.sessionEstablisher.dialEligible(selection.credentials)
|
||||
if (dialed.outcome === 'established') {
|
||||
// Why: a fresh socket satisfies any replacement intent queued mid-dial.
|
||||
@@ -269,15 +277,16 @@ export class MobileEndpointSupervisor {
|
||||
return
|
||||
}
|
||||
if (dialed.outcome === 'aborted') {
|
||||
this.logical.setRecoveryPath(null)
|
||||
// Why: direct won the race or the supervisor went inactive — not a
|
||||
// failure; booking backoff would delay the next genuine recovery.
|
||||
return
|
||||
}
|
||||
lastError = dialed.error
|
||||
// Why: cleanup may happen while a relay dial is awaiting the network;
|
||||
// record its outcome without recreating a foreground retry timer.
|
||||
const scheduleRetry = (!forceReplacement || ownsRecovery) && this.foreground && !this.stopped
|
||||
this.relayReconnect.registerFailure(lastError, scheduleRetry)
|
||||
this.relayReconnect.registerFailure(dialed.error, scheduleRetry)
|
||||
recoveryPresentation.clearIfCredentialBlocked(this.logical, this.relayReconnect)
|
||||
if (ownsRecovery) {
|
||||
suspendRelayIfStillConnected(this.relayReconnect, this.logical)
|
||||
}
|
||||
|
||||
@@ -7,9 +7,12 @@ import type { MobileRelayRpcSession } from './mobile-relay-rpc-session'
|
||||
import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel'
|
||||
import { RelayOuterError } from './mobile-relay-e2ee-link'
|
||||
import { RELAY_STABLE_CONNECTION_MS, RelayRetryDelays } from './mobile-relay-retry-delays'
|
||||
import { RelayCredentialEligibility } from './relay-credential-eligibility'
|
||||
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
|
||||
import type { ConnectionState, ForegroundNudgeReason } from './types'
|
||||
|
||||
type RelayCredentialLease = { expiresAt: number; version: number }
|
||||
|
||||
export type RelayReconnectDependencies = {
|
||||
now: () => number
|
||||
randomBytes: (length: number) => Uint8Array
|
||||
@@ -28,7 +31,7 @@ export class RelayReconnectController {
|
||||
private recoveryGate: RecoveryGate | null = null
|
||||
private gateReprobePending = false
|
||||
private gateReprobeStreak = 0
|
||||
private readonly rejectedCredentialVersions = new Set<number>()
|
||||
private readonly credentials: RelayCredentialEligibility
|
||||
private readonly delays: RelayRetryDelays
|
||||
|
||||
constructor(
|
||||
@@ -36,6 +39,7 @@ export class RelayReconnectController {
|
||||
private readonly onRetry: (forceReplacement?: boolean) => void
|
||||
) {
|
||||
this.delays = new RelayRetryDelays(dependencies.randomBytes)
|
||||
this.credentials = new RelayCredentialEligibility(dependencies.now)
|
||||
}
|
||||
|
||||
handleForeground(logical: StableLogicalRpcClient, wasForeground: boolean): void {
|
||||
@@ -105,7 +109,7 @@ export class RelayReconnectController {
|
||||
|
||||
resetForDirectConnection(): boolean {
|
||||
const needsCredentialRefresh =
|
||||
this.recoveryGate === 'fresh-credential' || this.rejectedCredentialVersions.size > 0
|
||||
this.recoveryGate === 'fresh-credential' || this.credentials.hasRejected()
|
||||
this.activeSession = null
|
||||
this.activeRelayConnectedAt = null
|
||||
if (needsCredentialRefresh) {
|
||||
@@ -126,22 +130,22 @@ export class RelayReconnectController {
|
||||
|
||||
completeCredentialRefresh(): void {
|
||||
if (this.recoveryGate === 'fresh-credential') {
|
||||
this.rejectedCredentialVersions.clear()
|
||||
this.credentials.clearRejected()
|
||||
this.reset()
|
||||
}
|
||||
}
|
||||
|
||||
eligibleCredentials<T extends { expiresAt: number; version: number }>(
|
||||
blocksUntilFreshCredential = (): boolean => this.recoveryGate === 'fresh-credential'
|
||||
|
||||
hasDialableCredential(...credentials: (RelayCredentialLease | null | undefined)[]): boolean {
|
||||
return this.credentials.hasDialable(...credentials)
|
||||
}
|
||||
|
||||
eligibleCredentials<T extends RelayCredentialLease>(
|
||||
...credentials: Array<T | null | undefined>
|
||||
): T[] {
|
||||
const eligible = credentials.filter((credential): credential is T =>
|
||||
Boolean(
|
||||
credential &&
|
||||
credential.expiresAt > this.dependencies.now() &&
|
||||
!this.rejectedCredentialVersions.has(credential.version)
|
||||
)
|
||||
)
|
||||
if (eligible.length === 0 && this.rejectedCredentialVersions.size > 0) {
|
||||
const eligible = this.credentials.eligible(...credentials)
|
||||
if (eligible.length === 0 && this.credentials.hasRejected()) {
|
||||
this.recoveryGate = 'fresh-credential'
|
||||
this.clearTimer()
|
||||
this.scheduleGateReprobe()
|
||||
@@ -152,7 +156,7 @@ export class RelayReconnectController {
|
||||
// For callers that found no dialable credential at all: keep a slow retry
|
||||
// alive so a later durable write can recover.
|
||||
armCredentialReprobe(): void {
|
||||
if (this.rejectedCredentialVersions.size > 0) {
|
||||
if (this.credentials.hasRejected()) {
|
||||
this.recoveryGate = 'fresh-credential'
|
||||
this.clearTimer()
|
||||
this.scheduleGateReprobe()
|
||||
@@ -176,14 +180,12 @@ export class RelayReconnectController {
|
||||
|
||||
// A durable bundle whose current version is not rejected reopens the gate.
|
||||
acceptFreshCredential(version: number): void {
|
||||
if (this.recoveryGate === 'fresh-credential' && !this.rejectedCredentialVersions.has(version)) {
|
||||
if (this.recoveryGate === 'fresh-credential' && !this.credentials.isRejected(version)) {
|
||||
this.liftGate()
|
||||
}
|
||||
}
|
||||
|
||||
recordRejectedCredential(version: number): void {
|
||||
this.rejectedCredentialVersions.add(version)
|
||||
}
|
||||
recordRejectedCredential = (version: number): void => this.credentials.recordRejected(version)
|
||||
|
||||
registerActiveFailure(logical: StableLogicalRpcClient): void {
|
||||
if (logical.getActivePath() !== 'relay') {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
|
||||
import type { RelayReconnectController } from './mobile-relay-reconnect-controller'
|
||||
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
|
||||
import type { ConnectionState } from './types'
|
||||
|
||||
export function onActiveFailure(
|
||||
logical: StableLogicalRpcClient,
|
||||
controller: RelayReconnectController,
|
||||
state: ConnectionState,
|
||||
bundle: MobileRelayCredentialBundle | null
|
||||
): void {
|
||||
if (
|
||||
controller.needsRecovery(state) &&
|
||||
logical.getActivePath() === 'relay' &&
|
||||
bundle &&
|
||||
controller.hasDialableCredential(bundle.current, bundle.grace)
|
||||
) {
|
||||
logical.setRecoveryPath('relay')
|
||||
}
|
||||
}
|
||||
|
||||
export function clearIfCredentialBlocked(
|
||||
logical: StableLogicalRpcClient,
|
||||
controller: RelayReconnectController
|
||||
): void {
|
||||
if (controller.blocksUntilFreshCredential()) {
|
||||
logical.setRecoveryPath(null)
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,9 @@ class FakeRelaySession extends FakeSession implements MobileRelayRpcSession {
|
||||
|
||||
class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
|
||||
private path: MobileConnectionPath
|
||||
private recoveryPath: MobileConnectionPath | null = null
|
||||
private generation = 1
|
||||
private readonly pathListeners = new Set<() => void>()
|
||||
|
||||
constructor(state: ConnectionState, path: MobileConnectionPath) {
|
||||
super(state)
|
||||
@@ -102,10 +105,28 @@ class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient {
|
||||
throw new Error(`replacement session ${session.getState()}`)
|
||||
}
|
||||
this.path = path
|
||||
this.recoveryPath = null
|
||||
this.generation += 1
|
||||
// Connected-state publication carries the migration cleanup.
|
||||
this.publishState('connected')
|
||||
})
|
||||
suspendActiveSession = vi.fn(() => this.publishState('disconnected'))
|
||||
getActivePath = () => this.path
|
||||
getPendingPath = () => (this.getState() === 'connected' ? null : this.recoveryPath)
|
||||
setRecoveryPath = vi.fn((path: MobileConnectionPath | null) => {
|
||||
const previous = this.getPendingPath()
|
||||
this.recoveryPath = path
|
||||
if (previous !== this.getPendingPath()) {
|
||||
for (const listener of this.pathListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
})
|
||||
onConnectionPathChange = vi.fn((listener: () => void) => {
|
||||
this.pathListeners.add(listener)
|
||||
return () => this.pathListeners.delete(listener)
|
||||
})
|
||||
getGeneration = () => this.generation
|
||||
}
|
||||
|
||||
const relay = {
|
||||
@@ -237,12 +258,15 @@ describe('relay runtime recovery without direct connectivity', () => {
|
||||
await supervisor.start()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(deps.openRelay).not.toHaveBeenCalled()
|
||||
expect(logical.setRecoveryPath).not.toHaveBeenCalledWith('relay')
|
||||
expect(logical.getPendingPath()).toBeNull()
|
||||
|
||||
readBundle.mockImplementation(async () => bundleWith(3, Number.MAX_SAFE_INTEGER))
|
||||
// Pre-fix, an expired bundle produced a silent no-op with nothing scheduled.
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
|
||||
expect(deps.openRelay).toHaveBeenCalledOnce()
|
||||
expect(logical.setRecoveryPath).toHaveBeenCalledWith('relay')
|
||||
expect(logical.getActivePath()).toBe('relay')
|
||||
supervisor.stop()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
type RelayCredentialLease = { expiresAt: number; version: number }
|
||||
|
||||
export class RelayCredentialEligibility {
|
||||
private readonly rejectedVersions = new Set<number>()
|
||||
|
||||
constructor(private readonly now: () => number) {}
|
||||
|
||||
hasRejected(): boolean {
|
||||
return this.rejectedVersions.size > 0
|
||||
}
|
||||
|
||||
clearRejected(): void {
|
||||
this.rejectedVersions.clear()
|
||||
}
|
||||
|
||||
isRejected(version: number): boolean {
|
||||
return this.rejectedVersions.has(version)
|
||||
}
|
||||
|
||||
recordRejected(version: number): void {
|
||||
this.rejectedVersions.add(version)
|
||||
}
|
||||
|
||||
hasDialable(...credentials: (RelayCredentialLease | null | undefined)[]): boolean {
|
||||
return credentials.some((credential) => this.isDialable(credential))
|
||||
}
|
||||
|
||||
eligible<T extends RelayCredentialLease>(...credentials: (T | null | undefined)[]): T[] {
|
||||
return credentials.filter((credential): credential is T => this.isDialable(credential))
|
||||
}
|
||||
|
||||
private isDialable<T extends RelayCredentialLease>(
|
||||
credential: T | null | undefined
|
||||
): credential is T {
|
||||
return Boolean(
|
||||
credential &&
|
||||
credential.expiresAt > this.now() &&
|
||||
!this.rejectedVersions.has(credential.version)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export type RpcClientContextValue = {
|
||||
getReconnectAttempt: (hostId: string) => number
|
||||
getLastConnectedAt: (hostId: string) => number | null
|
||||
getActivePath: (hostId: string) => MobileConnectionPath
|
||||
getPendingPath: (hostId: string) => MobileConnectionPath | null
|
||||
subscribeHostState: (hostId: string, listener: (state: ConnectionState) => void) => () => void
|
||||
getAllClients: () => { hostId: string; client: RpcClient }[]
|
||||
subscribeAllHosts: (listener: () => void) => () => void
|
||||
|
||||
@@ -4,7 +4,8 @@ import type { RpcClient } from './rpc-client'
|
||||
import { isRpcDeliveryUnknown, markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
|
||||
import {
|
||||
createStableLogicalRpcClient,
|
||||
LogicalClientCutoverError
|
||||
LogicalClientCutoverError,
|
||||
type MobileConnectionPath
|
||||
} from './stable-logical-rpc-client'
|
||||
|
||||
class FakeSession implements RpcClient {
|
||||
@@ -259,10 +260,13 @@ describe('stable logical RPC client', () => {
|
||||
const client = createStableLogicalRpcClient(direct, 'lan')
|
||||
direct.setState('reconnecting')
|
||||
const states: ConnectionState[] = []
|
||||
const paths: (MobileConnectionPath | null)[] = []
|
||||
client.onStateChange((next) => states.push(next))
|
||||
client.onConnectionPathChange(() => paths.push(client.getPendingPath()))
|
||||
|
||||
const migrating = client.migrateTo(replacement, 'relay')
|
||||
expect(client.getPendingPath()).toBe('relay')
|
||||
expect(paths).toEqual(['relay'])
|
||||
replacement.setState('connecting')
|
||||
replacement.setState('handshaking')
|
||||
|
||||
@@ -278,6 +282,37 @@ describe('stable logical RPC client', () => {
|
||||
expect(client.getActivePath()).toBe('relay')
|
||||
})
|
||||
|
||||
it('publishes recovery-path changes and keeps Relay pending between failed dials', async () => {
|
||||
const direct = new FakeSession('reconnecting')
|
||||
const replacement = new FakeSession('connecting')
|
||||
const client = createStableLogicalRpcClient(direct, 'tailscale')
|
||||
const paths: (MobileConnectionPath | null)[] = []
|
||||
client.onConnectionPathChange(() => paths.push(client.getPendingPath()))
|
||||
|
||||
client.setRecoveryPath('relay')
|
||||
const migrating = client.migrateTo(replacement, 'relay')
|
||||
replacement.setState('disconnected')
|
||||
await expect(migrating).rejects.toThrow(/disconnected/)
|
||||
|
||||
expect(client.getPendingPath()).toBe('relay')
|
||||
expect(paths).toEqual(['relay'])
|
||||
|
||||
client.setRecoveryPath(null)
|
||||
expect(client.getPendingPath()).toBeNull()
|
||||
expect(paths).toEqual(['relay', null])
|
||||
})
|
||||
|
||||
it('does not revive a stale recovery path after a connection later drops', () => {
|
||||
const direct = new FakeSession('reconnecting')
|
||||
const client = createStableLogicalRpcClient(direct, 'tailscale')
|
||||
|
||||
client.setRecoveryPath('relay')
|
||||
direct.setState('connected')
|
||||
direct.setState('reconnecting')
|
||||
|
||||
expect(client.getPendingPath()).toBeNull()
|
||||
})
|
||||
|
||||
it('drops the pending path when the previous session recovers mid-dial', async () => {
|
||||
const direct = new FakeSession('connected')
|
||||
const replacement = new FakeSession('connecting')
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from './migration-dial-state-forwarder'
|
||||
import { waitForAuthenticated } from './replacement-session-authentication'
|
||||
import { projectMobileRpcRequestParams } from './mobile-rpc-request-projection'
|
||||
import { LogicalClientConnectionPath } from './logical-client-connection-path'
|
||||
|
||||
export type MobileConnectionPath = 'lan' | 'tailscale' | 'relay'
|
||||
|
||||
@@ -47,9 +48,10 @@ export type StableLogicalRpcClient = RpcClient & {
|
||||
): Promise<void>
|
||||
suspendActiveSession(): void
|
||||
getActivePath(): MobileConnectionPath
|
||||
// Non-null only while a migration dial is publishing its own phases — the path the
|
||||
// user is waiting on, which the still-bound active path can't name.
|
||||
// The path the user is waiting on while migration or scheduled recovery is active.
|
||||
getPendingPath(): MobileConnectionPath | null
|
||||
setRecoveryPath(path: MobileConnectionPath | null): void
|
||||
onConnectionPathChange(listener: () => void): () => void
|
||||
getGeneration(): number
|
||||
}
|
||||
|
||||
@@ -59,7 +61,6 @@ export function createStableLogicalRpcClient(
|
||||
): StableLogicalRpcClient {
|
||||
let activeSession = initialSession
|
||||
let activePath = initialPath
|
||||
let pendingPath: MobileConnectionPath | null = null
|
||||
let generation = 1
|
||||
let closed = false
|
||||
let suspended = false
|
||||
@@ -69,6 +70,7 @@ export function createStableLogicalRpcClient(
|
||||
const pendingRequests = new Set<PendingRequest>()
|
||||
const stateListeners = new Set<(state: ConnectionState) => void>()
|
||||
let state = initialSession.getState()
|
||||
const connectionPath = new LogicalClientConnectionPath(() => state === 'connected')
|
||||
|
||||
bindActiveState(initialSession, generation)
|
||||
|
||||
@@ -207,7 +209,7 @@ export function createStableLogicalRpcClient(
|
||||
// (direct dial fails) sits in 'reconnecting' — already amber, so forwarding adds
|
||||
// nothing, but the user still has no idea relay is what's being tried.
|
||||
if (suspended || state !== 'connected') {
|
||||
pendingPath = path
|
||||
connectionPath.setMigration(path)
|
||||
}
|
||||
const forwarder = forwardMigrationDialState({
|
||||
session: nextSession,
|
||||
@@ -259,6 +261,7 @@ export function createStableLogicalRpcClient(
|
||||
}
|
||||
pendingRequests.clear()
|
||||
state = nextSession.getState()
|
||||
connectionPath.clearAfterConnected()
|
||||
for (const listener of stateListeners) {
|
||||
listener(state)
|
||||
}
|
||||
@@ -268,7 +271,9 @@ export function createStableLogicalRpcClient(
|
||||
getActivePath: () => activePath,
|
||||
// Why: a previous session that recovers mid-dial makes the pending path a lie —
|
||||
// once we're connected the user is no longer waiting on anything.
|
||||
getPendingPath: () => (state === 'connected' ? null : pendingPath),
|
||||
getPendingPath: () => connectionPath.pending(),
|
||||
setRecoveryPath: (path) => connectionPath.setRecovery(path),
|
||||
onConnectionPathChange: (listener) => connectionPath.subscribe(listener),
|
||||
getGeneration: () => generation
|
||||
}
|
||||
|
||||
@@ -276,7 +281,9 @@ export function createStableLogicalRpcClient(
|
||||
|
||||
function endDialForwarding(forwarder: MigrationDialStateForwarder, failed: boolean): void {
|
||||
forwarder.stop()
|
||||
pendingPath = null
|
||||
if (failed) {
|
||||
connectionPath.setMigration(null)
|
||||
}
|
||||
// Why: only walk back phases we published ourselves — a 'connected' here came from
|
||||
// the still-live previous session and outranks the dead dial.
|
||||
if (failed && forwarder.forwarded() && state !== 'connected') {
|
||||
@@ -314,6 +321,9 @@ export function createStableLogicalRpcClient(
|
||||
return
|
||||
}
|
||||
state = next
|
||||
if (next === 'connected') {
|
||||
connectionPath.clearAfterConnected()
|
||||
}
|
||||
for (const listener of stateListeners) {
|
||||
listener(next)
|
||||
}
|
||||
|
||||
@@ -136,10 +136,19 @@ export function useAllHostClients(hostIds: string[], options?: UseAllHostClients
|
||||
client: RpcClient
|
||||
state: ConnectionState
|
||||
path: MobileConnectionPath
|
||||
pendingPath: MobileConnectionPath | null
|
||||
}>((hostId) => {
|
||||
const client = clientsByHostId.get(hostId)
|
||||
return client
|
||||
? [{ hostId, client, state: ctx.getState(hostId), path: ctx.getActivePath(hostId) }]
|
||||
? [
|
||||
{
|
||||
hostId,
|
||||
client,
|
||||
state: ctx.getState(hostId),
|
||||
path: ctx.getActivePath(hostId),
|
||||
pendingPath: ctx.getPendingPath(hostId)
|
||||
}
|
||||
]
|
||||
: []
|
||||
})
|
||||
}, [ctx, hostIds, tick])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Worktree } from './workspace-list-sections'
|
||||
|
||||
// Why: matches desktop's PR_GROUP_META naming from worktree-list-groups.ts.
|
||||
// Why: matches desktop's PR_GROUP_META naming from worktree-list/rows/groups.ts.
|
||||
// no PR/draft/unknown -> "In Progress", open -> "In Review", merged -> "Done", closed -> "Closed"
|
||||
export type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed'
|
||||
|
||||
|
||||
@@ -727,7 +727,7 @@ final class Provider {
|
||||
|
||||
private func click(params: [String: JSONValue]) throws -> [String: Any] {
|
||||
let snapshot = try currentSnapshot(params: params)
|
||||
let button = params["mouseButton"]?.string ?? "left"
|
||||
let button = try mouseButton(params["mouseButton"]?.string)
|
||||
let count = try positiveInteger(params["clickCount"]?.number, defaultValue: 1, name: "clickCount")
|
||||
guard count <= SyntheticMouseClickDelivery.maxClickCount else {
|
||||
throw ProviderError.coded(
|
||||
@@ -741,13 +741,16 @@ final class Provider {
|
||||
recoverWindow(snapshot.app, windowId: snapshot.windowId, windowBounds: snapshot.windowBounds)
|
||||
if let elementIndex = try optionalInteger(params, "elementIndex") {
|
||||
let record = try element(snapshot, elementIndex)
|
||||
if modifiers.isEmpty, count <= 1, let actionName = try performClickAction(record: record, mouseButton: button) {
|
||||
if modifiers.isEmpty,
|
||||
count <= 1,
|
||||
button.hasAccessibilityAction,
|
||||
let actionName = try performClickAction(record: record, mouseButton: button) {
|
||||
return actionMetadata(path: "accessibility", actionName: actionName)
|
||||
}
|
||||
if let point = center(record.localFrame, in: snapshot.windowBounds) {
|
||||
try Input.click(
|
||||
at: point,
|
||||
button: mouseButton(button),
|
||||
button: button,
|
||||
count: count,
|
||||
modifiers: modifiers,
|
||||
targetWindow: snapshot
|
||||
@@ -763,7 +766,7 @@ final class Provider {
|
||||
let point = try coordinatePoint(params: params, xKey: "x", yKey: "y", snapshot: snapshot)
|
||||
try Input.click(
|
||||
at: point,
|
||||
button: mouseButton(button),
|
||||
button: button,
|
||||
count: count,
|
||||
modifiers: modifiers,
|
||||
targetWindow: snapshot
|
||||
@@ -774,8 +777,8 @@ final class Provider {
|
||||
)
|
||||
}
|
||||
|
||||
private func performClickAction(record: ElementRecord, mouseButton: String) throws -> String? {
|
||||
if mouseButton == "right" {
|
||||
private func performClickAction(record: ElementRecord, mouseButton: MouseButtonSelection) throws -> String? {
|
||||
if mouseButton == .right {
|
||||
return performAction(record.element, "AXShowMenu") ? "AXShowMenu" : nil
|
||||
}
|
||||
for action in ["AXPress", "AXConfirm", "AXOpen"] {
|
||||
@@ -1634,16 +1637,17 @@ private func screenshotScale(screenshot: ScreenshotPayload?, bounds: CGRect) ->
|
||||
)
|
||||
}
|
||||
|
||||
private enum MouseButton {
|
||||
case left
|
||||
case right
|
||||
|
||||
extension MouseButtonSelection {
|
||||
// Why: macOS has no dedicated middle-button event family; it rides `otherMouse*`
|
||||
// with the button number carried by `mouseButton:` on the event constructor.
|
||||
var cgButton: CGMouseButton {
|
||||
switch self {
|
||||
case .left:
|
||||
return .left
|
||||
case .right:
|
||||
return .right
|
||||
case .middle:
|
||||
return .center
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1653,6 +1657,8 @@ private enum MouseButton {
|
||||
return .leftMouseDown
|
||||
case .right:
|
||||
return .rightMouseDown
|
||||
case .middle:
|
||||
return .otherMouseDown
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1662,20 +1668,18 @@ private enum MouseButton {
|
||||
return .leftMouseUp
|
||||
case .right:
|
||||
return .rightMouseUp
|
||||
case .middle:
|
||||
return .otherMouseUp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func mouseButton(_ raw: String?) throws -> MouseButton {
|
||||
switch raw ?? "left" {
|
||||
case "left":
|
||||
return .left
|
||||
case "right":
|
||||
return .right
|
||||
case "middle":
|
||||
throw ProviderError.coded("invalid_argument", "middle-click is not yet supported")
|
||||
case let value:
|
||||
throw ProviderError.coded("invalid_argument", "unsupported mouse button '\(value)'")
|
||||
private func mouseButton(_ raw: String?) throws -> MouseButtonSelection {
|
||||
switch ActionArgumentValidation.mouseButton(raw) {
|
||||
case let .success(button):
|
||||
return button
|
||||
case let .failure(error):
|
||||
throw ProviderError.coded("invalid_argument", error.message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2492,7 +2496,7 @@ private func resizePng(_ image: CGImage, scale: CGFloat) -> BoundedPNG? {
|
||||
private enum Input {
|
||||
static func click(
|
||||
at point: CGPoint,
|
||||
button: MouseButton,
|
||||
button: MouseButtonSelection,
|
||||
count: Int,
|
||||
modifiers: [KeyModifier],
|
||||
targetWindow: Snapshot
|
||||
|
||||
+22
@@ -6,6 +6,18 @@ public struct ActionArgumentValidationError: Error, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum MouseButtonSelection: String, Equatable, Sendable, CaseIterable {
|
||||
case left
|
||||
case right
|
||||
case middle
|
||||
|
||||
/// Why: AXPress/AXShowMenu only model primary and secondary intent, so a middle
|
||||
/// click has no accessibility equivalent and must reach the app as real events.
|
||||
public var hasAccessibilityAction: Bool {
|
||||
self != .middle
|
||||
}
|
||||
}
|
||||
|
||||
public enum ActionArgumentValidation {
|
||||
public static func positiveInteger(
|
||||
_ value: Double?,
|
||||
@@ -31,6 +43,16 @@ public enum ActionArgumentValidation {
|
||||
return .success(value)
|
||||
}
|
||||
|
||||
public static func mouseButton(
|
||||
_ value: String?
|
||||
) -> Result<MouseButtonSelection, ActionArgumentValidationError> {
|
||||
guard let value else { return .success(.left) }
|
||||
guard let button = MouseButtonSelection(rawValue: value) else {
|
||||
return .failure(ActionArgumentValidationError("unsupported mouse button '\(value)'"))
|
||||
}
|
||||
return .success(button)
|
||||
}
|
||||
|
||||
public static func scrollDirection(_ value: String) -> Result<String, ActionArgumentValidationError> {
|
||||
switch value {
|
||||
case "up", "down", "left", "right":
|
||||
|
||||
+24
@@ -54,6 +54,30 @@ final class ActionArgumentValidationTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func testMouseButtonDefaultsToLeftAndAcceptsEveryButton() {
|
||||
XCTAssertEqual(try ActionArgumentValidation.mouseButton(nil).get(), .left)
|
||||
XCTAssertEqual(try ActionArgumentValidation.mouseButton("left").get(), .left)
|
||||
XCTAssertEqual(try ActionArgumentValidation.mouseButton("right").get(), .right)
|
||||
XCTAssertEqual(try ActionArgumentValidation.mouseButton("middle").get(), .middle)
|
||||
}
|
||||
|
||||
func testMouseButtonRejectsUnknownButtons() {
|
||||
XCTAssertEqual(
|
||||
failureMessage(ActionArgumentValidation.mouseButton("primary")),
|
||||
"unsupported mouse button 'primary'"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
failureMessage(ActionArgumentValidation.mouseButton("")),
|
||||
"unsupported mouse button ''"
|
||||
)
|
||||
}
|
||||
|
||||
func testOnlyMiddleButtonLacksAnAccessibilityAction() {
|
||||
XCTAssertTrue(MouseButtonSelection.left.hasAccessibilityAction)
|
||||
XCTAssertTrue(MouseButtonSelection.right.hasAccessibilityAction)
|
||||
XCTAssertFalse(MouseButtonSelection.middle.hasAccessibilityAction)
|
||||
}
|
||||
|
||||
func testScrollDirectionRejectsUnknownDirections() {
|
||||
XCTAssertEqual(try ActionArgumentValidation.scrollDirection("down").get(), "down")
|
||||
XCTAssertEqual(
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import Carbon
|
||||
import Foundation
|
||||
|
||||
struct KeyCharacters: Codable {
|
||||
let unmodified: String?
|
||||
let shifted: String?
|
||||
}
|
||||
|
||||
struct KeyboardLayoutSnapshot: Codable {
|
||||
let inputSourceId: String?
|
||||
let layoutSourceId: String?
|
||||
let keyCharacters: [String: KeyCharacters]
|
||||
}
|
||||
|
||||
let domKeyCodes: [(String, UInt16)] = [
|
||||
("KeyA", 0), ("KeyS", 1), ("KeyD", 2), ("KeyF", 3), ("KeyH", 4), ("KeyG", 5),
|
||||
("KeyZ", 6), ("KeyX", 7), ("KeyC", 8), ("KeyV", 9), ("KeyB", 11), ("KeyQ", 12),
|
||||
("KeyW", 13), ("KeyE", 14), ("KeyR", 15), ("KeyY", 16), ("KeyT", 17),
|
||||
("Digit1", 18), ("Digit2", 19), ("Digit3", 20), ("Digit4", 21), ("Digit6", 22),
|
||||
("Digit5", 23), ("Equal", 24), ("Digit9", 25), ("Digit7", 26), ("Minus", 27),
|
||||
("Digit8", 28), ("Digit0", 29), ("BracketRight", 30), ("KeyO", 31), ("KeyU", 32),
|
||||
("BracketLeft", 33), ("KeyI", 34), ("KeyP", 35), ("KeyL", 37), ("KeyJ", 38),
|
||||
("Quote", 39), ("KeyK", 40), ("Semicolon", 41), ("Backslash", 42), ("Comma", 43),
|
||||
("Slash", 44), ("KeyN", 45), ("KeyM", 46), ("Period", 47), ("Space", 49),
|
||||
("Backquote", 50), ("IntlBackslash", 10), ("IntlYen", 93), ("IntlRo", 94)
|
||||
]
|
||||
|
||||
func inputSourceId(_ source: TISInputSource?) -> String? {
|
||||
guard let source,
|
||||
let rawId = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) else {
|
||||
return nil
|
||||
}
|
||||
return Unmanaged<CFString>.fromOpaque(rawId).takeUnretainedValue() as String
|
||||
}
|
||||
|
||||
func translatedCharacter(
|
||||
layout: UnsafePointer<UCKeyboardLayout>,
|
||||
keyCode: UInt16,
|
||||
modifiers: UInt32
|
||||
) -> String? {
|
||||
var deadKeyState: UInt32 = 0
|
||||
var length = 0
|
||||
var characters = [UniChar](repeating: 0, count: 8)
|
||||
let status = UCKeyTranslate(
|
||||
layout,
|
||||
keyCode,
|
||||
UInt16(kUCKeyActionDown),
|
||||
modifiers,
|
||||
UInt32(LMGetKbdType()),
|
||||
OptionBits(kUCKeyTranslateNoDeadKeysMask),
|
||||
&deadKeyState,
|
||||
characters.count,
|
||||
&length,
|
||||
&characters
|
||||
)
|
||||
guard status == noErr, length > 0 else {
|
||||
return nil
|
||||
}
|
||||
return String(utf16CodeUnits: characters, count: length)
|
||||
}
|
||||
|
||||
func readStableSnapshot() -> KeyboardLayoutSnapshot? {
|
||||
let currentInputSource = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue()
|
||||
let currentLayoutSource = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue()
|
||||
let capturedInputSourceId = inputSourceId(currentInputSource)
|
||||
let capturedLayoutSourceId = inputSourceId(currentLayoutSource)
|
||||
var keyCharacters: [String: KeyCharacters] = [:]
|
||||
|
||||
if let currentLayoutSource,
|
||||
let rawData = TISGetInputSourceProperty(
|
||||
currentLayoutSource,
|
||||
kTISPropertyUnicodeKeyLayoutData
|
||||
) {
|
||||
let data = Unmanaged<CFData>.fromOpaque(rawData).takeUnretainedValue()
|
||||
if let bytes = CFDataGetBytePtr(data) {
|
||||
let layout = UnsafeRawPointer(bytes).assumingMemoryBound(to: UCKeyboardLayout.self)
|
||||
for (code, keyCode) in domKeyCodes {
|
||||
keyCharacters[code] = KeyCharacters(
|
||||
unmodified: translatedCharacter(layout: layout, keyCode: keyCode, modifiers: 0),
|
||||
shifted: translatedCharacter(
|
||||
layout: layout,
|
||||
keyCode: keyCode,
|
||||
modifiers: UInt32(shiftKey >> 8)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let finalInputSource = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue()
|
||||
let finalLayoutSource = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue()
|
||||
guard capturedInputSourceId == inputSourceId(finalInputSource),
|
||||
capturedLayoutSourceId == inputSourceId(finalLayoutSource) else {
|
||||
return nil
|
||||
}
|
||||
return KeyboardLayoutSnapshot(
|
||||
inputSourceId: capturedInputSourceId,
|
||||
layoutSourceId: capturedLayoutSourceId,
|
||||
keyCharacters: keyCharacters
|
||||
)
|
||||
}
|
||||
|
||||
for _ in 0..<2 {
|
||||
if let snapshot = readStableSnapshot() {
|
||||
let encoded = try JSONEncoder().encode(snapshot)
|
||||
print(String(decoding: encoded, as: UTF8.self))
|
||||
exit(0)
|
||||
}
|
||||
}
|
||||
exit(1)
|
||||
+4
-2
@@ -25,6 +25,7 @@
|
||||
"lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs",
|
||||
"prepare": "husky",
|
||||
"test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts",
|
||||
"test:skill-sharing:release": "vitest run --config config/vitest.config.ts src/main/skills src/main/runtime/rpc/methods/skills.test.ts src/relay/skill-install-handler.test.ts src/shared/skill-bundle-install-contract.test.ts src/shared/skill-install-contract.test.ts src/shared/skill-install-failure.test.ts src/shared/skill-package-manifest.test.ts",
|
||||
"test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs",
|
||||
"check:reliability-gates": "node config/scripts/check-reliability-gates.mjs",
|
||||
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
|
||||
@@ -54,6 +55,7 @@
|
||||
"dev:web": "vite --config vite.web.config.ts --host 127.0.0.1",
|
||||
"build:relay": "node config/scripts/build-relay.mjs",
|
||||
"build:computer-macos": "node config/scripts/build-computer-macos.mjs",
|
||||
"build:keyboard-layout-macos": "node config/scripts/build-keyboard-layout-macos.mjs",
|
||||
"build:notification-status-macos": "node config/scripts/build-notification-status-macos.mjs",
|
||||
"build:native": "node config/scripts/build-native-for-platform.mjs",
|
||||
"smoke:computer": "node config/scripts/computer-use-smoke.mjs",
|
||||
@@ -80,8 +82,8 @@
|
||||
"build:unpack": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --dir",
|
||||
"build:win": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --win",
|
||||
"build:icons": "bash resources/icon-source/generate.sh",
|
||||
"build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && node config/scripts/build-mac-local.mjs",
|
||||
"build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac",
|
||||
"build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run build:keyboard-layout-macos && pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && node config/scripts/build-mac-local.mjs",
|
||||
"build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && ORCA_MAC_RELEASE=1 pnpm run build:keyboard-layout-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac",
|
||||
"build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb",
|
||||
"test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project=electron-headless",
|
||||
"test:e2e:workspace-session-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-quit-relaunch-session.spec.ts tests/e2e/golden-terminal-file-link.spec.ts tests/e2e/golden-worktree-create-switch.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
|
||||
Generated
+24
-24
@@ -18,7 +18,7 @@ patchedDependencies:
|
||||
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
|
||||
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
|
||||
'@xterm/xterm@6.1.0-beta.287':
|
||||
hash: 8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67
|
||||
hash: 08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089
|
||||
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
|
||||
node-pty@1.1.0:
|
||||
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
|
||||
@@ -45,7 +45,7 @@ importers:
|
||||
version: 2.5.6
|
||||
'@xterm/addon-serialize':
|
||||
specifier: 0.15.0-beta.287
|
||||
version: 0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
|
||||
version: 0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
|
||||
'@xterm/headless':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287
|
||||
@@ -208,25 +208,25 @@ importers:
|
||||
version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
|
||||
'@xterm/addon-fit':
|
||||
specifier: 0.12.0-beta.287
|
||||
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
|
||||
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
|
||||
'@xterm/addon-ligatures':
|
||||
specifier: 0.11.0-beta.287
|
||||
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
|
||||
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
|
||||
'@xterm/addon-search':
|
||||
specifier: 0.17.0-beta.287
|
||||
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
|
||||
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
|
||||
'@xterm/addon-unicode11':
|
||||
specifier: 0.10.0-beta.287
|
||||
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
|
||||
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
|
||||
'@xterm/addon-web-links':
|
||||
specifier: 0.13.0-beta.287
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
|
||||
'@xterm/addon-webgl':
|
||||
specifier: 0.20.0-beta.286
|
||||
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))
|
||||
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
|
||||
'@xterm/xterm':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
version: 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -9591,39 +9591,39 @@ snapshots:
|
||||
|
||||
'@xmldom/xmldom@0.8.13': {}
|
||||
|
||||
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
|
||||
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
|
||||
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
|
||||
lru-cache: 11.5.1
|
||||
opentype.js: 2.0.0
|
||||
|
||||
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
|
||||
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
|
||||
|
||||
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
|
||||
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
|
||||
|
||||
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
|
||||
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
|
||||
|
||||
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
|
||||
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
|
||||
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67))':
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
|
||||
|
||||
'@xterm/headless@6.1.0-beta.287': {}
|
||||
|
||||
'@xterm/xterm@6.1.0-beta.287(patch_hash=8a2d562c08d6a1edb38002299fc7d9eac9b9940b876868c4a8edde466019da67)': {}
|
||||
'@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)': {}
|
||||
|
||||
abbrev@4.0.0: {}
|
||||
|
||||
|
||||
@@ -40,18 +40,18 @@
|
||||
{
|
||||
"name": "orca-cli",
|
||||
"sourcePath": "skills/orca-cli",
|
||||
"releaseRevision": 36,
|
||||
"packageDigest": "6eaa0624f45402646d87d9a559f2b3dbbc0efd1d5e56cdfde219671ad62d320e",
|
||||
"gitTreeSha": "a5a90c9731fbee834eca6d655b39eaa795dd2323",
|
||||
"releaseRevision": 37,
|
||||
"packageDigest": "d5648df9c29b479bbbe0dea68f0506b2b0bad0827ce551451fb8f300c113b305",
|
||||
"gitTreeSha": "ee3a35c76f875ea6ebaf68947af13ba6346b13f2",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 3913,
|
||||
"size": 3944,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012",
|
||||
"textNormalizedSha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012",
|
||||
"identitySha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012"
|
||||
"exactSha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163",
|
||||
"textNormalizedSha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163",
|
||||
"identitySha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -577,6 +577,22 @@
|
||||
"identitySha256": "ed609615d7fcefc30509b54a55a5513991f0dc45d817a1e12918f05eb4846012"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"releaseRevision": 37,
|
||||
"packageDigest": "d5648df9c29b479bbbe0dea68f0506b2b0bad0827ce551451fb8f300c113b305",
|
||||
"gitTreeSha": "ee3a35c76f875ea6ebaf68947af13ba6346b13f2",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 3944,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163",
|
||||
"textNormalizedSha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163",
|
||||
"identitySha256": "cdd5d9c8a95837a6cc24d68b150a117684afe9bbb2d763de2cc7fba1da1ed163"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"orchestration": [
|
||||
|
||||
@@ -70,6 +70,8 @@ ORCA computer get-app-state --app <app> --restore-window --json
|
||||
ORCA computer click --app <app> --element-index <index> --json
|
||||
ORCA computer click --app <app> --x 100 --y 100 --json
|
||||
ORCA computer click --app <app> --x 100 --y 100 --modifiers CmdOrCtrl+Shift --json
|
||||
ORCA computer click --app <app> --element-index <index> --mouse-button right --json
|
||||
ORCA computer click --app <app> --element-index <index> --mouse-button middle --json
|
||||
ORCA computer perform-secondary-action --app <app> --element-index <index> --action <name> --json
|
||||
ORCA computer set-value --app <app> --element-index <index> --value "text" --json
|
||||
ORCA computer type-text --app <app> --text "text" --json
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
name: orca-cli
|
||||
description: >-
|
||||
Use the public `orca` CLI to operate Orca-managed worktrees, folder contexts,
|
||||
terminals, repos, automations, artifacts, worktree comments, and the browser
|
||||
terminals, repos, automations, artifacts, skill sharing, worktree comments, and the browser
|
||||
embedded inside the Orca app. Use when the user says "$orca-cli", "use orca cli",
|
||||
"Orca worktree", "child worktree", "cardStatus", "spawn codex/claude in a worktree",
|
||||
"read/wait/send Orca terminal", "terminal send", "full handoff", "handover",
|
||||
"give this to another agent", "another worktree", "Orca browser", "orca artifacts",
|
||||
"share HTML/Markdown", "public artifact link", or "control the browser inside
|
||||
"share HTML/Markdown", "public artifact link", "share skills", or "control the browser inside
|
||||
Orca". Prefer this over raw `git worktree`, ad hoc
|
||||
PTYs, Playwright, or Computer Use when the task touches Orca-managed state.
|
||||
Use Computer Use for browser windows, webviews, or desktop UI outside Orca's
|
||||
@@ -272,6 +272,39 @@ ORCA artifacts delete <id> --json
|
||||
- `ORCA_CLOUD_AUTH_TOKEN` is a development-only authentication override. Prefer the active
|
||||
Orca profile's normal PropelAuth session and never expose the token in logs or agent output.
|
||||
|
||||
## Skill Sharing
|
||||
|
||||
Agents can publish one or more installed skills behind one unlisted link through the
|
||||
signed-in Orca account. The user must first grant the separate, default-off permission in
|
||||
Settings → Share Skills ("Allow agents and the Orca CLI to publish skill links"). There is
|
||||
no CLI or RPC way to grant it. Manual publishing from the reviewed desktop flow remains
|
||||
available without this agent permission.
|
||||
|
||||
```text
|
||||
ORCA skills installed --json
|
||||
ORCA skills share --skill <selector> [--skill <selector> ...] --bundle-name <name> --json
|
||||
```
|
||||
|
||||
- `skills installed` returns safe discovery IDs and names. It does not expose local skill
|
||||
paths in CLI output. Sharing then verifies that each `SKILL.md` declares a portable
|
||||
lowercase name containing only letters, numbers, and hyphens.
|
||||
- Each `--skill` must be an exact discovery ID or an unambiguous installed-skill name.
|
||||
Use IDs when names collide.
|
||||
- Multiple `--skill` flags create one bundle and one link. `--all` and arbitrary paths are
|
||||
intentionally unsupported; name every skill the user asked to publish.
|
||||
- Skill folders can contain scripts, configuration, credentials, or other private files.
|
||||
Treat the permission as authority, not blanket intent: publish only the explicitly
|
||||
requested skills and never widen the selection.
|
||||
- A denied command fails with `agent_skill_sharing_disabled`. Do not retry; ask the user to
|
||||
enable the switch in the desktop app if they want this action.
|
||||
- Orca stages one agent-published bundle at a time per host. If another publish is active,
|
||||
wait for it to finish before retrying `agent_skill_sharing_busy`.
|
||||
- Run the command in an Orca terminal on the machine that stores the skills. Forwarded WSL,
|
||||
SSH, and paired-runtime invocations fail before discovery so Orca cannot read from the
|
||||
wrong filesystem.
|
||||
- The JSON result contains the unlisted URL and public share/package/version IDs. It never
|
||||
includes cloud authentication tokens.
|
||||
|
||||
## Built-In Browser
|
||||
|
||||
The built-in browser is Orca's embedded browser tab surface, scoped to Orca worktrees; it is not Chrome/Safari or desktop app UI.
|
||||
@@ -343,7 +376,7 @@ Common recoveries:
|
||||
|
||||
## Next Action
|
||||
|
||||
Confirm `orca status --json` unless already checked this turn, then choose the narrowest command for the job: `worktree ps/current/create`, `terminal list/read/wait/send`, `automations list`, `artifacts list/share`, or built-in browser `snapshot`.
|
||||
Confirm `orca status --json` unless already checked this turn, then choose the narrowest command for the job: `worktree ps/current/create`, `terminal list/read/wait/send`, `automations list`, `artifacts list/share`, `skills installed/share`, or built-in browser `snapshot`.
|
||||
|
||||
## Mobile Emulator (iOS Simulator via serve-sim)
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
name: orca-cli
|
||||
description: >-
|
||||
Use the public `orca` CLI to operate Orca-managed worktrees, folder contexts,
|
||||
terminals, repos, automations, artifacts, worktree comments, and the browser
|
||||
terminals, repos, automations, artifacts, skill sharing, worktree comments, and the browser
|
||||
embedded inside the Orca app. Use when the user says "$orca-cli", "use orca cli",
|
||||
"Orca worktree", "child worktree", "cardStatus", "spawn codex/claude in a worktree",
|
||||
"read/wait/send Orca terminal", "terminal send", "full handoff", "handover",
|
||||
"give this to another agent", "another worktree", "Orca browser", "orca artifacts",
|
||||
"share HTML/Markdown", "public artifact link", or "control the browser inside
|
||||
"share HTML/Markdown", "public artifact link", "share skills", or "control the browser inside
|
||||
Orca". Prefer this over raw `git worktree`, ad hoc
|
||||
PTYs, Playwright, or Computer Use when the task touches Orca-managed state.
|
||||
Use Computer Use for browser windows, webviews, or desktop UI outside Orca's
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -235,6 +235,11 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
|
||||
keys: ['vm recipe doctor'],
|
||||
load: async () => (await import('./handlers/vm.js')).VM_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'skill-sharing',
|
||||
keys: ['skills installed', 'skills share'],
|
||||
load: async () => (await import('./handlers/skill-sharing.js')).SKILL_SHARING_HANDLERS
|
||||
},
|
||||
{
|
||||
name: 'skills',
|
||||
keys: ['skills list', 'skills get', 'skills install', 'skills update'],
|
||||
|
||||
@@ -80,6 +80,32 @@ describe('orca computer action CLI routing', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards a middle click to the runtime instead of silently downgrading it', async () => {
|
||||
queueFixtures(callMock, okFixture('req_click', sampleSnapshot()))
|
||||
|
||||
await main(
|
||||
[
|
||||
'computer',
|
||||
'click',
|
||||
'--session',
|
||||
'manual',
|
||||
'--app',
|
||||
'Finder',
|
||||
'--element-index',
|
||||
'3',
|
||||
'--mouse-button',
|
||||
'middle',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo/src'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'computer.click',
|
||||
expect.objectContaining({ mouseButton: 'middle' })
|
||||
)
|
||||
})
|
||||
|
||||
it('prints session and window context in action follow-up commands', async () => {
|
||||
queueFixtures(callMock, okFixture('req_click', sampleSnapshot()))
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { REPEATED_FLAG_SEPARATOR } from '../args'
|
||||
import { RuntimeRpcFailureError } from '../runtime-client'
|
||||
import { SKILL_SHARING_HANDLERS } from './skill-sharing'
|
||||
|
||||
const successMeta = { runtimeId: 'runtime-1' }
|
||||
|
||||
function context(
|
||||
call: ReturnType<typeof vi.fn>,
|
||||
flags = new Map<string, string | boolean>(),
|
||||
options: { isRemote?: boolean; json?: boolean } = {}
|
||||
) {
|
||||
return {
|
||||
client: { call, isRemote: options.isRemote ?? false },
|
||||
cwd: '/repo',
|
||||
flags,
|
||||
json: options.json ?? false
|
||||
} as never
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('skill sharing CLI handlers', () => {
|
||||
it('lists safe installed-skill selectors without local paths', async () => {
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
id: 'request-1',
|
||||
ok: true,
|
||||
result: {
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-id',
|
||||
name: 'alpha',
|
||||
description: 'Alpha skill',
|
||||
providers: ['codex'],
|
||||
sourceKind: 'home',
|
||||
sourceLabel: 'Codex',
|
||||
rootPath: '/secret/root',
|
||||
directoryPath: '/secret/root/alpha',
|
||||
skillFilePath: '/secret/root/alpha/SKILL.md',
|
||||
installed: true,
|
||||
updatedAt: null
|
||||
}
|
||||
],
|
||||
sources: [],
|
||||
scannedAt: 1
|
||||
},
|
||||
_meta: successMeta
|
||||
})
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
|
||||
await SKILL_SHARING_HANDLERS['skills installed']!(context(call, new Map(), { json: true }))
|
||||
|
||||
expect(call).toHaveBeenCalledWith('skills.discover', { cwd: '/repo' })
|
||||
const output = String(log.mock.calls[0][0])
|
||||
expect(output).toContain('skill-id')
|
||||
expect(output).not.toContain('/secret/root')
|
||||
})
|
||||
|
||||
it('denies publishing at preflight before invoking skills.share', async () => {
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
id: 'request-1',
|
||||
ok: true,
|
||||
result: { settings: { agentSkillSharingEnabled: false } },
|
||||
_meta: successMeta
|
||||
})
|
||||
|
||||
await expect(
|
||||
SKILL_SHARING_HANDLERS['skills share']!(
|
||||
context(
|
||||
call,
|
||||
new Map([
|
||||
['skill', 'alpha'],
|
||||
['bundle-name', 'team-skills']
|
||||
])
|
||||
)
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'agent_skill_sharing_disabled' })
|
||||
expect(call).toHaveBeenCalledExactlyOnceWith('settings.get')
|
||||
})
|
||||
|
||||
it('publishes multiple explicit skills and prints only public output', async () => {
|
||||
const call = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: 'settings',
|
||||
ok: true,
|
||||
result: { settings: { agentSkillSharingEnabled: true } },
|
||||
_meta: successMeta
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'share',
|
||||
ok: true,
|
||||
result: {
|
||||
status: 'ok',
|
||||
value: {
|
||||
share: { id: 'shr_public', url: 'https://share.onorca.dev/skills/share/shr_public' },
|
||||
version: {
|
||||
packageId: 'pkg_public',
|
||||
versionId: 'ver_public',
|
||||
name: 'team-skills'
|
||||
},
|
||||
selectedSkills: [
|
||||
{ id: 'alpha-id', name: 'alpha', description: null },
|
||||
{ id: 'beta-id', name: 'beta', description: null }
|
||||
]
|
||||
}
|
||||
},
|
||||
_meta: successMeta
|
||||
})
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
|
||||
await SKILL_SHARING_HANDLERS['skills share']!(
|
||||
context(
|
||||
call,
|
||||
new Map([
|
||||
['skill', `alpha${REPEATED_FLAG_SEPARATOR}beta`],
|
||||
['bundle-name', 'team-skills'],
|
||||
['release-notes', 'Initial bundle']
|
||||
]),
|
||||
{ json: true }
|
||||
)
|
||||
)
|
||||
|
||||
expect(call).toHaveBeenLastCalledWith(
|
||||
'skills.share',
|
||||
{
|
||||
skillSelectors: ['alpha', 'beta'],
|
||||
bundleName: 'team-skills',
|
||||
releaseNotes: 'Initial bundle',
|
||||
target: { cwd: '/repo' }
|
||||
},
|
||||
{ timeoutMs: 600_000 }
|
||||
)
|
||||
const output = String(log.mock.calls[0][0])
|
||||
expect(output).toContain('shr_public')
|
||||
expect(output).toContain('pkg_public')
|
||||
expect(output).not.toMatch(/token|secret/i)
|
||||
})
|
||||
|
||||
it('normalizes a human-readable bundle name before publishing', async () => {
|
||||
const call = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: 'settings',
|
||||
ok: true,
|
||||
result: { settings: { agentSkillSharingEnabled: true } },
|
||||
_meta: successMeta
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'share',
|
||||
ok: true,
|
||||
result: {
|
||||
status: 'ok',
|
||||
value: {
|
||||
share: { id: 'shr_public', url: 'https://share.onorca.dev/skills/share/shr_public' },
|
||||
version: {
|
||||
packageId: 'pkg_public',
|
||||
versionId: 'ver_public',
|
||||
name: 'team-skills-v2.0'
|
||||
},
|
||||
selectedSkills: [{ id: 'alpha-id', name: 'alpha', description: null }]
|
||||
}
|
||||
},
|
||||
_meta: successMeta
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
|
||||
await SKILL_SHARING_HANDLERS['skills share']!(
|
||||
context(
|
||||
call,
|
||||
new Map([
|
||||
['skill', 'alpha'],
|
||||
['bundle-name', 'Téam Skills -- v2.0']
|
||||
])
|
||||
)
|
||||
)
|
||||
|
||||
expect(call).toHaveBeenLastCalledWith(
|
||||
'skills.share',
|
||||
{
|
||||
skillSelectors: ['alpha'],
|
||||
bundleName: 'team-skills-v2.0',
|
||||
releaseNotes: '',
|
||||
target: { cwd: '/repo' }
|
||||
},
|
||||
{ timeoutMs: 600_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a bundle name that cannot produce a valid package name', async () => {
|
||||
const call = vi.fn()
|
||||
|
||||
await expect(
|
||||
SKILL_SHARING_HANDLERS['skills share']!(
|
||||
context(
|
||||
call,
|
||||
new Map([
|
||||
['skill', 'alpha'],
|
||||
['bundle-name', '🔥']
|
||||
])
|
||||
)
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
message: '--bundle-name must contain at least one English letter or number.'
|
||||
})
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a forwarded shell', false, '/remote/repo'],
|
||||
['a paired runtime', true, undefined]
|
||||
])('rejects %s before any discovery or publish call', async (_label, isRemote, forwardedCwd) => {
|
||||
if (forwardedCwd) {
|
||||
vi.stubEnv('ORCA_CLI_CWD', forwardedCwd)
|
||||
}
|
||||
const call = vi.fn()
|
||||
|
||||
await expect(
|
||||
SKILL_SHARING_HANDLERS['skills share']!(
|
||||
context(
|
||||
call,
|
||||
new Map([
|
||||
['skill', 'alpha'],
|
||||
['bundle-name', 'alpha']
|
||||
]),
|
||||
{ isRemote }
|
||||
)
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'invalid_environment' })
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('gives an actionable upgrade error for an older runtime', async () => {
|
||||
const call = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
id: 'settings',
|
||||
ok: true,
|
||||
result: { settings: {} },
|
||||
_meta: successMeta
|
||||
})
|
||||
.mockRejectedValueOnce(
|
||||
new RuntimeRpcFailureError({
|
||||
id: 'share',
|
||||
ok: false,
|
||||
error: { code: 'method_not_found', message: 'Unknown method' },
|
||||
_meta: successMeta
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
SKILL_SHARING_HANDLERS['skills share']!(
|
||||
context(
|
||||
call,
|
||||
new Map([
|
||||
['skill', 'alpha'],
|
||||
['bundle-name', 'alpha']
|
||||
])
|
||||
)
|
||||
)
|
||||
).rejects.toMatchObject({ code: 'update_required' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
import type {
|
||||
AgentSkillShareOperation,
|
||||
AgentSkillShareResult
|
||||
} from '../../shared/agent-skill-sharing-contract'
|
||||
import {
|
||||
AGENT_SKILL_SHARING_DISABLED_CODE,
|
||||
AGENT_SKILL_SHARING_DISABLED_MESSAGE,
|
||||
AGENT_SKILL_SHARING_DISABLED_NEXT_STEPS
|
||||
} from '../../shared/agent-skill-sharing-gate'
|
||||
import { normalizeSkillBundleName } from '../../shared/skill-bundle-name'
|
||||
import type { SkillCloudOperation } from '../../shared/skill-cloud-contract'
|
||||
import type { SkillDiscoveryResult } from '../../shared/skills'
|
||||
import type { CommandHandler, HandlerContext } from '../dispatch'
|
||||
import { getRepeatedStringFlag } from '../flags'
|
||||
import { printResult } from '../format'
|
||||
import {
|
||||
RuntimeClientError,
|
||||
RuntimeRpcFailureError,
|
||||
type RuntimeRpcSuccess
|
||||
} from '../runtime-client'
|
||||
|
||||
const SHARE_TIMEOUT_MS = 10 * 60_000
|
||||
|
||||
type InstalledSkillSummary = Pick<
|
||||
SkillDiscoveryResult['skills'][number],
|
||||
'id' | 'name' | 'description' | 'providers' | 'sourceKind' | 'sourceLabel'
|
||||
>
|
||||
|
||||
type SharedSkillSummary = {
|
||||
url: string
|
||||
shareId: string
|
||||
packageId: string
|
||||
versionId: string
|
||||
bundleName: string
|
||||
skills: AgentSkillShareResult['selectedSkills']
|
||||
}
|
||||
|
||||
function stringFlag(ctx: HandlerContext, name: string): string | undefined {
|
||||
const value = ctx.flags.get(name)
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined
|
||||
}
|
||||
|
||||
function rejectForwardedSkillFilesystem(ctx: HandlerContext, command: string): void {
|
||||
if (!process.env.ORCA_CLI_CWD && !ctx.client.isRemote) {
|
||||
return
|
||||
}
|
||||
throw new RuntimeClientError(
|
||||
'invalid_environment',
|
||||
`orca skills ${command} must run on the machine whose installed skills you want to use. Run the command from an Orca terminal on that machine.`
|
||||
)
|
||||
}
|
||||
|
||||
async function preflightPublishCapability(ctx: HandlerContext): Promise<void> {
|
||||
let enabled: unknown
|
||||
try {
|
||||
const response = await ctx.client.call<{
|
||||
settings?: { agentSkillSharingEnabled?: boolean }
|
||||
}>('settings.get')
|
||||
enabled = response.result?.settings?.agentSkillSharingEnabled
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (enabled === false) {
|
||||
throw new RuntimeClientError(
|
||||
AGENT_SKILL_SHARING_DISABLED_CODE,
|
||||
AGENT_SKILL_SHARING_DISABLED_MESSAGE,
|
||||
{ nextSteps: [...AGENT_SKILL_SHARING_DISABLED_NEXT_STEPS] }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function requireCloudOperation<T>(operation: SkillCloudOperation<T>): T {
|
||||
if (operation.status === 'ok') {
|
||||
return operation.value
|
||||
}
|
||||
if (operation.status === 'reconnect-required') {
|
||||
throw new RuntimeClientError('authentication_required', 'Sign in to Orca and try again.')
|
||||
}
|
||||
throw new RuntimeClientError('authentication_unconfigured', operation.message)
|
||||
}
|
||||
|
||||
function installedSummary(result: SkillDiscoveryResult): InstalledSkillSummary[] {
|
||||
return result.skills.map(({ id, name, description, providers, sourceKind, sourceLabel }) => ({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
providers,
|
||||
sourceKind,
|
||||
sourceLabel
|
||||
}))
|
||||
}
|
||||
|
||||
function formatInstalledSkills(skills: InstalledSkillSummary[]): string {
|
||||
if (skills.length === 0) {
|
||||
return 'No installed skills found.'
|
||||
}
|
||||
return skills
|
||||
.map(
|
||||
(skill) =>
|
||||
`${skill.name} (${skill.id})\n ${skill.description ?? 'No description'}\n ${skill.sourceLabel}`
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function sharedSummary(result: AgentSkillShareResult): SharedSkillSummary {
|
||||
return {
|
||||
url: result.share.url,
|
||||
shareId: result.share.id,
|
||||
packageId: result.version.packageId,
|
||||
versionId: result.version.versionId,
|
||||
bundleName: result.version.name,
|
||||
skills: result.selectedSkills
|
||||
}
|
||||
}
|
||||
|
||||
function formatSharedSkill(result: SharedSkillSummary): string {
|
||||
return `Shared ${result.skills.length} skill${result.skills.length === 1 ? '' : 's'}: ${result.url}`
|
||||
}
|
||||
|
||||
async function callShare(
|
||||
ctx: HandlerContext,
|
||||
params: {
|
||||
skillSelectors: string[]
|
||||
bundleName: string
|
||||
releaseNotes: string
|
||||
target: { cwd: string }
|
||||
}
|
||||
): Promise<RuntimeRpcSuccess<AgentSkillShareOperation>> {
|
||||
try {
|
||||
return await ctx.client.call<AgentSkillShareOperation>('skills.share', params, {
|
||||
timeoutMs: SHARE_TIMEOUT_MS
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeRpcFailureError && error.code === 'method_not_found') {
|
||||
throw new RuntimeClientError(
|
||||
'update_required',
|
||||
'The connected Orca runtime does not support agent skill sharing yet. Update Orca on that machine and try again.'
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export const SKILL_SHARING_HANDLERS: Record<string, CommandHandler> = {
|
||||
'skills installed': async (ctx) => {
|
||||
rejectForwardedSkillFilesystem(ctx, 'installed')
|
||||
const response = await ctx.client.call<SkillDiscoveryResult>('skills.discover', {
|
||||
cwd: ctx.cwd
|
||||
})
|
||||
const skills = installedSummary(response.result)
|
||||
printResult({ ...response, result: { skills } }, ctx.json, (value) =>
|
||||
formatInstalledSkills(value.skills)
|
||||
)
|
||||
},
|
||||
'skills share': async (ctx) => {
|
||||
rejectForwardedSkillFilesystem(ctx, 'share')
|
||||
const skillSelectors = getRepeatedStringFlag(ctx.flags, 'skill')
|
||||
if (skillSelectors.length === 0) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Select at least one installed skill with --skill. Run `orca skills installed` to list them.'
|
||||
)
|
||||
}
|
||||
const bundleLabel = stringFlag(ctx, 'bundle-name')
|
||||
if (!bundleLabel) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Missing required --bundle-name.')
|
||||
}
|
||||
const bundleName = normalizeSkillBundleName(bundleLabel)
|
||||
if (!bundleName) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--bundle-name must contain at least one English letter or number.'
|
||||
)
|
||||
}
|
||||
await preflightPublishCapability(ctx)
|
||||
const response = await callShare(ctx, {
|
||||
skillSelectors,
|
||||
bundleName,
|
||||
releaseNotes: stringFlag(ctx, 'release-notes') ?? '',
|
||||
target: { cwd: ctx.cwd }
|
||||
})
|
||||
const value = sharedSummary(requireCloudOperation(response.result))
|
||||
printResult({ ...response, result: value }, ctx.json, formatSharedSkill)
|
||||
}
|
||||
}
|
||||
@@ -61,3 +61,77 @@ describe('terminal close CLI', () => {
|
||||
expect(help).toContain('durable persistence')
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal send CLI', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('marks combined text and Enter as an agent prompt candidate', async () => {
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
result: { send: { handle: 'term-1', accepted: true, bytesWritten: 7 } }
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await TERMINAL_HANDLERS['terminal send']({
|
||||
flags: new Map<string, string | true>([
|
||||
['terminal', 'term-1'],
|
||||
['text', 'review'],
|
||||
['enter', true]
|
||||
]),
|
||||
client: { call } as unknown as RuntimeClient,
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
|
||||
expect(call).toHaveBeenCalledWith('terminal.send', {
|
||||
terminal: 'term-1',
|
||||
text: 'review',
|
||||
enter: true,
|
||||
interrupt: false,
|
||||
agentPrompt: true,
|
||||
client: { id: 'orca-cli', type: 'desktop' }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps text-only and bare Enter sends as direct terminal input', async () => {
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
result: { send: { handle: 'term-1', accepted: true, bytesWritten: 1 } }
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await TERMINAL_HANDLERS['terminal send']({
|
||||
flags: new Map<string, string | true>([
|
||||
['terminal', 'term-1'],
|
||||
['text', 'x']
|
||||
]),
|
||||
client: { call } as unknown as RuntimeClient,
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
await TERMINAL_HANDLERS['terminal send']({
|
||||
flags: new Map<string, string | true>([
|
||||
['terminal', 'term-1'],
|
||||
['enter', true]
|
||||
]),
|
||||
client: { call } as unknown as RuntimeClient,
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
|
||||
expect(call).toHaveBeenNthCalledWith(1, 'terminal.send', {
|
||||
terminal: 'term-1',
|
||||
text: 'x',
|
||||
enter: false,
|
||||
interrupt: false,
|
||||
client: { id: 'orca-cli', type: 'desktop' }
|
||||
})
|
||||
expect(call).toHaveBeenNthCalledWith(2, 'terminal.send', {
|
||||
terminal: 'term-1',
|
||||
text: undefined,
|
||||
enter: true,
|
||||
interrupt: false,
|
||||
client: { id: 'orca-cli', type: 'desktop' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -84,11 +84,15 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
|
||||
printResult(result, json, formatTerminalRead)
|
||||
},
|
||||
'terminal send': async ({ flags, client, cwd, json }) => {
|
||||
const text = getOptionalStringFlag(flags, 'text')
|
||||
const enter = flags.get('enter') === true
|
||||
const interrupt = flags.get('interrupt') === true
|
||||
const result = await client.call<{ send: RuntimeTerminalSend }>('terminal.send', {
|
||||
terminal: await getTerminalHandle(flags, cwd, client),
|
||||
text: getOptionalStringFlag(flags, 'text'),
|
||||
enter: flags.get('enter') === true,
|
||||
interrupt: flags.get('interrupt') === true,
|
||||
text,
|
||||
enter,
|
||||
interrupt,
|
||||
...(text && enter && !interrupt ? { agentPrompt: true } : {}),
|
||||
client: { id: 'orca-cli', type: 'desktop' }
|
||||
})
|
||||
printResult(result, json, formatTerminalSend)
|
||||
|
||||
@@ -23,6 +23,8 @@ Accounts:
|
||||
account list List managed Claude and Codex accounts on this Orca host
|
||||
|
||||
Skills:
|
||||
skills installed List installed skill selectors
|
||||
skills share Publish selected skills behind one unlisted link
|
||||
skills list List version-matched skill guides bundled with this Orca CLI
|
||||
skills get Print a version-matched skill guide as Markdown
|
||||
skills install Install bundled Orca skills globally via the community skills CLI
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
callMock,
|
||||
runtimeClientConstructorMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock,
|
||||
addEnvironmentFromPairingCodeMock,
|
||||
listEnvironmentsMock,
|
||||
spawnMock
|
||||
} = vi.hoisted(() => ({
|
||||
callMock: vi.fn(),
|
||||
runtimeClientConstructorMock: vi.fn(),
|
||||
serveOrcaAppMock: vi.fn(),
|
||||
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
|
||||
addEnvironmentFromPairingCodeMock: vi.fn(),
|
||||
listEnvironmentsMock: vi.fn(),
|
||||
spawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./runtime-client', async () => {
|
||||
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
|
||||
return createRuntimeClientModuleMock({
|
||||
callMock,
|
||||
runtimeClientConstructorMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('./runtime/environments', () => ({
|
||||
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
|
||||
listEnvironments: listEnvironmentsMock,
|
||||
removeEnvironment: vi.fn(),
|
||||
resolveEnvironment: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', async () => {
|
||||
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
|
||||
return createChildProcessModuleMock(spawnMock)
|
||||
})
|
||||
|
||||
import { main } from './index'
|
||||
import { okFixture, queueFixtures } from './test-fixtures'
|
||||
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
|
||||
|
||||
describe('orca cli worktree awareness', () => {
|
||||
useWorktreeAwarenessEnvironment({
|
||||
callMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock,
|
||||
addEnvironmentFromPairingCodeMock,
|
||||
listEnvironmentsMock,
|
||||
spawnMock
|
||||
})
|
||||
|
||||
it('passes positional automation ids to edit, remove, run, and show', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_edit', { automation: { id: 'auto-1', name: 'Paused' } }),
|
||||
okFixture('req_remove', { removed: true, id: 'auto-1' }),
|
||||
okFixture('req_run', {
|
||||
run: {
|
||||
id: 'run-1',
|
||||
automationId: 'auto-1',
|
||||
title: 'Paused run 1',
|
||||
status: 'pending',
|
||||
trigger: 'manual',
|
||||
scheduledFor: 1,
|
||||
workspaceId: null,
|
||||
sessionKind: 'terminal',
|
||||
chatSessionId: null,
|
||||
terminalSessionId: null,
|
||||
outputSnapshot: null,
|
||||
usage: null,
|
||||
error: null,
|
||||
startedAt: null,
|
||||
dispatchedAt: null,
|
||||
createdAt: 1
|
||||
}
|
||||
}),
|
||||
okFixture('req_show', { automation: { id: 'auto-1', name: 'Paused' } })
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['automations', 'edit', 'auto-1', '--disabled', '--json'], '/tmp/repo')
|
||||
await main(['automations', 'remove', 'auto-1', '--json'], '/tmp/repo')
|
||||
await main(['automations', 'run', 'auto-1', '--json'], '/tmp/repo')
|
||||
await main(['automations', 'show', 'auto-1', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'automation.update', {
|
||||
id: 'auto-1',
|
||||
updates: {
|
||||
name: undefined,
|
||||
prompt: undefined,
|
||||
agentId: undefined,
|
||||
repo: undefined,
|
||||
workspace: undefined,
|
||||
workspaceMode: undefined,
|
||||
baseBranch: undefined,
|
||||
timezone: undefined,
|
||||
enabled: false,
|
||||
missedRunGraceMinutes: undefined
|
||||
}
|
||||
})
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'automation.delete', {
|
||||
id: 'auto-1'
|
||||
})
|
||||
expect(callMock).toHaveBeenNthCalledWith(3, 'automation.runNow', {
|
||||
id: 'auto-1'
|
||||
})
|
||||
expect(callMock).toHaveBeenNthCalledWith(4, 'automation.show', {
|
||||
id: 'auto-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects ambiguous positional and flag automation ids before dispatch', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(['automations', 'show', 'auto-1', '--id', 'auto-2', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect(logSpy).toHaveBeenCalledTimes(1)
|
||||
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'invalid_argument',
|
||||
message: 'Pass --id either positionally or as a flag, not both.'
|
||||
}
|
||||
})
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
callMock,
|
||||
runtimeClientConstructorMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock,
|
||||
addEnvironmentFromPairingCodeMock,
|
||||
listEnvironmentsMock,
|
||||
spawnMock
|
||||
} = vi.hoisted(() => ({
|
||||
callMock: vi.fn(),
|
||||
runtimeClientConstructorMock: vi.fn(),
|
||||
serveOrcaAppMock: vi.fn(),
|
||||
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
|
||||
addEnvironmentFromPairingCodeMock: vi.fn(),
|
||||
listEnvironmentsMock: vi.fn(),
|
||||
spawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./runtime-client', async () => {
|
||||
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
|
||||
return createRuntimeClientModuleMock({
|
||||
callMock,
|
||||
runtimeClientConstructorMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('./runtime/environments', () => ({
|
||||
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
|
||||
listEnvironments: listEnvironmentsMock,
|
||||
removeEnvironment: vi.fn(),
|
||||
resolveEnvironment: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', async () => {
|
||||
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
|
||||
return createChildProcessModuleMock(spawnMock)
|
||||
})
|
||||
|
||||
import { main } from './index'
|
||||
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
|
||||
|
||||
describe('orca cli worktree awareness', () => {
|
||||
useWorktreeAwarenessEnvironment({
|
||||
callMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock,
|
||||
addEnvironmentFromPairingCodeMock,
|
||||
listEnvironmentsMock,
|
||||
spawnMock
|
||||
})
|
||||
|
||||
it('rejects invalid automation --day values before calling the runtime', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(
|
||||
[
|
||||
'automations',
|
||||
'create',
|
||||
'--name',
|
||||
'Weekly review',
|
||||
'--trigger',
|
||||
'weekly',
|
||||
'--day',
|
||||
'7',
|
||||
'--prompt',
|
||||
'Review open changes',
|
||||
'--provider',
|
||||
'codex',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
||||
'--day must be an integer from 0 to 6'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'day on daily preset',
|
||||
args: ['--trigger', 'daily', '--day', '2'],
|
||||
message: '--day can only be used with the weekly automation preset'
|
||||
},
|
||||
{
|
||||
name: 'time on custom cron',
|
||||
args: ['--trigger', '0 9 * * *', '--time', '10:30'],
|
||||
message: '--time can only be used with preset automation triggers'
|
||||
},
|
||||
{
|
||||
name: 'time on hourly preset',
|
||||
args: ['--trigger', 'hourly', '--time', '10:30'],
|
||||
message: '--time cannot be used with the hourly automation preset'
|
||||
}
|
||||
])('rejects automation schedule modifier mismatch: $name', async ({ args, message }) => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(
|
||||
[
|
||||
'automations',
|
||||
'create',
|
||||
'--name',
|
||||
'Daily review',
|
||||
...args,
|
||||
'--prompt',
|
||||
'Review open changes',
|
||||
'--provider',
|
||||
'codex',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(message)
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'create',
|
||||
args: [
|
||||
'automations',
|
||||
'create',
|
||||
'--name',
|
||||
'Daily review',
|
||||
'--trigger',
|
||||
'daily',
|
||||
'--time',
|
||||
'--prompt',
|
||||
'Review open changes',
|
||||
'--provider',
|
||||
'codex',
|
||||
'--json'
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'edit',
|
||||
args: ['automations', 'edit', 'auto-1', '--trigger', 'daily', '--time', '--json']
|
||||
}
|
||||
])('rejects bare automation --time on $name', async ({ args }) => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(args, '/tmp/repo')
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
||||
'--time must use HH:MM format'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
|
||||
it('rejects automation edit schedule modifiers without a schedule flag', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(['automations', 'edit', 'auto-1', '--day', '7', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
||||
'--day requires --trigger or --schedule'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
callMock,
|
||||
runtimeClientConstructorMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock,
|
||||
addEnvironmentFromPairingCodeMock,
|
||||
listEnvironmentsMock,
|
||||
spawnMock
|
||||
} = vi.hoisted(() => ({
|
||||
callMock: vi.fn(),
|
||||
runtimeClientConstructorMock: vi.fn(),
|
||||
serveOrcaAppMock: vi.fn(),
|
||||
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
|
||||
addEnvironmentFromPairingCodeMock: vi.fn(),
|
||||
listEnvironmentsMock: vi.fn(),
|
||||
spawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./runtime-client', async () => {
|
||||
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
|
||||
return createRuntimeClientModuleMock({
|
||||
callMock,
|
||||
runtimeClientConstructorMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('./runtime/environments', () => ({
|
||||
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
|
||||
listEnvironments: listEnvironmentsMock,
|
||||
removeEnvironment: vi.fn(),
|
||||
resolveEnvironment: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', async () => {
|
||||
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
|
||||
return createChildProcessModuleMock(spawnMock)
|
||||
})
|
||||
|
||||
import { main } from './index'
|
||||
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
|
||||
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
|
||||
|
||||
describe('orca cli worktree awareness', () => {
|
||||
useWorktreeAwarenessEnvironment({
|
||||
callMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock,
|
||||
addEnvironmentFromPairingCodeMock,
|
||||
listEnvironmentsMock,
|
||||
spawnMock
|
||||
})
|
||||
|
||||
it('passes automation session reuse flags through create and edit', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo', 'abc', 'repo-1')]),
|
||||
okFixture('req_create', {
|
||||
automation: { id: 'auto-1', name: 'Daily review' }
|
||||
}),
|
||||
okFixture('req_edit', {
|
||||
automation: { id: 'auto-1', name: 'Daily review' }
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
[
|
||||
'automations',
|
||||
'create',
|
||||
'--name',
|
||||
'Daily review',
|
||||
'--trigger',
|
||||
'daily',
|
||||
'--prompt',
|
||||
'Review open changes',
|
||||
'--provider',
|
||||
'codex',
|
||||
'--workspace',
|
||||
'current',
|
||||
'--reuse-session',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo/feature/src'
|
||||
)
|
||||
await main(['automations', 'edit', 'auto-1', '--fresh-session', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', {
|
||||
limit: 10_000
|
||||
})
|
||||
expect(callMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'automation.create',
|
||||
expect.objectContaining({
|
||||
workspace: 'id:repo-1::/tmp/repo/feature',
|
||||
workspaceMode: 'existing',
|
||||
reuseSession: true
|
||||
})
|
||||
)
|
||||
expect(callMock).toHaveBeenNthCalledWith(3, 'automation.update', {
|
||||
id: 'auto-1',
|
||||
updates: expect.objectContaining({ reuseSession: false })
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects conflicting automation session reuse flags', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(
|
||||
['automations', 'edit', 'auto-1', '--reuse-session', '--fresh-session', '--json'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
||||
'Use either --reuse-session or --fresh-session, not both.'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
callMock,
|
||||
runtimeClientConstructorMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock,
|
||||
addEnvironmentFromPairingCodeMock,
|
||||
listEnvironmentsMock,
|
||||
spawnMock
|
||||
} = vi.hoisted(() => ({
|
||||
callMock: vi.fn(),
|
||||
runtimeClientConstructorMock: vi.fn(),
|
||||
serveOrcaAppMock: vi.fn(),
|
||||
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
|
||||
addEnvironmentFromPairingCodeMock: vi.fn(),
|
||||
listEnvironmentsMock: vi.fn(),
|
||||
spawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./runtime-client', async () => {
|
||||
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
|
||||
return createRuntimeClientModuleMock({
|
||||
callMock,
|
||||
runtimeClientConstructorMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('./runtime/environments', () => ({
|
||||
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
|
||||
listEnvironments: listEnvironmentsMock,
|
||||
removeEnvironment: vi.fn(),
|
||||
resolveEnvironment: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', async () => {
|
||||
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
|
||||
return createChildProcessModuleMock(spawnMock)
|
||||
})
|
||||
|
||||
import { main } from './index'
|
||||
import { okFixture, queueFixtures } from './test-fixtures'
|
||||
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
|
||||
|
||||
describe('orca cli worktree awareness', () => {
|
||||
useWorktreeAwarenessEnvironment({
|
||||
callMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock,
|
||||
addEnvironmentFromPairingCodeMock,
|
||||
listEnvironmentsMock,
|
||||
spawnMock
|
||||
})
|
||||
|
||||
it('passes automation source context JSON through create', async () => {
|
||||
const sourceContext = {
|
||||
kind: 'task-source',
|
||||
provider: 'github',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'runtime:gpu',
|
||||
projectHostSetupId: 'setup-gpu',
|
||||
repoId: 'repo-gpu',
|
||||
providerIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' },
|
||||
accountLabel: 'gpu-bot'
|
||||
}
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_automation_create', {
|
||||
automation: { id: 'auto-1', name: 'GPU task review' }
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
[
|
||||
'automations',
|
||||
'create',
|
||||
'--name',
|
||||
'GPU task review',
|
||||
'--trigger',
|
||||
'daily',
|
||||
'--prompt',
|
||||
'Review open work',
|
||||
'--provider',
|
||||
'codex',
|
||||
'--repo',
|
||||
'id:repo-gpu',
|
||||
'--source-context',
|
||||
JSON.stringify(sourceContext),
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'automation.create',
|
||||
expect.objectContaining({
|
||||
repo: 'id:repo-gpu',
|
||||
sourceContext
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('clears automation source context on edit with null', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_edit', {
|
||||
automation: { id: 'auto-1', name: 'GPU task review' }
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['automations', 'edit', 'auto-1', '--source-context', 'null', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'automation.update',
|
||||
expect.objectContaining({
|
||||
id: 'auto-1',
|
||||
updates: expect.objectContaining({
|
||||
sourceContext: null
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects invalid automation source context JSON before calling the runtime', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const priorExitCode = process.exitCode
|
||||
|
||||
await main(
|
||||
[
|
||||
'automations',
|
||||
'create',
|
||||
'--name',
|
||||
'GPU task review',
|
||||
'--trigger',
|
||||
'daily',
|
||||
'--prompt',
|
||||
'Review open work',
|
||||
'--provider',
|
||||
'codex',
|
||||
'--repo',
|
||||
'id:repo-gpu',
|
||||
'--source-context',
|
||||
'{nope',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect([...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')).toContain(
|
||||
'--source-context must be valid JSON'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
|
||||
process.exitCode = priorExitCode
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user