mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
Merge remote-tracking branch 'origin/main' into tmp/native-chat-recovery-main-integration
This commit is contained in:
@@ -112,7 +112,6 @@ All stable kinds (`patch`, `minor`, `major`) are computed off the latest _stable
|
||||
|
||||
The scheduled 2x/day RC cron in [`release-rc.yml`](../../actions/workflows/release-rc.yml) is independent and continues to run automatically from `main`.
|
||||
|
||||
|
||||
## Release Channels
|
||||
|
||||
The public Homebrew cask tracks stable desktop releases:
|
||||
|
||||
@@ -308,7 +308,6 @@ jobs:
|
||||
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
|
||||
echo "notes_file=$notes_file" >>"$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
- name: Publish daily macOS artifacts
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: nick-fields/retry@v4
|
||||
|
||||
@@ -290,7 +290,6 @@ jobs:
|
||||
including back to Stable, works in-app from there."
|
||||
echo "tag=$TAG" >>"$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
- name: Publish hourly macOS artifacts
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
uses: nick-fields/retry@v4
|
||||
|
||||
@@ -34,8 +34,7 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GOOGLE_GENERATIVE_AI_API_KEY:
|
||||
${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
|
||||
GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
|
||||
@@ -844,6 +844,16 @@ jobs:
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- name: Restore golden test harness from the workflow ref
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- \
|
||||
tests/e2e/golden-source-control-open-diff.spec.ts \
|
||||
tests/e2e/golden-terminal-file-link.spec.ts
|
||||
|
||||
- name: Install native build tools
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb
|
||||
@@ -946,6 +956,16 @@ jobs:
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- name: Restore skill-sharing test harness from the workflow ref
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- \
|
||||
src/main/skills/skill-freshness-inventory.test.ts \
|
||||
src/main/skills/skill-provider-runtime-roots.test.ts
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
@@ -978,13 +998,26 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Install Ubuntu 20.04 prerequisites
|
||||
run: apt-get update && apt-get install -y build-essential ca-certificates git python3
|
||||
run: apt-get update && apt-get install -y build-essential ca-certificates git python3 unzip
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- name: Trust the checked-out workspace in the job container
|
||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
|
||||
- name: Restore skill-sharing test harness from the workflow ref
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- \
|
||||
src/main/skills/skill-freshness-inventory.test.ts \
|
||||
src/main/skills/skill-provider-runtime-roots.test.ts
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
@@ -7,13 +7,15 @@ All UI work — layout, color, typography, spacing, component selection, UX beha
|
||||
Use the `$electron` skill and Playwright CDP for rendered Orca UI checks. Do not use computer-use for Orca UI validation.
|
||||
|
||||
# Style
|
||||
|
||||
## Reuse Before Reimplementing
|
||||
|
||||
Before writing new logic at any scale — a function, component, IPC channel, state store, or whole subsystem/flow — check whether an existing implementation already does the job (or nearly does). Extend or generalize it instead of building a parallel version; only write from scratch when nothing fits. Keep the check proportionate: a quick search for trivial code, a real one before building anything substantial.
|
||||
|
||||
## Concise/Brief Non-obvious Comments ONLY
|
||||
* DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW")
|
||||
* BE CONCISE. 1 LINE if possible
|
||||
|
||||
- DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW")
|
||||
- BE CONCISE. 1 LINE if possible
|
||||
|
||||
## Lint Rules: Do Not Disable Max Lines
|
||||
|
||||
@@ -32,6 +34,7 @@ Never use vague names like `helpers`, `utils`, `common`, `misc`, or `shared-stuf
|
||||
- **Lint**: `oxlint`, or `pnpm run check:code-quality:changed` for changed files (full `pnpm lint` is slow); format with `pnpm format`
|
||||
|
||||
# Considerations
|
||||
|
||||
## Worktree Safety
|
||||
|
||||
Always use the primary working directory (the worktree) for all file reads and edits. Never follow absolute paths from subagent results that point to the main repo.
|
||||
|
||||
@@ -262,6 +262,7 @@ Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTIN
|
||||
</p>
|
||||
|
||||
## Signed Builds
|
||||
|
||||
Windows code signing sponored/provided by [SignPath.io](https://signpath.io), certificate by [SignPath Foundation](https://signpath.org).
|
||||
|
||||
## License
|
||||
|
||||
@@ -2148,9 +2148,7 @@
|
||||
},
|
||||
{
|
||||
"file": "src/cli/runtime/serve-signal-exit-diagnostic.test.ts",
|
||||
"assertions": [
|
||||
"late update handoff failure cannot rearm termination after child exit"
|
||||
]
|
||||
"assertions": ["late update handoff failure cannot rearm termination after child exit"]
|
||||
},
|
||||
{
|
||||
"file": "src/main/serve-update-handoff.test.ts",
|
||||
@@ -16840,9 +16838,7 @@
|
||||
},
|
||||
{
|
||||
"file": "src/main/browser/agent-browser-bridge-tab-routing.test.ts",
|
||||
"assertions": [
|
||||
"closing a tab retires the exact named agent-browser session"
|
||||
]
|
||||
"assertions": ["closing a tab retires the exact named agent-browser session"]
|
||||
},
|
||||
{
|
||||
"file": "src/main/startup/serve-signal-handlers.test.ts",
|
||||
|
||||
@@ -132,12 +132,16 @@ function compileNodePty(dir) {
|
||||
return built
|
||||
}
|
||||
console.log('[orcad-prebuilds] compiling node-pty from patched source ...')
|
||||
const result = spawnSync(process.platform === 'win32' ? 'npx.cmd' : 'npx', ['node-gyp', 'rebuild'], {
|
||||
cwd: dir,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
windowsHide: true
|
||||
})
|
||||
const result = spawnSync(
|
||||
process.platform === 'win32' ? 'npx.cmd' : 'npx',
|
||||
['node-gyp', 'rebuild'],
|
||||
{
|
||||
cwd: dir,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`[orcad-prebuilds] node-gyp rebuild failed (status ${result.status})`)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
slotName
|
||||
} from './build-orcad-prebuilds.mjs'
|
||||
|
||||
const PATCHED_BINDING_GYP = "'ldflags': ['-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed']"
|
||||
const PATCHED_BINDING_GYP =
|
||||
"'ldflags': ['-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed']"
|
||||
const PATCHED_PTY_CC = '__asm__(".symver openpty,openpty@" ORCA_GLIBC_COMPAT_VERSION);'
|
||||
|
||||
const dirs = []
|
||||
@@ -107,8 +108,9 @@ describe('mergeManifest', () => {
|
||||
|
||||
it('does not duplicate a slot rebuilt twice', () => {
|
||||
const once = mergeManifest(null, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' })
|
||||
expect(mergeManifest(once, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' }).slots)
|
||||
.toEqual(['darwin-arm64'])
|
||||
expect(
|
||||
mergeManifest(once, { slot: 'darwin-arm64', version: '1.1.0', nodeAbi: '127' }).slots
|
||||
).toEqual(['darwin-arm64'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
'use strict'
|
||||
|
||||
const NODE_PTY_JOB_EXPORTS = [
|
||||
'listJobProcessIds',
|
||||
'terminateJob',
|
||||
'assignCurrentProcessToJob'
|
||||
]
|
||||
const NODE_PTY_JOB_EXPORTS = ['listJobProcessIds', 'terminateJob', 'assignCurrentProcessToJob']
|
||||
|
||||
function assertNodePtyJobOwnership({ nativeName, native, platform = process.platform }) {
|
||||
if (platform !== 'win32' || nativeName !== 'conpty') {
|
||||
|
||||
@@ -10,6 +10,17 @@ const releaseWorkflow = parse(
|
||||
const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
|
||||
|
||||
describe('release E2E dispatch contract', () => {
|
||||
it('validates immutable tags with the current golden test harness', () => {
|
||||
const restoreStep = releaseWorkflow.jobs['terminal-rendering-golden'].steps.find(
|
||||
(step) => step.name === 'Restore golden test harness from the workflow ref'
|
||||
)
|
||||
|
||||
expect(restoreStep.env.WORKFLOW_SHA).toBe('${{ github.workflow_sha }}')
|
||||
expect(restoreStep.run).toContain('git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"')
|
||||
expect(restoreStep.run).toContain('golden-source-control-open-diff.spec.ts')
|
||||
expect(restoreStep.run).toContain('golden-terminal-file-link.spec.ts')
|
||||
})
|
||||
|
||||
it('dispatches tag-scoped E2E only after publication', () => {
|
||||
const dispatchJob = releaseWorkflow.jobs['post-release-e2e']
|
||||
const dispatchStep = dispatchJob.steps.find((step) => step.name === 'Dispatch tag-scoped E2E')
|
||||
|
||||
@@ -44,6 +44,48 @@ describe('skill-sharing release workflow', () => {
|
||||
expect(command).toContain('src/shared/skill-bundle-install-contract.test.ts')
|
||||
})
|
||||
|
||||
it('installs the archive tool required by Electron on the Linux floor', () => {
|
||||
const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
|
||||
const prerequisites = stepNamed(linux, 'Install Ubuntu 20.04 prerequisites')
|
||||
|
||||
expect(prerequisites.run).toMatch(/apt-get install[^\n]*\bunzip\b/)
|
||||
})
|
||||
|
||||
it('trusts only the checked-out workspace before container git operations', () => {
|
||||
const linux = workflow.jobs['skill-sharing-linux-floor-release-gate']
|
||||
const trustWorkspace = stepNamed(linux, 'Trust the checked-out workspace in the job container')
|
||||
const restoreHarness = stepNamed(
|
||||
linux,
|
||||
'Restore skill-sharing test harness from the workflow ref'
|
||||
)
|
||||
const safeDirectoryCommands = linux.steps
|
||||
.filter((step) => typeof step.run === 'string' && step.run.includes('safe.directory'))
|
||||
.map((step) => step.run)
|
||||
|
||||
expect(trustWorkspace.run).toBe('git config --global --add safe.directory "$GITHUB_WORKSPACE"')
|
||||
expect(linux.steps.indexOf(trustWorkspace)).toBeLessThan(linux.steps.indexOf(restoreHarness))
|
||||
expect(safeDirectoryCommands).toEqual([
|
||||
'git config --global --add safe.directory "$GITHUB_WORKSPACE"'
|
||||
])
|
||||
})
|
||||
|
||||
it('validates immutable tags with the current skill-sharing test harness', () => {
|
||||
for (const jobName of [
|
||||
'skill-sharing-release-gate',
|
||||
'skill-sharing-linux-floor-release-gate'
|
||||
]) {
|
||||
const restore = stepNamed(
|
||||
workflow.jobs[jobName],
|
||||
'Restore skill-sharing test harness from the workflow ref'
|
||||
)
|
||||
|
||||
expect(restore.env.WORKFLOW_SHA).toBe('${{ github.workflow_sha }}')
|
||||
expect(restore.run).toContain('git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"')
|
||||
expect(restore.run).toContain('skill-freshness-inventory.test.ts')
|
||||
expect(restore.run).toContain('skill-provider-runtime-roots.test.ts')
|
||||
}
|
||||
})
|
||||
|
||||
it('archives bounded machine-readable evidence from every platform', () => {
|
||||
for (const jobName of [
|
||||
'skill-sharing-release-gate',
|
||||
|
||||
+14
-14
@@ -137,20 +137,20 @@ Browse `src/renderer/src/components/ui/` for the full list. Most wrap a Radix UI
|
||||
|
||||
When a control has multiple plausible primitives, use this fork:
|
||||
|
||||
| You want… | Reach for | Don't use |
|
||||
| ------------------------------------------------------------ | -------------------------------------------------------------------- | ------------------------------------- |
|
||||
| Hover-only label on an icon-only button | `Tooltip` | `HoverCard` (too heavy), title attr |
|
||||
| Hover preview of richer content (avatar + summary) | `HoverCard` | `Tooltip` (no rich content) |
|
||||
| Click-revealed menu with actions | `DropdownMenu` | `Popover` with hand-rolled list |
|
||||
| Right-click contextual actions | `ContextMenu` | `DropdownMenu` (different invocation) |
|
||||
| Click-revealed surface with arbitrary content (form, picker) | `Popover` | `Dialog` (it traps focus and dims) |
|
||||
| Modal that demands a decision before you continue | `Dialog` | `Popover`, inline overlay |
|
||||
| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered |
|
||||
| Single choice from a known list | `Select` | Custom listbox |
|
||||
| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) |
|
||||
| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one |
|
||||
| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner |
|
||||
| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) |
|
||||
| You want… | Reach for | Don't use |
|
||||
| ------------------------------------------------------------ | ------------------------------------------ | ------------------------------------- |
|
||||
| Hover-only label on an icon-only button | `Tooltip` | `HoverCard` (too heavy), title attr |
|
||||
| Hover preview of richer content (avatar + summary) | `HoverCard` | `Tooltip` (no rich content) |
|
||||
| Click-revealed menu with actions | `DropdownMenu` | `Popover` with hand-rolled list |
|
||||
| Right-click contextual actions | `ContextMenu` | `DropdownMenu` (different invocation) |
|
||||
| Click-revealed surface with arbitrary content (form, picker) | `Popover` | `Dialog` (it traps focus and dims) |
|
||||
| Modal that demands a decision before you continue | `Dialog` | `Popover`, inline overlay |
|
||||
| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered |
|
||||
| Single choice from a known list | `Select` | Custom listbox |
|
||||
| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) |
|
||||
| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one |
|
||||
| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner |
|
||||
| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) |
|
||||
|
||||
If you find yourself styling around a primitive (`<Popover>` to act like a `<Dialog>`, or vice versa), stop and reconsider — the focus-management semantics differ and a future contributor will be misled by the mismatch.
|
||||
|
||||
|
||||
@@ -838,7 +838,7 @@ refuse to run there and print the command to run on the machine you want.
|
||||
- Clients cannot connect: make sure `--pairing-address` is an address reachable
|
||||
from the client, and make sure firewalls allow the selected `--port`.
|
||||
- Journal shows `Another Orca instance is already running for this userData
|
||||
profile` and the unit exits `3`: another process already owns the profile, so
|
||||
profile` and the unit exits `3`: another process already owns the profile, so
|
||||
`RestartPreventExitStatus=3` leaves the unit `failed` on purpose. Find the
|
||||
owner with `systemctl status orca-serve` and `pgrep -af orca`. Stop it (or
|
||||
keep it and leave the unit down), then run
|
||||
|
||||
@@ -25,11 +25,11 @@ broke launch on Ubuntu 20.04 ([#9902](https://github.com/stablyai/orca/issues/99
|
||||
The specific trap is glibc's 2.32–2.34 "libpthread/libutil merge", which moved
|
||||
several long-stable functions into libc under brand-new symbol versions:
|
||||
|
||||
| Symbol | New version | node-pty use |
|
||||
| ----------------- | ------------- | ----------------------- |
|
||||
| `pthread_sigmask` | `GLIBC_2.32` | reset child signal mask |
|
||||
| `openpty` | `GLIBC_2.34` | allocate the pty |
|
||||
| `forkpty` | `GLIBC_2.34` | fork the shell |
|
||||
| Symbol | New version | node-pty use |
|
||||
| ----------------- | ------------ | ----------------------- |
|
||||
| `pthread_sigmask` | `GLIBC_2.32` | reset child signal mask |
|
||||
| `openpty` | `GLIBC_2.34` | allocate the pty |
|
||||
| `forkpty` | `GLIBC_2.34` | fork the shell |
|
||||
|
||||
Electron itself (glibc 2.25) and the other bundled native modules
|
||||
(`sherpa-onnx`, `@parcel/watcher`, both prebuilt on old glibc) stay well under
|
||||
|
||||
@@ -10,12 +10,12 @@ Design background: `docs/design/shipping-orcad.html` §00c and §04.
|
||||
|
||||
A deployment is **orcad** plus **the terminal daemon**.
|
||||
|
||||
| | orcad | terminal daemon |
|
||||
| --- | --- | --- |
|
||||
| Started by | the supervisor | orcad, detached |
|
||||
| Owns | RPC, git, worktrees, persistence | every local PTY |
|
||||
| Lifetime | one supervised run | **outlives orcad** |
|
||||
| Endpoint | `ws://<bind>:<port>` | `<data-root>/daemon/daemon-v<N>.sock` |
|
||||
| | orcad | terminal daemon |
|
||||
| ---------- | -------------------------------- | ------------------------------------- |
|
||||
| Started by | the supervisor | orcad, detached |
|
||||
| Owns | RPC, git, worktrees, persistence | every local PTY |
|
||||
| Lifetime | one supervised run | **outlives orcad** |
|
||||
| Endpoint | `ws://<bind>:<port>` | `<data-root>/daemon/daemon-v<N>.sock` |
|
||||
|
||||
The daemon outliving orcad is the property the whole peer model is recommended for
|
||||
(`docs/reference/ssh-execution-boundary.md`): daemon-backed PTYs stay `live` across a runtime
|
||||
@@ -52,13 +52,13 @@ The data root is `$ORCA_USER_DATA`, else `$XDG_DATA_HOME/Orca`, else `~/.orca`.
|
||||
Before the profile index or the store is touched, orcad takes `<data-root>/orcad.lock`.
|
||||
It refuses to start when:
|
||||
|
||||
| Code | Meaning |
|
||||
| --- | --- |
|
||||
| `orcad_data_root_wrong_owner` | the root is owned by another uid (POSIX) |
|
||||
| `orcad_data_root_shared` | the root is group/world accessible and could not be tightened |
|
||||
| `orcad_instance_lock_held` | another live orcad owns this root |
|
||||
| `orcad_instance_lock_foreign_identity` | the lock belongs to a different identity |
|
||||
| `orcad_data_root_unusable` | the root cannot be created, stat'd or written |
|
||||
| Code | Meaning |
|
||||
| -------------------------------------- | ------------------------------------------------------------- |
|
||||
| `orcad_data_root_wrong_owner` | the root is owned by another uid (POSIX) |
|
||||
| `orcad_data_root_shared` | the root is group/world accessible and could not be tightened |
|
||||
| `orcad_instance_lock_held` | another live orcad owns this root |
|
||||
| `orcad_instance_lock_foreign_identity` | the lock belongs to a different identity |
|
||||
| `orcad_data_root_unusable` | the root cannot be created, stat'd or written |
|
||||
|
||||
A root that is merely too permissive and that we own is tightened to `0700` rather than
|
||||
refused — orcad stores credentials there unsealed (no OS keyring on this host), so the goal
|
||||
@@ -91,14 +91,15 @@ An external supervisor (systemd, launchd, a process manager). orcad conforms to
|
||||
and exits 1, so the failure stays attributable instead of arriving as an unlogged kill.
|
||||
- **Exit codes.**
|
||||
|
||||
| Code | Meaning | Supervisor should |
|
||||
| --- | --- | --- |
|
||||
| 0 | clean shutdown | restart per policy |
|
||||
| 1 | startup or shutdown failure | restart with backoff |
|
||||
| 78 | configuration fault (bind address, data root, instance lock) | **not** restart |
|
||||
| Code | Meaning | Supervisor should |
|
||||
| ---- | ------------------------------------------------------------ | -------------------- |
|
||||
| 0 | clean shutdown | restart per policy |
|
||||
| 1 | startup or shutdown failure | restart with backoff |
|
||||
| 78 | configuration fault (bind address, data root, instance lock) | **not** restart |
|
||||
|
||||
78 is `EX_CONFIG`. Put it in systemd's `RestartPreventExitStatus`: restarting on a data
|
||||
root owned by someone else is a restart-spin, not a recovery.
|
||||
|
||||
- **Logs.** orcad writes human-readable diagnostics to **stderr** and its readiness contract
|
||||
to **stdout**; the supervisor owns capture and rotation. The daemon, being detached, writes
|
||||
its own NDJSON lifecycle log to `<data-root>/logs/daemon.log` (suppressed by
|
||||
@@ -110,7 +111,7 @@ An external supervisor (systemd, launchd, a process manager). orcad conforms to
|
||||
- **Launch.** Forked detached from `daemon-entry.js` beside `orcad.js`, with its own PID
|
||||
record, token and socket under `<data-root>/daemon`.
|
||||
- **Adoption before spawn.** A daemon already answering the endpoint is adopted, not
|
||||
replaced, unless it is unhealthy, foreign, or built from a superseded bundle *and* owns no
|
||||
replaced, unless it is unhealthy, foreign, or built from a superseded bundle _and_ owns no
|
||||
live sessions. Replacing a healthy daemon kills its PTYs, so code freshness always defers
|
||||
to live work.
|
||||
- **Restart.** The adapter respawns the daemon on death, transparently to callers.
|
||||
|
||||
@@ -28,11 +28,11 @@ Three corrections to the first draft:
|
||||
verified. The ssh2 proxy-spawn at `:697` is effectively unreachable. Good news for migration, and
|
||||
the first draft's motivating example was simply wrong.
|
||||
- **Agent forwarding was overstated.** `agentForward` is gated on the user's `ForwardAgent yes`
|
||||
(`ssh-connection-utils.ts:203-205`). `config.agent` is always set, but that is agent *auth*, whose
|
||||
(`ssh-connection-utils.ts:203-205`). `config.agent` is always set, but that is agent _auth_, whose
|
||||
signatures bind the session id and cannot be replayed onward. The risk applies to users who opted
|
||||
into `ForwardAgent`, not everyone.
|
||||
- **Credential theft was understated, and the relay claim was backwards.** `isAgentFallbackError`
|
||||
treats *any* auth error as agent fallback (`ssh-connection-utils.ts:59-61`), so a MITM that rejects
|
||||
treats _any_ auth error as agent fallback (`ssh-connection-utils.ts:59-61`), so a MITM that rejects
|
||||
publickey walks the user to the password prompt (`ssh-connection.ts:844`) and the private-key
|
||||
**passphrase** prompt (`:834`), and `cachedPassword` is replayed without prompting on every
|
||||
reconnect (`:709`). Meanwhile the relay upload matters less than assumed — the attacker already
|
||||
@@ -65,7 +65,7 @@ Two consequences to own rather than discover:
|
||||
|
||||
There is no `ssh`-only way out of this: `-F /dev/null` does NOT invert the exclusion, it reports
|
||||
built-in defaults, so a probe built on it looks permissive on every machine. Verified against
|
||||
OpenSSH 10.2p1. So the file is read directly, answering a deliberately weaker question — *could*
|
||||
OpenSSH 10.2p1. So the file is read directly, answering a deliberately weaker question — _could_
|
||||
the site config be restricting host keys — where anything ambiguous (unreadable, an unresolvable
|
||||
`Include`, the directive present at all) keeps the refusal. Only a site config that demonstrably
|
||||
says nothing about host keys clears it, which is what stops the rule punishing every devcontainer,
|
||||
@@ -130,16 +130,16 @@ connect, training them to dismiss the one warning that matters.
|
||||
|
||||
> **Corrected against a live client.** The premise above is wrong about OpenSSH, though the
|
||||
> conclusion survives. `check_key_in_hostkeys` is not type-scoped at all: ANY non-marker entry for
|
||||
> the host that is not byte-equal produces `HOST_CHANGED`. Verified on 127.0.0.1:2223 — known_hosts
|
||||
> the host that is not byte-equal produces `HOST_CHANGED`. Verified on 127.0.0.1:2223 — `known_hosts`
|
||||
> holding only `ssh-rsa` against an ed25519-only server prints `IDENTIFICATION HAS CHANGED` and
|
||||
> refuses. So ssh does not avoid the false alarm by scoping; it avoids the *situation* via
|
||||
> refuses. So ssh does not avoid the false alarm by scoping; it avoids the _situation_ via
|
||||
> `order_hostkeyalgs`, and hard-fails when the situation arises anyway. Our split into `mismatch`
|
||||
> and `unknown-type-known-host` therefore only chooses the wording — both refuse, which is ssh's
|
||||
> action. What the ordering below buys us is what it buys ssh: the situation mostly never arises.
|
||||
|
||||
**But scoping alone is a downgrade vector, and this is the correction that most changes the design.**
|
||||
OpenSSH is safe here only because `order_hostkeyalgs()` reorders the client's proposed host-key
|
||||
algorithms to put the types already in `known_hosts` first, and RFC 4253 gives the *client's* order
|
||||
algorithms to put the types already in `known_hosts` first, and RFC 4253 gives the _client's_ order
|
||||
priority — so a server cannot choose a type the client deprioritised. ssh2 negotiates ed25519 first
|
||||
regardless. An attacker who cannot forge the RSA key on file simply presents ed25519 and receives a
|
||||
friendly first-contact prompt instead of a hard failure.
|
||||
@@ -147,7 +147,7 @@ friendly first-contact prompt instead of a hard failure.
|
||||
Therefore: **set ssh2's `algorithms.serverHostKey` to lead with the key types already known for that
|
||||
host.** Type scoping without algorithm ordering is not a safe design.
|
||||
|
||||
And when the presented type is unknown *while other types are known for this host*, that is
|
||||
And when the presented type is unknown _while other types are known for this host_, that is
|
||||
`unknown-type-known-host` — never a plain TOFU prompt. It must say we already hold a different key
|
||||
for this host.
|
||||
|
||||
@@ -193,7 +193,7 @@ means nothing is known.
|
||||
|
||||
### D5. Recovery must not live in the failure dialog
|
||||
|
||||
A "forget this host key" button *in* the mismatch dialog is D4's rejected "trust anyway" with one
|
||||
A "forget this host key" button _in_ the mismatch dialog is D4's rejected "trust anyway" with one
|
||||
extra click. Recovery lives in target settings: a separate, deliberate surface, no auto-retry, and it
|
||||
shows the stored fingerprint so the user is choosing knowingly.
|
||||
|
||||
@@ -201,7 +201,7 @@ Offer it only when **our** store is what disagreed; when `known_hosts` disagrees
|
||||
record cannot unblock the connect. Messages, written to avoid naming internals:
|
||||
|
||||
> **Ours disagreed** — "The host key for `build-01` changed since you last connected from Orca. If you
|
||||
> rebuilt or reprovisioned this machine, this is expected." → *Forget the saved key* / *Cancel*
|
||||
> rebuilt or reprovisioned this machine, this is expected." → _Forget the saved key_ / _Cancel_
|
||||
|
||||
> **`known_hosts` disagreed** — "The host key for `build-01` does not match the entry in
|
||||
> `~/.ssh/known_hosts`. `ssh` and `git` will refuse this host too. Run `ssh-keygen -R build-01`." →
|
||||
@@ -239,7 +239,7 @@ machines, two targets can name one machine, and a re-created target must not los
|
||||
The store is a **dedicated file**, not the main persistence blob (`persistence.ts:7088`): a settings
|
||||
restore or rollback must not silently reset trust. Accept and mismatch events are logged.
|
||||
|
||||
`hostKeyFingerprint` is now security-relevant *and* wire-relevant — it is an isolation namespace sent
|
||||
`hostKeyFingerprint` is now security-relevant _and_ wire-relevant — it is an isolation namespace sent
|
||||
to the host (`ssh-relay-session.ts:1298`, `managed-hook-owner-identity.ts:187`). It is `undefined` on
|
||||
the system transport, so **no trust logic may key off it**, and its format must not change (see
|
||||
Traps).
|
||||
@@ -247,11 +247,11 @@ Traps).
|
||||
## Phasing — ship the defence before the dialog
|
||||
|
||||
Review made the case that the riskiest part of this change is not the security model but the modal.
|
||||
Startup restore fires eager connects for *all* previously-active targets in parallel (`App.tsx:1041`)
|
||||
Startup restore fires eager connects for _all_ previously-active targets in parallel (`App.tsx:1041`)
|
||||
with a 15s timeout, while a prompt would live 120s — N unknown hosts means N stacked dialogs
|
||||
outliving the timeout that already deferred them. Runtime-owned ephemeral VMs
|
||||
(`ephemeral-vm-runtime-ssh.ts:31`) dial a freshly provisioned host with a brand-new key on every
|
||||
launch. Paired-web connects run on the *host desktop* (`runtime/rpc/methods/ssh.ts:32`), so the
|
||||
launch. Paired-web connects run on the _host desktop_ (`runtime/rpc/methods/ssh.ts:32`), so the
|
||||
dialog would open on someone else's screen while the web user watches a spinner.
|
||||
|
||||
**Phase 1 — no new modal.** Consult `known_hosts` + our store. `match` connects. `unknown` persists
|
||||
@@ -301,10 +301,11 @@ Each of these makes the fix silently do nothing. All confirmed in our tree.
|
||||
## Scope
|
||||
|
||||
**In scope, corrected:** IPv6 literals and `[host]:port` bracket parsing. Review was right that this
|
||||
is a *parser* requirement, not a scope call — getting it wrong means hosts `ssh` knows come back
|
||||
is a _parser_ requirement, not a scope call — getting it wrong means hosts `ssh` knows come back
|
||||
`unknown`, which is the prompt-training harm D3 exists to avoid.
|
||||
|
||||
**Out of scope, with consequences stated:**
|
||||
|
||||
- **`CheckHostIP`** — OpenSSH defaults it off; we form candidates from the hostname only.
|
||||
- **WSL** — `src/main/ssh/` has no WSL awareness; a distro's `known_hosts` is unreachable, so WSL
|
||||
users get first-contact treatment for hosts they already verified.
|
||||
@@ -337,7 +338,7 @@ verified by running an OpenSSH 10.2p1 client against a real `sshd` on `127.0.0.1
|
||||
its verdict:
|
||||
|
||||
- **The bare-host fallback pass never reports a change.** With `StrictHostKeyChecking=accept-new`, a
|
||||
bare line holding a *different* key, dialed on a non-default port, made ssh connect and append a
|
||||
bare line holding a _different_ key, dialed on a non-default port, made ssh connect and append a
|
||||
new `[127.0.0.1]:2222` line — first contact, no `IDENTIFICATION HAS CHANGED`. Reporting `mismatch`
|
||||
on that pass would refuse hosts ssh connects to happily, and would have looked like the cautious
|
||||
choice.
|
||||
@@ -355,16 +356,16 @@ subtly wrong.**
|
||||
Fixed after review:
|
||||
|
||||
1. **Our own store was type-downgradable.** The inline lookup filtered by key type first and could
|
||||
only answer match/mismatch/unknown, so a record of a *different* type read as `unknown`. D3's
|
||||
only answer match/mismatch/unknown, so a record of a _different_ type read as `unknown`. D3's
|
||||
downgrade, applied to the records we create ourselves. Stored types now also feed the algorithm
|
||||
ordering — without that the guard is only half present.
|
||||
2. **We keyed on the Orca label.** `ssh -G` echoes its own argument back as `hostname` when no Host
|
||||
block matches, so for a manual target `resolved.hostname` *is* the label — the one name D2
|
||||
block matches, so for a manual target `resolved.hostname` _is_ the label — the one name D2
|
||||
forbids. We consulted no entries at all.
|
||||
3. **A refused key still walked the credential ladder.** ssh2 reports a denial as a generic auth
|
||||
failure, so we went on to prompt for the passphrase and hand it to the host we had just refused.
|
||||
Rejections are now a typed error recognised before any fallback.
|
||||
4. **Fail-closed nearly became fail-always.** "No readable known_hosts" counted a *missing* file the
|
||||
4. **Fail-closed nearly became fail-always.** "No readable `known_hosts`" counted a _missing_ file the
|
||||
same as an unreadable one, so a profile that had never connected — everyone's first run — would
|
||||
have been refused, and the suite passed only because dev machines have a `known_hosts`.
|
||||
5. **Ephemeral runtimes were refused for a policy they cannot satisfy.** The carve-out sat below the
|
||||
@@ -404,7 +405,7 @@ tooltip, and the terminal reconnect overlay never asked for it at all. Still ope
|
||||
`getPublicSshState`, so a paired-web client always sees exactly `SSH connection unavailable`.
|
||||
Pre-existing, but it makes the Phase 2 dialog message unreachable there without a change. Note the
|
||||
redaction is not web-only: any target owned by a paired runtime environment is redacted, so a
|
||||
*desktop* user viewing a remote-Orca-server-owned host gets the same generic string.
|
||||
_desktop_ user viewing a remote-Orca-server-owned host gets the same generic string.
|
||||
- **RPC fail-fast** becomes load-bearing the moment the dialog exists (see Phasing).
|
||||
|
||||
**Known gaps that Phase 1 accepts, listed so they are choices and not surprises:**
|
||||
|
||||
@@ -15,7 +15,10 @@ since removed.
|
||||
table. It wraps a Toolhelp32 snapshot from `@vscode/windows-process-tree`.
|
||||
|
||||
```ts
|
||||
import { readWindowsProcessTable, readWindowsProcessTableFresh } from '../windows/windows-process-table'
|
||||
import {
|
||||
readWindowsProcessTable,
|
||||
readWindowsProcessTableFresh
|
||||
} from '../windows/windows-process-table'
|
||||
```
|
||||
|
||||
- `readWindowsProcessTable()` — shared TTL cache. Use for anything periodic.
|
||||
@@ -31,11 +34,11 @@ survived its own teardown (#9045).
|
||||
|
||||
Measured on Windows 11 with 1050 processes (p50 / p95):
|
||||
|
||||
| | p50 | p95 |
|
||||
| --- | --- | --- |
|
||||
| pid + ppid + name | 15.9 ms | 17.5 ms |
|
||||
| + memory + command line | 30.6 ms | 33.7 ms |
|
||||
| `Get-CimInstance` via PowerShell | 706 ms | 723 ms |
|
||||
| | p50 | p95 |
|
||||
| -------------------------------- | ------- | ------- |
|
||||
| pid + ppid + name | 15.9 ms | 17.5 ms |
|
||||
| + memory + command line | 30.6 ms | 33.7 ms |
|
||||
| `Get-CimInstance` via PowerShell | 706 ms | 723 ms |
|
||||
|
||||
Those CIM numbers are from a 1050-process host. The scan scales with process
|
||||
count: on a 1486-process Windows SSH host it measured **1.36 s** and produced
|
||||
@@ -62,7 +65,7 @@ Two guards, and they work together:
|
||||
the module refuses every further read until that read's callback fires.
|
||||
|
||||
The wedge used to be a 30 s cooldown that let one probe through per window. That
|
||||
bounded the *rate* of new callbacks but not the total: a permanently wedged
|
||||
bounded the _rate_ of new callbacks but not the total: a permanently wedged
|
||||
reader retained one more closure every 30 s for as long as the app ran, and each
|
||||
probe also blocked its caller for the full 3 s deadline first. Gating on the
|
||||
outstanding read instead bounds retention at exactly one callback, and gives up
|
||||
@@ -90,7 +93,7 @@ checked on a real Windows SSH host with 1486 processes:
|
||||
|
||||
**Installing it normally rebuilds from source, and that build fails.** The
|
||||
tarball carries a `binding.gyp`, so npm runs `node-gyp rebuild` regardless of
|
||||
what is already compiled inside it. On a host that *already had* MSVC Build
|
||||
what is already compiled inside it. On a host that _already had_ MSVC Build
|
||||
Tools 2022 installed, that build still failed:
|
||||
|
||||
```
|
||||
@@ -103,7 +106,7 @@ would then break outright rather than degrade: `installNativeDeps` throws on
|
||||
failure, and the toolchain-skip retry is gated to Linux.
|
||||
|
||||
**Skipping the build and using the shipped binary returns a truncated table.**
|
||||
Contrary to what this file used to claim, the published 0.8.0 tarball *does*
|
||||
Contrary to what this file used to claim, the published 0.8.0 tarball _does_
|
||||
contain `build/Release/windows_process_tree.node` — an MSVC build directory that
|
||||
looks accidentally published (`.obj` and `.tlog` files ship with it). It is
|
||||
N-API, so it loads on any modern Node. But it predates our patch and still has
|
||||
@@ -151,14 +154,14 @@ it as an optional relay artifact.
|
||||
`config/scripts/build-windows-process-tree-relay-addon.mjs` builds it from the
|
||||
source pnpm has already patched, on a Windows runner, and refuses to run if
|
||||
any patch hunk is missing — the Spectre hunk fails loudly, the 1024-process
|
||||
hunk fails *silently*, and the relative gyp path dies at configure on Windows.
|
||||
hunk fails _silently_, and the relative gyp path dies at configure on Windows.
|
||||
The source is checked rather than the install trusted. It also reads the PE
|
||||
machine field of the output, because a cross-build that quietly emitted host
|
||||
arch would ship a binary the target cannot load.
|
||||
|
||||
Windows arm64 cross-compiles from the x64 runner — verified on real hardware,
|
||||
producing `IMAGE_FILE_MACHINE_ARM64` (0xaa64) against x64's 0x8664. It needs the
|
||||
optional *MSVC v143 ARM64 build tools* component; without it node-gyp fails with
|
||||
optional _MSVC v143 ARM64 build tools_ component; without it node-gyp fails with
|
||||
`MSB8020`, which is why the addon build runs before the long packaging step.
|
||||
`ORCA_REQUIRE_RELAY_NATIVE_ADDONS` is a per-arch list so a future arch can be
|
||||
added best-effort before it is promoted to required.
|
||||
@@ -258,7 +261,7 @@ The per-PTY job deliberately does **not** set
|
||||
`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. Measured on Windows 11: with that flag,
|
||||
releasing the handle when the shell exits also kills whatever the user left
|
||||
running, so typing `exit` in a pane reaped a `start /b` server that used to
|
||||
survive. The job exists to make an *explicit* teardown exact, not to redefine
|
||||
survive. The job exists to make an _explicit_ teardown exact, not to redefine
|
||||
what a clean exit means.
|
||||
|
||||
Reaping a dead daemon's shells (#9195, #10415) is therefore a **second, nested
|
||||
@@ -283,7 +286,7 @@ kill-on-close job on the app, which is exactly what the crash-survival
|
||||
guarantee forbids.
|
||||
|
||||
Once the shell exits, node-pty drops its handle record and closes the job, so a
|
||||
terminated tree reports `null` rather than `[]`. Null means *unverifiable* in
|
||||
terminated tree reports `null` rather than `[]`. Null means _unverifiable_ in
|
||||
the sense of [`ssh-execution-boundary.md`](./ssh-execution-boundary.md) — no job
|
||||
support, not a ConPTY, or no longer tracked. It is never evidence that
|
||||
processes died.
|
||||
|
||||
@@ -25,7 +25,7 @@ scripts:
|
||||
|
||||
## Why the script declares it, not the terminal preference
|
||||
|
||||
`terminalWindowsShell` says which shell *interactive terminals* open in. It says nothing about the
|
||||
`terminalWindowsShell` says which shell _interactive terminals_ open in. It says nothing about the
|
||||
language a project's setup script is written in. Deriving the runner from it had two consequences:
|
||||
|
||||
- Windows users with batch-syntax setup scripts silently switched to bash on upgrade, so `copy`,
|
||||
|
||||
@@ -35,7 +35,7 @@ whichever poller arrives first afterwards pays a full-fleet `git worktree list`
|
||||
fan-out. The many high-frequency callers — `listTerminals` without a selector,
|
||||
`showTerminal`, `getWorktreePs`, `listManagedWorktrees`,
|
||||
`resolveWorktreeSelector`, orchestration authority refresh — only determine
|
||||
*who* pays, not *how often*. Steady-state subprocess volume is therefore
|
||||
_who_ pays, not _how often_. Steady-state subprocess volume is therefore
|
||||
`repos / 30 s`, independent of poll rate, which is exactly the observed
|
||||
~1 sweep / 30.5 s.
|
||||
|
||||
@@ -86,16 +86,16 @@ common directory without a subprocess (read `.git`; if it is a `gitdir:` file,
|
||||
follow it and then its `commondir`; if `.git` is absent, treat `repoPath` as a
|
||||
bare gitdir), then records:
|
||||
|
||||
| Input | External change it catches |
|
||||
| --- | --- |
|
||||
| sorted entry names of `<commonDir>/worktrees` | `worktree add`, `worktree remove`, `worktree prune` |
|
||||
| existence of `repoPath` | main checkout deleted |
|
||||
| `<commonDir>/packed-refs` mtime + size | a tip moved while its loose ref is packed away |
|
||||
| `<commonDir>/reftable` mtime + size | a tip moved under the reftable backend |
|
||||
| per checkout: `HEAD` contents | branch switch, detach (the detached oid is in HEAD itself) |
|
||||
| per checkout: contents of the ref HEAD names | a plain `git commit`, `reset`, or `fetch` that moves the tip |
|
||||
| per entry: `gitdir` contents | `worktree move`, `worktree repair` |
|
||||
| per entry: `locked` presence | `worktree lock` / `unlock` |
|
||||
| Input | External change it catches |
|
||||
| -------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| sorted entry names of `<commonDir>/worktrees` | `worktree add`, `worktree remove`, `worktree prune` |
|
||||
| existence of `repoPath` | main checkout deleted |
|
||||
| `<commonDir>/packed-refs` mtime + size | a tip moved while its loose ref is packed away |
|
||||
| `<commonDir>/reftable` mtime + size | a tip moved under the reftable backend |
|
||||
| per checkout: `HEAD` contents | branch switch, detach (the detached oid is in HEAD itself) |
|
||||
| per checkout: contents of the ref HEAD names | a plain `git commit`, `reset`, or `fetch` that moves the tip |
|
||||
| per entry: `gitdir` contents | `worktree move`, `worktree repair` |
|
||||
| per entry: `locked` presence | `worktree lock` / `unlock` |
|
||||
| per entry: existence of the path named by `gitdir` | a worktree directory deleted with `rm -rf` (flips `prunable`) |
|
||||
|
||||
"per checkout" covers the main worktree and each linked worktree. Reading the
|
||||
@@ -159,21 +159,21 @@ rescans. Capturing after the scan would let that mutation be masked forever.
|
||||
|
||||
Untouched. `invalidateWorktreeScanCacheForRepo` deletes the entry (fingerprint
|
||||
included) and bumps the generation, so every event-driven path still forces a
|
||||
real scan on the next read. The fingerprint only ever *extends* an entry that
|
||||
real scan on the next read. The fingerprint only ever _extends_ an entry that
|
||||
the TTL alone would have refreshed.
|
||||
|
||||
## Freshness budget
|
||||
|
||||
| Change | Before | After |
|
||||
| --- | --- | --- |
|
||||
| Orca-initiated create/remove/rename/sparse/repo edit | immediate (event) | immediate (event) |
|
||||
| SSH reconnect / provider generation bump | immediate (event) | immediate (event) |
|
||||
| External `worktree add/remove/move/prune/lock` | ≤ 30 s | ≤ 30 s |
|
||||
| External `git checkout` / `commit` / `reset` in any worktree | ≤ 30 s | ≤ 30 s |
|
||||
| External `rm -rf <worktree>` | ≤ 30 s | ≤ 30 s |
|
||||
| External sparse-checkout pattern edit | ≤ 30 s | ≤ 5 min |
|
||||
| Packed/reftable tip moved within one mtime tick at an equal file size | ≤ 30 s | ≤ 5 min |
|
||||
| SSH / WSL repos, folder workspaces | unchanged | unchanged |
|
||||
| Change | Before | After |
|
||||
| --------------------------------------------------------------------- | ----------------- | ----------------- |
|
||||
| Orca-initiated create/remove/rename/sparse/repo edit | immediate (event) | immediate (event) |
|
||||
| SSH reconnect / provider generation bump | immediate (event) | immediate (event) |
|
||||
| External `worktree add/remove/move/prune/lock` | ≤ 30 s | ≤ 30 s |
|
||||
| External `git checkout` / `commit` / `reset` in any worktree | ≤ 30 s | ≤ 30 s |
|
||||
| External `rm -rf <worktree>` | ≤ 30 s | ≤ 30 s |
|
||||
| External sparse-checkout pattern edit | ≤ 30 s | ≤ 5 min |
|
||||
| Packed/reftable tip moved within one mtime tick at an equal file size | ≤ 30 s | ≤ 5 min |
|
||||
| SSH / WSL repos, folder workspaces | unchanged | unchanged |
|
||||
|
||||
The two regressions are bounded by the reconciliation interval and are both
|
||||
changes Orca does not make itself.
|
||||
@@ -185,10 +185,10 @@ thread" in the naive sense — but they are not equally free there. Measured on
|
||||
macOS with a 1 ms interval sampling event-loop lag while each ran 30 times
|
||||
against a repo with 20 linked worktrees:
|
||||
|
||||
| | wall per call | main-thread stall per call | worst single stall |
|
||||
| --- | --- | --- | --- |
|
||||
| `git worktree list` | 18.66 ms | 2.69 ms | 3.02 ms |
|
||||
| fingerprint probe | 1.66 ms | 0.01 ms | 0.04 ms |
|
||||
| | wall per call | main-thread stall per call | worst single stall |
|
||||
| ------------------- | ------------- | -------------------------- | ------------------ |
|
||||
| `git worktree list` | 18.66 ms | 2.69 ms | 3.02 ms |
|
||||
| fingerprint probe | 1.66 ms | 0.01 ms | 0.04 ms |
|
||||
|
||||
`fs/promises` dispatches to libuv's threadpool, so ~99 % of the probe's latency
|
||||
is off-thread. Spawning Git does not: `uv_spawn`, fd and pipe setup, and stdout
|
||||
@@ -212,10 +212,10 @@ sparse-checkout probes already do.
|
||||
reported steady state — 10 idle local repos, a caller polling at 1 Hz for 30
|
||||
simulated minutes — and counts `git worktree list` invocations:
|
||||
|
||||
| | `git worktree list` per 30 min | per hour |
|
||||
| --- | --- | --- |
|
||||
| TTL only (before) | 600 | 1,200 |
|
||||
| fingerprint gate (after) | 60 | 120 |
|
||||
| | `git worktree list` per 30 min | per hour |
|
||||
| ------------------------ | ------------------------------ | -------- |
|
||||
| TTL only (before) | 600 | 1,200 |
|
||||
| fingerprint gate (after) | 60 | 120 |
|
||||
|
||||
A 90 % reduction, with the remainder being the bounded reconciliation. Repos
|
||||
with genuine external activity keep rescanning at the 30 s cadence because the
|
||||
@@ -227,7 +227,7 @@ Extrapolating to the original trace's shape (10 repos, 3 h 27 min): 4,272
|
||||
## Rejected alternatives
|
||||
|
||||
**Raise `WORKTREE_SCAN_CACHE_TTL_MS` to 5 min.** One line, same subprocess
|
||||
reduction, but it degrades *every* external-change latency to 5 min, including
|
||||
reduction, but it degrades _every_ external-change latency to 5 min, including
|
||||
the common "I ran `git worktree add` in a terminal" case. The fingerprint buys
|
||||
the same reduction without that regression.
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ exactly the case a POSIX script uses to mean a literal dollar.
|
||||
Build argv with `buildWslExecArgs()` in `src/shared/wsl-login-shell-command.ts`. A test walks the
|
||||
tree and fails if the `--` form reappears.
|
||||
|
||||
The `--` inside `sh -s -- <path>` is a *shell* argument separator and is unrelated; leave it alone.
|
||||
The `--` inside `sh -s -- <path>` is a _shell_ argument separator and is unrelated; leave it alone.
|
||||
|
||||
## 2. Machine-read output must be fenced
|
||||
|
||||
|
||||
@@ -4,24 +4,24 @@ Three layers of coverage, because each catches what the others structurally cann
|
||||
|
||||
## 1. Unit — runs everywhere, every PR
|
||||
|
||||
| Suite | Pins |
|
||||
|---|---|
|
||||
| `src/main/wsl/wsl-runner.test.ts` | Separator, lane selection, fencing, WSLENV, guest cwd, script interpreter, budget split, refusal on unresolved PATH |
|
||||
| `src/main/wsl/wsl-guest-environment.test.ts` | Burst collapse, per-distro isolation, malformed-payload rejection, transient vs permanent, retry windows, joiner budget |
|
||||
| `src/main/wsl/wsl-w1-w3-contract.test.ts` | The W1→W3 chain end to end: absolute `wsl.exe`, argv array, bounded call, no `--`, script byte-identical, WSLENV, no shell on probe, login PATH still applied |
|
||||
| `src/shared/source-scan/source-tree-scan.test.ts` | The guard helpers. A guard that under-reports is worse than none |
|
||||
| Suite | Pins |
|
||||
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/main/wsl/wsl-runner.test.ts` | Separator, lane selection, fencing, WSLENV, guest cwd, script interpreter, budget split, refusal on unresolved PATH |
|
||||
| `src/main/wsl/wsl-guest-environment.test.ts` | Burst collapse, per-distro isolation, malformed-payload rejection, transient vs permanent, retry windows, joiner budget |
|
||||
| `src/main/wsl/wsl-w1-w3-contract.test.ts` | The W1→W3 chain end to end: absolute `wsl.exe`, argv array, bounded call, no `--`, script byte-identical, WSLENV, no shell on probe, login PATH still applied |
|
||||
| `src/shared/source-scan/source-tree-scan.test.ts` | The guard helpers. A guard that under-reports is worse than none |
|
||||
|
||||
## 2. Ratchets — the goalposts, enforced continuously
|
||||
|
||||
| Guard | Measures |
|
||||
|---|---|
|
||||
| `wsl-invocation-boundary.test.ts` | Files spawning `wsl.exe` outside the runner, plus bash-only payloads that fail to declare `shell: 'bash'` |
|
||||
| `windows-console-visibility.test.ts` | Direct child-process calls missing `windowsHide` |
|
||||
| `child-process-import-boundary.test.ts` | Files importing `child_process` outside the chokepoint |
|
||||
| `wsl-exec-mode-separator.test.ts` | The banned `--` separator |
|
||||
| `pty-descendant-termination-job-coverage.test.ts` | Every sweep passes `terminateOwnedTree` |
|
||||
| Guard | Measures |
|
||||
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `wsl-invocation-boundary.test.ts` | Files spawning `wsl.exe` outside the runner, plus bash-only payloads that fail to declare `shell: 'bash'` |
|
||||
| `windows-console-visibility.test.ts` | Direct child-process calls missing `windowsHide` |
|
||||
| `child-process-import-boundary.test.ts` | Files importing `child_process` outside the chokepoint |
|
||||
| `wsl-exec-mode-separator.test.ts` | The banned `--` separator |
|
||||
| `pty-descendant-termination-job-coverage.test.ts` | Every sweep passes `terminateOwnedTree` |
|
||||
|
||||
Each fails on a **new** offender *and* on a **stale** entry, so the count can only fall. Verify a guard by planting a violation and watching it get named — that step has found a bug in the guard itself three times.
|
||||
Each fails on a **new** offender _and_ on a **stale** entry, so the count can only fall. Verify a guard by planting a violation and watching it get named — that step has found a bug in the guard itself three times.
|
||||
|
||||
## 3. Real-binary — the assertions nothing else can make
|
||||
|
||||
@@ -48,4 +48,4 @@ Recorded rather than implied, because a guard that looks complete is worse than
|
||||
|
||||
### Verifying a guard change
|
||||
|
||||
Plant a violation and watch it fail. Every guard fix in this workstream that was verified only by reading was wrong — three consecutive attempts at an exact lexer each shipped a desync that *reduced* the offender count, which read as progress. Plant at least: a plain call, one in a template-literal-heavy file, one in a regex-heavy file, `windowsHide: false`, a ternary first argument, and a renamed import.
|
||||
Plant a violation and watch it fail. Every guard fix in this workstream that was verified only by reading was wrong — three consecutive attempts at an exact lexer each shipped a desync that _reduced_ the offender count, which read as progress. Plant at least: a plain call, one in a template-literal-heavy file, one in a regex-heavy file, `windowsHide: false`, a ternary first argument, and a renamed import.
|
||||
|
||||
@@ -115,7 +115,7 @@ the resulting patch is silently wrong — the failure mode is a `.mjs` that is
|
||||
`forbiddenBuildScripts` in the manifest encodes this and the generator refuses
|
||||
to run a build step that names one of those scripts.
|
||||
|
||||
The generator also builds the *unmodified* commit first and asserts that it
|
||||
The generator also builds the _unmodified_ commit first and asserts that it
|
||||
reproduces the published `lib/` byte for byte before it emits anything. A
|
||||
toolchain or build-order problem therefore surfaces as an explicit "did not
|
||||
reproduce the published bundles" error rather than as 7 MB of mystery diff.
|
||||
|
||||
@@ -89,20 +89,20 @@ issues `adb shell input` events; AVD names resolve to running adb serials.
|
||||
Use `--json` for agent-friendly output. Coordinates are **normalized 0..1**
|
||||
(top-left origin) — never pixels; Orca converts using the live screen size.
|
||||
|
||||
| Goal | Command | Notes |
|
||||
|----------------------------|----------------------------------------------------------------|-------|
|
||||
| List devices + AVDs | `ORCA emulator devices --json` | Cross-platform; shows iOS + Android with a platform column, booted vs shutdown. |
|
||||
| Single tap | `ORCA emulator tap <x> <y> --device <serial>` | Normalized 0..1. Preferred for single taps. |
|
||||
| Swipe / gesture | `ORCA emulator gesture '<json>' --device <serial>` | adb approximates the path by its endpoints (start→end). |
|
||||
| Type text | `ORCA emulator type "user@example.com" --device <serial>` | US ASCII; spaces handled. No newlines. |
|
||||
| Hardware button | `ORCA emulator button back --device <serial>` | home, back, recents, power, volume_up, volume_down. |
|
||||
| Rotate | `ORCA emulator rotate landscape_left --device <serial>` | Sets user_rotation (disables auto-rotate). |
|
||||
| Install an APK | `ORCA emulator install ./app-debug.apk --reinstall --device <serial>` | `--reinstall` passes `-r`. |
|
||||
| Launch an app | `ORCA emulator launch com.acme.app --activity .MainActivity --device <serial>` | Omit `--activity` to launch the default LAUNCHER activity. |
|
||||
| Grant a permission | `ORCA emulator permissions grant com.acme.app android.permission.CAMERA --device <serial>` | grant / revoke / reset. |
|
||||
| Accessibility tree | `ORCA emulator ax --device <serial> --json` | `uiautomator dump` parsed to a node tree. |
|
||||
| Logcat (one-shot) | `ORCA emulator logcat --lines 200 --device <serial>` | Dumps recent lines; parsed to entries. |
|
||||
| Raw adb shell | `ORCA emulator exec --command "getprop ro.build.version.sdk" --device <serial>` | Runs `adb -s <serial> shell <command>`. |
|
||||
| Goal | Command | Notes |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------- |
|
||||
| List devices + AVDs | `ORCA emulator devices --json` | Cross-platform; shows iOS + Android with a platform column, booted vs shutdown. |
|
||||
| Single tap | `ORCA emulator tap <x> <y> --device <serial>` | Normalized 0..1. Preferred for single taps. |
|
||||
| Swipe / gesture | `ORCA emulator gesture '<json>' --device <serial>` | adb approximates the path by its endpoints (start→end). |
|
||||
| Type text | `ORCA emulator type "user@example.com" --device <serial>` | US ASCII; spaces handled. No newlines. |
|
||||
| Hardware button | `ORCA emulator button back --device <serial>` | home, back, recents, power, volume_up, volume_down. |
|
||||
| Rotate | `ORCA emulator rotate landscape_left --device <serial>` | Sets user_rotation (disables auto-rotate). |
|
||||
| Install an APK | `ORCA emulator install ./app-debug.apk --reinstall --device <serial>` | `--reinstall` passes `-r`. |
|
||||
| Launch an app | `ORCA emulator launch com.acme.app --activity .MainActivity --device <serial>` | Omit `--activity` to launch the default LAUNCHER activity. |
|
||||
| Grant a permission | `ORCA emulator permissions grant com.acme.app android.permission.CAMERA --device <serial>` | grant / revoke / reset. |
|
||||
| Accessibility tree | `ORCA emulator ax --device <serial> --json` | `uiautomator dump` parsed to a node tree. |
|
||||
| Logcat (one-shot) | `ORCA emulator logcat --lines 200 --device <serial>` | Dumps recent lines; parsed to entries. |
|
||||
| Raw adb shell | `ORCA emulator exec --command "getprop ro.build.version.sdk" --device <serial>` | Runs `adb -s <serial> shell <command>`. |
|
||||
|
||||
## Critical gotchas (teach agents)
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ shell-neutral for POSIX shells, PowerShell, and cmd.exe.
|
||||
- The agent should use Orca's preview pane instead of external Simulator.app or raw serve-sim URLs.
|
||||
|
||||
**When NOT to use**
|
||||
|
||||
- Android emulators → use the `orca-emulator-android` skill (same `ORCA emulator` namespace, cross-platform via adb/emulator).
|
||||
- Building or installing the app itself → use `xcodebuild`, `xcrun simctl install`, `expo run:ios`, etc. (launch the app, then use `ORCA emulator` to drive it).
|
||||
- In-app debugging (state, network, views) → use the app's own tools or the browser pane if it's a webview.
|
||||
@@ -75,6 +76,7 @@ An active emulator "session" for the worktree is required for most commands. Use
|
||||
```
|
||||
|
||||
Orca owns:
|
||||
|
||||
- Starting/stopping the serve-sim helper (via --detach or direct).
|
||||
- Per-worktree "active" emulator (like active browser tab).
|
||||
- Explicit targeting with `--worktree`, `--device`, `--emulator <id>`.
|
||||
@@ -82,26 +84,26 @@ Orca owns:
|
||||
|
||||
Agents use the Orca executable chosen above (on PATH in Orca terminals) and never have to manage PIDs, state files in /tmp, or raw WS URLs themselves.
|
||||
|
||||
**For `pnpm dev` testing:** run `pnpm build:cli` first (rebuilds the CLI + ensures the `orca-dev` shim points at *this* worktree). Then inside the dev app use `orca-dev emulator ...` (or the direct `./config/scripts/orca-dev.mjs emulator ...` from the repo root). The orchestration preambles and dev launchers automatically select the dev command name so the CLI reaches your in-memory EmulatorBridge / runtime. Plain `orca` reaches a packaged install instead.
|
||||
**For `pnpm dev` testing:** run `pnpm build:cli` first (rebuilds the CLI + ensures the `orca-dev` shim points at _this_ worktree). Then inside the dev app use `orca-dev emulator ...` (or the direct `./config/scripts/orca-dev.mjs emulator ...` from the repo root). The orchestration preambles and dev launchers automatically select the dev command name so the CLI reaches your in-memory EmulatorBridge / runtime. Plain `orca` reaches a packaged install instead.
|
||||
|
||||
## Common operations
|
||||
|
||||
Use `--json` for agent-friendly output. Commands are workspace-scoped by default (current worktree's active emulator).
|
||||
|
||||
| Goal | Command | Notes |
|
||||
|-----------------------------|----------------------------------------------|-------|
|
||||
| List available / running | `ORCA emulator list [--worktree <sel>]` | Shows Orca-managed + raw serve-sim streams. Use output for explicit --device/--emulator. |
|
||||
| Attach / make active | `ORCA emulator attach "iPhone 16 Pro" [--worktree <sel>] [--focus]` | Starts helper if needed (serve-sim --detach). Sets active for unqualified commands. --focus optional (does not auto-steal UI focus by default). |
|
||||
| Single tap | `ORCA emulator tap <x> <y> [--device <id>]` | Normalized 0..1 coords. **Preferred over gesture for simple taps.** |
|
||||
| Multi-step gesture | `ORCA emulator gesture '<json>'` | See gestures reference (begin/move/end). Use tap for singles. |
|
||||
| Type text | `ORCA emulator type "text" [--device <id>]` | US ASCII only. Supports stdin/file via exec if needed. |
|
||||
| Hardware button | `ORCA emulator button home [--device <id>]` | home, swipe_home, app_switcher, lock, siri, side_button. |
|
||||
| Rotate device | `ORCA emulator rotate landscape_left` | Remembers orientation for subsequent gestures. |
|
||||
| Camera injection | `ORCA emulator camera com.acme.App --webcam` | Or --file, placeholder. Hot-swap with switch. May (re)launch app. |
|
||||
| Permissions | `ORCA emulator permissions grant camera com.acme.App` | grant/revoke/reset/list. See full subcommand help. |
|
||||
| Accessibility tree | `ORCA emulator ax [--device <id>]` | Raw serve-sim AX node tree (labels, roles, nested children, capped at 500 nodes; frames normalized 0..1 with top-left origin — tap an element at its frame center: x+width/2, y+height/2). Needs an active session. |
|
||||
| Raw / advanced | `ORCA emulator exec --command "tap 0.5 0.7"` | Or "ca-debug blended on", "memory-warning", full serve-sim subcommands (no "serve-sim" prefix needed in the command string). Bridge injects active device context. |
|
||||
| Stop | `ORCA emulator kill [--device <id>]` | Or let pane close / Orca quit clean up. |
|
||||
| Goal | Command | Notes |
|
||||
| ------------------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| List available / running | `ORCA emulator list [--worktree <sel>]` | Shows Orca-managed + raw serve-sim streams. Use output for explicit --device/--emulator. |
|
||||
| Attach / make active | `ORCA emulator attach "iPhone 16 Pro" [--worktree <sel>] [--focus]` | Starts helper if needed (serve-sim --detach). Sets active for unqualified commands. --focus optional (does not auto-steal UI focus by default). |
|
||||
| Single tap | `ORCA emulator tap <x> <y> [--device <id>]` | Normalized 0..1 coords. **Preferred over gesture for simple taps.** |
|
||||
| Multi-step gesture | `ORCA emulator gesture '<json>'` | See gestures reference (begin/move/end). Use tap for singles. |
|
||||
| Type text | `ORCA emulator type "text" [--device <id>]` | US ASCII only. Supports stdin/file via exec if needed. |
|
||||
| Hardware button | `ORCA emulator button home [--device <id>]` | home, swipe_home, app_switcher, lock, siri, side_button. |
|
||||
| Rotate device | `ORCA emulator rotate landscape_left` | Remembers orientation for subsequent gestures. |
|
||||
| Camera injection | `ORCA emulator camera com.acme.App --webcam` | Or --file, placeholder. Hot-swap with switch. May (re)launch app. |
|
||||
| Permissions | `ORCA emulator permissions grant camera com.acme.App` | grant/revoke/reset/list. See full subcommand help. |
|
||||
| Accessibility tree | `ORCA emulator ax [--device <id>]` | Raw serve-sim AX node tree (labels, roles, nested children, capped at 500 nodes; frames normalized 0..1 with top-left origin — tap an element at its frame center: x+width/2, y+height/2). Needs an active session. |
|
||||
| Raw / advanced | `ORCA emulator exec --command "tap 0.5 0.7"` | Or "ca-debug blended on", "memory-warning", full serve-sim subcommands (no "serve-sim" prefix needed in the command string). Bridge injects active device context. |
|
||||
| Stop | `ORCA emulator kill [--device <id>]` | Or let pane close / Orca quit clean up. |
|
||||
|
||||
Most support `--worktree <selector>` and explicit `--device <udid|name>` or `--emulator <id>` (from list) for targeting.
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ a long time, or need the user at the keyboard. Never create an Orca workspace or
|
||||
- **Coding-agent CLI + account:** which agent runs in the VM (`codex`, `claude`, …) and that the user
|
||||
has an account for it — it gets logged in during the Phase-3 auth snapshot (§4).
|
||||
- **Git auth:** the token source for cloning a private repo (`GH_TOKEN`/`GITHUB_TOKEN` or `gh auth
|
||||
token`; §5).
|
||||
token`; §5).
|
||||
3. **Check prerequisites (§2)** — detect the provider CLI + auth and confirm the items above are in
|
||||
place before any paid step.
|
||||
4. **Scaffold scripts + state file** from §7 (worked Vercel example: §7f; SSH host: §7g; Docker SSH:
|
||||
@@ -221,9 +221,9 @@ reserve stdout for the final JSON and log progress to stderr. Include a shared `
|
||||
|
||||
- **Local-side** (`create`/`suspend`/`resume`/`destroy` + the base-snapshot/auth scripts the user
|
||||
invokes) runs **on the user's desktop**, so it must run on their OS. macOS/Linux: `#!/usr/bin/env
|
||||
bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`
|
||||
bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`
|
||||
or require WSL/Git-Bash and point `orca.yaml` at the right launcher.
|
||||
- **Remote-side** (commands you `exec` *inside* the Linux VM) always runs in the VM's Linux shell, so
|
||||
- **Remote-side** (commands you `exec` _inside_ the Linux VM) always runs in the VM's Linux shell, so
|
||||
bash is fine there regardless of the user's OS.
|
||||
|
||||
### 7a. Base-snapshot (`<provider>-base-snapshot.sh`) — Phase 2
|
||||
@@ -303,7 +303,11 @@ There is **no `--host` flag**. `--project-root` must be an absolute directory on
|
||||
keeps serving:
|
||||
|
||||
```json
|
||||
{ "schemaVersion": 1, "pairingCode": "<orca pairing URL>", "projectRoot": "<the --project-root you passed>" }
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"pairingCode": "<orca pairing URL>",
|
||||
"projectRoot": "<the --project-root you passed>"
|
||||
}
|
||||
```
|
||||
|
||||
`pairingCode` is the pairing URL, already pointing at whatever you passed as `--pairing-address` — so set
|
||||
@@ -498,7 +502,7 @@ git checkout -B "$ORCA_REPO_BRANCH" "$ORCA_REPO_REF_HEAD"
|
||||
|
||||
Fail if the requested schema is not `2`; do not silently fall back to the ordinary recipe shape.
|
||||
|
||||
**Networking → which `target` fields to set** (how *your desktop* reaches the box — there is no
|
||||
**Networking → which `target` fields to set** (how _your desktop_ reaches the box — there is no
|
||||
`orca serve` URL in SSH mode):
|
||||
|
||||
- Public IP / DNS, or a Tailscale/VPN address → `host`; SSH port → `port` (usually 22).
|
||||
@@ -511,7 +515,7 @@ Fail if the requested schema is not `2`; do not silently fall back to the ordina
|
||||
reconnect grace window.
|
||||
|
||||
**Toolchain & agent auth on a persistent (no-snapshot) host — do this ONCE, by hand, before wiring the
|
||||
recipe** (there's no base image to bake; the host *is* the base). Run the §7f Phase-2 install steps and
|
||||
recipe** (there's no base image to bake; the host _is_ the base). Run the §7f Phase-2 install steps and
|
||||
the §7f Phase-3 `<agent> login --device-auth` **directly over SSH on the host** (interactive, e.g.
|
||||
`ssh -t user@host '<agent> login --device-auth'`). After that the host stays ready across workspaces.
|
||||
|
||||
@@ -615,7 +619,7 @@ $ErrorActionPreference = 'Stop'
|
||||
# progress/errors → Write-Error / the error stream, never stdout.
|
||||
```
|
||||
|
||||
The remote-side commands you run *inside* the Linux VM stay bash regardless of the desktop OS.
|
||||
The remote-side commands you run _inside_ the Linux VM stay bash regardless of the desktop OS.
|
||||
|
||||
---
|
||||
|
||||
@@ -702,10 +706,10 @@ each stage so you can self-diagnose without asking the user to relay logs:
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"checks": [ { "id": "recipe.provision", "status": "fail", "message": "…" } ],
|
||||
"checks": [{ "id": "recipe.provision", "status": "fail", "message": "…" }],
|
||||
"provisionTranscript": {
|
||||
"provision": { "exitCode": 0, "signal": null, "stdout": "…", "stderr": "…", "parseError": "…" },
|
||||
"destroy": { "exitCode": 0, "signal": null, "stdout": "…", "stderr": "…" }
|
||||
"destroy": { "exitCode": 0, "signal": null, "stdout": "…", "stderr": "…" }
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -743,7 +747,7 @@ startup-only `docker run` before the full clone/install path.
|
||||
- **Agent verified as "not logged in" despite a good login.** `codex login status` (and similar) print
|
||||
"Logged in …" to **stderr**; an stdout-only `grep` misses it. Prefer the status **exit code**; if you
|
||||
grep, fold stderr first (`status 2>&1 | grep …`) and match the exact success line — not `grep -qi
|
||||
'logged in'`, which also matches "not logged in".
|
||||
'logged in'`, which also matches "not logged in".
|
||||
- **Headless agent login hangs.** Plain OAuth `login` starts a loopback callback server on a VM/container
|
||||
port the host browser can't reach. Use the **device-auth** flow (`login --device-auth`) — it prints a
|
||||
URL + code the user opens on the host.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -8,14 +8,19 @@ import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const { spawnMock, isPwshAvailableMock, resolveAgentForegroundProcessMock, readConptyMock, jobReadableMock } =
|
||||
vi.hoisted(() => ({
|
||||
spawnMock: vi.fn(),
|
||||
isPwshAvailableMock: vi.fn(),
|
||||
resolveAgentForegroundProcessMock: vi.fn(),
|
||||
readConptyMock: vi.fn(),
|
||||
jobReadableMock: vi.fn()
|
||||
}))
|
||||
const {
|
||||
spawnMock,
|
||||
isPwshAvailableMock,
|
||||
resolveAgentForegroundProcessMock,
|
||||
readConptyMock,
|
||||
jobReadableMock
|
||||
} = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn(),
|
||||
isPwshAvailableMock: vi.fn(),
|
||||
resolveAgentForegroundProcessMock: vi.fn(),
|
||||
readConptyMock: vi.fn(),
|
||||
jobReadableMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('node-pty', () => ({ spawn: spawnMock }))
|
||||
vi.mock('../pwsh', () => ({ isPwshAvailable: isPwshAvailableMock }))
|
||||
|
||||
@@ -117,11 +117,7 @@ export function parseUnmetGlibcVersion(loaderError: string): string | null {
|
||||
}
|
||||
|
||||
/** `NODE_MODULE_VERSION 115 ... requires NODE_MODULE_VERSION 127` -> { built: '115', host: '127' }. */
|
||||
export function parseNodeAbiMismatch(
|
||||
loaderError: string
|
||||
): { built: string; host: string } | null {
|
||||
const match = loaderError.match(
|
||||
/NODE_MODULE_VERSION\s+(\d+)\D+NODE_MODULE_VERSION\s+(\d+)/
|
||||
)
|
||||
export function parseNodeAbiMismatch(loaderError: string): { built: string; host: string } | null {
|
||||
const match = loaderError.match(/NODE_MODULE_VERSION\s+(\d+)\D+NODE_MODULE_VERSION\s+(\d+)/)
|
||||
return match ? { built: match[1], host: match[2] } : null
|
||||
}
|
||||
|
||||
@@ -27,7 +27,12 @@ export type PrebuiltSlotManifest = {
|
||||
|
||||
export type PrebuiltSlotOutcome =
|
||||
| { installed: true; slot: string; spawnHelper: boolean }
|
||||
| { installed: false; slot: string; why: 'no-slot' | 'no-prebuilds-dir' | 'abi-mismatch'; detail?: string }
|
||||
| {
|
||||
installed: false
|
||||
slot: string
|
||||
why: 'no-slot' | 'no-prebuilds-dir' | 'abi-mismatch'
|
||||
detail?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a deployment's prebuilds live: beside the bundle that is running. `argv[1]` is
|
||||
@@ -55,7 +60,9 @@ export function readPrebuiltSlotManifest(prebuildsDir: string): PrebuiltSlotMani
|
||||
module: typeof manifest.module === 'string' ? manifest.module : 'node-pty',
|
||||
version: manifest.version,
|
||||
nodeAbi: manifest.nodeAbi,
|
||||
slots: Array.isArray(manifest.slots) ? manifest.slots.filter((s) => typeof s === 'string') : []
|
||||
slots: Array.isArray(manifest.slots)
|
||||
? manifest.slots.filter((s) => typeof s === 'string')
|
||||
: []
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
|
||||
@@ -157,7 +157,11 @@ async function writeSkillLockHash(homeDir: string, skillFolderHash: string): Pro
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((root) => rm(root, { recursive: true })))
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((root) => rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }))
|
||||
)
|
||||
})
|
||||
|
||||
describe('read-only skill freshness inventory', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { join } from 'node:path'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveDefaultHermesSkillsRoot,
|
||||
@@ -10,14 +10,16 @@ import {
|
||||
|
||||
describe('skill provider runtime roots', () => {
|
||||
it('maps Claude and Grok config homes to their global skill roots', () => {
|
||||
const claudeRoot = resolve('/srv/claude')
|
||||
const grokRoot = resolve('/srv/grok')
|
||||
expect(
|
||||
resolveEnvironmentSkillProviderRoots({
|
||||
CLAUDE_CONFIG_DIR: join('/srv', 'claude'),
|
||||
GROK_HOME: join('/srv', 'grok')
|
||||
CLAUDE_CONFIG_DIR: claudeRoot,
|
||||
GROK_HOME: grokRoot
|
||||
})
|
||||
).toEqual({
|
||||
claude: join('/srv', 'claude', 'skills'),
|
||||
grok: join('/srv', 'grok', 'skills')
|
||||
claude: join(claudeRoot, 'skills'),
|
||||
grok: join(grokRoot, 'skills')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,14 +29,16 @@ describe('skill provider runtime roots', () => {
|
||||
GROK_HOME: '../grok'
|
||||
})
|
||||
expect(roots).toEqual({})
|
||||
expect(withClaudeSkillProviderRoot(roots, join('/managed', 'claude'))).toEqual({
|
||||
claude: join('/managed', 'claude', 'skills')
|
||||
const managedClaudeRoot = resolve('/managed/claude')
|
||||
expect(withClaudeSkillProviderRoot(roots, managedClaudeRoot)).toEqual({
|
||||
claude: join(managedClaudeRoot, 'skills')
|
||||
})
|
||||
})
|
||||
|
||||
it('maps a relocated HERMES_HOME to its skill root and ignores relative ones', () => {
|
||||
expect(resolveEnvironmentHermesSkillsRoot({ HERMES_HOME: join('/srv', 'hermes') })).toBe(
|
||||
join('/srv', 'hermes', 'skills')
|
||||
const hermesRoot = resolve('/srv/hermes')
|
||||
expect(resolveEnvironmentHermesSkillsRoot({ HERMES_HOME: hermesRoot })).toBe(
|
||||
join(hermesRoot, 'skills')
|
||||
)
|
||||
expect(resolveEnvironmentHermesSkillsRoot({ HERMES_HOME: '../hermes' })).toBeNull()
|
||||
expect(resolveEnvironmentHermesSkillsRoot({})).toBeNull()
|
||||
@@ -52,13 +56,14 @@ describe('skill provider runtime roots', () => {
|
||||
})
|
||||
|
||||
it('defaults the Hermes skills root under LOCALAPPDATA on Windows', () => {
|
||||
const localAppData = resolve('/local')
|
||||
const resolved = resolveDefaultHermesSkillsRoot({
|
||||
homeDir: join('/users', 'alice'),
|
||||
platform: 'win32',
|
||||
env: { LOCALAPPDATA: join('/local') },
|
||||
directoryExists: (candidate) => candidate === join('/local', 'hermes')
|
||||
env: { LOCALAPPDATA: localAppData },
|
||||
directoryExists: (candidate) => candidate === join(localAppData, 'hermes')
|
||||
})
|
||||
expect(resolved).toBe(join('/local', 'hermes', 'skills'))
|
||||
expect(resolved).toBe(join(localAppData, 'hermes', 'skills'))
|
||||
})
|
||||
|
||||
it('keeps a pre-LOCALAPPDATA Windows dotfolder install discoverable', () => {
|
||||
@@ -72,13 +77,14 @@ describe('skill provider runtime roots', () => {
|
||||
})
|
||||
|
||||
it('prefers the LOCALAPPDATA tree on Windows when both layouts exist', () => {
|
||||
const localAppData = resolve('/local')
|
||||
const resolved = resolveDefaultHermesSkillsRoot({
|
||||
homeDir: join('/users', 'alice'),
|
||||
platform: 'win32',
|
||||
env: { LOCALAPPDATA: join('/local') },
|
||||
env: { LOCALAPPDATA: localAppData },
|
||||
directoryExists: () => true
|
||||
})
|
||||
expect(resolved).toBe(join('/local', 'hermes', 'skills'))
|
||||
expect(resolved).toBe(join(localAppData, 'hermes', 'skills'))
|
||||
})
|
||||
|
||||
it('falls back to the dotfolder when Windows exposes no usable LOCALAPPDATA', () => {
|
||||
|
||||
@@ -163,4 +163,3 @@ export function formatMissingToolchainError(
|
||||
]
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
|
||||
@@ -169,7 +169,5 @@ export function orcadGcPinnedDirNames(
|
||||
const versions = [record.active, record.previous, daemonEntryVersion ?? null].filter(
|
||||
(v): v is string => typeof v === 'string' && v.length > 0
|
||||
)
|
||||
return [...new Set(versions)].map((version) =>
|
||||
remoteInstallDirName(ORCAD_INSTALL_MODEL, version)
|
||||
)
|
||||
return [...new Set(versions)].map((version) => remoteInstallDirName(ORCAD_INSTALL_MODEL, version))
|
||||
}
|
||||
|
||||
@@ -209,7 +209,9 @@ describe('deployOrcad', () => {
|
||||
const result = await deployOrcad(options())
|
||||
expect(result).toMatchObject({ code: 'orcad_activation_daemon_degraded' })
|
||||
expect(
|
||||
vi.mocked(writeRelayFile).mock.calls.some((call) => String(call[2]).endsWith('orcad-active.json'))
|
||||
vi
|
||||
.mocked(writeRelayFile)
|
||||
.mock.calls.some((call) => String(call[2]).endsWith('orcad-active.json'))
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -32,10 +32,7 @@ import {
|
||||
type OrcadActivationRecord,
|
||||
type OrcadStateSnapshot
|
||||
} from './orcad-activation-record'
|
||||
import {
|
||||
orcadActivationPath,
|
||||
readOrcadActivationRecord
|
||||
} from './orcad-activation-record-store'
|
||||
import { orcadActivationPath, readOrcadActivationRecord } from './orcad-activation-record-store'
|
||||
import { evaluateOrcadActivation, type OrcadActivationVerdict } from './orcad-activation-gate'
|
||||
import { planOrcadUpdate, type OrcadTerminalCensus } from './orcad-update-plan'
|
||||
import {
|
||||
@@ -111,9 +108,11 @@ async function installOrcadBundle(
|
||||
fullVersion: string,
|
||||
remoteDir: string
|
||||
): Promise<void> {
|
||||
if (await isRemoteInstallComplete(options.conn, ORCAD_INSTALL_MODEL, remoteDir, options.host, {
|
||||
signal: options.signal
|
||||
})) {
|
||||
if (
|
||||
await isRemoteInstallComplete(options.conn, ORCAD_INSTALL_MODEL, remoteDir, options.host, {
|
||||
signal: options.signal
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
await acquireInstallLock(options.conn, remoteDir, options.host, { signal: options.signal })
|
||||
@@ -151,7 +150,12 @@ async function captureSnapshot(
|
||||
takenAt: Date
|
||||
): Promise<OrcadStateSnapshot | null> {
|
||||
const dirName = orcadSnapshotDirName(fullVersion, takenAt.getTime())
|
||||
const snapshotDir = joinRemotePath(options.host, baseDir(options), ORCAD_STATE_SNAPSHOT_DIR, dirName)
|
||||
const snapshotDir = joinRemotePath(
|
||||
options.host,
|
||||
baseDir(options),
|
||||
ORCAD_STATE_SNAPSHOT_DIR,
|
||||
dirName
|
||||
)
|
||||
const capture = parseOrcadSnapshotCapture(
|
||||
await exec(
|
||||
options,
|
||||
@@ -287,9 +291,12 @@ export async function deployOrcad(options: OrcadDeployOptions): Promise<OrcadDep
|
||||
record.active
|
||||
)
|
||||
const stopped = parseOrcadStopOutcome(
|
||||
await exec(options, stopOrcadCommand(options.host, outgoingDir, {
|
||||
waitSeconds: STOP_WAIT_SECONDS
|
||||
}))
|
||||
await exec(
|
||||
options,
|
||||
stopOrcadCommand(options.host, outgoingDir, {
|
||||
waitSeconds: STOP_WAIT_SECONDS
|
||||
})
|
||||
)
|
||||
)
|
||||
if (!orcadStopFreedTheHost(stopped)) {
|
||||
return {
|
||||
|
||||
@@ -78,7 +78,9 @@ describe('orcad GC', () => {
|
||||
currentDirAbsPath: '/home/u/.orca-remote/orcad-0.2.0+bb',
|
||||
record: emptyOrcadActivationRecord()
|
||||
})
|
||||
const listCommand = mockExec.mock.calls.map((call) => String(call[1])).find((c) => c.includes('find'))
|
||||
const listCommand = mockExec.mock.calls
|
||||
.map((call) => String(call[1]))
|
||||
.find((c) => c.includes('find'))
|
||||
expect(listCommand).toContain("-name 'orcad-*'")
|
||||
expect(listCommand).not.toContain("-name 'relay-*'")
|
||||
})
|
||||
|
||||
@@ -25,10 +25,7 @@ import type { ServeReadiness } from '../server/serve-readiness'
|
||||
export const ORCAD_READINESS_FILENAME = '.orcad-readiness'
|
||||
/** Stderr, including the bind-exposure line and every supervision message. */
|
||||
export const ORCAD_LOG_FILENAME = 'orcad.log'
|
||||
export {
|
||||
ORCAD_PID_FILENAME,
|
||||
OrcadRemoteLaunchUnsupportedError
|
||||
} from './orcad-remote-host-support'
|
||||
export { ORCAD_PID_FILENAME, OrcadRemoteLaunchUnsupportedError } from './orcad-remote-host-support'
|
||||
|
||||
export type OrcadLaunchSpec = {
|
||||
remoteInstallDir: string
|
||||
@@ -51,7 +48,9 @@ export type OrcadLaunchSpec = {
|
||||
export function orcadLaunchCommand(host: RemoteHostPlatform, spec: OrcadLaunchSpec): string {
|
||||
assertPosixHost(host)
|
||||
const dir = shellEscape(spec.remoteInstallDir)
|
||||
const readiness = shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_READINESS_FILENAME))
|
||||
const readiness = shellEscape(
|
||||
joinRemotePath(host, spec.remoteInstallDir, ORCAD_READINESS_FILENAME)
|
||||
)
|
||||
const log = shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_LOG_FILENAME))
|
||||
const pidFile = shellEscape(joinRemotePath(host, spec.remoteInstallDir, ORCAD_PID_FILENAME))
|
||||
const entry = shellEscape(joinRemotePath(host, spec.remoteInstallDir, 'orcad.js'))
|
||||
|
||||
@@ -13,10 +13,7 @@ vi.mock('./ssh-relay-install-transfers', () => ({
|
||||
import { execCommand } from './ssh-relay-deploy-helpers'
|
||||
import { writeRelayFile } from './ssh-relay-install-transfers'
|
||||
import { rollbackOrcad, type OrcadRollbackOptions } from './orcad-remote-rollback'
|
||||
import {
|
||||
emptyOrcadActivationRecord,
|
||||
type OrcadActivationRecord
|
||||
} from './orcad-activation-record'
|
||||
import { emptyOrcadActivationRecord, type OrcadActivationRecord } from './orcad-activation-record'
|
||||
import { getRemoteHostPlatform } from './ssh-remote-platform'
|
||||
import type { SshConnection } from './ssh-connection'
|
||||
|
||||
|
||||
@@ -109,9 +109,7 @@ async function readStateWritesSinceActivation(
|
||||
return newest === null ? null : newest >= activatedAtSeconds
|
||||
}
|
||||
|
||||
export async function rollbackOrcad(
|
||||
options: OrcadRollbackOptions
|
||||
): Promise<OrcadRollbackResult> {
|
||||
export async function rollbackOrcad(options: OrcadRollbackOptions): Promise<OrcadRollbackResult> {
|
||||
const now = options.now ?? ((): Date => new Date())
|
||||
const snapshotPresent = options.record.snapshot
|
||||
? (
|
||||
@@ -183,11 +181,7 @@ export async function rollbackOrcad(
|
||||
}
|
||||
}
|
||||
|
||||
const targetDir = computeRemoteInstallDir(
|
||||
ORCAD_INSTALL_MODEL,
|
||||
options.remoteHome,
|
||||
safety.target
|
||||
)
|
||||
const targetDir = computeRemoteInstallDir(ORCAD_INSTALL_MODEL, options.remoteHome, safety.target)
|
||||
await exec(
|
||||
options,
|
||||
orcadLaunchCommand(options.host, {
|
||||
|
||||
@@ -143,9 +143,9 @@ describe('liveness and stop commands, run for real', () => {
|
||||
const exited = new Promise<NodeJS.Signals | null>((resolve) =>
|
||||
child.once('exit', (_code, signal) => resolve(signal))
|
||||
)
|
||||
expect(parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 10 })))).toBe(
|
||||
'stopped'
|
||||
)
|
||||
expect(
|
||||
parseOrcadStopOutcome(sh(stopOrcadCommand(host, versionDir, { waitSeconds: 10 })))
|
||||
).toBe('stopped')
|
||||
expect(await exited).toBe('SIGTERM')
|
||||
expect(parseOrcadLiveness(sh(orcadLivenessProbeCommand(host, versionDir)))).toBe('DEAD')
|
||||
} finally {
|
||||
|
||||
@@ -211,7 +211,9 @@ async function isCandidateSafeToRemove(
|
||||
if (!(await isRelayInstallLockStale(conn, lockDir, host))) {
|
||||
return false
|
||||
}
|
||||
process.stderr.write?.(`[${model.id}] GC: lock at ${lockDir} is stale; treating as recoverable\n`)
|
||||
process.stderr.write?.(
|
||||
`[${model.id}] GC: lock at ${lockDir} is stale; treating as recoverable\n`
|
||||
)
|
||||
}
|
||||
|
||||
// Legacy dirs predate .install-complete; skip the sentinel and rely on the live-socket probe alone.
|
||||
@@ -231,7 +233,6 @@ async function isCandidateSafeToRemove(
|
||||
return !(await options.isDirLive(dir))
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The relay's GC, bound to its own namespace and its own liveness probe (a live unix socket
|
||||
* or Windows pipe inside the version dir).
|
||||
|
||||
@@ -92,7 +92,11 @@ export function computeRemoteInstallDir(
|
||||
pathFlavor === 'windows'
|
||||
? getRemoteHostPlatform('win32-x64')
|
||||
: getRemoteHostPlatform('linux-x64')
|
||||
return joinRemotePath(host, remoteHome, ...remoteInstallDirSegments(model, fullVersion, pathFlavor))
|
||||
return joinRemotePath(
|
||||
host,
|
||||
remoteHome,
|
||||
...remoteInstallDirSegments(model, fullVersion, pathFlavor)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -522,5 +522,4 @@ describe('rich markdown Tab key handler', () => {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Download, Loader2, ShieldCheck } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { useAppStore } from '@/store'
|
||||
import type {
|
||||
SkillInstallDestination,
|
||||
@@ -32,6 +25,7 @@ import { useSkillInstallProgress } from './skill-install-progress-state'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { resolveSkillShareForInstall } from './skill-warning-preview-gate'
|
||||
import { useSkillInstallRisk } from './use-skill-install-risk'
|
||||
import { SkillInstallDialogFooter } from './SkillInstallDialogFooter'
|
||||
|
||||
export function SkillInstallDialog({
|
||||
open,
|
||||
@@ -374,65 +368,21 @@ export function SkillInstallDialog({
|
||||
{installProgress.phaseLabel}
|
||||
</p>
|
||||
) : null}
|
||||
{!bundleVersion ? (
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={close} disabled={busy}>
|
||||
{translate('auto.components.skills.SkillInstallDialog.d198ec91e5', 'Close')}
|
||||
</Button>
|
||||
{!preview && !resolvingInitialLink ? (
|
||||
<Button type="button" disabled={busy || !link.trim()} onClick={() => void inspect()}>
|
||||
{busy ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<ShieldCheck className="size-4" />
|
||||
)}
|
||||
{busy
|
||||
? translate(
|
||||
'auto.components.skills.SkillInstallReviewContent.69236de8d6',
|
||||
'Checking…'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.skills.SkillInstallReviewContent.157de228b4',
|
||||
'Inspect skill'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{busy && installProgress.activeOperationId ? (
|
||||
<Button type="button" variant="secondary" onClick={() => void cancelInstall()}>
|
||||
{translate(
|
||||
'auto.components.skills.SkillInstallDialog.05588076a9',
|
||||
'Cancel installation'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{preview &&
|
||||
(!result || ['conflict', 'partial', 'failed', 'cancelled'].includes(result.status)) ? (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={busy || (scope === 'workspace' && !workspace)}
|
||||
onClick={() => void install()}
|
||||
className="w-32"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="size-4" />
|
||||
)}
|
||||
{busy
|
||||
? translate('auto.components.skills.SkillInstallDialog.241e72f9d6', 'Installing…')
|
||||
: result
|
||||
? translate(
|
||||
'auto.components.skills.SkillInstallDialog.59c3b76cdd',
|
||||
'Retry install'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.skills.SkillInstallDialog.39acb9e8f4',
|
||||
'Install skill'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
) : null}
|
||||
<SkillInstallDialogFooter
|
||||
activeOperationId={installProgress.activeOperationId}
|
||||
busy={busy}
|
||||
hasBundleVersion={Boolean(bundleVersion)}
|
||||
hasPreview={Boolean(preview)}
|
||||
link={link}
|
||||
resolvingInitialLink={resolvingInitialLink}
|
||||
result={result}
|
||||
scope={scope}
|
||||
workspace={workspace}
|
||||
onCancelInstall={() => void cancelInstall()}
|
||||
onClose={close}
|
||||
onInspect={() => void inspect()}
|
||||
onInstall={(discardLocal) => void install(discardLocal)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Download, Loader2, ShieldCheck } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogFooter } from '@/components/ui/dialog'
|
||||
import type { SkillInstallResult } from '../../../../shared/skill-install-contract'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type SkillInstallDialogFooterProps = {
|
||||
activeOperationId: string | null
|
||||
busy: boolean
|
||||
hasBundleVersion: boolean
|
||||
hasPreview: boolean
|
||||
link: string
|
||||
resolvingInitialLink: boolean
|
||||
result: SkillInstallResult | null
|
||||
scope: 'global' | 'workspace'
|
||||
workspace: string
|
||||
onCancelInstall: () => void
|
||||
onClose: () => void
|
||||
onInspect: () => void
|
||||
onInstall: (discardLocal?: boolean) => void
|
||||
}
|
||||
|
||||
export function SkillInstallDialogFooter({
|
||||
activeOperationId,
|
||||
busy,
|
||||
hasBundleVersion,
|
||||
hasPreview,
|
||||
link,
|
||||
resolvingInitialLink,
|
||||
result,
|
||||
scope,
|
||||
workspace,
|
||||
onCancelInstall,
|
||||
onClose,
|
||||
onInspect,
|
||||
onInstall
|
||||
}: SkillInstallDialogFooterProps): React.JSX.Element | null {
|
||||
if (hasBundleVersion) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
|
||||
{translate('auto.components.skills.SkillInstallDialog.d198ec91e5', 'Close')}
|
||||
</Button>
|
||||
{!hasPreview && !resolvingInitialLink ? (
|
||||
<Button type="button" disabled={busy || !link.trim()} onClick={onInspect}>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <ShieldCheck className="size-4" />}
|
||||
{busy
|
||||
? translate('auto.components.skills.SkillInstallReviewContent.69236de8d6', 'Checking…')
|
||||
: translate(
|
||||
'auto.components.skills.SkillInstallReviewContent.157de228b4',
|
||||
'Inspect skill'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{busy && activeOperationId ? (
|
||||
<Button type="button" variant="secondary" onClick={onCancelInstall}>
|
||||
{translate('auto.components.skills.SkillInstallDialog.05588076a9', 'Cancel installation')}
|
||||
</Button>
|
||||
) : null}
|
||||
{hasPreview &&
|
||||
(!result || ['conflict', 'partial', 'failed', 'cancelled'].includes(result.status)) ? (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={busy || (scope === 'workspace' && !workspace)}
|
||||
onClick={() => onInstall()}
|
||||
className="w-32"
|
||||
>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
||||
{busy
|
||||
? translate('auto.components.skills.SkillInstallDialog.241e72f9d6', 'Installing…')
|
||||
: result
|
||||
? translate('auto.components.skills.SkillInstallDialog.59c3b76cdd', 'Retry install')
|
||||
: translate('auto.components.skills.SkillInstallDialog.39acb9e8f4', 'Install skill')}
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Dialog } from '@/components/ui/dialog'
|
||||
import { useAppStore } from '@/store'
|
||||
import type {
|
||||
ManagedSkillInstall,
|
||||
@@ -17,18 +8,15 @@ import type {
|
||||
import type { SkillBundleInstallResult } from '../../../../shared/skill-bundle-install-contract'
|
||||
import type { SkillCloudPackageDetails } from '../../../../shared/skill-cloud-contract'
|
||||
import { notifyInstalledAgentSkillsChanged } from '@/hooks/useInstalledAgentSkills'
|
||||
import { skillInstallResultLabel } from './skill-install-result-label'
|
||||
import { skillInstallManagementCopy } from './skill-install-management-copy'
|
||||
import { useSkillInstallProgress } from './skill-install-progress-state'
|
||||
import { SkillManagedInstallRow } from './SkillManagedInstallRow'
|
||||
import { summarizeManagedSkillRemoval } from './skill-managed-removal-summary'
|
||||
import {
|
||||
groupManagedSkillInstalls,
|
||||
type SkillManagedInstallGroup
|
||||
} from './skill-managed-install-groups'
|
||||
import { SkillInstallMachineSelect } from './SkillInstallMachineSelect'
|
||||
import { SkillInstallManagementStatus } from './SkillInstallManagementStatus'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { SkillInstallManagementDialogContent } from './SkillInstallManagementDialogContent'
|
||||
|
||||
export function SkillInstallManagementDialog({
|
||||
open,
|
||||
@@ -351,77 +339,39 @@ export function SkillInstallManagementDialog({
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const destructiveConflict =
|
||||
result?.status === 'conflict' ||
|
||||
Boolean(selected?.installs.some((install) => install.state === 'modified'))
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && !busy && close()}>
|
||||
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-x-hidden overflow-y-auto scrollbar-sleek sm:max-w-2xl [&>*]:min-w-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copy.title}</DialogTitle>
|
||||
<DialogDescription>{copy.description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<SkillInstallMachineSelect
|
||||
value={environmentId}
|
||||
onChange={setEnvironmentId}
|
||||
localLabel={copy.localMachine}
|
||||
sshLabel={copy.ssh}
|
||||
disconnectedLabel={copy.disconnected}
|
||||
environments={runtimeEnvironments}
|
||||
sshTargets={[...sshTargetLabels.entries()].map(([id, label]) => ({
|
||||
id: `ssh:${id}`,
|
||||
label,
|
||||
connected: sshConnectionStates.get(id)?.status === 'connected'
|
||||
}))}
|
||||
/>
|
||||
|
||||
{busy && installs.length === 0 ? <Loader2 className="mx-auto size-5 animate-spin" /> : null}
|
||||
{!busy && installs.length === 0 ? (
|
||||
<p className="rounded-md border border-border p-4 text-sm text-muted-foreground">
|
||||
{copy.noInstalls}
|
||||
</p>
|
||||
) : null}
|
||||
{groups.length > 0 ? (
|
||||
<ul className="divide-y divide-border rounded-md border border-border">
|
||||
{groups.map((group) => (
|
||||
<SkillManagedInstallRow
|
||||
key={group.key}
|
||||
group={group}
|
||||
open={selectedKey === group.key}
|
||||
details={selectedKey === group.key ? details : null}
|
||||
versionId={selectedKey === group.key ? versionId : ''}
|
||||
busy={busy}
|
||||
confirmRemove={selectedKey === group.key && confirmRemove}
|
||||
installActive={Boolean(installProgress.activeOperationId)}
|
||||
editedWarning={selectedKey === group.key && destructiveConflict}
|
||||
bundleResult={selectedKey === group.key ? bundleResult : null}
|
||||
result={selectedKey === group.key ? result : null}
|
||||
onOpenChange={(next) => (next ? void selectInstall(group) : collapse())}
|
||||
onVersionChange={setVersionId}
|
||||
onInstall={(discardLocal) => void installVersion(discardLocal)}
|
||||
onCancelInstall={() => void cancelInstall()}
|
||||
onSendToMachine={(shareId) => {
|
||||
close()
|
||||
useAppStore.getState().openSkillShare(shareId)
|
||||
}}
|
||||
onRemove={(discardLocal) => void remove(discardLocal)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<SkillInstallManagementStatus
|
||||
resultLabel={result ? skillInstallResultLabel(result) : null}
|
||||
progressLabel={installProgress.phaseLabel}
|
||||
error={error}
|
||||
notice={notice}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={close} disabled={busy}>
|
||||
{copy.close}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
<SkillInstallManagementDialogContent
|
||||
bundleResult={bundleResult}
|
||||
busy={busy}
|
||||
confirmRemove={confirmRemove}
|
||||
copy={copy}
|
||||
details={details}
|
||||
environmentId={environmentId}
|
||||
error={error}
|
||||
groups={groups}
|
||||
installs={installs}
|
||||
installActive={Boolean(installProgress.activeOperationId)}
|
||||
notice={notice}
|
||||
progressLabel={installProgress.phaseLabel}
|
||||
result={result}
|
||||
runtimeEnvironments={runtimeEnvironments}
|
||||
selectedKey={selectedKey}
|
||||
sshConnectionStates={sshConnectionStates}
|
||||
sshTargetLabels={sshTargetLabels}
|
||||
versionId={versionId}
|
||||
onCancelInstall={() => void cancelInstall()}
|
||||
onClose={close}
|
||||
onEnvironmentChange={setEnvironmentId}
|
||||
onInstall={(discardLocal) => void installVersion(discardLocal)}
|
||||
onOpenChange={(group, next) => (next ? void selectInstall(group) : collapse())}
|
||||
onRemove={(discardLocal) => void remove(discardLocal)}
|
||||
onSendToMachine={(shareId) => {
|
||||
close()
|
||||
useAppStore.getState().openSkillShare(shareId)
|
||||
}}
|
||||
onVersionChange={setVersionId}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import type {
|
||||
ManagedSkillInstall,
|
||||
SkillInstallResult
|
||||
} from '../../../../shared/skill-install-contract'
|
||||
import type { SkillBundleInstallResult } from '../../../../shared/skill-bundle-install-contract'
|
||||
import type { SkillCloudPackageDetails } from '../../../../shared/skill-cloud-contract'
|
||||
import type { skillInstallManagementCopy } from './skill-install-management-copy'
|
||||
import { skillInstallResultLabel } from './skill-install-result-label'
|
||||
import { SkillInstallMachineSelect } from './SkillInstallMachineSelect'
|
||||
import { SkillInstallManagementStatus } from './SkillInstallManagementStatus'
|
||||
import { SkillManagedInstallRow } from './SkillManagedInstallRow'
|
||||
import type { SkillManagedInstallGroup } from './skill-managed-install-groups'
|
||||
|
||||
type SkillInstallManagementDialogContentProps = {
|
||||
bundleResult: SkillBundleInstallResult | null
|
||||
busy: boolean
|
||||
confirmRemove: boolean
|
||||
copy: ReturnType<typeof skillInstallManagementCopy>
|
||||
details: SkillCloudPackageDetails | null
|
||||
environmentId: string
|
||||
error: string | null
|
||||
groups: SkillManagedInstallGroup[]
|
||||
installs: ManagedSkillInstall[]
|
||||
installActive: boolean
|
||||
notice: string | null
|
||||
result: SkillInstallResult | null
|
||||
progressLabel: string | null
|
||||
runtimeEnvironments: readonly { id: string; name: string }[]
|
||||
selectedKey: string
|
||||
sshConnectionStates: ReadonlyMap<string, { status: string }>
|
||||
sshTargetLabels: ReadonlyMap<string, string>
|
||||
versionId: string
|
||||
onCancelInstall: () => void
|
||||
onClose: () => void
|
||||
onEnvironmentChange: (value: string) => void
|
||||
onInstall: (discardLocal?: boolean) => void
|
||||
onOpenChange: (group: SkillManagedInstallGroup, open: boolean) => void
|
||||
onRemove: (discardLocal?: boolean) => void
|
||||
onSendToMachine: (shareId: string) => void
|
||||
onVersionChange: (versionId: string) => void
|
||||
}
|
||||
|
||||
export function SkillInstallManagementDialogContent({
|
||||
bundleResult,
|
||||
busy,
|
||||
confirmRemove,
|
||||
copy,
|
||||
details,
|
||||
environmentId,
|
||||
error,
|
||||
groups,
|
||||
installs,
|
||||
installActive,
|
||||
notice,
|
||||
progressLabel,
|
||||
result,
|
||||
runtimeEnvironments,
|
||||
selectedKey,
|
||||
sshConnectionStates,
|
||||
sshTargetLabels,
|
||||
versionId,
|
||||
onCancelInstall,
|
||||
onClose,
|
||||
onEnvironmentChange,
|
||||
onInstall,
|
||||
onOpenChange,
|
||||
onRemove,
|
||||
onSendToMachine,
|
||||
onVersionChange
|
||||
}: SkillInstallManagementDialogContentProps): React.JSX.Element {
|
||||
const destructiveConflict =
|
||||
result?.status === 'conflict' ||
|
||||
Boolean(
|
||||
groups
|
||||
.find((group) => group.key === selectedKey)
|
||||
?.installs.some((install) => install.state === 'modified')
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-x-hidden overflow-y-auto scrollbar-sleek sm:max-w-2xl [&>*]:min-w-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copy.title}</DialogTitle>
|
||||
<DialogDescription>{copy.description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<SkillInstallMachineSelect
|
||||
value={environmentId}
|
||||
onChange={onEnvironmentChange}
|
||||
localLabel={copy.localMachine}
|
||||
sshLabel={copy.ssh}
|
||||
disconnectedLabel={copy.disconnected}
|
||||
environments={runtimeEnvironments}
|
||||
sshTargets={[...sshTargetLabels.entries()].map(([id, label]) => ({
|
||||
id: `ssh:${id}`,
|
||||
label,
|
||||
connected: sshConnectionStates.get(id)?.status === 'connected'
|
||||
}))}
|
||||
/>
|
||||
{busy && installs.length === 0 ? <Loader2 className="mx-auto size-5 animate-spin" /> : null}
|
||||
{!busy && installs.length === 0 ? (
|
||||
<p className="rounded-md border border-border p-4 text-sm text-muted-foreground">
|
||||
{copy.noInstalls}
|
||||
</p>
|
||||
) : null}
|
||||
{groups.length > 0 ? (
|
||||
<ul className="divide-y divide-border rounded-md border border-border">
|
||||
{groups.map((group) => (
|
||||
<SkillManagedInstallRow
|
||||
key={group.key}
|
||||
group={group}
|
||||
open={selectedKey === group.key}
|
||||
details={selectedKey === group.key ? details : null}
|
||||
versionId={selectedKey === group.key ? versionId : ''}
|
||||
busy={busy}
|
||||
confirmRemove={selectedKey === group.key && confirmRemove}
|
||||
installActive={installActive}
|
||||
editedWarning={selectedKey === group.key && destructiveConflict}
|
||||
bundleResult={selectedKey === group.key ? bundleResult : null}
|
||||
result={selectedKey === group.key ? result : null}
|
||||
onOpenChange={(next) => onOpenChange(group, next)}
|
||||
onVersionChange={onVersionChange}
|
||||
onInstall={onInstall}
|
||||
onCancelInstall={onCancelInstall}
|
||||
onSendToMachine={onSendToMachine}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<SkillInstallManagementStatus
|
||||
resultLabel={result ? skillInstallResultLabel(result) : null}
|
||||
progressLabel={progressLabel}
|
||||
error={error}
|
||||
notice={notice}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
|
||||
{copy.close}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
@@ -1,98 +1,98 @@
|
||||
| Emoji | Name | Category | Unicode | Description | Mood | Popularity | Year Added |
|
||||
|-------|------|----------|---------|-------------|------|------------|------------|
|
||||
| 😀 | Grinning Face | Smileys | U+1F600 | A happy grinning face | Positive | 95 | 2015 |
|
||||
| 🚀 | Rocket | Travel | U+1F680 | A soaring rocket | Exciting | 88 | 2010 |
|
||||
| 🧑💻 | Technologist | People | U+1F9D1 U+200D U+1F4BB | Person at computer | Focused | 92 | 2020 |
|
||||
| 🎉 | Party Popper | Events | U+1F389 | Celebration popper | Festive | 97 | 2010 |
|
||||
| 🐱 | Cat Face | Animals | U+1F431 | A cute cat face | Cute | 85 | 2010 |
|
||||
| 🍕 | Pizza | Food | U+1F355 | Slice of pizza | Hungry | 90 | 2010 |
|
||||
| 💻 | Laptop | Objects | U+1F4BB | A personal computer | Productive | 78 | 2010 |
|
||||
| 🌍 | Globe Europe-Africa | Travel | U+1F30D | Earth showing Europe/Africa | Wanderlust | 72 | 2010 |
|
||||
| ❤️ | Red Heart | Symbols | U+2764 | A red heart symbol | Loving | 99 | 2010 |
|
||||
| 🎵 | Musical Note | Music | U+1F3B5 | Single eighth note | Musical | 76 | 2010 |
|
||||
| 🤖 | Robot Face | Smileys | U+1F916 | A mechanical robot | Neutral | 81 | 2016 |
|
||||
| ☕ | Hot Beverage | Food | U+1F375 | A steaming coffee cup | Awake | 89 | 2010 |
|
||||
| 🌈 | Rainbow | Nature | U+1F308 | A colorful rainbow | Hopeful | 83 | 2010 |
|
||||
| 🦄 | Unicorn Face | Animals | U+1F984 | A fantasy unicorn | Magical | 91 | 2015 |
|
||||
| ⚡ | High Voltage | Symbols | U+26A1 | Lightning bolt | Energetic | 86 | 2010 |
|
||||
| 🎸 | Guitar | Music | U+1F3B8 | A six-string guitar | Rocking | 71 | 2010 |
|
||||
| 🍀 | Four Leaf Clover | Nature | U+1F340 | A lucky clover | Lucky | 82 | 2010 |
|
||||
| 🏆 | Trophy | Awards | U+1F3C6 | A gold championship cup | Victorious | 87 | 2010 |
|
||||
| 🧠 | Brain | Body | U+1F9E0 | A human brain | Smart | 93 | 2018 |
|
||||
| 🌮 | Taco | Food | U+1F32E | A folded taco | Tasty | 84 | 2015 |
|
||||
| 🛸 | Flying Saucer | Travel | U+1F6F8 | An alien spacecraft | Mysterious | 69 | 2017 |
|
||||
| 🎨 | Artist Palette | Arts | U+1F3A8 | A painter's palette | Creative | 77 | 2010 |
|
||||
| 🐧 | Penguin | Animals | U+1F427 | A cute penguin | Chill | 79 | 2010 |
|
||||
| 💎 | Gem Stone | Objects | U+1F48E | A sparkling diamond | Precious | 74 | 2010 |
|
||||
| 🎮 | Video Game | Objects | U+1F3AE | A game controller | Gaming | 88 | 2010 |
|
||||
| 🌊 | Water Wave | Nature | U+1F30A | A crashing ocean wave | Calm | 80 | 2010 |
|
||||
| 🔥 | Fire | Nature | U+1F525 | A burning flame | Lit | 98 | 2010 |
|
||||
| 🌟 | Glowing Star | Symbols | U+1F31F | A twinkling star | Bright | 85 | 2010 |
|
||||
| 🎤 | Microphone | Music | U+1F3A4 | A stage microphone | Singing | 73 | 2010 |
|
||||
| 🐶 | Dog Face | Animals | U+1F436 | A friendly dog face | Loyal | 86 | 2010 |
|
||||
| 🍩 | Doughnut | Food | U+1F369 | A frosted doughnut | Sweet | 82 | 2010 |
|
||||
| 🚲 | Bicycle | Travel | U+1F6B2 | A push bike | Active | 67 | 2010 |
|
||||
| 🎭 | Performing Arts | Arts | U+1F3AD | Theater masks | Dramatic | 64 | 2010 |
|
||||
| 🐉 | Dragon | Animals | U+1F409 | A mythical dragon | Powerful | 78 | 2010 |
|
||||
| 🎪 | Circus Tent | Events | U+1F3AA | A striped circus tent | Fun | 61 | 2010 |
|
||||
| 🧊 | Ice Cube | Objects | U+1F9CA | A block of ice | Cool | 70 | 2019 |
|
||||
| 🎯 | Bullseye | Symbols | U+1F3AF | A dart hitting target | Focused | 83 | 2010 |
|
||||
| 🐬 | Dolphin | Animals | U+1F42C | A leaping dolphin | Playful | 80 | 2010 |
|
||||
| 🚁 | Helicopter | Travel | U+1F681 | A flying helicopter | Aerial | 63 | 2010 |
|
||||
| 🦊 | Fox Face | Animals | U+1F98A | A clever fox | Sneaky | 79 | 2016 |
|
||||
| 🎲 | Game Die | Objects | U+1F3B2 | A six-sided die | Random | 68 | 2010 |
|
||||
| 🍔 | Hamburger | Food | U+1F354 | A classic burger | Juicy | 91 | 2010 |
|
||||
| 🦋 | Butterfly | Animals | U+1F98B | A colorful butterfly | Graceful | 84 | 2016 |
|
||||
| 🏔️ | Snow-Capped Mountain | Travel | U+1F3D4 | A snowy mountain peak | Adventurous | 71 | 2010 |
|
||||
| 🎩 | Top Hat | Objects | U+1F3A9 | A formal top hat | Classy | 62 | 2010 |
|
||||
| 🐳 | Spouting Whale | Animals | U+1F433 | A whale spraying water | Majestic | 76 | 2010 |
|
||||
| 🚀 | Rocket | Travel | U+1F680 | A soaring rocket | Exciting | 88 | 2010 |
|
||||
| 🎹 | Musical Keyboard | Music | U+1F3B9 | A piano keyboard | Melodic | 66 | 2010 |
|
||||
| 🍉 | Watermelon | Food | U+1F349 | A slice of watermelon | Refreshing | 81 | 2010 |
|
||||
| 🧩 | Puzzle Piece | Objects | U+1F9E9 | A jigsaw puzzle piece | Challenging | 75 | 2018 |
|
||||
| 🦀 | Crab | Animals | U+1F980 | A red crab | Feisty | 68 | 2015 |
|
||||
| 🏄 | Surfing | Sports | U+1F3C4 | Someone riding a wave | Gnarly | 65 | 2010 |
|
||||
| 🧑🎤 | Singer | People | U+1F9D1 U+200D U+1F3A4 | A stage performer | Talented | 78 | 2020 |
|
||||
| 🌵 | Cactus | Nature | U+1F335 | A desert cactus | Tough | 73 | 2010 |
|
||||
| 🧲 | Magnet | Objects | U+1F9F2 | A horseshoe magnet | Attractive | 58 | 2018 |
|
||||
| 🦆 | Duck | Animals | U+1F986 | A yellow duck | Quirky | 74 | 2016 |
|
||||
| 🚇 | Metro | Travel | U+1F687 | A subway train | Urban | 59 | 2010 |
|
||||
| 🧾 | Receipt | Objects | U+1F9FE | A paper receipt | Transactional | 56 | 2018 |
|
||||
| 🫘 | Beans | Food | U+1FAD8 | A bowl of beans | Nutritious | 42 | 2021 |
|
||||
| 🦕 | Sauropod | Animals | U+1F995 | A long-neck dinosaur | Prehistoric | 72 | 2016 |
|
||||
| 🏵️ | Rosette | Awards | U+1F3F5 | A decorative rosette | Honorable | 51 | 2010 |
|
||||
| 🦮 | Guide Dog | Animals | U+1F9AE | A seeing-eye dog | Helpful | 67 | 2019 |
|
||||
| 🦴 | Bone | Body | U+1F9B4 | A dog bone | Gnawing | 60 | 2019 |
|
||||
| 🥏 | Flying Disc | Sports | U+1F94F | A frisbee | Airborne | 48 | 2017 |
|
||||
| 🦧 | Orangutan | Animals | U+1F9A7 | A red-haired ape | Wise | 55 | 2019 |
|
||||
| 🥭 | Mango | Food | U+1F96D | A ripe tropical fruit | Tropical | 71 | 2018 |
|
||||
| 🧋 | Bubble Tea | Food | U+1F9CB | Tapioca pearl drink | Trendy | 78 | 2020 |
|
||||
| 🪀 | Yo-Yo | Objects | U+1FA80 | A string toy | Nostalgic | 44 | 2020 |
|
||||
| 🦤 | Dodo | Animals | U+1F9A4 | An extinct flightless bird | Extinct | 53 | 2019 |
|
||||
| 🪐 | Ringed Planet | Space | U+1FA90 | Saturn with rings | Astronomical | 77 | 2020 |
|
||||
| 🛹 | Skateboard | Sports | U+1F6F9 | A kick push skateboard | Rad | 67 | 2019 |
|
||||
| 🥲 | Smiling Face with Tear | Smileys | U+1F972 | Happy but crying inside | Bittersweet | 90 | 2020 |
|
||||
| 🫠 | Melting Face | Smileys | U+1FAE0 | Face slowly melting | Uncomfortable | 69 | 2021 |
|
||||
| 🪩 | Mirror Ball | Objects | U+1FAA9 | A disco ball | Groovy | 73 | 2022 |
|
||||
| 🪭 | Folding Hand Fan | Objects | U+1FAAF | A collapsible hand fan | Elegant | 38 | 2023 |
|
||||
| 🫎 | Moose | Animals | U+1FACE | A large antlered mammal | Majestic | 45 | 2023 |
|
||||
| 🪸 | Coral | Nature | U+1FAB8 | A coral reef formation | Vibrant | 52 | 2023 |
|
||||
| 🪼 | Jellyfish | Animals | U+1FABC | A floating gelatinous sea creature | Ethereal | 49 | 2023 |
|
||||
| 🫏 | Donkey | Animals | U+1FACF | A gray donkey | Stubborn | 41 | 2023 |
|
||||
| 🫅 | Person with Crown | People | U+1FAC5 | A crowned royal figure | Regal | 58 | 2022 |
|
||||
| 🪇 | Maracas | Music | U+1FA87 | A handheld percussion shaker | Rhythmic | 43 | 2023 |
|
||||
| 🫘 | Beans | Food | U+1FAD8 | A bowl of legumes | Hearty | 42 | 2021 |
|
||||
| 🦪 | Oyster | Food | U+1F9AA | A closed shellfish | Briny | 54 | 2019 |
|
||||
| 🪨 | Rock | Nature | U+1FAA8 | A grey stone | Solid | 47 | 2020 |
|
||||
| 🥸 | Disguised Face | Smileys | U+1F978 | Face with fake nose/glasses | Sneaky | 66 | 2021 |
|
||||
| 🦬 | Bison | Animals | U+1F9AC | A large North American bovine | Sturdy | 50 | 2019 |
|
||||
| 🥟 | Dumpling | Food | U+1F95F | A folded filled dough | Steamy | 76 | 2018 |
|
||||
| 🪴 | Potted Plant | Nature | U+1FAB4 | A houseplant in a pot | Green | 68 | 2020 |
|
||||
| 🛼 | Roller Skate | Sports | U+1F6FC | A quad-wheeled skate | Retro | 56 | 2021 |
|
||||
| 🥏 | Flying Disc | Sports | U+1F94F | A plastic throwing disc | Soaring | 48 | 2017 |
|
||||
| 🧃 | Beverage Box | Food | U+1F9C3 | A juice box | Convenient | 63 | 2018 |
|
||||
| 🦚 | Peacock | Animals | U+1F99A | A bird with iridescent tail | Proud | 75 | 2018 |
|
||||
| 🫧 | Bubbles | Nature | U+1FAE7 | Floating iridescent spheres | Light | 57 | 2023 |
|
||||
| 🥅 | Goal Net | Sports | U+1F945 | A soccer/hockey goal | Scoring | 44 | 2017 |
|
||||
| 🦺 | Safety Vest | Objects | U+1F9BA | A high-visibility vest | Protected | 51 | 2019 |
|
||||
| ✈️ | Airplane | Travel | U+2708 | A flying commercial jet | Traveling | 94 | 2010 |
|
||||
| Emoji | Name | Category | Unicode | Description | Mood | Popularity | Year Added |
|
||||
| ----- | ---------------------- | -------- | ---------------------- | ---------------------------------- | ------------- | ---------- | ---------- |
|
||||
| 😀 | Grinning Face | Smileys | U+1F600 | A happy grinning face | Positive | 95 | 2015 |
|
||||
| 🚀 | Rocket | Travel | U+1F680 | A soaring rocket | Exciting | 88 | 2010 |
|
||||
| 🧑💻 | Technologist | People | U+1F9D1 U+200D U+1F4BB | Person at computer | Focused | 92 | 2020 |
|
||||
| 🎉 | Party Popper | Events | U+1F389 | Celebration popper | Festive | 97 | 2010 |
|
||||
| 🐱 | Cat Face | Animals | U+1F431 | A cute cat face | Cute | 85 | 2010 |
|
||||
| 🍕 | Pizza | Food | U+1F355 | Slice of pizza | Hungry | 90 | 2010 |
|
||||
| 💻 | Laptop | Objects | U+1F4BB | A personal computer | Productive | 78 | 2010 |
|
||||
| 🌍 | Globe Europe-Africa | Travel | U+1F30D | Earth showing Europe/Africa | Wanderlust | 72 | 2010 |
|
||||
| ❤️ | Red Heart | Symbols | U+2764 | A red heart symbol | Loving | 99 | 2010 |
|
||||
| 🎵 | Musical Note | Music | U+1F3B5 | Single eighth note | Musical | 76 | 2010 |
|
||||
| 🤖 | Robot Face | Smileys | U+1F916 | A mechanical robot | Neutral | 81 | 2016 |
|
||||
| ☕ | Hot Beverage | Food | U+1F375 | A steaming coffee cup | Awake | 89 | 2010 |
|
||||
| 🌈 | Rainbow | Nature | U+1F308 | A colorful rainbow | Hopeful | 83 | 2010 |
|
||||
| 🦄 | Unicorn Face | Animals | U+1F984 | A fantasy unicorn | Magical | 91 | 2015 |
|
||||
| ⚡ | High Voltage | Symbols | U+26A1 | Lightning bolt | Energetic | 86 | 2010 |
|
||||
| 🎸 | Guitar | Music | U+1F3B8 | A six-string guitar | Rocking | 71 | 2010 |
|
||||
| 🍀 | Four Leaf Clover | Nature | U+1F340 | A lucky clover | Lucky | 82 | 2010 |
|
||||
| 🏆 | Trophy | Awards | U+1F3C6 | A gold championship cup | Victorious | 87 | 2010 |
|
||||
| 🧠 | Brain | Body | U+1F9E0 | A human brain | Smart | 93 | 2018 |
|
||||
| 🌮 | Taco | Food | U+1F32E | A folded taco | Tasty | 84 | 2015 |
|
||||
| 🛸 | Flying Saucer | Travel | U+1F6F8 | An alien spacecraft | Mysterious | 69 | 2017 |
|
||||
| 🎨 | Artist Palette | Arts | U+1F3A8 | A painter's palette | Creative | 77 | 2010 |
|
||||
| 🐧 | Penguin | Animals | U+1F427 | A cute penguin | Chill | 79 | 2010 |
|
||||
| 💎 | Gem Stone | Objects | U+1F48E | A sparkling diamond | Precious | 74 | 2010 |
|
||||
| 🎮 | Video Game | Objects | U+1F3AE | A game controller | Gaming | 88 | 2010 |
|
||||
| 🌊 | Water Wave | Nature | U+1F30A | A crashing ocean wave | Calm | 80 | 2010 |
|
||||
| 🔥 | Fire | Nature | U+1F525 | A burning flame | Lit | 98 | 2010 |
|
||||
| 🌟 | Glowing Star | Symbols | U+1F31F | A twinkling star | Bright | 85 | 2010 |
|
||||
| 🎤 | Microphone | Music | U+1F3A4 | A stage microphone | Singing | 73 | 2010 |
|
||||
| 🐶 | Dog Face | Animals | U+1F436 | A friendly dog face | Loyal | 86 | 2010 |
|
||||
| 🍩 | Doughnut | Food | U+1F369 | A frosted doughnut | Sweet | 82 | 2010 |
|
||||
| 🚲 | Bicycle | Travel | U+1F6B2 | A push bike | Active | 67 | 2010 |
|
||||
| 🎭 | Performing Arts | Arts | U+1F3AD | Theater masks | Dramatic | 64 | 2010 |
|
||||
| 🐉 | Dragon | Animals | U+1F409 | A mythical dragon | Powerful | 78 | 2010 |
|
||||
| 🎪 | Circus Tent | Events | U+1F3AA | A striped circus tent | Fun | 61 | 2010 |
|
||||
| 🧊 | Ice Cube | Objects | U+1F9CA | A block of ice | Cool | 70 | 2019 |
|
||||
| 🎯 | Bullseye | Symbols | U+1F3AF | A dart hitting target | Focused | 83 | 2010 |
|
||||
| 🐬 | Dolphin | Animals | U+1F42C | A leaping dolphin | Playful | 80 | 2010 |
|
||||
| 🚁 | Helicopter | Travel | U+1F681 | A flying helicopter | Aerial | 63 | 2010 |
|
||||
| 🦊 | Fox Face | Animals | U+1F98A | A clever fox | Sneaky | 79 | 2016 |
|
||||
| 🎲 | Game Die | Objects | U+1F3B2 | A six-sided die | Random | 68 | 2010 |
|
||||
| 🍔 | Hamburger | Food | U+1F354 | A classic burger | Juicy | 91 | 2010 |
|
||||
| 🦋 | Butterfly | Animals | U+1F98B | A colorful butterfly | Graceful | 84 | 2016 |
|
||||
| 🏔️ | Snow-Capped Mountain | Travel | U+1F3D4 | A snowy mountain peak | Adventurous | 71 | 2010 |
|
||||
| 🎩 | Top Hat | Objects | U+1F3A9 | A formal top hat | Classy | 62 | 2010 |
|
||||
| 🐳 | Spouting Whale | Animals | U+1F433 | A whale spraying water | Majestic | 76 | 2010 |
|
||||
| 🚀 | Rocket | Travel | U+1F680 | A soaring rocket | Exciting | 88 | 2010 |
|
||||
| 🎹 | Musical Keyboard | Music | U+1F3B9 | A piano keyboard | Melodic | 66 | 2010 |
|
||||
| 🍉 | Watermelon | Food | U+1F349 | A slice of watermelon | Refreshing | 81 | 2010 |
|
||||
| 🧩 | Puzzle Piece | Objects | U+1F9E9 | A jigsaw puzzle piece | Challenging | 75 | 2018 |
|
||||
| 🦀 | Crab | Animals | U+1F980 | A red crab | Feisty | 68 | 2015 |
|
||||
| 🏄 | Surfing | Sports | U+1F3C4 | Someone riding a wave | Gnarly | 65 | 2010 |
|
||||
| 🧑🎤 | Singer | People | U+1F9D1 U+200D U+1F3A4 | A stage performer | Talented | 78 | 2020 |
|
||||
| 🌵 | Cactus | Nature | U+1F335 | A desert cactus | Tough | 73 | 2010 |
|
||||
| 🧲 | Magnet | Objects | U+1F9F2 | A horseshoe magnet | Attractive | 58 | 2018 |
|
||||
| 🦆 | Duck | Animals | U+1F986 | A yellow duck | Quirky | 74 | 2016 |
|
||||
| 🚇 | Metro | Travel | U+1F687 | A subway train | Urban | 59 | 2010 |
|
||||
| 🧾 | Receipt | Objects | U+1F9FE | A paper receipt | Transactional | 56 | 2018 |
|
||||
| 🫘 | Beans | Food | U+1FAD8 | A bowl of beans | Nutritious | 42 | 2021 |
|
||||
| 🦕 | Sauropod | Animals | U+1F995 | A long-neck dinosaur | Prehistoric | 72 | 2016 |
|
||||
| 🏵️ | Rosette | Awards | U+1F3F5 | A decorative rosette | Honorable | 51 | 2010 |
|
||||
| 🦮 | Guide Dog | Animals | U+1F9AE | A seeing-eye dog | Helpful | 67 | 2019 |
|
||||
| 🦴 | Bone | Body | U+1F9B4 | A dog bone | Gnawing | 60 | 2019 |
|
||||
| 🥏 | Flying Disc | Sports | U+1F94F | A frisbee | Airborne | 48 | 2017 |
|
||||
| 🦧 | Orangutan | Animals | U+1F9A7 | A red-haired ape | Wise | 55 | 2019 |
|
||||
| 🥭 | Mango | Food | U+1F96D | A ripe tropical fruit | Tropical | 71 | 2018 |
|
||||
| 🧋 | Bubble Tea | Food | U+1F9CB | Tapioca pearl drink | Trendy | 78 | 2020 |
|
||||
| 🪀 | Yo-Yo | Objects | U+1FA80 | A string toy | Nostalgic | 44 | 2020 |
|
||||
| 🦤 | Dodo | Animals | U+1F9A4 | An extinct flightless bird | Extinct | 53 | 2019 |
|
||||
| 🪐 | Ringed Planet | Space | U+1FA90 | Saturn with rings | Astronomical | 77 | 2020 |
|
||||
| 🛹 | Skateboard | Sports | U+1F6F9 | A kick push skateboard | Rad | 67 | 2019 |
|
||||
| 🥲 | Smiling Face with Tear | Smileys | U+1F972 | Happy but crying inside | Bittersweet | 90 | 2020 |
|
||||
| 🫠 | Melting Face | Smileys | U+1FAE0 | Face slowly melting | Uncomfortable | 69 | 2021 |
|
||||
| 🪩 | Mirror Ball | Objects | U+1FAA9 | A disco ball | Groovy | 73 | 2022 |
|
||||
| 🪭 | Folding Hand Fan | Objects | U+1FAAF | A collapsible hand fan | Elegant | 38 | 2023 |
|
||||
| 🫎 | Moose | Animals | U+1FACE | A large antlered mammal | Majestic | 45 | 2023 |
|
||||
| 🪸 | Coral | Nature | U+1FAB8 | A coral reef formation | Vibrant | 52 | 2023 |
|
||||
| 🪼 | Jellyfish | Animals | U+1FABC | A floating gelatinous sea creature | Ethereal | 49 | 2023 |
|
||||
| 🫏 | Donkey | Animals | U+1FACF | A gray donkey | Stubborn | 41 | 2023 |
|
||||
| 🫅 | Person with Crown | People | U+1FAC5 | A crowned royal figure | Regal | 58 | 2022 |
|
||||
| 🪇 | Maracas | Music | U+1FA87 | A handheld percussion shaker | Rhythmic | 43 | 2023 |
|
||||
| 🫘 | Beans | Food | U+1FAD8 | A bowl of legumes | Hearty | 42 | 2021 |
|
||||
| 🦪 | Oyster | Food | U+1F9AA | A closed shellfish | Briny | 54 | 2019 |
|
||||
| 🪨 | Rock | Nature | U+1FAA8 | A grey stone | Solid | 47 | 2020 |
|
||||
| 🥸 | Disguised Face | Smileys | U+1F978 | Face with fake nose/glasses | Sneaky | 66 | 2021 |
|
||||
| 🦬 | Bison | Animals | U+1F9AC | A large North American bovine | Sturdy | 50 | 2019 |
|
||||
| 🥟 | Dumpling | Food | U+1F95F | A folded filled dough | Steamy | 76 | 2018 |
|
||||
| 🪴 | Potted Plant | Nature | U+1FAB4 | A houseplant in a pot | Green | 68 | 2020 |
|
||||
| 🛼 | Roller Skate | Sports | U+1F6FC | A quad-wheeled skate | Retro | 56 | 2021 |
|
||||
| 🥏 | Flying Disc | Sports | U+1F94F | A plastic throwing disc | Soaring | 48 | 2017 |
|
||||
| 🧃 | Beverage Box | Food | U+1F9C3 | A juice box | Convenient | 63 | 2018 |
|
||||
| 🦚 | Peacock | Animals | U+1F99A | A bird with iridescent tail | Proud | 75 | 2018 |
|
||||
| 🫧 | Bubbles | Nature | U+1FAE7 | Floating iridescent spheres | Light | 57 | 2023 |
|
||||
| 🥅 | Goal Net | Sports | U+1F945 | A soccer/hockey goal | Scoring | 44 | 2017 |
|
||||
| 🦺 | Safety Vest | Objects | U+1F9BA | A high-visibility vest | Protected | 51 | 2019 |
|
||||
| ✈️ | Airplane | Travel | U+2708 | A flying commercial jet | Traveling | 94 | 2010 |
|
||||
|
||||
@@ -43,7 +43,7 @@ test('@golden opens an unstaged file diff from Source Control', async ({
|
||||
).toBeVisible()
|
||||
await expect(orcaPage.locator('.editor-header-path').first()).toHaveAttribute(
|
||||
'title',
|
||||
`${realpathSync(path.join(fixture.worktreePath, GOLDEN_CHANGED_PATH))} (diff)`
|
||||
`${realpathSync(path.join(fixture.worktreePath, GOLDEN_CHANGED_PATH)).replaceAll('\\', '/')} (diff)`
|
||||
)
|
||||
|
||||
const probe = orcaPage.getByRole('button', { name: /Source Control/ })
|
||||
|
||||
@@ -18,6 +18,11 @@ type LinkClientPoint = { x: number; y: number }
|
||||
|
||||
const LINK_SCAN_CHAR_LIMIT = 12_000
|
||||
|
||||
function canonicalFileIdentity(value: string): string {
|
||||
const normalized = path.resolve(value).replaceAll('\\', '/')
|
||||
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
|
||||
}
|
||||
|
||||
async function locateLink(page: Page, needle: string): Promise<LinkProbe | null> {
|
||||
return page.evaluate((needle) => {
|
||||
const state = window.__store?.getState()
|
||||
@@ -34,11 +39,19 @@ async function locateLink(page: Page, needle: string): Promise<LinkProbe | null>
|
||||
return null
|
||||
}
|
||||
const buffer = pane.terminal.buffer.active
|
||||
for (let row = 0; row < pane.terminal.rows; row += 1) {
|
||||
const line = buffer.getLine(buffer.viewportY + row)
|
||||
const col = line?.translateToString(true).indexOf(needle) ?? -1
|
||||
if (col >= 0) {
|
||||
return { col: col + Math.floor(needle.length / 2), row, tabId }
|
||||
// Preserve fixed-width cells so paths split across xterm rows stay searchable.
|
||||
const visibleCells = Array.from({ length: pane.terminal.rows }, (_, row) =>
|
||||
(buffer.getLine(buffer.viewportY + row)?.translateToString(false) ?? '').padEnd(
|
||||
pane.terminal.cols
|
||||
)
|
||||
).join('')
|
||||
const start = visibleCells.indexOf(needle)
|
||||
if (start !== -1) {
|
||||
const center = start + Math.floor(needle.length / 2)
|
||||
return {
|
||||
col: center % pane.terminal.cols,
|
||||
row: Math.floor(center / pane.terminal.cols),
|
||||
tabId
|
||||
}
|
||||
}
|
||||
return null
|
||||
@@ -162,14 +175,13 @@ test('opens a terminal file link and observes an external edit @golden', async (
|
||||
await expect(explorerRow).toHaveAttribute('data-selected', 'true', { timeout: 10_000 })
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
orcaPage.evaluate(
|
||||
(expectedPath) => window.__monacoEditorE2E?.filePath === expectedPath,
|
||||
filePath
|
||||
async () =>
|
||||
canonicalFileIdentity(
|
||||
(await orcaPage.evaluate(() => window.__monacoEditorE2E?.filePath)) ?? ''
|
||||
),
|
||||
{ timeout: 20_000, message: 'Monaco opened a different file identity' }
|
||||
)
|
||||
.toBe(true)
|
||||
.toBe(canonicalFileIdentity(filePath))
|
||||
|
||||
writeFileSync(filePath, `${original.trimEnd()}\n\n${changedMarker}\n`)
|
||||
await expect
|
||||
@@ -196,14 +208,18 @@ test('reuses a terminal file link already open in a sibling workspace @golden',
|
||||
test.setTimeout(180_000)
|
||||
await waitForSessionReady(orcaPage)
|
||||
const sourceWorktreeId = await waitForActiveWorktree(orcaPage)
|
||||
const sibling = await orcaPage.evaluate((sourceId) => {
|
||||
const worktrees = await orcaPage.evaluate((sourceId) => {
|
||||
const state = window.__store?.getState()
|
||||
return (
|
||||
Object.values(state?.worktreesByRepo ?? {})
|
||||
.flat()
|
||||
.find((worktree) => worktree.id !== sourceId) ?? null
|
||||
)
|
||||
const entries = Object.values(state?.worktreesByRepo ?? {}).flat()
|
||||
return {
|
||||
source: entries.find((worktree) => worktree.id === sourceId) ?? null,
|
||||
sibling: entries.find((worktree) => worktree.id !== sourceId) ?? null
|
||||
}
|
||||
}, sourceWorktreeId)
|
||||
const { source, sibling } = worktrees
|
||||
if (!source) {
|
||||
throw new Error('source worktree fixture unavailable')
|
||||
}
|
||||
if (!sibling) {
|
||||
throw new Error('sibling worktree fixture unavailable')
|
||||
}
|
||||
@@ -230,7 +246,24 @@ test('reuses a terminal file link already open in a sibling workspace @golden',
|
||||
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
await orcaPage.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
state?.setSidebarOpen(false)
|
||||
state?.setRightSidebarOpen(false)
|
||||
})
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
orcaPage.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
const tabId = state?.activeTabId
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
return manager?.getActivePane?.()?.terminal.cols ?? 0
|
||||
}),
|
||||
{ message: 'terminal did not expand after closing the sidebars' }
|
||||
)
|
||||
.toBeGreaterThan(120)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage, 30_000)
|
||||
await waitForPtyShellEcho(orcaPage, ptyId, 15_000)
|
||||
const printedPath = process.platform === 'win32' ? filePath.replaceAll('\\', '/') : filePath
|
||||
const command = nodeTerminalCommand(['-e', `console.log(${JSON.stringify(printedPath)})`])
|
||||
@@ -261,13 +294,18 @@ test('reuses a terminal file link already open in a sibling workspace @golden',
|
||||
await expect(editorHeader).toContainText('package.json', { timeout: 20_000 })
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
orcaPage.evaluate(() => ({
|
||||
filePath: window.__monacoEditorE2E?.filePath ?? null,
|
||||
async () => {
|
||||
const rendered = await orcaPage.evaluate(() => ({
|
||||
filePath: window.__monacoEditorE2E?.filePath ?? '',
|
||||
activeWorktreeId: window.__store?.getState()?.activeWorktreeId ?? null
|
||||
})),
|
||||
}))
|
||||
return {
|
||||
filePath: canonicalFileIdentity(rendered.filePath),
|
||||
activeWorktreeId: rendered.activeWorktreeId
|
||||
}
|
||||
},
|
||||
{ timeout: 20_000, message: 'sibling workspace never rendered the linked file' }
|
||||
)
|
||||
.toEqual({ filePath, activeWorktreeId: sibling.id })
|
||||
.toEqual({ filePath: canonicalFileIdentity(filePath), activeWorktreeId: sibling.id })
|
||||
await expect(orcaPage.getByText('Loading...', { exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -16,15 +16,15 @@ Commits under test (vs main):
|
||||
|
||||
## Already covered (do **not** re-test in E2E)
|
||||
|
||||
| Area | Where |
|
||||
|------|--------|
|
||||
| `listConfigHosts` / `resolveConfigHost` IPC registration | `src/main/ipc/ssh.test.ts` |
|
||||
| Search, result limit, suppressed aliases, alreadyInOrca | `ssh-config-host-picker.test.ts` |
|
||||
| Generation guard, freeze-while-resolving, late resolve | `AddRemoteHostDialog.config-picker.test.tsx` |
|
||||
| Bulk `importConfig()` without `reAdopt` | `add-remote-host-ssh-actions.test.ts` |
|
||||
| Alias folding / duplicate save check | `ssh-target-duplicate.test.ts` |
|
||||
| `configured-only` host registry / setup fail-closed | unit tests in shared + project-host-workspace-target |
|
||||
| Settings modal viewport stability | `ssh-host-form-modal.spec.ts` |
|
||||
| Area | Where |
|
||||
| -------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| `listConfigHosts` / `resolveConfigHost` IPC registration | `src/main/ipc/ssh.test.ts` |
|
||||
| Search, result limit, suppressed aliases, alreadyInOrca | `ssh-config-host-picker.test.ts` |
|
||||
| Generation guard, freeze-while-resolving, late resolve | `AddRemoteHostDialog.config-picker.test.tsx` |
|
||||
| Bulk `importConfig()` without `reAdopt` | `add-remote-host-ssh-actions.test.ts` |
|
||||
| Alias folding / duplicate save check | `ssh-target-duplicate.test.ts` |
|
||||
| `configured-only` host registry / setup fail-closed | unit tests in shared + project-host-workspace-target |
|
||||
| Settings modal viewport stability | `ssh-host-form-modal.spec.ts` |
|
||||
|
||||
E2E is reserved for real Electron HOME isolation, real `~/.ssh/config` parse,
|
||||
real `ssh -G` resolve, and user-visible DOM outcomes.
|
||||
@@ -66,74 +66,74 @@ Optional second file if the Settings Import case grows:
|
||||
|
||||
### P1 — Empty config empty state
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Setup** | Do not create `~/.ssh/config` (or write empty file). |
|
||||
| **Steps** | Open Add SSH host → Fill from ~/.ssh/config… |
|
||||
| | |
|
||||
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Setup** | Do not create `~/.ssh/config` (or write empty file). |
|
||||
| **Steps** | Open Add SSH host → Fill from ~/.ssh/config… |
|
||||
| **Expect** | Dialog title **Choose from ~/.ssh/config**; body **No hosts in ~/.ssh/config**; **Add all to Orca** disabled; **Back** returns to form. |
|
||||
|
||||
### P2 — Seeded hosts listed with summary lines
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Setup** | Write config with ≥2 concrete Hosts, e.g. `e2e-alpha` / `e2e-bravo` with HostName, User, Port. |
|
||||
| **Steps** | Open picker. |
|
||||
| | |
|
||||
| ---------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Setup** | Write config with ≥2 concrete Hosts, e.g. `e2e-alpha` / `e2e-bravo` with HostName, User, Port. |
|
||||
| **Steps** | Open picker. |
|
||||
| **Expect** | Host list `SSH config hosts` shows both aliases; subtitle `user@hostname:port`; button **Add all 2 to Orca** enabled. |
|
||||
|
||||
### P3 — Select host prefills form (and Save persists)
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Setup** | Config Host `e2e-prod` → HostName `prod.example.test`, User `deploy`, Port `2222`. |
|
||||
| **Steps** | Pick `e2e-prod` → wait for form → click **Save**. |
|
||||
| **Expect** | After pick: Host/alias field = `prod.example.test`, Username `deploy`, Port `2222`, Label `e2e-prod` (or alias); optional toast *Filled from e2e-prod*; Identity file may stay empty with config hint. After Save: dialog closes; target appears in Settings → SSH (or listTargets shows matching host). |
|
||||
| | |
|
||||
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Setup** | Config Host `e2e-prod` → HostName `prod.example.test`, User `deploy`, Port `2222`. |
|
||||
| **Steps** | Pick `e2e-prod` → wait for form → click **Save**. |
|
||||
| **Expect** | After pick: Host/alias field = `prod.example.test`, Username `deploy`, Port `2222`, Label `e2e-prod` (or alias); optional toast _Filled from e2e-prod_; Identity file may stay empty with config hint. After Save: dialog closes; target appears in Settings → SSH (or listTargets shows matching host). |
|
||||
|
||||
### P4 — Filter narrows list
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Setup** | Hosts `e2e-alpha`, `e2e-bravo`. |
|
||||
| **Steps** | Open picker; filter `bravo`. |
|
||||
| | |
|
||||
| ---------- | ------------------------------------------------------------------------ |
|
||||
| **Setup** | Hosts `e2e-alpha`, `e2e-bravo`. |
|
||||
| **Steps** | Open picker; filter `bravo`. |
|
||||
| **Expect** | Only bravo row; alpha gone; **No matching hosts** if filter is nonsense. |
|
||||
|
||||
### P5 — Already-in-Orca badge + disabled row
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Setup** | Config hosts alpha + bravo. Seed Orca target with `configHost`/`label` matching alpha (via `ssh.addTarget`). |
|
||||
| **Steps** | Open picker. |
|
||||
| **Expect** | Alpha shows **In Orca** badge and is not clickable; bravo still selectable; **Add all 1 to Orca** (not 2). |
|
||||
| | |
|
||||
| ---------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| **Setup** | Config hosts alpha + bravo. Seed Orca target with `configHost`/`label` matching alpha (via `ssh.addTarget`). |
|
||||
| **Steps** | Open picker. |
|
||||
| **Expect** | Alpha shows **In Orca** badge and is not clickable; bravo still selectable; **Add all 1 to Orca** (not 2). |
|
||||
|
||||
### P6 — Add all N to Orca imports new hosts only
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Setup** | Config with 2 new hosts; no Orca targets for them. |
|
||||
| **Steps** | **Add all 2 to Orca** → wait for success toast / return to form or list refresh. |
|
||||
| | |
|
||||
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Setup** | Config with 2 new hosts; no Orca targets for them. |
|
||||
| **Steps** | **Add all 2 to Orca** → wait for success toast / return to form or list refresh. |
|
||||
| **Expect** | Both targets exist (DOM in Settings SSH and/or listTargets); re-open picker shows **All hosts already in Orca** / both **In Orca**. |
|
||||
|
||||
### P7 — Add all does **not** re-adopt deleted hosts
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Setup** | Config with alpha + bravo; Add all → remove alpha via API (creates suppress tombstone). |
|
||||
| **Steps** | Re-open picker; note count; optionally click Add all again. |
|
||||
| | |
|
||||
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Setup** | Config with alpha + bravo; Add all → remove alpha via API (creates suppress tombstone). |
|
||||
| **Steps** | Re-open picker; note count; optionally click Add all again. |
|
||||
| **Expect** | Alpha absent from picker (suppressed) or not re-created; only new hosts counted; `listTargets` still lacks deleted alpha after second Add all. |
|
||||
|
||||
### P8 — Back discards pending pick path
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Setup** | Seeded config. |
|
||||
| **Steps** | Open picker → **Back** without selecting. |
|
||||
| | |
|
||||
| ---------- | ------------------------------------------------------ |
|
||||
| **Setup** | Seeded config. |
|
||||
| **Steps** | Open picker → **Back** without selecting. |
|
||||
| **Expect** | Form fields still empty (Host blank); no filled toast. |
|
||||
|
||||
### P9 — Settings Import re-adopts (contrast with P7)
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Setup** | Same as P7 after delete. |
|
||||
| **Steps** | Settings → SSH → **Import** (explicit reAdopt path). |
|
||||
| | |
|
||||
| ---------- | ---------------------------------------------------------------------- |
|
||||
| **Setup** | Same as P7 after delete. |
|
||||
| **Steps** | Settings → SSH → **Import** (explicit reAdopt path). |
|
||||
| **Expect** | Deleted config host reappears as an Orca target; toast sync count ≥ 1. |
|
||||
|
||||
---
|
||||
@@ -155,17 +155,17 @@ Skip: 100-host truncation, resolve races, GSSAPI system-default, composer host-a
|
||||
|
||||
## Implementation status (done)
|
||||
|
||||
| Case | Spec |
|
||||
|------|------|
|
||||
| P1 empty state | `ssh-config-host-picker.spec.ts` |
|
||||
| P2 list + Add all enabled | `ssh-config-host-picker.spec.ts` |
|
||||
| Case | Spec |
|
||||
| ------------------------------------- | -------------------------------- |
|
||||
| P1 empty state | `ssh-config-host-picker.spec.ts` |
|
||||
| P2 list + Add all enabled | `ssh-config-host-picker.spec.ts` |
|
||||
| P3 select + Save (+ N3 identity hint) | `ssh-config-host-picker.spec.ts` |
|
||||
| P4 filter | `ssh-config-host-picker.spec.ts` |
|
||||
| P5 In Orca badge / count | `ssh-config-host-import.spec.ts` |
|
||||
| P6 Add all imports | `ssh-config-host-import.spec.ts` |
|
||||
| P7 no re-adopt after delete | `ssh-config-host-import.spec.ts` |
|
||||
| P8 Back without select | `ssh-config-host-picker.spec.ts` |
|
||||
| P9 Settings Import re-adopts | `ssh-config-host-import.spec.ts` |
|
||||
| P4 filter | `ssh-config-host-picker.spec.ts` |
|
||||
| P5 In Orca badge / count | `ssh-config-host-import.spec.ts` |
|
||||
| P6 Add all imports | `ssh-config-host-import.spec.ts` |
|
||||
| P7 no re-adopt after delete | `ssh-config-host-import.spec.ts` |
|
||||
| P8 Back without select | `ssh-config-host-picker.spec.ts` |
|
||||
| P9 Settings Import re-adopts | `ssh-config-host-import.spec.ts` |
|
||||
|
||||
Shared helpers: `tests/e2e/helpers/ssh-config-host-picker.ts`
|
||||
|
||||
|
||||
@@ -58,11 +58,11 @@ dir holding `conpty.dll` + `OpenConsole.exe`).
|
||||
|
||||
Tiers differ only in which top-level `*.dll` files they carry:
|
||||
|
||||
| Tier | Top-level DLLs |
|
||||
| --------- | ---------------------------------------------------------------- |
|
||||
| `full` | **all** top-level `*.dll` |
|
||||
| Tier | Top-level DLLs |
|
||||
| --------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `full` | **all** top-level `*.dll` |
|
||||
| `no-gpu` | all **except** GPU/render DLLs (`libEGL`, `libGLESv2`, `vk_swiftshader`, `vulkan-1`, `d3dcompiler_47`); **keeps** `ffmpeg.dll` |
|
||||
| `minimal` | **none** (exe + data blobs + daemon bundle + node-pty only) |
|
||||
| `minimal` | **none** (exe + data blobs + daemon bundle + node-pty only) |
|
||||
|
||||
Trimming further is a config change (edit `TIER_DEFINITIONS` / `GPU_DLLS`), not
|
||||
a code change.
|
||||
@@ -95,7 +95,7 @@ The reverted #7421 added a `node-pty` patch that reads
|
||||
`config/patches/node-pty@1.1.0.patch` does NOT contain that override** — it was
|
||||
reverted. The spike therefore relies on **layout preservation** (copying the
|
||||
node-pty tree at its default relative path) rather than the env override. The
|
||||
spike still *sets* `ORCA_NODE_PTY_NATIVE_DIR` to the relocated native dir so it
|
||||
spike still _sets_ `ORCA_NODE_PTY_NATIVE_DIR` to the relocated native dir so it
|
||||
keeps working if pointed at a build that carries the patch, but on this branch
|
||||
the var is inert.
|
||||
|
||||
|
||||
@@ -243,13 +243,19 @@ async function finalHealthCheck(baseDir) {
|
||||
let gotEvent = false
|
||||
let sub = null
|
||||
try {
|
||||
sub = await client.subscribeViaWatcherProcess(dir, (err, events) => {
|
||||
if (!err && events.length > 0) {
|
||||
gotEvent = true
|
||||
}
|
||||
}, OPTS)
|
||||
sub = await client.subscribeViaWatcherProcess(
|
||||
dir,
|
||||
(err, events) => {
|
||||
if (!err && events.length > 0) {
|
||||
gotEvent = true
|
||||
}
|
||||
},
|
||||
OPTS
|
||||
)
|
||||
} catch (err) {
|
||||
process.stderr.write(`[fixed-harness] health subscribe attempt ${attempt} failed: ${err.message}\n`)
|
||||
process.stderr.write(
|
||||
`[fixed-harness] health subscribe attempt ${attempt} failed: ${err.message}\n`
|
||||
)
|
||||
rmrf(dir)
|
||||
await sleep(5000)
|
||||
continue
|
||||
@@ -279,10 +285,13 @@ async function main() {
|
||||
|
||||
// Watchdog: a stuck lane must fail loudly, and a premature natural exit
|
||||
// (empty event loop) must not read as success.
|
||||
const watchdog = setTimeout(() => {
|
||||
process.stderr.write('[fixed-harness] FAIL: watchdog — scenarios did not complete\n')
|
||||
process.exit(7)
|
||||
}, durationMs * 4 + 60_000)
|
||||
const watchdog = setTimeout(
|
||||
() => {
|
||||
process.stderr.write('[fixed-harness] FAIL: watchdog — scenarios did not complete\n')
|
||||
process.exit(7)
|
||||
},
|
||||
durationMs * 4 + 60_000
|
||||
)
|
||||
process.on('exit', (code) => {
|
||||
if (!completed && code === 0) {
|
||||
process.exitCode = 8
|
||||
@@ -313,7 +322,9 @@ async function main() {
|
||||
`interruptions=${stats.interruptions} healthy=${healthy} fallback=${stats.fallbackDetected}\n`
|
||||
)
|
||||
if (stats.fallbackDetected) {
|
||||
process.stderr.write('[fixed-harness] FAIL: in-process fallback used — isolation not exercised\n')
|
||||
process.stderr.write(
|
||||
'[fixed-harness] FAIL: in-process fallback used — isolation not exercised\n'
|
||||
)
|
||||
process.exitCode = 6
|
||||
return
|
||||
}
|
||||
|
||||
@@ -52,7 +52,9 @@ async function main() {
|
||||
const { code, signal } = await runOnce(s, durationMs)
|
||||
const elapsed = Date.now() - started
|
||||
const meaning = KNOWN[code] || (code === 0 ? 'clean exit' : 'unexpected')
|
||||
console.log(`=== [${s}] iter ${i}: exit=${code} (${hex(code ?? -1)}) signal=${signal} ${meaning} after ${elapsed}ms`)
|
||||
console.log(
|
||||
`=== [${s}] iter ${i}: exit=${code} (${hex(code ?? -1)}) signal=${signal} ${meaning} after ${elapsed}ms`
|
||||
)
|
||||
results.push({ scenario: s, iteration: i, code, signal, elapsed })
|
||||
if (code !== 0 && code !== null) {
|
||||
crashed = true
|
||||
|
||||
@@ -99,13 +99,13 @@ and the safety guards above would (correctly) refuse to run. **Isolated mode**
|
||||
real install.
|
||||
|
||||
**The /D mechanism.** electron-builder's NSIS honors the standard NSIS `/D=<path>`
|
||||
override for the install *directory* (`node_modules/app-builder-lib/templates/nsis/multiUser.nsh`).
|
||||
override for the install _directory_ (`node_modules/app-builder-lib/templates/nsis/multiUser.nsh`).
|
||||
`/D` is special: it must be the **last** argument and **cannot be quoted**, so the
|
||||
path must be absolute and **spaces-free** (validated by `validateInstallDir`). The
|
||||
installer's kill-sweep only matches processes under its own `$INSTDIR`, so a
|
||||
separate directory never touches the real install's app or daemon processes.
|
||||
|
||||
**Why registry/shortcut backup-restore exists.** `/D` relocates *files only*.
|
||||
**Why registry/shortcut backup-restore exists.** `/D` relocates _files only_.
|
||||
Regardless of `/D`, the installer writes `InstallLocation` + the uninstall entry to
|
||||
the **same per-user HKCU keys** as the real install
|
||||
(`HKCU\Software\<APP_GUID>` and
|
||||
@@ -191,22 +191,22 @@ powershell -File tests/tools/win-update-e2e/window-enum.ps1
|
||||
|
||||
## Files
|
||||
|
||||
| File | Responsibility |
|
||||
| -------------------------- | ----------------------------------------------------------------------------- |
|
||||
| `run.mjs` | Orchestrator + CLI entry |
|
||||
| `cli-args.mjs` | Argument parsing / validation |
|
||||
| `preflight.mjs` | win32/elevation checks, pre-existing-app refusal, baseline snapshot |
|
||||
| `installer-steps.mjs` | Silent install/update/uninstall, exe discovery, gh download |
|
||||
| `registry-shortcut-backup.mjs` | Isolated mode: snapshot/restore the shared HKCU keys + Orca shortcuts |
|
||||
| `app-driver.mjs` | Playwright Electron launch + terminal driving (production-safe DOM selectors) |
|
||||
| `interactivity-probes.mjs` | Sentinel-file echo / heartbeat / Ctrl+C probes |
|
||||
| `daemon-processes.mjs` | Daemon PID discovery (command-line marker + pid file), scoped |
|
||||
| `window-enum.ps1` | Shared visible-top-level-window enumerator (P/Invoke `EnumWindows`) |
|
||||
| `window-watch.ps1` | Background baseline-diff watch loop → JSONL |
|
||||
| `window-watch.mjs` | Node wrapper: start/stop watch, `--selftest`, baseline capture |
|
||||
| `assertions.mjs` | Window-event classification + profile PASS/FAIL table |
|
||||
| `platform-guard.mjs` | `assertWin32`, elevation detection |
|
||||
| `powershell-runner.mjs` | Windows PowerShell 5.1 spawn helpers |
|
||||
| File | Responsibility |
|
||||
| ------------------------------ | ----------------------------------------------------------------------------- |
|
||||
| `run.mjs` | Orchestrator + CLI entry |
|
||||
| `cli-args.mjs` | Argument parsing / validation |
|
||||
| `preflight.mjs` | win32/elevation checks, pre-existing-app refusal, baseline snapshot |
|
||||
| `installer-steps.mjs` | Silent install/update/uninstall, exe discovery, gh download |
|
||||
| `registry-shortcut-backup.mjs` | Isolated mode: snapshot/restore the shared HKCU keys + Orca shortcuts |
|
||||
| `app-driver.mjs` | Playwright Electron launch + terminal driving (production-safe DOM selectors) |
|
||||
| `interactivity-probes.mjs` | Sentinel-file echo / heartbeat / Ctrl+C probes |
|
||||
| `daemon-processes.mjs` | Daemon PID discovery (command-line marker + pid file), scoped |
|
||||
| `window-enum.ps1` | Shared visible-top-level-window enumerator (P/Invoke `EnumWindows`) |
|
||||
| `window-watch.ps1` | Background baseline-diff watch loop → JSONL |
|
||||
| `window-watch.mjs` | Node wrapper: start/stop watch, `--selftest`, baseline capture |
|
||||
| `assertions.mjs` | Window-event classification + profile PASS/FAIL table |
|
||||
| `platform-guard.mjs` | `assertWin32`, elevation detection |
|
||||
| `powershell-runner.mjs` | Windows PowerShell 5.1 spawn helpers |
|
||||
|
||||
## Known limitations
|
||||
|
||||
|
||||
Reference in New Issue
Block a user