Compare commits

..
Author SHA1 Message Date
Mohamed Boudra b28aafa1df fix(forges): isolate repository identity by remote
Revalidate pull request sources when a checkout changes forge servers.
2026-11-02 07:32:03 +01:00
Mohamed Boudra 5feb68da4f fix(workspaces): preserve pull requests across checkout changes
Keep branch associations when the local commit changes and resolve transferred source identities before matching. Wait for the composer to accept input before queueing in browser tests.
2026-11-02 07:18:48 +01:00
Mohamed Boudra 24ef4c48cc fix(forges): retain pull requests after repository renames
Compare the head repository with the forge-resolved identity when its configured path has moved.
2026-11-02 06:55:23 +01:00
Mohamed Boudra a7d5f99a1c fix(forges): retain transferred forks in crowded searches
Resolve the head repository before choosing the bounded fallback so transferred forks keep their pull request association.
2026-11-02 06:42:09 +01:00
Mohamed Boudra f1e262a142 fix(forges): preserve pull request repository identity
Treat bounded discovery as inconclusive and resolve redirected repositories before selecting requests.
2026-11-02 06:35:23 +01:00
Mohamed Boudra dc7ade5264 fix(workspaces): retain merged pull request status
A workspace can still represent a merged pull request after its checkout advances. Match the repository and branch for display, and require a fresh exact-head match before automatic archive.
2026-11-02 06:24:00 +01:00
da8d58bfee fix(plugins): preserve agent message provenance in timeline transforms (#8492)
Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 05:04:23 +00:00
Mohamed Boudra 7c2812dde6 fix(app): add hosts without leaving the current screen (#8512)
* fix(app): add hosts without leaving the current screen

A retained Settings route replayed its addHost query after appearance remounts. Own the connection flow outside screen lifetimes and let each consumer choose its completion navigation.

* fix(app): release the project dialog before adding a host

* fix(app): size the QR scanner viewport on wide layouts

* test(app): align creation and desktop setup with in-place host flow
2026-11-02 11:07:48 +07:00
Mohamed Boudra b474beedce Homepage shows an agent building a plugin live (#8515)
* feat(website): show agents building plugins on the homepage

The plugins section now leads with the workflow instead of the API list:
you ask an agent for a panel and it appears, without a restart. It plays
as a timeline (prompt, tool calls, result) and then slides in the panel
the agent built, followed by the 400+ community plugins and their
categories. It moves above the automation section.

Mock tool calls on the homepage now match the app's collapsed timeline
rows: same labels and icons, muted text in the UI font, and Paseo MCP
tools named by action ("Create agent"). The hero pills are now nouns.

* fix(website): show the finished plugin demo when motion is reduced

The demo staged its items and slid the panel across its width even for visitors who ask for reduced motion. It now starts in its finished state for them, matching the hero mockup.
2026-11-02 10:50:12 +07:00
e2f0779379 Render Markdown tables written directly below prose (#8485)
* fix(app): render tables immediately after paragraphs

Insert a paragraph break before GFM tables adjacent to prose so MD4C
recognizes them, preserving quote/list containers and line endings.

* fix(app): preserve table headers during source adaptation

Replace the preceding line ending when inserting a table separator.
This keeps the separator before the header's source range, so entity
and local image edits cannot consume the paragraph break.

---------

Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: thsrite <thsrite@gmail.com>
Co-authored-by: Woosuk Nam <me@suk.kr>
2026-11-02 03:30:52 +00:00
paseo-bot[bot] a166a3317a test(terminal): check original processes after protocol cleanup (#8502)
Startup launches another worker after closing obsolete protocols, so a reused numeric PID does not prove the original process survived. Check the recorded birth identities and require cleanup to be confirmed.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 03:01:36 +00:00
paseo-bot[bot]andNoah efc64a5443 Explain the mode required to create agents on another provider (#8483)
* fix(server): say when create_agent needs an explicit mode

* test(server): verify cross-provider mode discovery through the daemon

Exercise the MCP contract, refusal without a child, provider inspection, and explicit-mode creation through the public daemon interfaces. Qualify unattended advice because targets without an unattended mode still require an explicit choice.

Co-authored-by: Noah <noahguerin20@gmail.com>

* test(server): run mode discovery in the integration suite

The unit command excludes E2E files, and CI integration uses an explicit list. Add the cross-provider MCP regression to that existing list so CI exercises its guidance, refusal, discovery, and retry assertions.

---------

Co-authored-by: Noah <noahguerin20@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 02:08:31 +00:00
7efa14238d fix(claude): report API-error turns as failed (#8480)
* fix(claude): fail a turn that Claude Code ends on an API error

Claude Code reports a turn that stopped on an API error (a session or
usage limit, an invalid key, an overloaded request) as a `success`
result with `is_error: true`, the error text in `result`, after a
synthetic assistant reply carrying the same text and an `error` field.
The provider read only the subtype, so the turn completed with the
error as its answer: the agent went idle with no lastError and showed
as finished in the sidebar.

Treat such a result as a failed turn with the result text as its
error. Hold the synthetic error reply back until the result arrives so
the failure row does not repeat it; a reply the turn moves past, or a
result without is_error, still shows it.

Co-authored-by: RoundRedDot <roundreddot@gmail.com>

* fix(claude): retain final usage when an API-error turn fails

Publish the converted result totals before the failure event so the
existing usage handler preserves work done earlier in the turn.

---------

Co-authored-by: luke <luopeihuan@gmail.com>
Co-authored-by: RoundRedDot <roundreddot@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-02 01:07:00 +00:00
paseo-bot[bot] dbbcb103ba fix(opencode): preserve denial reasons when a message supersedes approval (#8477)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 23:50:55 +00:00
Thomas VanandClaude Opus 5.5 7f318bf7cf fix(codex): end an interrupted turn's shell commands (#7525)
`turn/interrupt` ends a Codex turn and leaves the shell commands it started running: a
command the user just stopped runs on to its own end, alongside the next turn.

Once Codex accepts the interrupt, the provider now ends each command the interrupted turn
started that has not exited, by process id, with `thread/backgroundTerminals/terminate`,
and a turn Paseo starts waits for those calls before its `turn/start`.

- Attribution: `thread/backgroundTerminals/list` carries no turn, so the provider records
  each root-thread `commandExecution` process id under the turn of its `item/started`. A
  `completed` item drops its id; a `failed` one keeps it, because Codex reports an
  interrupted turn's still-running command as failed. A turn that ends any way but
  `interrupted` forgets its ids, so what an earlier turn left running on purpose is never
  ended. At most 16 turns are remembered.
- A late command: the turn keeps its accepted interrupt, so a command whose `item/started`
  arrives after Codex accepted the interrupt (one queued behind an earlier notification's
  conversion, for instance) is ended as soon as it is seen. Each process is terminated
  once, and a turn start waits for every pending sweep.
- It never sends `thread/backgroundTerminals/clean`, which ends every terminal on the
  thread. It ends nothing after an already-idle interrupt, nothing on a subagent's thread,
  and nothing after an interrupt of a turn Codex never identified.
- Bounds: each terminate times out after 2 s; a failure is logged
  (`provider.codex.interrupt.terminal_terminate_failed`) and fails nothing. `startTurn`
  waits at most the interrupt's bound plus one terminate's plus 500 ms.

Tests (codex-interrupted-turn-terminals.test.ts) drive a real CodexAppServerAgentSession
against the scripted app-server in codex/test-utils/fake-app-server.ts: exactly the
interrupted turn's processes are terminated, by id, and `clean` is never sent; a turn
Codex starts before acknowledging the interrupt keeps its commands; the next `turn/start`
waits for the terminates; a command announced after the interrupt was accepted is
terminated once and the next turn waits for both sweeps, whichever answers first; a
completed command is forgotten and a failed one kept; only the latest sixteen accepted
interrupts are kept; a failing or unanswered terminate does not block the next turn. The
harness waits for the interrupted turn's `turn_canceled` before it starts the next turn,
as a caller does: notifications are handled asynchronously, so the interrupt's answer can
arrive before the turn has ended.

Fixes #7522


Claude-Session: https://claude.ai/code/session_01Ay568T2kmtotoh23Dtzi5K

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:58:12 +00:00
paseo-bot[bot] 04fe4be40f fix(claude): retain configured model metadata before a turn (#8465)
Rebuilding an idle session cleared its observed model and published null despite retaining an explicit model in its configuration. Fall back to that configuration until the next turn supplies an observed model.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 18:59:04 +00:00
paseo-bot[bot] 2d75cc80b2 test(server): cover routed permissions in one host journey (#8486)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 18:34:57 +00:00
paseo-bot[bot]andDominic Anaeto bfac132920 Stop Git polling while workspace folders are missing (#8462)
* fix(server): stop polling git for a workspace whose folder is gone

When a workspace's folder disappears together with its project (the
repository deleted, a volume unmounted), reconciliation leaves the
workspace registered, and the daemon kept polling it: a failed git
command and a warning about once a minute, and a working-tree watcher
retry every minute or two, for as long as the daemon ran.

The degraded poll now checks that the workspace folder exists before
refreshing. A workspace whose folder is gone is suspended once: it is
detached from its repository and working-tree targets, its timers stop,
and one warning is logged. A worktree that shared a repository target
hands the repository's polling and background fetch to a surviving
worktree. Nothing is archived. A working-tree watch that an open diff
still holds skips its consumer notifications and watcher recovery while
its folder is missing.

While suspended, the workspace runs no git. A stat every 60 seconds, a
new subscription, a forced snapshot or a refresh request checks whether
the folder is back; when it is, the workspace reloads its git facts,
refreshes, and rebuilds its observation.

Fixes #7856

* fix(server): keep missing workspaces suspended during refresh

Admit Git refreshes only after the folder has returned. Cover missing-folder polling, explicit refreshes, and restoration with real Git and the Linux observer.

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>

* fix(server): preserve workspace snapshots through suspension

Drop queued refreshes and withhold publication when a folder disappears during an active read. Wait for that read before restoring observation so its stale result cannot overwrite the recovered snapshot.

---------

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 18:02:26 +00:00
paseo-bot[bot]andDominic Anaeto 34f996b32a Preserve pending questions and approvals after peer messages (#8453)
* fix(server): keep a recipient's pending permission when another agent messages it

A message from another agent (send_agent_message with sourceAgentId) was
enqueued with clearPendingPermissions: true, so it superseded the
recipient's pending permission request and the request was denied before
a human saw it. Agent-sourced messages now leave pending permissions
alone. Typed messages and sourceless `paseo send` still clear them.

Fixes #7602

* test(server): verify peer messages preserve answerable questions

Exercise message delivery through the daemon and provider interfaces so the regression checks that the question survives and its answer reaches the provider. Retain the human-steer cancellation behavior.

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>

* test(terminal): make runtime copy fixture independent of umask

Set the source helper permissions after creation so the copy test measures preservation instead of inheriting the shell file-creation mask. The existing test fails under umask 077 without this setup correction.

Fixes #8175

---------

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 17:11:42 +00:00
3e83f433da Import large Claude sessions without exceeding the string limit (#8457)
* fix(claude): read the session rename from the end of a large transcript

Importing a Claude session read the whole transcript into one UTF-8 string to
look for its `custom-title` record, so a transcript past V8's maximum string
length failed the import with `RangeError: Invalid string length` before the
incremental history reader ever ran. The scan now reads the file backwards a
chunk at a time, which keeps the original intent of not walking the transcript
and never holds it as one string. Records without the needle are not decoded, so
a transcript with no rename costs a byte search per chunk.

Closes #8451

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(app): wait for send readiness in composer recall

The previous prompt can appear and clear its draft before the send acknowledgement arrives. Wait for Send to be enabled before pressing Enter so recall setup does not silently leave the next prompt unsent.

Co-authored-by: qwist1233-cpu <qwist1233@gmail.com>

* fix(claude): scan large transcript records once

Retain unfinished record chunks and assemble each record once instead of copying and rescanning a growing buffer on every backward read. Cover import of a large record after the last explicit rename.

---------

Co-authored-by: qwist1233-cpu <qwist1233@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 15:56:42 +00:00
paseo-bot[bot] 674530152e test(server): cover filename downloads in one workspace journey (#8475)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 15:31:08 +00:00
Dominic 919e44b319 fix(server): start configured plugins concurrently (#8427)
The daemon accepts connections only after its configured plugins have
started, and PluginService.start started them one at a time, so each
plugin's build and process launch added to daemon startup. Configured
plugins now start together. Their providers and usage sources are still
published in config order: a plugin that finishes early waits for the
plugins listed before it, so when two plugins claim the same provider or
usage-source ID, the one listed first keeps it, as before. A failing
plugin still fails on its own.

Fixes #7979
2026-11-01 12:59:34 +00:00
paseo-bot[bot] 4fe44038e3 test(server): check subdirectory diffs before and after commit (#8466)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 12:35:50 +00:00
Dominic 9b0542d127 fix(server): retry a connected host that failed the capability check (#8423)
* fix(server): retry a connected host that failed the capability check

Every failure in a connected host's acceptance step was terminal: the
row went to "error" with no retry, and the status listener ignored
later reconnects. A host rejected with unsupported_capability stayed
unusable after it was updated, until the registration was saved again
or the managing daemon restarted. Capability and other non-identity
acceptance failures now go "offline" with nextRetryAt and retry with
capped backoff (1.5 s doubling to 30 s), so an updated host comes
online by itself. Self, duplicate, changed-identity and authentication
failures stay terminal.

Fixes #7971

* fix(server): keep a retried host's relay security floor and update guidance

A scheduled capability retry built a new transport factory, which
dropped the relay security floor learned from a protected handshake, so
the retry could negotiate legacy encryption and send credentials.
Retries now reuse the registration's transport factory (createHostClient
accepts one). The update guidance also stays visible while a retry
connects, the backoff resets after a successful acceptance, and
disabling or removing a host cancels its pending retry.
2026-11-01 10:59:33 +00:00
Dominic 8130147426 fix(app): stop the terminal from typing its version reply into the shell (#8419)
The browser terminal is a renderer and leaves protocol replies to the
daemon, but it still answered XTVERSION (CSI > q) and DECRQSS
(DCS $ q ... ST). Those replies went out through onData as if the user
had typed them, so a query from a program in the shell left text like
">|xterm.js(6.1.0-beta.292)" on the next input line. Both queries are
now swallowed with the other protocol queries.

Fixes #8111
2026-11-01 10:04:25 +00:00
Mohamed Boudra fa89738e3a chore(desktop): upgrade packaging tool and remove signing backport (#8450)
* chore(desktop): upgrade packaging tool and remove signing backport

* fix(nix): resolve the desktop workspace packaging tool
2026-11-01 14:46:52 +07:00
paseo-bot[bot]andDominic Anaeto 14519bdb5b Read archived Pi chats after their working folder is removed (#8442)
* fix(pi): open an archived Pi agent's history after its folder is removed

Opening an archived agent's timeline resumes its provider for history.
Pi's resumeSession never received the resume purpose, so it launched the
Pi RPC process, and its MCP support probe, in the agent's stored working
directory. When that folder had been removed, for example with a merged
worktree, Node failed the spawn with "spawn <command> ENOENT" and the
history could not be read.

Starting the process elsewhere is not enough: with --session, Pi checks
the cwd stored in the session file's header and, outside interactive
mode, exits when that folder is missing. A history resume whose stored
folder is no longer a directory now starts Pi from the home directory on
a temporary copy of the session file whose header names that directory.
The copy streams everything after the header unchanged and is deleted
when the session closes. The agent keeps reporting its stored folder and
original session file, and the original file is not written to.

The directory check moves out of the ACP provider, which already starts
history loads from the home directory (#6228), into a shared helper both
providers use.

Fixes #7969

* test(terminal): keep the argv test child alive through its assertion

The POSIX argv test reads the terminal screen while its child can exit and dispose the parser. Keep the child alive until the assertion completes, as other screen tests do, and let the existing session cleanup terminate it.

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>

---------

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 06:52:48 +00:00
Mohamed Boudra 891e62309c fix(release): use the keychain password for macOS signing setup (#8445)
Backport the pinned packaging tool correction in the workflow so existing release tags can be rebuilt without changing their dependencies.
2026-11-01 13:35:11 +07:00
paseo-bot[bot] 6ae6308cfb test(server): control failed Git clone responses in credential test (#8428)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 06:01:30 +00:00
Mohamed Boudra ef77b823ae chore(release): reconcile 0.15.0-beta.1 metadata (#8443)
* docs(changelog): prepare 0.15.0-beta.1

(cherry picked from commit 7e5ad53491)

* chore(release): cut 0.15.0-beta.1

(cherry picked from commit 8bd38fb236)

* test(docs): follow nested worktrees page
2026-11-01 12:56:41 +07:00
Mohamed Boudra 60f29a4350 docs(worktrees): add nested worktreeinclude reference 2026-11-01 06:31:01 +01:00
paseo-bot[bot]andDominic Anaeto 8efb21f55c fix(server): close abandoned workspace service connections (#8421)
* fix(server): close the upstream connection when a proxied client leaves

The service proxy piped the service's response to the client but never
destroyed the upstream request when the client went away. The upstream
socket stayed open and unread, so a Server-Sent Events service kept
writing into it and grew in memory. The proxy now destroys the upstream
request when the client response closes before it finished, without
logging that deliberate abort as an unreachable upstream, and a
WebSocket upgrade closes the service socket when the client socket
closes.

Fixes #8149

* test(server): publish terminal activity fixture atomically

File existence did not mean the child had finished writing its JSON. Publish the completed fixture by rename so a concurrent reader cannot parse an empty file.

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>

* test(server): keep rollback listener ports distinct

Selecting both free ports before binding either lets the OS return the same port twice. Hold the intentionally occupied main port before selecting the standalone port so the rollback probe cannot reach the wrong server.

---------

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 03:55:48 +00:00
paseo-ai[bot] b2666a0aec fix: update lockfile signatures and Nix hash [skip ci] 2026-11-01 03:43:24 +00:00
116 changed files with 9220 additions and 2421 deletions
-8
View File
@@ -406,10 +406,7 @@
{
"files": [
"packages/app/src/app/_layout.tsx",
"packages/app/src/app/pair-scan.tsx",
"packages/app/src/components/adaptive-modal-sheet.tsx",
"packages/app/src/components/add-host-method-modal.tsx",
"packages/app/src/components/add-host-modal.tsx",
"packages/app/src/components/agent-list.tsx",
"packages/app/src/components/agent-status-dot.tsx",
"packages/app/src/components/attachment-lightbox.tsx",
@@ -428,7 +425,6 @@
"packages/app/src/components/hosts/host-picker.tsx",
"packages/app/src/components/icons/paseo-logo.tsx",
"packages/app/src/components/left-sidebar.tsx",
"packages/app/src/components/pair-link-modal.tsx",
"packages/app/src/components/plan-card.tsx",
"packages/app/src/components/provider-diagnostic-sheet.tsx",
"packages/app/src/components/question-form-card.tsx",
@@ -543,14 +539,10 @@
"packages/app/src/agent-skills/selection-sheet.tsx",
"packages/app/src/agent-stream/chat-outline/rail.web.tsx",
"packages/app/src/agent-stream/view.tsx",
"packages/app/src/app/pair-scan.tsx",
"packages/app/src/changelog/internal/changelog-sheet.tsx",
"packages/app/src/command-center/command-center.tsx",
"packages/app/src/components/adaptive-modal-sheet.tsx",
"packages/app/src/components/add-host-method-modal.tsx",
"packages/app/src/components/add-host-modal.tsx",
"packages/app/src/components/add-project-flow.tsx",
"packages/app/src/components/add-remote-ssh-host-modal.tsx",
"packages/app/src/components/agent-list.tsx",
"packages/app/src/components/app-diagnostic-sheet.tsx",
"packages/app/src/components/attachment-lightbox.tsx",
+4 -1
View File
@@ -74,7 +74,10 @@ registrations remain inspectable; a changed destination requires explicit identi
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
operations; requesting this feature gives update guidance.
operations; requesting this feature gives update guidance. The registry retries an older destination
with capped backoff, so it comes online once updated. Self, duplicate, changed-identity, and
authentication failures stay in `error` until an update changes the connection, `enabled`, or
`replaceIdentity`, or A restarts.
Disposing a routed API releases its observations and session. It does not remove the saved host or
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
+4 -4
View File
@@ -35,7 +35,7 @@ The authored interface ramp uses a 14px base. New native installs default to 15p
Weight has three tiers, applied by role:
- **Screen titles** — the title in app chrome — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. The New workspace hero is the only larger product title; it uses `fontSize["2xl"]` (`packages/app/src/screens/new-workspace-screen.tsx`).
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx:19-23`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
@@ -102,7 +102,7 @@ Five primitives. The pick is determined by option count, the need to search, and
`<ContextMenu>` is for right-click and long-press on a target. The row is the trigger; there is no visible affordance. Used for incidental actions on workspace rows in the sidebar (`packages/app/src/components/sidebar-workspace-list.tsx`).
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
`<AdaptiveModalSheet>` owns the presentation. Its content inset — the gutter that puts sheet content on the same rails as the sheet header — and compact bottom safe-area padding are the sheet's, not the caller's. A caller declares layout intent through `contentStyle` and never branches on form factor to add its own margins. If a sheet's first snap point is shorter than its header, content, and safe-area clearance, raise that snap point rather than moving the sheet container.
@@ -114,7 +114,7 @@ Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value
## 7. Density and rhythm
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/components/add-host-modal.tsx`).
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/hosts/add-host/internal/add-host-modal.tsx`).
Workspace and chat surfaces use the full width — these are working surfaces, not reading surfaces. The composer carries `MAX_CONTENT_WIDTH` from `packages/app/src/constants/layout.ts` to keep lines readable while letting the workspace pane fill the rest.
@@ -278,7 +278,7 @@ New status pills use `<StatusBadge>`. Identity, shortcut, and interactive link b
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
| Form modal (label + input fields, primary + cancel) | `packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
| Form modal (label + input fields, primary + cancel) | `packages/app/src/hosts/add-host/internal/add-host-modal.tsx`, `packages/app/src/hosts/add-host/internal/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
| Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` |
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
+6
View File
@@ -58,6 +58,12 @@ restore the same workspace and could repeat the crash.
## App-Wide Route Hops
Keep Add host in `hosts/add-host`, opened through `useAddHost()`. It is an in-place
flow; callers choose what happens after success. Settings opts into the new host’s
Overview. Do not send callers to Settings with an `addHost` query parameter: a
retained route replays that request when appearance changes remount its content.
The flow provider and sheet host stay outside appearance keys so open drafts survive.
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
into a host workspace, use `navigateToWorkspace()`. Do not make the caller
branch on its current route.
+20 -11
View File
@@ -97,19 +97,28 @@ Register the adapter in `defaultForgeRegistry` with:
- `matchesHost` from manifest `cloudHosts`
- `probeHost` when self-hosted/Enterprise detection is supported
Current change-request lookup uses two identities deliberately:
Match a change request using the head repository and branch. Within the bounded
candidate window, prefer an open request, otherwise select the most recently
updated terminal request, breaking ties by number. A full inconclusive window is
a failed discovery, so a poll retains its previous status. Gitea does not expose
a source-branch filter: after its bounded open window it also examines one recent
all-state page. An older open request outside both windows cannot take precedence
over a recent terminal request on a cold lookup. When a workspace already shows
an open request, revalidate it before replacing it with another terminal request.
A reused branch can display a historical request
until a new one opens. Display does not depend on checkout HEAD: the checkout can
be behind the remote PR after its branch is updated during merge.
- An open PR/MR belongs to the checkout when its head branch and head repository
match. Its remote head SHA may differ because the checkout can be ahead,
behind, or contain commits that have not been pushed yet.
- A merged or closed PR/MR belongs to the checkout only when its recorded head
SHA exactly matches the checkout's current `HEAD`. Branch names are reusable;
selecting the newest terminal request by branch alone can silently attach an
old promotion or feature request to new work.
For mixed SSH and HTTPS ports on one host, require the source repository's native
clone URL to match the normalized checkout endpoint. Different HTTP ports remain
distinct forge instances. Unknown clone URLs do not establish identity.
Thread the checkout head SHA through adapter cache and poll identities as well
as the lookup itself. Otherwise a commit made on the same branch can inherit the
previous commit's cached terminal status until the cache expires.
Auto-archive has a narrower contract. It requires a fresh, observed open-to-merged
transition and an exact match between the checkout HEAD and the forge's recorded
PR head, a successful upstream safety read, and the workspace safety checks. A
successful read reporting no upstream is distinct from a failed read. Keep
checkout HEAD in cache and poll identities so a checkout change refreshes those
safety facts.
Cloud hosts in the manifest are a bounded public-host list, not a self-host
allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge
+1 -1
View File
@@ -127,7 +127,7 @@ buildNpmPackage {
# The Nix output is not a distributable DMG, so leave it unsigned and
# disable the hardened runtime that requires a matching signature.
CSC_IDENTITY_AUTO_DISCOVERY=false \
../../node_modules/.bin/electron-builder \
npm exec --offline -- electron-builder \
--config electron-builder.yml \
--dir \
--mac \
+1 -1
View File
@@ -1 +1 @@
sha256-CeT2PtYIAFwvBC9LIku8VkTl0pvKxYzSCn0QW2ManfU=
sha256-KN/AVZV/o0TleLLpmggSrdJqjFDJ/NJYVHiC54xb4jQ=
+1018 -1295
View File
File diff suppressed because it is too large Load Diff
@@ -5,6 +5,7 @@ import path from "node:path";
import { test, expect } from "../support/fixtures";
import {
addProjectFlow,
chooseAddHostFromProjectFlow,
addProjectFlowBack,
addProjectFlowHost,
addProjectFlowInput,
@@ -19,6 +20,7 @@ import {
} from "../support/helpers/add-project-flow";
import { gotoAppShell } from "../support/helpers/app";
import {
disconnectAllHostsAndReload,
addConnectedHostAndReload,
addConnectedHostsAndReload,
addOfflineHostAndReload,
@@ -69,6 +71,19 @@ async function expectProjectHasNoWorkspaces(projectId: string): Promise<void> {
test.describe("Add Project command-center flow", () => {
test.describe.configure({ timeout: 180_000 });
test("Add host replaces the project dialog when no hosts are connected", async ({ page }) => {
await gotoAppShell(page);
await disconnectAllHostsAndReload(page);
await openAddProjectHostSelection(page);
const originalUrl = page.url();
await chooseAddHostFromProjectFlow(page);
await expect(addProjectFlow(page)).toBeHidden();
await expect(
page.getByRole("button", { name: "Direct connection", exact: true }),
).toBeVisible();
await expect(page).toHaveURL(originalUrl);
});
test("method selection shows the daemon's available project sources without search", async ({
page,
}) => {
@@ -3,7 +3,7 @@ import {
composerLocator,
expectComposerDraft,
expectComposerVisible,
submitMessage,
fillComposerDraft,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -12,8 +12,14 @@ function userMessages(page: Page) {
return page.getByTestId("user-message");
}
async function sendDraftWhenReady(page: Page) {
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
await pressInComposer(page, "Enter");
}
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
await submitMessage(page, text);
await fillComposerDraft(page, text);
await sendDraftWhenReady(page);
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
await expectComposerDraft(page, "");
}
@@ -49,7 +55,7 @@ test.describe("Composer recall", () => {
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "Enter");
await sendDraftWhenReady(page);
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
await expect(userMessages(page).last()).toContainText("add tests for login");
} finally {
@@ -294,10 +294,17 @@ test("creates, renames, copies, and deletes entries through the file explorer",
await entry("renamed.txt").click({ button: "right" });
const deleteAction = page.getByTestId(/file-explorer-row-\d+-delete$/);
const deleteLabelColor = await deleteAction
.getByText("Delete", { exact: true })
.evaluate((element) => getComputedStyle(element).color);
await expect(deleteAction.locator("svg")).toHaveCSS("stroke", deleteLabelColor);
await expect
.poll(async () => {
const labelColor = await deleteAction
.getByText("Delete", { exact: true })
.evaluate((element) => getComputedStyle(element).color);
const iconColor = await deleteAction
.locator("svg")
.evaluate((element) => getComputedStyle(element).stroke);
return labelColor !== "" && iconColor === labelColor;
})
.toBe(true);
const cancelledConfirmation = new Promise<string>((resolve) => {
page.once("dialog", async (dialog) => {
const message = dialog.message();
@@ -122,10 +122,14 @@ test.describe("New workspace entry points", () => {
});
const hostTrigger = page.getByTestId("host-picker-trigger");
await expect(hostTrigger).toContainText("localhost", { timeout: 30_000 });
const originalUrl = page.url();
await hostTrigger.click();
await expect(page.getByTestId("new-workspace-host-picker-add-host")).toBeVisible();
await page.getByTestId("new-workspace-host-picker-add-host").click();
await expect(page).toHaveURL(/\/settings\/general\?addHost=/);
await expect(
page.getByRole("button", { name: "Direct connection", exact: true }),
).toBeVisible();
await expect(page).toHaveURL(originalUrl);
} finally {
await seeded.cleanup();
}
@@ -1,3 +1,8 @@
import {
openGlobalNewWorkspaceComposer,
fillNewWorkspaceDraft,
expectNewWorkspaceDraft,
} from "../support/helpers/new-workspace";
import { readFile, mkdir, rename, rm } from "node:fs/promises";
import path from "node:path";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -273,3 +278,71 @@ test("restart recovery host controls persist the opt-out and custom prompt", asy
await expectRestartRecoveryPrompt(page, "Continue the interrupted work.");
await editRestartRecoveryPrompt(page, "Resume working.");
});
test("sidebar Add host preserves its caller and stays closed after theme changes", async ({
page,
}) => {
await gotoAppShell(page);
await expect(page).toHaveURL(/\/open-project$/);
const originalUrl = page.url();
await openSidebarAddHost(page);
await expect(page).toHaveURL(originalUrl);
await closeAddHostAndChangeTheme(page);
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeHidden();
await expect(page).toHaveURL(originalUrl);
});
async function openSidebarAddHost(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Hosts", exact: true }).click();
await page.getByRole("button", { name: "Add host", exact: true }).click();
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeVisible();
}
async function closeAddHostAndChangeTheme(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Close", exact: true }).click();
await expect(page.getByRole("button", { name: "Direct connection", exact: true })).toBeHidden();
await page.keyboard.press("Control+Alt+t");
}
test("sidebar Add host completes without navigating", async ({ page }) => {
await gotoAppShell(page);
await expect(page).toHaveURL(/\/open-project$/);
const originalUrl = page.url();
await openSidebarAddHost(page);
await connectToTestHost(page);
await expect(page).toHaveURL(originalUrl);
await expect(page.getByRole("button", { name: "Hosts", exact: true })).toBeVisible();
});
test("Settings Add host opens the saved host Overview", async ({ page }) => {
await gotoAppShell(page);
await openSettings(page);
await addDirectHostFromSettings(page, { host: "127.0.0.1", port: Number(getE2EDaemonPort()) });
await expectSettingsHeader(page, "Overview");
await expect(page).toHaveURL(new RegExp(`/settings/hosts/${getServerId()}/host$`));
});
test("New workspace Add host preserves the prompt after connecting", async ({ page }) => {
await gotoAppShell(page);
await openGlobalNewWorkspaceComposer(page);
await fillNewWorkspaceDraft(page, "Keep this unfinished prompt while I add a host.");
const originalUrl = page.url();
await openNewWorkspaceAddHost(page);
await expect(page).toHaveURL(originalUrl);
await connectToTestHost(page);
await expect(page).toHaveURL(originalUrl);
await expectNewWorkspaceDraft(page, "Keep this unfinished prompt while I add a host.");
});
async function openNewWorkspaceAddHost(page: import("@playwright/test").Page) {
await page.getByTestId("host-picker-trigger").click();
await page.getByRole("button", { name: "Add host", exact: true }).click();
}
async function connectToTestHost(page: import("@playwright/test").Page) {
await page.getByRole("button", { name: "Direct connection", exact: true }).click();
await page.getByRole("textbox", { name: "Host", exact: true }).fill("127.0.0.1");
await page.getByRole("textbox", { name: "Port", exact: true }).fill(String(getE2EDaemonPort()));
await page.getByRole("button", { name: "Connect", exact: true }).click();
await expect(page.getByTestId("add-host-modal")).toBeHidden({ timeout: 30_000 });
}
@@ -113,3 +113,7 @@ export async function expectAddProjectResultVisible(page: Page, text: string): P
})
.toBe(true);
}
export async function chooseAddHostFromProjectFlow(page: Page): Promise<void> {
await page.getByRole("button", { name: /Add host.*No connected hosts/ }).click();
}
@@ -84,6 +84,9 @@ export async function typeIntoFocusedComposer(page: Page, text: string): Promise
}
export async function sendDraftToQueue(page: Page): Promise<void> {
await expect(
page.getByRole("button", { name: /^(Send and steer|Send and interrupt)$/ }),
).toBeEnabled({ timeout: 30_000 });
await composerInput(page).press("Control+Enter");
}
@@ -94,6 +94,9 @@ if (origin === fixtureRemote) {
labels: [],
baseRefName: "main",
headRefName: isFork ? "pr-branch-2" : "pr-branch-1",
headRefOid: spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).stdout?.trim() || "",
headRepositoryOwner: { login: isFork ? "fork-owner" : "paseo-e2e" },
headRepository: { name: "local-fixture" },
updatedAt: "2026-01-01T00:00:00Z"
};
process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr));
+22
View File
@@ -273,3 +273,25 @@ export async function reloadPreservingHostRegistry(page: Page): Promise<void> {
);
await page.reload();
}
export async function disconnectAllHostsAndReload(page: Page): Promise<void> {
await page.evaluate(
({ registryKey, nonceKey, disableSeedKey }) => {
const nonce = localStorage.getItem(nonceKey);
if (!nonce) throw new Error("Expected the e2e seed nonce");
const hosts = JSON.parse(localStorage.getItem(registryKey) ?? "[]");
for (const host of hosts) {
host.connections = [{ id: "offline", type: "directTcp", endpoint: "127.0.0.1:59999" }];
host.preferredConnectionId = "offline";
}
localStorage.setItem(registryKey, JSON.stringify(hosts));
localStorage.setItem(disableSeedKey, nonce);
},
{
registryKey: REGISTRY_KEY,
nonceKey: SEED_NONCE_KEY,
disableSeedKey: DISABLE_DEFAULT_SEED_ONCE_KEY,
},
);
await page.reload();
}
+9 -6
View File
@@ -1,3 +1,4 @@
import { AddHostProvider, AddHostFlowHost } from "@/hosts/add-host";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar";
@@ -465,6 +466,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const surface = (
<View style={layoutStyles.surfaceFill}>
{workspaceChrome}
<AddHostFlowHost />
<AppearanceStyleBoundary>
{!isCompactLayout && appChromeLayout.sidebarToggleOwner === "window" ? (
<WindowChromeRegion corners="top-left">
@@ -805,7 +807,6 @@ function RootStack() {
<Stack.Screen name="open-project" />
<Stack.Screen name="sessions" />
<Stack.Screen name="schedules" />
<Stack.Screen name="pair-scan" />
</Stack.Protected>
<Stack.Screen name="h/[serverId]" />
<Stack.Screen name="settings/hosts/[serverId]/index" />
@@ -890,11 +891,13 @@ function RootAppTree() {
<GestureHandlerRootView style={flexStyle}>
<View style={layoutStyles.surfaceFill}>
<LucideProvider strokeWidth={ICON_STROKE_WIDTH}>
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
<AddHostProvider>
<RootProviders>
<RuntimeProviders>
<AppShell />
</RuntimeProviders>
</RootProviders>
</AddHostProvider>
</LucideProvider>
</View>
</GestureHandlerRootView>
+2 -3
View File
@@ -35,10 +35,9 @@ function SettingsDaemonRedirect() {
}
export default function SettingsSectionRoute() {
const params = useLocalSearchParams<{ section?: string; addHost?: string }>();
const params = useLocalSearchParams<{ section?: string }>();
const rawSection = typeof params.section === "string" ? params.section : "";
const section: SettingsSectionSlug = isSettingsSectionSlug(rawSection) ? rawSection : "general";
const openAddHostIntent = typeof params.addHost === "string" ? params.addHost : null;
const view = useMemo(() => ({ kind: "section" as const, section }), [section]);
// COMPAT(settingsDaemonRedirect): added 2026-07-08, remove after 2027-01-08.
@@ -50,5 +49,5 @@ export default function SettingsSectionRoute() {
);
}
return <SettingsScreen view={view} openAddHostIntent={openAddHostIntent} />;
return <SettingsScreen view={view} />;
}
@@ -1,3 +1,4 @@
import { useAddHost } from "@/hosts/add-host";
import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
@@ -92,7 +93,7 @@ import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
import type { AddProjectFlowRequest } from "@/stores/add-project-flow-store";
import type { Theme } from "@/styles/theme";
import { shortenPath } from "@/utils/shorten-path";
import { buildNewWorkspaceRoute, buildSettingsAddHostRoute } from "@/utils/host-routes";
import { buildNewWorkspaceRoute } from "@/utils/host-routes";
interface AddProjectFlowProps {
request: AddProjectFlowRequest;
@@ -325,6 +326,7 @@ function setPageStatus(
// The product flow is intentionally one cohesive page-stack state machine.
// eslint-disable-next-line complexity
export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
const addHost = useAddHost();
const hosts = useHosts();
const hostIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const connectionStatuses = useHostRuntimeConnectionStatuses(hostIds);
@@ -616,9 +618,9 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
icon: Plus,
testID: "add-project-flow-add-host",
select: () => {
// The native project Modal must release its window before the root sheet opens.
onClose();
usePanelStore.getState().showMobileAgent();
router.push(buildSettingsAddHostRoute(Date.now()));
addHost();
},
});
}
@@ -717,6 +719,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
directoryPaths,
githubQuery.data,
host,
addHost,
onClose,
openAddedProject,
page,
+4 -9
View File
@@ -1,3 +1,4 @@
import { useAddHost } from "@/hosts/add-host";
import { router } from "expo-router";
import {
CircleGauge,
@@ -62,7 +63,7 @@ import { usePanelStore } from "@/stores/panel-store";
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
import { buildSettingsRoute } from "@/utils/host-routes";
import { openHostOverview } from "@/navigation/settings-navigation";
import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage";
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
@@ -177,14 +178,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
router.push(buildSettingsRoute());
}, []);
const handleAddHostMobile = useCallback(() => {
showMobileAgent();
router.push(buildSettingsAddHostRoute(Date.now()));
}, [showMobileAgent]);
const handleAddHostDesktop = useCallback(() => {
router.push(buildSettingsAddHostRoute(Date.now()));
}, []);
const handleAddHostMobile = useAddHost();
const handleAddHostDesktop = handleAddHostMobile;
const handleOpenHostSettingsMobile = useCallback(
(serverId: string) => {
@@ -130,6 +130,186 @@ it("preserves empty table cells while escaping spoilers in cell text", () => {
);
});
it("renders a table directly after a paragraph through the library parser", async () => {
const source = "**Notes**\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |";
const separated = "**Notes**\n\n| Item | Detail |\n| --- | --- |\n| A | one |\n| B | two |";
const flags = { hardSoftBreaks: true, latexMath: false };
const expected = await parseMarkdown(separated, flags);
expect(expected.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]);
expect(await parseMarkdown(prepareMarkdownSource(source).render(), flags)).toEqual(expected);
});
it.each([
[
"a quote",
"> Notes\n> | Item |\n> | --- |\n> | A |",
"> Notes\n>\n> | Item |\n> | --- |\n> | A |",
["Document", ["Blockquote", "Paragraph", "Table"]],
],
[
"a list item",
"- Notes\n | Item |\n | --- |\n | A |\n- Next",
"- Notes\n\n | Item |\n | --- |\n | A |\n- Next",
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
],
[
"a quoted list item",
"> - Notes\n> | Item |\n> | --- |\n> | A |",
"> - Notes\n>\n> | Item |\n> | --- |\n> | A |",
["Document", ["Blockquote", ["UnorderedList", ["ListItem", "Paragraph", "Table"]]]],
],
[
"a quote inside a list item",
"- > Notes\n > | Item |\n > | --- |\n > | A |",
"- > Notes\n >\n > | Item |\n > | --- |\n > | A |",
["Document", ["UnorderedList", ["ListItem", ["Blockquote", "Paragraph", "Table"]]]],
],
[
"aligned columns without outer pipes",
"Notes\nItem | Detail\n:--- | ---:\nA | one",
"Notes\n\nItem | Detail\n:--- | ---:\nA | one",
["Document", "Paragraph", "Table"],
],
[
"an entity at the start of a header without outer pipes",
"Notes\nAT&amp;T | Detail\n--- | ---\nA | one",
"Notes\n\nAT\\&T | Detail\n--- | ---\nA | one",
["Document", "Paragraph", "Table"],
],
[
"a local image at the start of a header without outer pipes",
"Notes\n![Local](assets/a.png) | Detail\n--- | ---\nA | one",
"Notes\n\nLocal | Detail\n--- | ---\nA | one",
["Document", "Paragraph", "Table"],
],
[
"CRLF lines",
"Notes\r\n| Item |\r\n| --- |\r\n| A |",
"Notes\r\n\r\n| Item |\r\n| --- |\r\n| A |",
["Document", "Paragraph", "Table"],
],
[
"CR lines",
"Notes\r| Item |\r| --- |\r| A |",
"Notes\r\r| Item |\r| --- |\r| A |",
["Document", "Paragraph", "Table"],
],
[
"a quote with CRLF lines",
"> Notes\r\n> | Item |\r\n> | --- |\r\n> | A |",
"> Notes\r\n>\r\n> | Item |\r\n> | --- |\r\n> | A |",
["Document", ["Blockquote", "Paragraph", "Table"]],
],
[
"a list item with CRLF lines",
"- Notes\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next",
"- Notes\r\n\r\n | Item |\r\n | --- |\r\n | A |\r\n- Next",
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
],
[
"a quote with CR lines",
"> Notes\r> | Item |\r> | --- |\r> | A |",
"> Notes\r>\r> | Item |\r> | --- |\r> | A |",
["Document", ["Blockquote", "Paragraph", "Table"]],
],
[
"a list item with CR lines",
"- Notes\r | Item |\r | --- |\r | A |\r- Next",
"- Notes\r\r | Item |\r | --- |\r | A |\r- Next",
["Document", ["UnorderedList", ["ListItem", "Paragraph", "Table"], ["ListItem", "Paragraph"]]],
],
[
"a non-interrupting ordered marker with tildes",
"Intro\n2. ~~~\n Notes\n | A |\n | --- |\n | x |",
"Intro\n2. ~~~\n Notes\n\n | A |\n | --- |\n | x |",
["Document", "Paragraph", "Table"],
],
[
"a non-interrupting ordered marker with backticks",
"Notes\n2. ```\n | Item |\n | --- |\n | A |",
"Notes\n2. ```\n\n | Item |\n | --- |\n | A |",
["Document", "Paragraph", "Table"],
],
])("preserves tables after paragraphs in %s", async (_name, source, separated, expected) => {
const rendered = prepareMarkdownSource(source).render();
expect(rendered).toEqual(separated);
const flags = { hardSoftBreaks: true, latexMath: false };
function blocks(node: ASTNode): unknown {
if (node.type === "Paragraph" || node.type === "Table") return node.type;
return [node.type, ...(node.children ?? []).map(blocks)];
}
expect(blocks(await parseMarkdown(rendered, flags))).toEqual(expected);
});
it.each([
[
"a literal backslash",
"Notes\\\n| Item |\n| --- |\n| A |",
"Notes\\\n\n| Item |\n| --- |\n| A |",
"Notes\\",
],
[
"an entity and literal backslash with LF lines",
"AT&amp;T\\\n| Item |\n| --- |\n| A |",
"AT\\&T\\\n\n| Item |\n| --- |\n| A |",
"AT&T\\",
],
[
"an entity and literal backslash with CRLF lines",
"AT&amp;T\\\r\n| Item |\r\n| --- |\r\n| A |",
"AT\\&T\\\r\n\r\n| Item |\r\n| --- |\r\n| A |",
"AT&T\\",
],
[
"an entity and literal backslash with CR lines",
"AT&amp;T\\\r| Item |\r| --- |\r| A |",
"AT\\&T\\\r\r| Item |\r| --- |\r| A |",
"AT&T\\",
],
])(
"preserves paragraph text before an interrupting table in %s",
async (_name, source, separated, text) => {
const rendered = prepareMarkdownSource(source).render();
expect(rendered).toEqual(separated);
const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false });
expect(tree.children?.map((node) => node.type)).toEqual(["Paragraph", "Table"]);
expect(tree.children?.[0]?.children?.map((node) => node.content).join("")).toEqual(text);
},
);
it.each([
"```yaml\n---\nkey: value\n---\n```\n",
"~~~md\n| Item |\n| --- |\n| A |\n~~~\n",
"````md\n```\n| Item |\n| --- |\n```\n`````\n",
"> ```md\n> | Item |\n> | --- |\n> ```\n",
"> ```md\n> Code\n",
"- ```md\n Code\n",
" ```\n\n",
"```not`a-fence\n",
])("renders a table after a fence without changing its code: %s", async (prefix) => {
const table = "| Item |\n| --- |\n| A |";
const rendered = prepareMarkdownSource(`${prefix}Notes\n${table}`).render();
expect(rendered).toEqual(`${prefix}Notes\n\n${table}`);
const tree = await parseMarkdown(rendered, { hardSoftBreaks: true, latexMath: false });
expect(tree.children?.slice(-2).map((node) => node.type)).toEqual(["Paragraph", "Table"]);
});
it.each([
"| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100),
"Notes\n\n| Item | Detail |\n| --- | --- |\n" + "| A | one |\n".repeat(100),
"Notes\n\n| Item |\n| --- |\n| A |",
"| Item |\n| --- |\n| A |",
"```md\nNotes\n| Item |\n| --- |\n| A |\n```",
"```yaml\n---\nkey: value\n---\n```",
"```yaml\n---\nkey: value",
"~~~md\n| Item |\n| --- |\n| A |\n~~~",
"````md\n```\n| Item |\n| --- |\n```\n````",
" Notes\n | Item |\n | --- |\n | A |",
"Notes\n| Item | Detail |\n| --- |\n| A |",
])("leaves separated tables and table-like text unchanged: %s", (source) => {
expect(prepareMarkdownSource(source).render()).toEqual(source);
});
it.each([
"src/components/message.tsx",
"C:\\repo\\file.ts",
+24 -5
View File
@@ -138,6 +138,10 @@ export function prepareMarkdownSource(
!source.includes("![") &&
!source.includes("||") &&
!source.includes("&") &&
// Match content, header, and delimiter lines; quote-only lines count as blank.
!/^[\t >]*[^\t >\r\n][^\r\n]*(?:\r\n|\r|\n)[^\r\n]+(?:\r\n|\r|\n)(?=[\t >:|-]*\|)[\t >|]*:?-[-\t :|]*$/m.test(
source,
) &&
!(workspaceRoot && source.includes("]("))
) {
return { images, ...metadata, render: () => source };
@@ -212,11 +216,25 @@ export function prepareMarkdownSource(
if (lineEnd < end) split(lineEnd);
}
}
function visit(node: Nodes) {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (start !== undefined && end !== undefined) {
function visit(node: Nodes, previous?: Nodes) {
const position = node.position;
if (position?.start.offset !== undefined && position.end.offset !== undefined) {
const start = position.start.offset;
const end = position.end.offset;
if (node.type === "paragraph") splitStandaloneImages(node, start, end);
if (
node.type === "table" &&
previous?.type === "paragraph" &&
previous.position?.end.line === position.start.line - 1
) {
// MD4C only recognizes a table header on the first line of a paragraph.
const lineStart =
Math.max(source.lastIndexOf("\n", start - 1), source.lastIndexOf("\r", start - 1)) + 1;
const newline = source.slice(lineStart - 2, lineStart).replace(/[^\r\n]/g, "");
const blank = source.slice(lineStart, start).replace(/[^>]/g, " ").trimEnd();
const boundary = lineStart - newline.length;
edits.push({ start: boundary, end: lineStart, text: `${newline}${blank}${newline}` });
}
if (node.type === "link") {
const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot);
if (title !== null)
@@ -244,7 +262,8 @@ export function prepareMarkdownSource(
if (text !== source.slice(start, end)) edits.push({ start, end, text });
}
}
if ("children" in node) node.children.forEach(visit);
if ("children" in node)
node.children.forEach((child, index) => visit(child, node.children[index - 1]));
}
visit(getTree());
return {
+7 -48
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useAddHost } from "@/hosts/add-host";
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, ScrollView } from "react-native";
import { useRouter } from "expo-router";
@@ -12,11 +13,7 @@ import {
Terminal,
} from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { AddHostModal } from "./add-host-modal";
import { AddRemoteSshHostModal } from "./add-remote-ssh-host-modal";
import { PairLinkModal } from "./pair-link-modal";
import { Button } from "@/components/ui/button";
import { resolveAppVersion } from "@/utils/app-version";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
@@ -162,20 +159,13 @@ function useAnyHostOnline(serverIds: string[]): string | null {
);
}
export interface WelcomeScreenProps {
onHostAdded?: (profile: HostProfile) => void;
}
export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
export function WelcomeScreen() {
const { theme } = useUnistyles();
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const router = useRouter();
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);
const [isDirectOpen, setIsDirectOpen] = useState(false);
const [isRemoteSshOpen, setIsRemoteSshOpen] = useState(false);
const [isPasteLinkOpen, setIsPasteLinkOpen] = useState(false);
const hosts = useHosts();
const anyOnlineServerId = useAnyHostOnline(hosts.map((h) => h.serverId));
@@ -196,23 +186,10 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
router.push("/settings");
}, [router]);
const handleOpenDirect = useCallback(() => setIsDirectOpen(true), []);
const handleCloseDirect = useCallback(() => setIsDirectOpen(false), []);
const handleOpenRemoteSsh = useCallback(() => setIsRemoteSshOpen(true), []);
const handleCloseRemoteSsh = useCallback(() => setIsRemoteSshOpen(false), []);
const handleOpenPasteLink = useCallback(() => setIsPasteLinkOpen(true), []);
const handleClosePasteLink = useCallback(() => setIsPasteLinkOpen(false), []);
const handleScanQr = useCallback(() => {
router.push("/pair-scan?source=onboarding");
}, [router]);
const handleHostSaved = useCallback(
({ profile }: { profile: HostProfile; serverId: string }) => {
onHostAdded?.(profile);
finishOnboarding();
},
[onHostAdded, finishOnboarding],
);
const handleOpenDirect = useAddHost({ method: "direct", onAdded: finishOnboarding });
const handleOpenRemoteSsh = useAddHost({ method: "ssh", onAdded: finishOnboarding });
const handleOpenPasteLink = useAddHost({ method: "link", onAdded: finishOnboarding });
const handleScanQr = useAddHost({ method: "scan", onAdded: finishOnboarding });
const actions: WelcomeAction[] =
isWeb || isFdroidBuild
@@ -316,24 +293,6 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
</Button>
</View>
<Text style={styles.versionLabel}>{appVersionText}</Text>
<AddHostModal
visible={isDirectOpen}
onClose={handleCloseDirect}
onSaved={handleHostSaved}
/>
<AddRemoteSshHostModal
visible={isRemoteSshOpen}
onClose={handleCloseRemoteSsh}
onSaved={handleHostSaved}
/>
<PairLinkModal
visible={isPasteLinkOpen}
onClose={handleClosePasteLink}
onSaved={handleHostSaved}
/>
</ScrollView>
</View>
);
+89
View File
@@ -0,0 +1,89 @@
import { createContext, useCallback, useContext, useState, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useStore } from "zustand";
import { AddHostMethodModal } from "./internal/add-host-method-modal";
import { AddHostModal } from "./internal/add-host-modal";
import { AddRemoteSshHostModal } from "./internal/add-remote-ssh-host-modal";
import { PairLinkModal } from "./internal/pair-link-modal";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { PairScanContent } from "./internal/scan";
import { createAddHostFlow, type AddHostOptions, type AddHostSession } from "./internal/model";
const Context = createContext<ReturnType<typeof createAddHostFlow> | null>(null);
/** Owns the flow outside screen and appearance lifetimes. Callers own success navigation. */
export function AddHostProvider({ children }: { children: ReactNode }) {
const [flow] = useState(createAddHostFlow);
return <Context.Provider value={flow}>{children}</Context.Provider>;
}
export function AddHostFlowHost() {
const flow = useContext(Context);
if (!flow) throw new Error("AddHostFlowHost requires AddHostProvider");
const session = useStore(flow.store, (state) => state.session);
return session ? <AddHostSessionView key={session.id} session={session} /> : null;
}
export function useAddHost({ method, onAdded }: AddHostOptions = {}) {
const flow = useContext(Context);
if (!flow) throw new Error("useAddHost requires AddHostProvider");
return useCallback(() => flow.open({ method, onAdded }), [flow, method, onAdded]);
}
function AddHostSessionView({ session }: { session: AddHostSession }) {
const { t } = useTranslation();
const method = useStore(session.step, (state) => state.method);
const back = useCallback(() => session.choose(null), [session]);
const direct = useCallback(() => session.choose("direct"), [session]);
const ssh = useCallback(() => session.choose("ssh"), [session]);
const link = useCallback(() => session.choose("link"), [session]);
const scan = useCallback(() => session.choose("scan"), [session]);
const scanned = useCallback((serverId: string) => session.complete({ serverId }), [session]);
const header = useMemo(
() => ({ title: t("pairing.scan.title"), back: { onPress: back } }),
[t, back],
);
return (
<>
<AddHostMethodModal
visible={method === null}
onClose={session.close}
onDirectConnection={direct}
onRemoteSsh={ssh}
onPasteLink={link}
onScanQr={scan}
/>
<AddHostModal
visible={method === "direct"}
onClose={session.close}
onCancel={back}
onSaved={session.complete}
/>
<AddRemoteSshHostModal
visible={method === "ssh"}
onClose={session.close}
onCancel={back}
onSaved={session.complete}
/>
<PairLinkModal
visible={method === "link"}
onClose={session.close}
onCancel={back}
onSaved={session.complete}
/>
{method === "scan" ? (
<AdaptiveModalSheet
header={header}
visible
onClose={session.close}
scrollable={false}
snapPoints={SCAN_SNAP_POINTS}
desktopHeight="85%"
>
<PairScanContent onConnected={scanned} onClose={back} />
</AdaptiveModalSheet>
) : null}
</>
);
}
const SCAN_SNAP_POINTS = ["90%"];
@@ -1,9 +1,10 @@
import { PressHighlight } from "@/components/ui/press-highlight";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { QrCode, Link2, ClipboardPaste, Terminal } from "lucide-react-native";
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { isFdroidBuild } from "@/constants/build-profile";
import { isNative } from "@/constants/platform";
import { isElectronRuntime } from "@/desktop/host";
@@ -84,7 +85,7 @@ export function AddHostMethodModal({
onClose={onClose}
testID="add-host-method-modal"
>
<Pressable
<PressHighlight
style={styles.option}
onPress={handleDirect}
accessibilityRole="button"
@@ -98,10 +99,10 @@ export function AddHostMethodModal({
{t("pairing.connectionMethods.direct.description")}
</Text>
</View>
</Pressable>
</PressHighlight>
{isElectronRuntime() ? (
<Pressable
<PressHighlight
style={styles.option}
onPress={handleRemoteSsh}
accessibilityRole="button"
@@ -115,11 +116,11 @@ export function AddHostMethodModal({
{t("pairing.connectionMethods.remoteSsh.description")}
</Text>
</View>
</Pressable>
</PressHighlight>
) : null}
{isNative && !isFdroidBuild ? (
<Pressable
<PressHighlight
style={styles.option}
onPress={handleScan}
accessibilityRole="button"
@@ -132,10 +133,10 @@ export function AddHostMethodModal({
{t("pairing.connectionMethods.scanQr.description")}
</Text>
</View>
</Pressable>
</PressHighlight>
) : null}
<Pressable
<PressHighlight
style={styles.option}
onPress={handlePaste}
accessibilityRole="button"
@@ -149,7 +150,7 @@ export function AddHostMethodModal({
{t("pairing.connectionMethods.pasteLink.description")}
</Text>
</View>
</Pressable>
</PressHighlight>
</AdaptiveModalSheet>
);
}
@@ -1,17 +1,22 @@
import { PressHighlight } from "@/components/ui/press-highlight";
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Pressable, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { Alert, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
import { parseConnectionUri, serializeConnectionUri } from "@/utils/daemon-endpoints";
import { buildConnectionFailureCopy } from "./connection-failure-copy";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import { buildConnectionFailureCopy } from "@/components/connection-failure-copy";
import {
AdaptiveModalSheet,
AdaptiveTextInput,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { PairingTargetTracker } from "./pair-link-credentials";
import { buildDirectConnectionUri } from "./direct-connection-host";
import { PairingTargetTracker } from "@/components/pair-link-credentials";
import { buildDirectConnectionUri } from "@/components/direct-connection-host";
const FLEX_ONE_STYLE = { flex: 1 } as const;
@@ -188,7 +193,6 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
}
function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts();
const { probeAndUpsertDirectConnection, beginLinkPairing } = useHostMutations();
@@ -207,10 +211,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
const [inputResetKey, bumpInputResetKey] = useReducer((key: number) => key + 1, 0);
const advancedTarget = useRef(new PairingTargetTracker("", true));
const connectIcon = useMemo(
() => <Link2 size={16} color={theme.colors.accentForeground} />,
[theme.colors.accentForeground],
);
const connectIcon = useMemo(() => <ThemedLink2 size={16} />, []);
const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []);
const portFieldStyle = useMemo(() => [styles.field, styles.portField], []);
const checkboxStyle = useMemo(
@@ -415,8 +416,8 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
setIsAdvancedOpen(false);
}, [advancedUri, directConnectionLabels, host, isAdvancedOpen, password, port, useTls]);
const AdvancedIcon = isAdvancedOpen ? ChevronDown : ChevronRight;
const PasswordIcon = isPasswordVisible ? EyeOff : Eye;
const AdvancedIcon = isAdvancedOpen ? ThemedChevronDown : ThemedChevronRight;
const PasswordIcon = isPasswordVisible ? ThemedEyeOff : ThemedEye;
return (
<AdaptiveModalSheet
@@ -430,7 +431,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
<View style={styles.portRow}>
<View style={hostFieldStyle}>
<Text style={styles.label}>{t("pairing.direct.fields.host")}</Text>
<AdaptiveTextInput
<ThemedAdaptiveTextInput
testID="direct-host-input"
nativeID="direct-host-input"
accessibilityLabel={t("pairing.direct.fields.host")}
@@ -438,7 +439,6 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
resetKey={`direct-host-${inputResetKey}`}
onChangeText={setHost}
placeholder="localhost"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
@@ -449,7 +449,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
</View>
<View style={portFieldStyle}>
<Text style={styles.label}>{t("pairing.direct.fields.port")}</Text>
<AdaptiveTextInput
<ThemedAdaptiveTextInput
testID="direct-port-input"
nativeID="direct-port-input"
accessibilityLabel={t("pairing.direct.fields.port")}
@@ -457,7 +457,6 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
resetKey={`direct-port-${inputResetKey}`}
onChangeText={setPort}
placeholder="6767"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
@@ -469,7 +468,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
</View>
</View>
<Pressable
<PressHighlight
style={styles.checkboxRow}
onPress={handleToggleUseTls}
disabled={isSaving}
@@ -482,17 +481,17 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
<View style={checkboxStyle}>
{useTls ? (
<View testID="direct-ssl-toggle-checked">
<Check size={14} color={theme.colors.accentForeground} />
<ThemedCheck size={14} />
</View>
) : null}
</View>
<Text style={styles.label}>{t("pairing.direct.fields.useSsl")}</Text>
</Pressable>
</PressHighlight>
<View style={styles.field}>
<Text style={styles.label}>{t("pairing.direct.fields.password")}</Text>
<View style={styles.passwordRow}>
<AdaptiveTextInput
<ThemedAdaptiveTextInput
testID="direct-password-input"
nativeID="direct-password-input"
accessibilityLabel={t("pairing.direct.fields.password")}
@@ -500,7 +499,6 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
resetKey={`direct-password-${inputResetKey}`}
onChangeText={setPassword}
placeholder={t("pairing.direct.fields.optional")}
placeholderTextColor={theme.colors.foregroundMuted}
style={passwordInputStyle}
autoCapitalize="none"
autoCorrect={false}
@@ -509,7 +507,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
returnKeyType="done"
onSubmitEditing={handleSubmitEditing}
/>
<Pressable
<PressHighlight
style={styles.iconButton}
onPress={handleTogglePasswordVisibility}
disabled={isSaving}
@@ -521,13 +519,13 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
}
testID="direct-password-visibility-toggle"
>
<PasswordIcon size={18} color={theme.colors.foregroundMuted} />
</Pressable>
<PasswordIcon size={18} />
</PressHighlight>
</View>
</View>
<View style={styles.field}>
<Pressable
<PressHighlight
style={styles.advancedToggle}
onPress={handleToggleAdvanced}
disabled={isSaving}
@@ -537,11 +535,11 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
}
testID="direct-host-advanced-toggle"
>
<AdvancedIcon size={16} color={theme.colors.foregroundMuted} />
<AdvancedIcon size={16} />
<Text style={styles.advancedText}>{t("pairing.direct.advanced.label")}</Text>
</Pressable>
</PressHighlight>
{isAdvancedOpen ? (
<AdaptiveTextInput
<ThemedAdaptiveTextInput
testID="direct-host-uri-input"
nativeID="direct-host-uri-input"
accessibilityLabel={t("pairing.direct.fields.connectionUri")}
@@ -549,7 +547,6 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
resetKey={`direct-host-uri-${inputResetKey}`}
onChangeText={handleChangeAdvancedUri}
placeholder="tcp://localhost:6767?ssl=true"
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoCapitalize="none"
autoCorrect={false}
@@ -585,3 +582,23 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
</AdaptiveModalSheet>
);
}
const ThemedLink2 = withUnistyles(Link2, (theme) => ({ color: theme.colors.accentForeground }));
const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.accentForeground }));
const ThemedChevronDown = withUnistyles(ChevronDown, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({
color: theme.colors.foregroundMuted,
}));
const ThemedEye = withUnistyles(Eye, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedEyeOff = withUnistyles(EyeOff, (theme) => ({ color: theme.colors.foregroundMuted }));
const ThemedAdaptiveTextInput = withUnistyles(AdaptiveTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
@@ -1,7 +1,7 @@
import { PressHighlight } from "@/components/ui/press-highlight";
import { useCallback, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Pressable,
Text,
View,
type PressableStateCallbackType,
@@ -23,7 +23,7 @@ import {
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { useIsCompactFormFactor } from "@/constants/layout";
import { DaemonConnectionTestError } from "@/utils/test-daemon-connection";
import { AdaptiveModalSheet, type SheetHeader } from "./adaptive-modal-sheet";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
const FLEX_ONE_STYLE = { flex: 1 } as const;
const ThemedTerminal = withUnistyles(Terminal);
@@ -174,11 +174,11 @@ export function AddRemoteSshHostModal({
}
clear();
onClose();
onSaved?.({
...result,
isNewHost: !hosts.some((profile) => profile.serverId === result.serverId),
});
onClose();
}, [clear, hosts, isSaving, onClose, onSaved, probeAndUpsertRemoteSshConnection, t]);
const handleTargetChange = useCallback((value: string) => {
targetRef.current = value;
@@ -260,7 +260,7 @@ export function AddRemoteSshHostModal({
onSubmitEditing={handleSubmit}
/>
</View>
<Pressable
<PressHighlight
style={iconButtonStyle}
onPress={handleTogglePasswordVisibility}
disabled={isSaving}
@@ -273,7 +273,7 @@ export function AddRemoteSshHostModal({
testID="remote-ssh-password-visibility-toggle"
>
{isPasswordVisible ? <ThemedEyeOff size={18} /> : <ThemedEye size={18} />}
</Pressable>
</PressHighlight>
</View>
</Field>
<View style={styles.actions}>
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { createAddHostFlow } from "./model";
describe("add host flow", () => {
it("closes before notifying the consumer and ignores duplicate completion", () => {
const flow = createAddHostFlow();
const added: string[] = [];
flow.open({
onAdded: (serverId) => {
expect(flow.store.getState().session).toBeNull();
added.push(serverId);
},
});
const session = flow.store.getState().session!;
session.choose("direct");
session.complete({ serverId: "new-host" });
session.complete({ serverId: "new-host" });
expect(added).toEqual(["new-host"]);
});
it("cancels without a success action and accepts a fresh request", () => {
const flow = createAddHostFlow();
const added: string[] = [];
flow.open({ onAdded: (id) => added.push(id) });
const cancelled = flow.store.getState().session!;
cancelled.choose("scan");
cancelled.close();
expect(flow.store.getState().session).toBeNull();
flow.open({ method: "link" });
const current = flow.store.getState().session!;
cancelled.complete({ serverId: "late-result" });
cancelled.close();
expect(flow.store.getState().session).toBe(current);
expect(current.step.getState()).toEqual({ method: "link" });
expect(added).toEqual([]);
current.complete({ serverId: "new-host" });
expect(flow.store.getState().session).toBeNull();
});
it("returns to method selection within the same request", () => {
const flow = createAddHostFlow();
flow.open();
const session = flow.store.getState().session!;
session.choose("ssh");
session.choose(null);
expect(flow.store.getState().session).toBe(session);
expect(session.step.getState()).toEqual({ method: null });
});
});
@@ -0,0 +1,47 @@
import { createStore, type StoreApi } from "zustand/vanilla";
export type AddHostMethod = "direct" | "ssh" | "link" | "scan";
export interface AddHostOptions {
method?: AddHostMethod;
onAdded?: (serverId: string) => void;
}
export interface AddHostSession {
id: number;
step: StoreApi<{ method: AddHostMethod | null }>;
choose(method: AddHostMethod | null): void;
close(): void;
complete(result: { serverId: string }): void;
}
export function createAddHostFlow() {
const store = createStore<{ session: AddHostSession | null }>(() => ({ session: null }));
let nextId = 0;
function createSession(options: AddHostOptions) {
const step = createStore<{ method: AddHostMethod | null }>(() => ({
method: options.method ?? null,
}));
const session = {
id: ++nextId,
step,
choose(method: AddHostMethod | null) {
step.setState({ method });
},
close() {
if (store.getState().session === session) store.setState({ session: null });
},
complete({ serverId }: { serverId: string }) {
if (store.getState().session !== session) return;
session.close();
options.onAdded?.(serverId);
},
};
return session;
}
return {
store,
open(options: AddHostOptions = {}) {
store.setState({ session: createSession(options) });
},
};
}
@@ -1,16 +1,20 @@
import { useCallback, useMemo, useReducer, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Link } from "lucide-react-native";
import type { HostProfile } from "@/types/host-connection";
import { useHosts, useHostMutations, type PasswordRequiredPairing } from "@/runtime/host-runtime";
import { parseRelayConnectionUri } from "@/utils/daemon-endpoints";
import { parseConnectionOfferFromUrl } from "@getpaseo/protocol/connection-offer";
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
import {
AdaptiveModalSheet,
AdaptiveTextInput,
type SheetHeader,
} from "@/components/adaptive-modal-sheet";
import { getConnectionAuthFailureReason } from "@/utils/test-daemon-connection";
import { PairingTargetTracker } from "./pair-link-credentials";
import { PairingTargetTracker } from "@/components/pair-link-credentials";
import { Button } from "@/components/ui/button";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
@@ -100,7 +104,6 @@ function PairLinkModalContent({
onCancel,
onSaved,
}: PairLinkModalProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const daemons = useHosts();
const { beginLinkPairing } = useHostMutations();
@@ -126,10 +129,7 @@ function PairLinkModalContent({
resetPasswordInput();
}, []);
const pairIcon = useMemo(
() => <Link size={16} color={theme.colors.accentForeground} />,
[theme.colors.accentForeground],
);
const pairIcon = useMemo(() => <ThemedLink size={16} />, []);
const handleClose = useCallback(() => {
if (isSaving) return;
@@ -212,7 +212,7 @@ function PairLinkModalContent({
<View style={styles.field}>
<Text style={styles.label}>{t("pairing.link.label")}</Text>
<AdaptiveTextInput
<ThemedAdaptiveTextInput
ref={inputRef}
initialValue={initialUrl}
testID="pair-link-input"
@@ -220,7 +220,6 @@ function PairLinkModalContent({
accessibilityLabel={t("pairing.link.label")}
onChangeText={handleChangeOfferUrl}
placeholder="https://app.paseo.sh/#offer=..."
placeholderTextColor={theme.colors.foregroundMuted}
style={styles.input}
autoFocus
autoCapitalize="none"
@@ -235,7 +234,7 @@ function PairLinkModalContent({
<Text style={styles.label}>
{t("pairing.hostPassword.title", { host: parsedHostLabel(offerUrlRef.current) })}
</Text>
<AdaptiveTextInput
<ThemedAdaptiveTextInput
testID="pair-link-password-input"
resetKey={`pair-link-password-${passwordResetKey}`}
accessibilityLabel={t("pairing.hostPassword.label")}
@@ -274,3 +273,9 @@ function PairLinkModalContent({
</AdaptiveModalSheet>
);
}
const ThemedLink = withUnistyles(Link, (theme) => ({ color: theme.colors.accentForeground }));
const ThemedAdaptiveTextInput = withUnistyles(AdaptiveTextInput, (theme) => ({
placeholderTextColor: theme.colors.foregroundMuted,
}));
@@ -1,17 +1,14 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet } from "react-native-unistyles";
import { CameraView, useCameraPermissions } from "expo-camera";
import type { BarcodeScanningResult, BarcodeSettings } from "expo-camera";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { openPairScan } from "@/hosts/pair-scan-model";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { openPairScan } from "./pair-scan-model";
import { isWeb } from "@/constants/platform";
import { BackHeader } from "@/components/headers/back-header";
import { PairLinkModal } from "@/components/pair-link-modal";
import { PairLinkModal } from "./pair-link-modal";
import { Button } from "@/components/ui/button";
const styles = StyleSheet.create((theme) => ({
@@ -113,50 +110,26 @@ const styles = StyleSheet.create((theme) => ({
},
}));
export default function PairScanScreen() {
const { theme } = useUnistyles();
export function PairScanContent({
onConnected,
onClose,
}: {
onConnected: (serverId: string) => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const router = useRouter();
const params = useLocalSearchParams<{
source?: string;
}>();
const source = typeof params.source === "string" ? params.source : "settings";
const [permission, requestPermission] = useCameraPermissions();
const navigateToPairedHost = useCallback(
(serverId: string) => {
if (source === "onboarding") {
router.replace(buildHostRootRoute(serverId));
return;
}
router.replace(buildSettingsHostRoute(serverId));
},
[router, source],
);
const [scan] = useState(() =>
openPairScan({
importConnectionLink: (link) =>
getHostRuntimeStore().importConnectionLink(
link,
source === "onboarding" ? "hostRoot" : "hostSettings",
),
onConnected: navigateToPairedHost,
importConnectionLink: (link) => getHostRuntimeStore().importConnectionLink(link, "hostRoot"),
onConnected,
}),
);
const scanState = useSyncExternalStore(scan.subscribe, scan.getState, scan.getState);
useEffect(() => () => scan.close(), [scan]);
const closeToSource = useCallback(() => {
try {
router.back();
} catch {
router.replace("/" as Href);
}
}, [router]);
useEffect(() => {
if (isWeb) return;
if (permission && permission.granted) return;
@@ -169,35 +142,29 @@ export default function PairScanScreen() {
[scan],
);
const handleRouterBack = useCallback(() => router.back(), [router]);
const savePasswordPairing = useCallback(
({ serverId }: { serverId: string }) => navigateToPairedHost(serverId),
[navigateToPairedHost],
({ serverId }: { serverId: string }) => onConnected(serverId),
[onConnected],
);
const handleRequestPermission = useCallback(() => {
void requestPermission();
}, [requestPermission]);
const bodyStyle = useMemo(
() => [styles.body, { paddingBottom: insets.bottom + theme.spacing[6] }],
[insets.bottom, theme.spacing],
);
const helperTextStyle = useMemo(
() => [styles.helperText, { color: theme.colors.foreground }],
[theme.colors.foreground],
() => [styles.body, { paddingBottom: insets.bottom + 24 }],
[insets.bottom],
);
if (isWeb) {
return (
<View style={styles.container}>
<BackHeader title={t("pairing.scan.title")} onBack={handleRouterBack} />
<View style={bodyStyle}>
<View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>{t("pairing.scan.webUnavailableTitle")}</Text>
<Text style={styles.permissionBody}>{t("pairing.scan.webUnavailableBody")}</Text>
<Pressable style={styles.permissionButton} onPress={closeToSource}>
<Text style={styles.permissionButtonText}>{t("pairing.scan.backToSettings")}</Text>
</Pressable>
<Button style={styles.permissionButton} onPress={onClose}>
<Text style={styles.permissionButtonText}>{t("common.actions.back")}</Text>
</Button>
</View>
</View>
</View>
@@ -208,16 +175,14 @@ export default function PairScanScreen() {
return (
<View style={styles.container}>
<BackHeader title={t("pairing.scan.title")} onBack={closeToSource} />
<View style={bodyStyle}>
{!granted ? (
<View style={styles.permissionCard}>
<Text style={styles.permissionTitle}>{t("pairing.scan.cameraPermissionTitle")}</Text>
<Text style={styles.permissionBody}>{t("pairing.scan.cameraPermissionBody")}</Text>
<Pressable style={styles.permissionButton} onPress={handleRequestPermission}>
<Button style={styles.permissionButton} onPress={handleRequestPermission}>
<Text style={styles.permissionButtonText}>{t("pairing.scan.grantPermission")}</Text>
</Pressable>
</Button>
</View>
) : (
<View style={styles.cameraWrap}>
@@ -235,10 +200,10 @@ export default function PairScanScreen() {
<View style={[styles.corner, styles.cornerBR]} />
</View>
{scanState.status === "pairing" ? (
<Text style={helperTextStyle}>{t("pairing.scan.pairing")}</Text>
<Text style={styles.helperText}>{t("pairing.scan.pairing")}</Text>
) : null}
{scanState.status === "stopped" && scanState.error ? (
<Text style={helperTextStyle}>{scanState.error}</Text>
<Text style={styles.helperText}>{scanState.error}</Text>
) : null}
{scanState.status === "stopped" ? (
<Button
+4 -11
View File
@@ -1,12 +1,11 @@
import { useAddHost } from "@/hosts/add-host";
import { Modal } from "@/components/ui/modal";
import { usePanelStore } from "@/stores/panel-store";
import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
import { router } from "expo-router";
import { Server } from "lucide-react-native";
import { create } from "zustand";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
@@ -20,7 +19,6 @@ import {
} from "@/lib/overlay-root";
import { useHosts } from "@/runtime/host-runtime";
import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection";
import { buildSettingsAddHostRoute } from "@/utils/host-routes";
type HostFilter = (host: HostProfile) => boolean;
type HostChoiceHandler = (serverId: string) => void | Promise<void>;
@@ -65,6 +63,7 @@ function matchesHostQuery(host: HostProfile, query: string): boolean {
}
export function useHostChooser() {
const addHost = useAddHost();
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const open = useHostChooserStore((state) => state.open);
@@ -76,13 +75,7 @@ export function useHostChooser() {
);
if (availableHosts.length === 0) {
(
input.onNoHosts ??
(() => {
usePanelStore.getState().showMobileAgent();
router.push(buildSettingsAddHostRoute(Date.now()));
})
)();
(input.onNoHosts ?? addHost)();
return false;
}
@@ -98,7 +91,7 @@ export function useHostChooser() {
});
return true;
},
[hosts, localServerId, open],
[hosts, localServerId, open, addHost],
);
}
@@ -14,6 +14,45 @@ function thought(text: string, status: "loading" | "ready" = "loading"): StreamI
}
describe("plugin timeline projection", () => {
it.each(["message", "finished", "errored", "permission-required", "closed"] as const)(
"preserves immutable agent-message provenance for %s cards",
(event) => {
const agentMessage = {
event,
sender: { id: "remote::reviewer", title: "QA question-timing #7014/#7000/#6809" },
text: "The original message, including **Markdown**.",
};
const source: StreamItem = {
kind: "tool_call",
id: "message-1",
timestamp: new Date("2026-01-01T00:00:00.000Z"),
payload: {
source: "agent",
data: {
provider: "codex",
callId: "delivery-1",
name: "agent_message",
status: "completed",
error: null,
detail: { type: "plain_text", text: agentMessage.text, icon: "bot" },
agentMessage,
},
},
};
const inspect: TimelineItemTransform = ({ item }) => {
expect(item.type).toBe("tool_call");
if (item.type !== "tool_call") throw new Error("Expected a tool call");
expect(item.agentMessage).toEqual(agentMessage);
expect(item.agentMessage).not.toBe(agentMessage);
expect(Object.isFrozen(item.agentMessage)).toBe(true);
expect(Object.isFrozen(item.agentMessage?.sender)).toBe(true);
return undefined;
};
expect(projectPluginTimelineItems([source], inspect)).toEqual([source]);
expect(Object.isFrozen(agentMessage.sender)).toBe(false);
},
);
it("does not expose mutable tool detail from stream state", () => {
const detail = { type: "read" as const, filePath: "/repo/original.ts" };
const source: StreamItem = {
@@ -22,13 +22,14 @@ function cloneAndFreeze<T>(value: T): T {
}
function sourceToolCallTimelineItem(data: AgentToolCallData): ToolCallTimelineItem {
const { callId, name, status, error, detail, metadata } = data;
const { callId, name, status, error, detail, metadata, agentMessage } = data;
const base = {
type: "tool_call" as const,
callId,
name,
detail,
...(metadata ? { metadata } : {}),
...(agentMessage ? { agentMessage } : {}),
};
switch (status) {
case "running":
@@ -1,3 +1,4 @@
import { useAddHost } from "@/hosts/add-host";
import type {
CreateAgentRequestOptions,
CreateWorkspaceRequestOptions,
@@ -8,7 +9,6 @@ import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { router } from "expo-router";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { createNameId } from "mnemonic-id";
@@ -62,7 +62,6 @@ import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import { toErrorMessage } from "@/utils/error-messages";
import { buildSettingsAddHostRoute } from "@/utils/host-routes";
import {
getHostProjectId,
getWorktreeSupportForHostProject,
@@ -1166,9 +1165,7 @@ export function NewWorkspaceScreen({
openAddProjectPicker(selectedServerId);
}, [openAddProjectPicker, selectedServerId]);
const handleAddHost = useCallback(() => {
router.push(buildSettingsAddHostRoute(Date.now()));
}, []);
const handleAddHost = useAddHost();
const openPicker = useCallback(() => {
setPickerOpen(true);
+8 -97
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAddHost } from "@/hosts/add-host";
import { useCallback, useMemo, useRef, useState } from "react";
import type { ComponentType, ReactNode } from "react";
import {
Alert,
@@ -74,10 +75,6 @@ import { WindowChromeRegion, WindowChromeSafeArea } from "@/utils/desktop-window
import { confirmDialog } from "@/utils/confirm-dialog";
import { BackHeader } from "@/components/headers/back-header";
import { PageLayout } from "@/components/page-layout";
import { AddHostMethodModal } from "@/components/add-host-method-modal";
import { AddHostModal } from "@/components/add-host-modal";
import { AddRemoteSshHostModal } from "@/components/add-remote-ssh-host-modal";
import { PairLinkModal } from "@/components/pair-link-modal";
import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-section";
import { EditorSection } from "@/screens/settings/editor-section";
import { FilesSection } from "@/screens/settings/files-section";
@@ -1089,21 +1086,15 @@ function SettingsSidebar({
export interface SettingsScreenProps {
view: SettingsView;
openAddHostIntent?: string | null;
}
export default function SettingsScreen({ view, openAddHostIntent = null }: SettingsScreenProps) {
export default function SettingsScreen({ view }: SettingsScreenProps) {
const router = useRouter();
const { t } = useTranslation();
const voiceAudioEngine = useVoiceAudioEngineOptional();
const { settings, isLoading: settingsLoading, updateSettings } = useAppSettings();
const [isAddHostMethodVisible, setIsAddHostMethodVisible] = useState(false);
const [isDirectHostVisible, setIsDirectHostVisible] = useState(false);
const [isRemoteSshVisible, setIsRemoteSshVisible] = useState(false);
const [isPasteLinkVisible, setIsPasteLinkVisible] = useState(false);
const [isPlaybackTestRunning, setIsPlaybackTestRunning] = useState(false);
const [playbackTestResult, setPlaybackTestResult] = useState<string | null>(null);
const lastOpenedAddHostIntentRef = useRef<string | null>(null);
const isDesktopApp = isElectronRuntime();
const appVersion = resolveAppVersion();
const appVersionText = formatVersionWithPrefix(appVersion);
@@ -1164,50 +1155,10 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
}
}, [isPlaybackTestRunning, t, voiceAudioEngine]);
const closeAddConnectionFlow = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsDirectHostVisible(false);
setIsRemoteSshVisible(false);
setIsPasteLinkVisible(false);
}, []);
const goBackToAddConnectionMethods = useCallback(() => {
setIsDirectHostVisible(false);
setIsRemoteSshVisible(false);
setIsPasteLinkVisible(false);
setIsAddHostMethodVisible(true);
}, []);
const handleAddHost = useCallback(() => {
setIsAddHostMethodVisible(true);
}, []);
useEffect(() => {
if (!openAddHostIntent || lastOpenedAddHostIntentRef.current === openAddHostIntent) {
return;
}
lastOpenedAddHostIntentRef.current = openAddHostIntent;
handleAddHost();
}, [handleAddHost, openAddHostIntent]);
const handleSelectDirectConnection = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsDirectHostVisible(true);
}, []);
const handleSelectRemoteSsh = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsRemoteSshVisible(true);
}, []);
const handleSelectPasteLink = useCallback(() => {
setIsAddHostMethodVisible(false);
setIsPasteLinkVisible(true);
}, []);
const handleHostAdded = useCallback(
({ serverId }: { serverId: string }) => {
const target = buildSettingsHostSectionRoute(serverId, "connections");
(serverId: string) => {
selectSettingsHost(serverId);
const target = buildSettingsHostSectionRoute(serverId, "host");
if (isCompactLayout) {
router.push(target);
} else {
@@ -1217,6 +1168,8 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
[isCompactLayout, router],
);
const handleAddHost = useAddHost({ onAdded: handleHostAdded });
const handleSelectSection = useCallback(
(section: SettingsSectionSlug) => {
const target = buildSettingsSectionRoute(section);
@@ -1272,14 +1225,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
[activeHostServerId, handleAddHost, isCompactLayout, router],
);
const handleScanQr = useCallback(() => {
closeAddConnectionFlow();
router.push({
pathname: "/pair-scan",
params: { source: "settings" },
});
}, [closeAddConnectionFlow, router]);
const handleHostRemoved = useCallback(() => {
const fallback = buildSettingsSectionRoute("general");
if (isCompactLayout) {
@@ -1388,37 +1333,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
);
}
const addHostModals = (
<>
<AddHostMethodModal
visible={isAddHostMethodVisible}
onClose={closeAddConnectionFlow}
onDirectConnection={handleSelectDirectConnection}
onRemoteSsh={handleSelectRemoteSsh}
onPasteLink={handleSelectPasteLink}
onScanQr={handleScanQr}
/>
<AddHostModal
visible={isDirectHostVisible}
onClose={closeAddConnectionFlow}
onCancel={goBackToAddConnectionMethods}
onSaved={handleHostAdded}
/>
<AddRemoteSshHostModal
visible={isRemoteSshVisible}
onClose={closeAddConnectionFlow}
onCancel={goBackToAddConnectionMethods}
onSaved={handleHostAdded}
/>
<PairLinkModal
visible={isPasteLinkVisible}
onClose={closeAddConnectionFlow}
onCancel={goBackToAddConnectionMethods}
onSaved={handleHostAdded}
/>
</>
);
// Mobile root: full-screen sidebar-as-list.
if (isCompactLayout && view.kind === "root") {
return (
@@ -1436,7 +1350,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
layout="mobile"
/>
</ScrollView>
{addHostModals}
</View>
);
}
@@ -1447,7 +1360,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
<PageLayout title={detailTitle} onBack={handleBackFromDetail}>
{content}
</PageLayout>
{addHostModals}
</View>
);
}
@@ -1482,7 +1394,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
</View>
</WindowChromeRegion>
</View>
{addHostModals}
</View>
);
}
@@ -717,11 +717,13 @@ describe("terminal emulator runtime in a real browser", () => {
{ name: "DA1-zero", bytes: "\x1b[0c" },
{ name: "DA2", bytes: "\x1b[>c" },
{ name: "DA3", bytes: "\x1b[=c" },
{ name: "XTVERSION", bytes: "\x1b[>q" },
{ name: "DSR-5", bytes: "\x1b[5n" },
{ name: "DSR-6", bytes: "\x1b[6n" },
{ name: "DSR-?6", bytes: "\x1b[?6n" },
{ name: "DECRQM", bytes: "\x1b[1$p" },
{ name: "DECRQM-?", bytes: "\x1b[?1$p" },
{ name: "DECRQSS", bytes: "\x1bP$qm\x1b\\" },
{ name: "OSC-10-foreground-color", bytes: "\x1b]10;?\x07" },
{ name: "OSC-11-background-color", bytes: "\x1b]11;?\x07" },
{ name: "OSC-12-cursor-color", bytes: "\x1b]12;?\x07" },
@@ -647,6 +647,7 @@ export class TerminalEmulatorRuntime {
terminal.parser.registerCsiHandler({ final: "c" }, () => true);
terminal.parser.registerCsiHandler({ prefix: ">", final: "c" }, () => true);
terminal.parser.registerCsiHandler({ prefix: "=", final: "c" }, () => true);
terminal.parser.registerCsiHandler({ prefix: ">", final: "q" }, () => true);
terminal.parser.registerCsiHandler({ final: "n" }, () => true);
terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true);
terminal.parser.registerCsiHandler({ final: "R" }, () => true);
@@ -655,6 +656,7 @@ export class TerminalEmulatorRuntime {
{ prefix: "?", intermediates: "$", final: "p" },
() => true,
);
terminal.parser.registerDcsHandler({ intermediates: "$", final: "q" }, () => true);
for (const code of [10, 11, 12]) {
terminal.parser.registerOscHandler(code, (data) => data.trim() === "?");
}
@@ -8,7 +8,6 @@ import {
buildOpenProjectRoute,
resolveKnownHostRoute,
buildSessionsRoute,
buildSettingsAddHostRoute,
buildProjectSettingsRoute,
buildProjectsSettingsRoute,
decodeFilePathFromPathSegment,
@@ -185,14 +184,6 @@ describe("workspace route parsing", () => {
});
describe("projects settings routes", () => {
it("buildSettingsAddHostRoute opens settings with the add-host flag", () => {
expect(buildSettingsAddHostRoute()).toBe("/settings/general?addHost=1");
});
it("buildSettingsAddHostRoute accepts a repeatable intent id", () => {
expect(buildSettingsAddHostRoute("retry 1")).toBe("/settings/general?addHost=retry%201");
});
it("buildProjectsSettingsRoute scopes the list to a host", () => {
expect(buildProjectsSettingsRoute("host a")).toBe("/settings/hosts/host%20a/projects");
});
-4
View File
@@ -553,10 +553,6 @@ export function buildSettingsSectionRoute(section: SettingsSectionSlug) {
return `/settings/${section}` as const;
}
export function buildSettingsAddHostRoute(intentId: string | number = "1") {
return `/settings/general?addHost=${encodeURIComponent(String(intentId))}` as const;
}
export function buildSettingsHostRoute(serverId: string) {
const normalized = trimNonEmpty(serverId);
if (!normalized) {
+4 -3
View File
@@ -204,15 +204,16 @@ export function createHostTransportFactory(
}
export function createHostClient(
connection: string,
config: Omit<DaemonClientConfig, "url" | "transportFactory"> & HostTransportOptions,
config: Omit<DaemonClientConfig, "url"> & HostTransportOptions,
budget?: HostTransportBudget,
): DaemonClient {
const target = parseHostTarget(connection);
const { webSocketAgent, ...clientConfig } = config;
const { webSocketAgent, transportFactory, ...clientConfig } = config;
return new DaemonClient({
...clientConfig,
url: "ws://host-connector/ws",
...(target.password ? { password: target.password } : {}),
transportFactory: createHostTransportFactory(connection, budget, { webSocketAgent }),
transportFactory:
transportFactory ?? createHostTransportFactory(connection, budget, { webSocketAgent }),
});
}
+16
View File
@@ -12,6 +12,7 @@ import { fileURLToPath } from "node:url";
import { experimental_createMCPClient } from "ai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { chromium } from "playwright";
import { expect } from "@playwright/test";
import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs";
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
import { runAdaptiveThemeRegression } from "./appearance-theme.electron.mjs";
@@ -198,6 +199,20 @@ async function waitForDesktopStatus(page) {
);
}
async function settleDesktopStartup(page) {
await expect(
page.getByRole("button", { name: "Desktop browser workspace 1", exact: true }),
).toBeVisible({ timeout: timeoutMs });
// The dev launcher supplies its package directory as an open-project request.
// Before the local host connects, that request opens the connection prompt.
// Finish startup before exercising the unrelated Settings/browser workflows.
const connectionPrompt = page.getByTestId("add-host-method-modal");
if (await connectionPrompt.isVisible()) {
await connectionPrompt.getByRole("button", { name: "Close", exact: true }).click();
await expect(connectionPrompt).toBeHidden();
}
}
async function startTargetPage() {
const server = createServer((_request, response) => {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
@@ -1208,6 +1223,7 @@ async function main() {
const page = await waitForAppPage(browser, expoPort);
await hideDeveloperToasts(page);
const status = await waitForDesktopStatus(page);
await settleDesktopStartup(page);
const checkPluginLinks = () =>
runPluginLinksRegression({
+1 -1
View File
@@ -41,7 +41,7 @@
"@types/node": "24.6.0",
"@types/ws": "^8.5.14",
"electron": "44.2.0",
"electron-builder": "26.8.1",
"electron-builder": "26.18.0",
"typescript": "5.9.3",
"unzip-crx-3": "^0.2.0",
"vitest": "^4.1.6",
+1 -1
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/create-agent-mode.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -78,7 +78,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
availableModes: OPENCODE_MODES,
}),
).toThrow(
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. Available modes for 'opencode': build, plan",
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode in settings.modeId. Available modes for 'opencode': build, plan",
);
});
@@ -92,7 +92,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
availableModes: CODEX_MODES,
}),
).toThrow(
"cannot inherit mode '<none>' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access",
"cannot inherit mode '<none>' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access",
);
});
@@ -180,7 +180,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
targetUnattendedMode: "full-access",
}),
).toThrow(
"cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access",
"cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access",
);
});
@@ -195,7 +195,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
targetUnattendedMode: undefined,
}),
).toThrow(
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode. Available modes for 'zai-custom': unknown",
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode in settings.modeId. Available modes for 'zai-custom': unknown",
);
});
@@ -77,7 +77,7 @@ export function resolveAndValidateCreateAgentMode(
}
throw new Error(
`cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
`cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode in settings.modeId. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
);
}
@@ -120,6 +120,7 @@ import {
} from "../provider-launch-config.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
import { resolveResumeProcessCwd } from "./resume-process-cwd.js";
import {
buildStringCommandShellInvocation,
createStringCommandShellEnvOverlay,
@@ -165,18 +166,6 @@ function rejectOnProcessFailure(child: ChildProcess, stderrChunks: string[]): Pr
});
}
async function isDirectory(target: string): Promise<boolean> {
try {
return (await fs.stat(target)).isDirectory();
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT" || code === "ENOTDIR") {
return false;
}
throw error;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
@@ -2883,11 +2872,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
// Loading an archived agent's history runs nothing in its working directory, which
// may have been removed with its worktree. session/load still names the original
// directory; only the process starts from the home directory.
private async resolveProcessCwd(): Promise<string> {
if (this.resumePurpose !== "history" || (await isDirectory(this.config.cwd))) {
return this.config.cwd;
}
return homedir();
private resolveProcessCwd(): Promise<string> {
return resolveResumeProcessCwd(this.config.cwd, this.resumePurpose);
}
private async spawnProcess(): Promise<SpawnedACPProcess> {
@@ -0,0 +1,190 @@
import { expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
import { ClaudeAgentClient } from "./agent.js";
import type { ClaudeQueryFactory } from "./query.js";
const LIMIT = "You've hit your session limit · resets 5:20pm (Asia/Singapore)";
interface ScriptedTurn {
// An API error reply as Claude Code writes it: a synthetic assistant message with `error`.
errorReply?: string;
// Assistant text the model wrote before or after it.
before?: string;
after?: string;
isError: boolean;
usage?: {
inputTokens: number;
cachedInputTokens: number;
outputTokens: number;
totalCostUsd: number;
};
}
// A query dependency that speaks Claude Code's stream interface, one scripted turn per prompt.
function scriptedQuery(turns: ScriptedTurn[]): ClaudeQueryFactory {
return ({ prompt }) => {
let index = 0;
const assistant = (id: string, text: string, error?: string) => ({
type: "assistant",
session_id: "error-session",
parent_tool_use_id: null,
uuid: id,
message: {
id,
role: "assistant",
model: error ? "<synthetic>" : "claude-opus-5-5",
content: [{ type: "text", text }],
stop_reason: "stop_sequence",
usage: { input_tokens: 0, output_tokens: 0 },
},
...(error ? { error } : {}),
});
const messages = (async function* () {
if (typeof prompt === "string") throw new Error("Expected streaming input");
for await (const _message of prompt) {
const turn = turns[index];
index += 1;
yield {
type: "system",
subtype: "init",
session_id: "error-session",
permissionMode: "default",
model: "claude-opus-5-5",
};
if (turn.before) yield assistant(`before-${index}`, turn.before);
if (turn.errorReply) yield assistant(`error-${index}`, turn.errorReply, "rate_limit");
if (turn.after) yield assistant(`after-${index}`, turn.after);
yield {
type: "result",
subtype: "success",
session_id: "error-session",
duration_ms: 1,
duration_api_ms: 0,
is_error: turn.isError,
num_turns: 1,
result: turn.isError ? (turn.errorReply ?? "") : (turn.after ?? turn.errorReply ?? ""),
stop_reason: "stop_sequence",
total_cost_usd: turn.usage?.totalCostUsd ?? 0,
usage: {
input_tokens: turn.usage?.inputTokens ?? 0,
output_tokens: turn.usage?.outputTokens ?? 0,
cache_read_input_tokens: turn.usage?.cachedInputTokens ?? 0,
},
modelUsage: {},
permission_denials: [],
uuid: `result-${index}`,
};
}
})();
return Object.assign(messages, {
close: () => {
void messages.return();
},
interrupt: async () => undefined,
setModel: async () => undefined,
setPermissionMode: async () => undefined,
supportedModels: async () => [],
supportedCommands: async () => [],
}) as ReturnType<ClaudeQueryFactory>;
};
}
async function runTurns(turns: ScriptedTurn[]) {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: scriptedQuery(turns),
resolveBinary: async () => process.execPath,
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
try {
const results = [];
for (const [index] of turns.entries()) {
results.push(await collectSessionTurnEvents(session, `turn ${index + 1}`));
}
return results;
} finally {
await session.close();
}
}
function assistantTexts(events: Awaited<ReturnType<typeof runTurns>>[number]): string[] {
return events.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [],
);
}
function terminal(events: Awaited<ReturnType<typeof runTurns>>[number]) {
return events.find(
(event) =>
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled",
);
}
test("a turn Claude Code ends on an API error fails with the error, shown once", async () => {
const [limited, overloaded, next] = await runTurns([
{ errorReply: LIMIT, isError: true },
{
before: "Checking the deploy doc.",
errorReply: "API Error: 529 Overloaded",
isError: true,
},
{ after: "Done.", isError: false },
]);
expect(terminal(limited)).toMatchObject({
type: "turn_failed",
error: LIMIT,
});
expect(assistantTexts(limited)).toEqual([]);
expect(terminal(overloaded)).toMatchObject({
type: "turn_failed",
error: "API Error: 529 Overloaded",
});
expect(assistantTexts(overloaded)).toEqual(["Checking the deploy doc."]);
// The session keeps working after a failed turn.
expect(terminal(next)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(next)).toEqual(["Done."]);
});
test("a failed turn retains its final token counts and cost", async () => {
const usage = {
inputTokens: 120,
cachedInputTokens: 80,
outputTokens: 30,
totalCostUsd: 0.012,
};
const [failed] = await runTurns([
{ before: "Checking the deploy doc.", errorReply: LIMIT, isError: true, usage },
]);
expect(failed).toContainEqual(
expect.objectContaining({ type: "usage_updated", usage: expect.objectContaining(usage) }),
);
expect(terminal(failed)).toMatchObject({ type: "turn_failed", error: LIMIT });
expect(assistantTexts(failed)).toEqual(["Checking the deploy doc."]);
});
test("an error reply the turn moves past is still shown", async () => {
const [recovered, completed] = await runTurns([
{
errorReply: "API Error: 529 Overloaded",
after: "Recovered.",
isError: false,
},
{ errorReply: LIMIT, isError: false },
]);
expect(terminal(recovered)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(recovered)).toEqual(["API Error: 529 Overloaded", "Recovered."]);
expect(terminal(completed)).toMatchObject({ type: "turn_completed" });
expect(assistantTexts(completed)).toEqual([LIMIT]);
});
@@ -1,3 +1,4 @@
import { constants as bufferConstants } from "node:buffer";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
@@ -236,4 +237,64 @@ describe("importing a renamed Claude session", () => {
await expect(importSession(sessionId)).rejects.toThrow();
});
test("reads the rename from a transcript too large to hold as one string", async () => {
const sessionId = "oversized-transcript";
const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir });
await fs.mkdir(projectDir, { recursive: true });
const transcript = path.join(projectDir, `${sessionId}.jsonl`);
// A hole stands in for the long session this used to fail on: the file only
// has to be longer than a string can be, and the rename sits at its end.
await fs.writeFile(transcript, "");
await fs.truncate(transcript, bufferConstants.MAX_STRING_LENGTH + 1);
await fs.appendFile(
transcript,
`\n${[
userRecord({ sessionId, content: "Trace the slow query" }),
{ type: "custom-title", customTitle: "Slow query hunt", sessionId },
]
.map((record) => JSON.stringify(record))
.join("\n")}\n`,
);
const imported = await importSession(sessionId);
expect(imported.config.title).toBe("Slow query hunt");
});
test("reads the rename from behind more records than one read covers", async () => {
const sessionId = "late-records-rename";
await writeSession({
sessionId,
records: [
userRecord({ sessionId, content: "Review this project" }),
{ type: "custom-title", customTitle: "Scrollback review", sessionId },
// One record longer than a single read, then enough records to leave the
// rename several reads back from the end of the file.
userRecord({ sessionId, content: "x".repeat(128 * 1024) }),
...Array.from({ length: 64 }, (_, index) =>
userRecord({ sessionId, content: `${"y".repeat(4 * 1024)} ${index}` }),
),
],
});
const imported = await importSession(sessionId);
expect(imported.config.title).toBe("Scrollback review");
});
test("imports a large record after the last rename without repeatedly copying it", async () => {
const sessionId = "large-record-after-rename";
await writeSession({
sessionId,
records: [
{ type: "custom-title", customTitle: "Large attachment review", sessionId },
userRecord({ sessionId, content: "x".repeat(96 * 1024 * 1024) }),
],
});
const imported = await importSession(sessionId);
expect(imported.config.title).toBe("Large attachment review");
});
});
@@ -0,0 +1,48 @@
import pino from "pino";
import { describe, expect, test } from "vitest";
import { ClaudeAgentClient } from "./agent.js";
describe("Claude runtime info before a turn", () => {
const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }) });
test("reports the explicitly configured model before the first turn", async () => {
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-haiku-5-5",
});
try {
expect(await session.getRuntimeInfo()).toMatchObject({ model: "claude-haiku-5-5" });
} finally {
await session.close();
}
});
test("reports the persisted configured model after rebuilding an idle session", async () => {
const session = await client.resumeSession({
provider: "claude",
sessionId: "runtime-model-before-turn",
metadata: { cwd: process.cwd(), model: "claude-haiku-5-5" },
});
try {
expect(await session.getRuntimeInfo()).toMatchObject({
sessionId: "runtime-model-before-turn",
model: "claude-haiku-5-5",
});
expect((await session.getRuntimeInfo()).model).toBe("claude-haiku-5-5");
expect((await session.getRuntimeInfo()).extra?.runtimeModel).toBeUndefined();
} finally {
await session.close();
}
});
test("leaves the model unknown when Claude will choose its default", async () => {
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
try {
expect((await session.getRuntimeInfo()).model).toBeNull();
} finally {
await session.close();
}
});
});
@@ -2389,6 +2389,12 @@ class ClaudeAgentSession implements AgentSession {
private mainTurnInFlight = false;
private foregroundHasVisibleActivity = false;
private activeTurnHasAssistantText = false;
/**
* The reply Claude Code writes when a turn stops on an API error ("You've hit your session
* limit", "API Error: 529 ..."), held back until the result says whether the turn failed: a
* failed turn shows the same text as its error, so showing both would repeat it.
*/
private withheldErrorReply: AgentTimelineItem[] = [];
private readonly contextUsage: ClaudeContextUsageState;
private userMessageIds: string[] = [];
private readonly emittedUserMessageIds = new Set<string>();
@@ -2460,7 +2466,7 @@ class ClaudeAgentSession implements AgentSession {
const info: AgentRuntimeInfo = {
provider: "claude",
sessionId: this.claudeSessionId,
model: this.lastOptionsModel,
model: this.lastOptionsModel ?? this.config.model ?? null,
modeId: this.currentMode ?? null,
...(this.lastRuntimeModel
? {
@@ -2531,6 +2537,7 @@ class ClaudeAgentSession implements AgentSession {
this.activeForegroundTurnId = turnId;
this.foregroundHasVisibleActivity = false;
this.activeTurnHasAssistantText = false;
this.withheldErrorReply = [];
this.contextUsage.beginTurn();
this.transitionTurnState("foreground", "foreground turn started");
this.clearRecentStderr();
@@ -4419,6 +4426,21 @@ class ClaudeAgentSession implements AgentSession {
}) satisfies AgentStreamEvent,
);
if (!readClaudeParentToolUseId(message)) {
if (message.type === "assistant" && message.error) {
this.withheldErrorReply.push(...assistantTimelineEvents.map((event) => event.item));
return messageEvents;
}
if (
message.type === "assistant" ||
message.type === "user" ||
message.type === "stream_event"
) {
const released: AgentStreamEvent[] = [];
this.releaseWithheldErrorReply(released);
return [...released, ...messageEvents, ...assistantTimelineEvents];
}
}
return [...messageEvents, ...assistantTimelineEvents];
}
@@ -4966,6 +4988,23 @@ class ClaudeAgentSession implements AgentSession {
events: AgentStreamEvent[],
): void {
const usage = this.convertUsage(message);
// Claude Code reports a turn that stopped on an API error (a usage limit, an overloaded or
// rejected request) as `success` with `is_error`, carrying the error text in `result`.
if (message.subtype === "success" && message.is_error) {
const withheld = this.withheldErrorReply;
this.withheldErrorReply = [];
const resultText = typeof message.result === "string" ? message.result.trim() : "";
const withheldText = withheld
.map((item) => (item.type === "assistant_message" ? item.text : ""))
.join("\n")
.trim();
if (usage) events.push({ type: "usage_updated", provider: "claude", usage });
events.push(...this.sidechainTracker.finishAll("failed"));
events.push(this.buildTurnFailedEvent(resultText || withheldText || "Claude run failed"));
return;
}
const releasedErrorReply = this.withheldErrorReply.length > 0;
this.releaseWithheldErrorReply(events);
if (message.subtype === "success") {
events.push(...this.sidechainTracker.finishAll("completed"));
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
@@ -4975,7 +5014,12 @@ class ClaudeAgentSession implements AgentSession {
// accounting from provider gateways does not duplicate streamed output.
const resultText = typeof message.result === "string" ? message.result.trim() : "";
const outputTokens = message.usage?.output_tokens;
if (resultText.length > 0 && outputTokens === 0 && !this.activeTurnHasAssistantText) {
if (
resultText.length > 0 &&
outputTokens === 0 &&
!this.activeTurnHasAssistantText &&
!releasedErrorReply
) {
events.push({
type: "timeline",
provider: "claude",
@@ -4997,6 +5041,14 @@ class ClaudeAgentSession implements AgentSession {
events.push(this.buildTurnFailedEvent(errorMessage));
}
private releaseWithheldErrorReply(events: AgentStreamEvent[]): void {
if (this.withheldErrorReply.length === 0) return;
for (const item of this.withheldErrorReply) {
events.push({ type: "timeline", item, provider: "claude" });
}
this.withheldErrorReply = [];
}
private createClaudeSessionChangedNotice(
oldSessionId: string,
newSessionId: string,
@@ -6853,40 +6905,79 @@ function normalizeClaudeSessionTitle(title: string | null): string | null {
}
const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title";
const CLAUDE_CUSTOM_TITLE_NEEDLE = Buffer.from(`"${CLAUDE_CUSTOM_TITLE_TYPE}"`);
const CLAUDE_TITLE_SCAN_CHUNK_BYTES = 64 * 1024;
function readClaudeCustomTitleFromRecord(record: Buffer): string | null {
// The needle also matches a prompt that quotes it, so a record still has to
// parse as a `custom-title` entry carrying a usable name. Records without the
// needle are never decoded, so a long one costs a byte search and nothing more.
if (!record.includes(CLAUDE_CUSTOM_TITLE_NEEDLE)) {
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(record.toString("utf8").trim());
} catch {
return null;
}
const entry = toObjectRecord(parsed);
if (!entry || entry.isSidechain || entry.type !== CLAUDE_CUSTOM_TITLE_TYPE) {
return null;
}
return normalizeClaudeSessionTitle(
typeof entry.customTitle === "string" ? entry.customTitle : null,
);
}
/**
* The `custom-title` record is re-emitted every time the session is saved, so
* the current name is the last usable one in the file. Searching backwards finds it without
* walking the transcript, which matters because sessions reach tens of
* megabytes.
* the current name is the last usable one in the file. Reading backwards a chunk
* at a time finds it without walking the transcript, which matters because
* sessions reach tens of megabytes, and never holds the file as one string, so a
* transcript past V8's maximum string length still imports.
*/
function readLastClaudeCustomTitle(content: string): string | null {
const needle = `"${CLAUDE_CUSTOM_TITLE_TYPE}"`;
let at = content.lastIndexOf(needle);
while (at >= 0) {
const lineStart = content.lastIndexOf("\n", at) + 1;
const lineEnd = content.indexOf("\n", at);
const line = lineEnd === -1 ? content.slice(lineStart) : content.slice(lineStart, lineEnd);
let parsed: unknown;
try {
parsed = JSON.parse(line.trim());
} catch {
parsed = undefined;
}
const entry = toObjectRecord(parsed);
if (entry && !entry.isSidechain && entry.type === CLAUDE_CUSTOM_TITLE_TYPE) {
const title = normalizeClaudeSessionTitle(
typeof entry.customTitle === "string" ? entry.customTitle : null,
async function readLastClaudeCustomTitle(filePath: string): Promise<string | null> {
const handle = await fsPromises.open(filePath, "r");
try {
const stats = await handle.stat();
if (!stats.isFile()) {
// Opening a directory succeeds on Windows even though reading it does not,
// so reject the way reading the transcript as a file would.
throw Object.assign(
new Error(`EISDIR: illegal operation on a directory, read '${filePath}'`),
{ code: "EISDIR", path: filePath },
);
if (title) {
return title;
}
}
// The needle also matches a prompt that quotes it, and a record can carry no
// usable name, so keep looking backwards for one that does.
at = at <= 0 ? -1 : content.lastIndexOf(needle, at - 1);
let end = stats.size;
// Retain unfinished record pieces in reverse file order. Assemble each
// complete record once, so a large tool/image record costs linear work.
let pieces: Buffer[] = [];
while (end > 0) {
const length = Math.min(CLAUDE_TITLE_SCAN_CHUNK_BYTES, end);
const chunk = Buffer.alloc(length);
await handle.read(chunk, 0, length, end - length);
let recordEnd = chunk.length;
for (
let at = chunk.lastIndexOf(10);
at >= 0;
at = at === 0 ? -1 : chunk.lastIndexOf(10, at - 1)
) {
pieces.push(chunk.subarray(at + 1, recordEnd));
const record = Buffer.concat(pieces.toReversed());
const title = readClaudeCustomTitleFromRecord(record);
if (title) return title;
pieces = [];
recordEnd = at;
}
if (recordEnd > 0) pieces.push(chunk.subarray(0, recordEnd));
end -= length;
}
// The first record in the file has no newline in front of it.
return readClaudeCustomTitleFromRecord(Buffer.concat(pieces.toReversed()));
} finally {
await handle.close();
}
return null;
}
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
@@ -6898,9 +6989,8 @@ async function readClaudeRenamedSessionTitle(input: {
sessionId: string;
configDir: string;
}): Promise<string | null> {
let content: string;
try {
content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8");
return await readLastClaudeCustomTitle(await claudeTranscriptPath(input));
} catch (error) {
// A session with no transcript on disk has no rename to carry over. Every
// other read failure is a real fault and stays explicit.
@@ -6909,7 +6999,6 @@ async function readClaudeRenamedSessionTitle(input: {
}
throw error;
}
return readLastClaudeCustomTitle(content);
}
function normalizeImportablePromptPreview(text: string): string | null {
@@ -150,6 +150,15 @@ function isCodexAlreadyUnarchivedError(error: unknown, threadId: string): boolea
const TURN_START_TIMEOUT_MS = 90 * 1000;
const INTERRUPT_TIMEOUT_MS = 2_000;
// `turn/interrupt` ends a turn but leaves the shell commands it started running.
// Each terminate is bounded by the first constant. A turn start waits for the
// interrupted turn's commands to end, at most the interrupt's own bound plus one
// terminate's and a margin, so it gives up only when a request outlives its timeout.
const INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS = 2_000;
const INTERRUPTED_TURN_CLEANUP_WAIT_MS =
INTERRUPT_TIMEOUT_MS + INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS + 500;
// Turns whose commands are remembered; older turns are forgotten first.
const INTERRUPTED_TURN_TRACKED_TURNS = 16;
const CODEX_PROVIDER = "codex" as const;
// These native provider identities belong to Codex, not to an older custom
// Paseo provider. Never include them in a custom provider's legacy import scan.
@@ -3476,6 +3485,14 @@ export class CodexAppServerAgentSession implements AgentSession {
private pendingFileChangeOutputDeltas = new Map<string, string[]>();
private pendingAssistantMessageBoundary = false;
private terminalCommandByProcessId = new Map<string, string>();
// The root thread's still-running command process ids, keyed by the native turn that
// started them. Codex's `thread/backgroundTerminals/list` carries no turn, so
// `item/started` is the only record of which turn owns a process.
private terminalProcessIdsByTurnId = new Map<
string,
{ processIds: Set<string>; terminatedProcessIds: Set<string>; interruptAccepted: boolean }
>();
private interruptedTurnCleanup: Promise<void> | null = null;
private pendingUnlabeledTerminalInteractions = new Map<
string,
Array<{ callId: string; stdin: string | null }>
@@ -4431,6 +4448,9 @@ export class CodexAppServerAgentSession implements AgentSession {
hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
hasCodexConfig: turnStart.hasCodexConfig,
});
// The interrupted turn's commands end before this turn begins, so none of them
// outlives it into the new turn.
await this.waitForInterruptedTurnCleanup();
if (pendingStart.cancelRequested) {
throw new Error("Codex turn start was interrupted before reaching Codex");
}
@@ -5036,15 +5056,27 @@ export class CodexAppServerAgentSession implements AgentSession {
if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) {
throw new Error("Cannot interrupt Codex before turn/started identifies the active turn");
}
const client = this.client;
const threadId = this.currentThreadId;
const interruptRequest = client.request(
"turn/interrupt",
{
threadId,
turnId,
},
INTERRUPT_TIMEOUT_MS,
);
// Once Codex accepts the interrupt, end the commands this turn started. Registered
// before the await, so a turn start racing this interrupt waits for it. An already-idle
// turn was not interrupted, and what it left running stays.
this.trackInterruptedTurnCleanup(
interruptRequest.then(
() => this.endInterruptedTurnProcesses(client, threadId, turnId),
() => undefined,
),
);
try {
await this.client.request(
"turn/interrupt",
{
threadId: this.currentThreadId,
turnId,
},
INTERRUPT_TIMEOUT_MS,
);
await interruptRequest;
} catch (error) {
if (!isCodexAlreadyIdleInterrupt(error)) {
throw error;
@@ -5085,6 +5117,137 @@ export class CodexAppServerAgentSession implements AgentSession {
return !this.client.hasPendingRequests();
}
// Remember which turn started a command, so an interrupt ends that turn's commands and
// no other turn's. Only the root thread's: a subagent's commands live on its own thread.
// A command that starts after its turn's interrupt was accepted is ended as soon as it
// is seen.
private recordTurnTerminalProcess(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
): void {
const processId = parsed.item.processId;
if (
parsed.source !== "item" ||
!parsed.turnId ||
!parsed.threadId ||
parsed.threadId !== this.currentThreadId ||
typeof processId !== "string" ||
!processId ||
normalizeCodexThreadItemType(
typeof parsed.item.type === "string" ? parsed.item.type : undefined,
) !== "commandExecution"
) {
return;
}
const record = this.terminalProcessIdsByTurnId.get(parsed.turnId);
if (parsed.kind === "item_completed") {
// Only a completed command has exited. A failed one can still be running:
// Codex reports an interrupted turn's live command as failed.
if (parsed.item.status === "completed") record?.processIds.delete(processId);
return;
}
const current = record ?? this.getTurnTerminalRecord(parsed.turnId);
current.processIds.add(processId);
if (current.interruptAccepted && this.client) {
this.trackInterruptedTurnCleanup(
this.endInterruptedTurnProcesses(this.client, parsed.threadId, parsed.turnId),
);
}
}
// An accepted interrupt needs a record even before any of its turn's commands is seen:
// an `item/started` can still be queued behind an earlier notification's conversion.
private getTurnTerminalRecord(turnId: string) {
const existing = this.terminalProcessIdsByTurnId.get(turnId);
if (existing) return existing;
const record = {
processIds: new Set<string>(),
terminatedProcessIds: new Set<string>(),
interruptAccepted: false,
};
this.terminalProcessIdsByTurnId.set(turnId, record);
for (const oldestTurnId of this.terminalProcessIdsByTurnId.keys()) {
if (this.terminalProcessIdsByTurnId.size <= INTERRUPTED_TURN_TRACKED_TURNS) break;
this.terminalProcessIdsByTurnId.delete(oldestTurnId);
}
return record;
}
// A later sweep joins the pending one, so a turn start waits for both.
private trackInterruptedTurnCleanup(cleanup: Promise<void>): void {
const tracked = Promise.all([this.interruptedTurnCleanup, cleanup])
.then(() => undefined)
.finally(() => {
if (this.interruptedTurnCleanup === tracked) this.interruptedTurnCleanup = null;
});
this.interruptedTurnCleanup = tracked;
}
// Terminate each process the interrupted turn started, by its own id, once each.
// `thread/backgroundTerminals/clean` is never used: it ends every terminal on the thread,
// an earlier turn's included. A process already gone answers `terminated: false`. Never
// rejects; failures are logged.
private async endInterruptedTurnProcesses(
client: CodexAppServerClient,
threadId: string | null,
turnId: string,
): Promise<void> {
if (!threadId) return;
const record = this.getTurnTerminalRecord(turnId);
record.interruptAccepted = true;
const processIds = [...record.processIds].filter(
(processId) => !record.terminatedProcessIds.has(processId),
);
for (const processId of processIds) record.terminatedProcessIds.add(processId);
const logContext = { agentId: this.agentId, provider: CODEX_PROVIDER, threadId, turnId };
await Promise.all(
processIds.map(async (processId) => {
try {
const response = await client.request(
"thread/backgroundTerminals/terminate",
{ threadId, processId },
INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS,
);
this.logger.info(
{ ...logContext, processId, terminated: toObjectRecord(response)?.terminated ?? null },
"provider.codex.interrupt.terminal_terminated",
);
} catch (error) {
this.logger.warn(
{ ...logContext, processId, err: error },
"provider.codex.interrupt.terminal_terminate_failed",
);
}
}),
);
}
// A new turn starts only after every pending sweep of interrupted turns' commands has
// ended, or after the bound, whichever is first.
private async waitForInterruptedTurnCleanup(): Promise<void> {
if (!this.interruptedTurnCleanup) return;
let timer: ReturnType<typeof setTimeout> | undefined;
const timedOut = await Promise.race([
(async () => {
while (this.interruptedTurnCleanup) await this.interruptedTurnCleanup;
return false;
})(),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(true), INTERRUPTED_TURN_CLEANUP_WAIT_MS);
}),
]);
clearTimeout(timer);
if (timedOut) {
this.logger.warn(
{
agentId: this.agentId,
provider: CODEX_PROVIDER,
waitMs: INTERRUPTED_TURN_CLEANUP_WAIT_MS,
},
"provider.codex.interrupt.terminal_cleanup_timed_out",
);
}
}
async close(): Promise<void> {
this.closed = true;
this.clearPendingPermissions();
@@ -6209,6 +6372,11 @@ export class CodexAppServerAgentSession implements AgentSession {
return;
}
this.completePendingRootCompactions();
// What a turn that was not interrupted leaves running, it left on purpose; no later
// interrupt may end it.
if (parsed.status !== "interrupted" && this.currentTurnId) {
this.terminalProcessIdsByTurnId.delete(this.currentTurnId);
}
if (parsed.status === "failed") {
this.emitEvent({
type: "turn_failed",
@@ -6613,6 +6781,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private async handleItemCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
): Promise<void> {
this.recordTurnTerminalProcess(parsed);
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
// and canonical `item/*`. Render ordinary items only from the canonical
// channel, but accept a legacy-only child announcement so it can establish
@@ -6775,6 +6944,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private async handleItemStartedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
): Promise<void> {
this.recordTurnTerminalProcess(parsed);
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
return;
}
@@ -0,0 +1,374 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import type { AgentSession } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
import {
createFakeCodexAppServer,
type FakeCodexAppServer,
waitForNextEvent,
waitForTimelineToolCall,
} from "./codex/test-utils/fake-app-server.js";
const THREAD = "thread-1";
const TERMINATE = "thread/backgroundTerminals/terminate";
type Handler = (params: unknown) => unknown;
function deferred<T = void>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function paramsOf(value: unknown): Record<string, unknown> {
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
}
let sessions: AgentSession[] = [];
afterEach(async () => {
for (const session of sessions) await session.close().catch(() => undefined);
sessions = [];
});
/**
* A Codex app-server whose interrupt ends the turn the way Codex does: an `interrupted`
* turn completion, then the response. `terminate` answers per process.
*/
function createHarness(
options: {
terminate?: (processId: string) => unknown;
interruptResponse?: (completeTurn: () => void) => unknown;
} = {},
) {
const handlers: Record<string, Handler> = {
"turn/interrupt": () => {
const completeTurn = () =>
appServer.completeTurn({ threadId: THREAD, status: "interrupted" });
if (options.interruptResponse) return options.interruptResponse(completeTurn);
completeTurn();
return {};
},
[TERMINATE]: (params) =>
options.terminate?.(String(paramsOf(params).processId)) ?? { terminated: true },
};
const appServer: FakeCodexAppServer = createFakeCodexAppServer(handlers);
const session = new CodexAppServerAgentSession(
{
provider: "codex",
cwd: "/workspace/project",
modeId: "auto",
model: "gpt-5.4",
},
null,
createTestLogger(),
async () => appServer.child,
);
sessions.push(session);
function requests(method: string): Record<string, unknown>[] {
return appServer
.requests()
.filter((message) => message.method === method && "id" in message)
.map((message) => paramsOf(message.params));
}
function terminated(): string[] {
return requests(TERMINATE)
.map((params) => String(params.processId))
.sort();
}
// Starts a turn and lets Codex identify it as `nativeTurnId`.
async function startTurn(prompt: string, nativeTurnId: string): Promise<void> {
const before = requests("turn/start").length;
const started = session.startTurn(prompt);
await vi.waitFor(() => expect(requests("turn/start")).toHaveLength(before + 1), {
timeout: 5_000,
});
const turnStarted = waitForNextEvent(session, "turn_started");
appServer.startsTurn({ threadId: THREAD, turnId: nativeTurnId });
await started;
await turnStarted;
}
async function runsCommand(params: { turnId: string; processId: string }): Promise<void> {
const itemId = `exec-${params.processId}`;
const shown = waitForTimelineToolCall(session, itemId);
appServer.startsTerminalCommand({
threadId: THREAD,
turnId: params.turnId,
itemId,
processId: params.processId,
command: `sleep ${params.processId}`,
});
await shown;
}
async function completesTurn(): Promise<void> {
const completed = waitForNextEvent(session, "turn_completed");
appServer.completeTurn({ threadId: THREAD });
await completed;
}
// Interrupts the running turn and waits for the session to report it canceled, as a
// caller does before it starts the next turn: Codex notifications are handled in order
// but asynchronously, so the interrupt's answer can arrive before the turn has ended.
async function interrupts(): Promise<void> {
const canceled = waitForNextEvent(session, "turn_canceled");
await session.interrupt();
await canceled;
}
return {
appServer,
session,
requests,
terminated,
startTurn,
runsCommand,
completesTurn,
interrupts,
};
}
// Turn A leaves a server running on purpose and completes. Turn B starts two
// commands, and a subagent on its own thread starts a third, then B is interrupted.
async function interruptTurnB(harness: ReturnType<typeof createHarness>): Promise<void> {
await harness.startTurn("start a server", "native-A");
await harness.runsCommand({ turnId: "native-A", processId: "101" });
await harness.completesTurn();
await harness.startTurn("run two commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
// A subagent thread's command shows no row here; the next command's row shows it
// was read, because notifications are read in order.
harness.appServer.startsTerminalCommand({
threadId: "child-thread",
turnId: "native-B",
itemId: "exec-209",
processId: "209",
command: "sleep 209",
});
await harness.runsCommand({ turnId: "native-B", processId: "202" });
await harness.interrupts();
}
describe("an interrupted Codex turn's commands end before the next turn", () => {
test("terminates exactly the processes the interrupted turn started, by id, and never cleans", async () => {
const harness = createHarness();
await interruptTurnB(harness);
await harness.startTurn("correction", "native-C");
expect(harness.terminated()).toEqual(["201", "202"]);
expect(harness.requests(TERMINATE).every((params) => params.threadId === THREAD)).toBe(true);
// `clean` would end every terminal on the thread, an earlier turn's included.
expect(
harness.appServer
.requests()
.filter((message) => String(message.method).startsWith("thread/backgroundTerminals/"))
.map((message) => message.method),
).toEqual([TERMINATE, TERMINATE]);
harness.appServer.assertNoErrors();
});
test("leaves the next turn's commands running when Codex starts that turn before acknowledging the interrupt", async () => {
const acknowledge = deferred();
const harness = createHarness({
// Codex ends B, starts a turn of its own (a goal continuation), and only then
// answers the interrupt.
interruptResponse: (completeTurn) => {
completeTurn();
return acknowledge.promise.then(() => ({}));
},
});
await harness.startTurn("run two commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
await harness.runsCommand({ turnId: "native-B", processId: "202" });
const interrupting = harness.session.interrupt();
await vi.waitFor(() => expect(harness.requests("turn/interrupt")).toHaveLength(1));
const autonomousTurn = waitForNextEvent(harness.session, "turn_started");
harness.appServer.startsTurn({ threadId: THREAD, turnId: "native-C" });
await autonomousTurn;
await harness.runsCommand({ turnId: "native-C", processId: "301" });
acknowledge.resolve();
await interrupting;
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(harness.terminated()).toEqual(["201", "202"]);
});
test("sends the next turn/start only after the terminates are answered", async () => {
const answer = deferred();
const harness = createHarness({
terminate: () => answer.promise.then(() => ({ terminated: true })),
});
await interruptTurnB(harness);
const next = harness.startTurn("correction", "native-C");
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
await new Promise((resolve) => setTimeout(resolve, 200));
expect(harness.requests("turn/start")).toHaveLength(2);
answer.resolve();
await next;
expect(harness.requests("turn/start")).toHaveLength(3);
});
test.each(["initial", "late"] as const)(
"tracks both cleanup sweeps when the %s answer arrives first",
async (answeredFirst) => {
const first = deferred();
const second = deferred();
const harness = createHarness({
terminate: (processId) =>
(processId === "201" ? first : second).promise.then(() => ({ terminated: true })),
});
await harness.startTurn("run commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
await harness.interrupts();
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201"]));
const next = harness.startTurn("correction", "native-C");
await new Promise((resolve) => setTimeout(resolve, 100));
expect(harness.requests("turn/start")).toHaveLength(1);
// The next turn is already waiting when another process is announced.
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "late-202",
processId: "202",
command: "sleep 202",
});
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "duplicate-202",
processId: "202",
command: "sleep 202",
});
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
(answeredFirst === "initial" ? first : second).resolve();
await new Promise((resolve) => setTimeout(resolve, 100));
expect(harness.requests("turn/start")).toHaveLength(1);
(answeredFirst === "initial" ? second : first).resolve();
await next;
expect(harness.terminated()).toEqual(["201", "202"]);
// Accepted state survives the completed sweep and a newer foreground turn.
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "late-203",
processId: "203",
command: "sleep 203",
});
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202", "203"]));
harness.appServer.assertNoErrors();
},
);
test("keeps the first sweep pending when a late sweep answers before the next start", async () => {
const first = deferred();
const harness = createHarness({
terminate: (processId) =>
processId === "201"
? first.promise.then(() => ({ terminated: true }))
: { terminated: true },
});
await harness.startTurn("run commands", "native-B");
await harness.runsCommand({ turnId: "native-B", processId: "201" });
await harness.interrupts();
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-B",
itemId: "late-202",
processId: "202",
command: "sleep 202",
});
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
const next = harness.startTurn("correction", "native-C");
await new Promise((resolve) => setTimeout(resolve, 100));
expect(harness.requests("turn/start")).toHaveLength(1);
first.resolve();
await next;
harness.appServer.assertNoErrors();
});
test("forgets completed commands but retains failed commands at interrupt", async () => {
const harness = createHarness();
await harness.startTurn("run commands", "native-B");
for (const processId of ["201", "202"]) {
await harness.runsCommand({ turnId: "native-B", processId });
harness.appServer.child.stdout.write(
JSON.stringify({
method: "item/completed",
params: {
threadId: THREAD,
turnId: "native-B",
item: {
id: `exec-${processId}`,
type: "commandExecution",
processId,
command: `sleep ${processId}`,
status: processId === "201" ? "completed" : "failed",
exitCode: processId === "201" ? 0 : null,
},
},
}) + "\n",
);
}
// A following command's row is a barrier for both completion handlers.
await harness.runsCommand({ turnId: "native-B", processId: "203" });
await harness.interrupts();
await harness.startTurn("correction", "native-C");
expect(harness.terminated()).toEqual(["202", "203"]);
harness.appServer.assertNoErrors();
});
test("retains accepted interrupts for only the latest sixteen turns", async () => {
const harness = createHarness();
for (let index = 0; index < 17; index++) {
await harness.startTurn("run a command", `native-${index}`);
await harness.interrupts();
}
await harness.startTurn("correction", "native-current");
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-0",
itemId: "evicted-201",
processId: "201",
command: "sleep 201",
});
harness.appServer.startsTerminalCommand({
threadId: THREAD,
turnId: "native-16",
itemId: "retained-202",
processId: "202",
command: "sleep 202",
});
await harness.runsCommand({ turnId: "native-current", processId: "301" });
expect(harness.terminated()).toEqual(["202"]);
harness.appServer.assertNoErrors();
});
test("a failing or unanswered terminate does not block or fail the next turn", async () => {
const harness = createHarness({
// 201 is refused while the next turn waits; 202 is never answered.
terminate: (processId) =>
processId === "201"
? new Promise((resolve) => setTimeout(resolve, 300)).then(() => ({
__jsonRpcError: { code: -32600, message: "invalid background terminal process id" },
}))
: new Promise(() => {}),
});
await interruptTurnB(harness);
const startedAt = Date.now();
await harness.startTurn("correction", "native-C");
expect(Date.now() - startedAt).toBeLessThan(5_000);
expect(harness.requests("turn/start")).toHaveLength(3);
expect(harness.terminated()).toEqual(["201", "202"]);
}, 15_000);
});
@@ -4,6 +4,102 @@ import { createTestLogger } from "../../../../../test-utils/test-logger.js";
import type { V2Api } from "./api.js";
import { OpenCodeV2AgentClient } from "./agent.js";
import { V2Harness } from "../test-utils/v2-harness.js";
import type { AgentPermissionResponse } from "../../../agent-sdk-types.js";
describe("OpenCode v2 denial reasons", () => {
test.each<{
response: AgentPermissionResponse;
decision: "reject" | "once" | "always";
message?: string;
}>([
{
response: { behavior: "deny", message: "Use the existing file instead." },
decision: "reject",
message: "Use the existing file instead.",
},
{ response: { behavior: "deny" }, decision: "reject" },
{ response: { behavior: "allow" }, decision: "once" },
{ response: { behavior: "allow", selectedActionId: "always" }, decision: "always" },
])("preserves a permission response: $response", async ({ response, decision, message }) => {
const harness = new V2Harness();
let pending = true;
harness.api.permission.list = async () =>
pending
? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }]
: [];
const replies: Parameters<V2Api["permission"]["reply"]>[0][] = [];
harness.api.permission.reply = async (input) => {
replies.push(input);
pending = false;
};
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
try {
await session.respondToPermission("approval", response);
expect(replies).toEqual([
{
sessionID: "session",
requestID: "approval",
decision,
...(message === undefined ? {} : { message }),
},
]);
expect(session.getPendingPermissions()).toEqual([]);
} finally {
await session.close();
}
});
test.each([true, false])(
"explains cards superseded by a steer when clearing is %s",
async (clearPendingPermissions) => {
const harness = new V2Harness();
harness.autoComplete = false;
let pending = true;
harness.api.permission.list = async () =>
pending
? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }]
: [];
const replies: Parameters<V2Api["permission"]["reply"]>[0][] = [];
harness.api.permission.reply = async (input) => {
replies.push(input);
pending = false;
};
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
try {
const { turnId } = await session.startTurn("Run pwd");
await expect.poll(() => harness.prompts).toEqual(["Run pwd"]);
await expect(
session.steerActiveTurn!("go ahead", { expectedTurnId: turnId, clearPendingPermissions }),
).resolves.toEqual({ status: "accepted" });
expect(harness.prompts).toEqual(["Run pwd", "go ahead"]);
expect(replies).toEqual(
clearPendingPermissions
? [
{
sessionID: "session",
requestID: "approval",
decision: "reject",
message:
"The user answered with a message instead of approving. Their message follows.",
},
]
: [],
);
expect(session.getPendingPermissions()).toHaveLength(clearPendingPermissions ? 0 : 1);
} finally {
await session.close();
}
},
);
});
describe("OpenCode v2 questions", () => {
test("maps selected labels to native values and parses numeric and boolean answers", async () => {
@@ -68,6 +68,9 @@ export class SessionPermissions {
sessionID: this.permissionOwners.get(requestId) ?? this.id,
requestID: requestId,
decision: permissionReply(response),
...(response.behavior === "deny" && response.message !== undefined
? { message: response.message }
: {}),
});
}
this.resolvePending(requestId, response);
@@ -100,7 +100,11 @@ export class OpenCodeV2Session implements AgentSession {
),
clearPermissions: async () => {
for (const request of this.permissions.list())
await this.permissions.respondToPermission(request.id, { behavior: "deny" });
await this.permissions.respondToPermission(request.id, {
behavior: "deny",
message:
"The user answered with a message instead of approving. Their message follows.",
});
},
});
this.usage = new SessionUsage({
@@ -5,11 +5,13 @@ import {
mkdirSync,
mkdtempSync,
openSync,
readFileSync,
readSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { homedir, tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { setImmediate as waitForImmediate } from "node:timers/promises";
@@ -23,6 +25,7 @@ import {
PiRpcAgentSession,
transformPiModels,
} from "./agent.js";
import { PiCliRuntime } from "./cli-runtime.js";
import { FakePi } from "./test-utils/fake-pi.js";
import { createPiExtensionHost } from "./extensions/index.js";
import { PiExtensionHost } from "./extensions/host.js";
@@ -2719,6 +2722,7 @@ describe("PiRpcAgentClient", () => {
const root = mkdtempSync(path.join(tmpdir(), "paseo-pi-import-config-"));
const cwd = path.join(root, "workspace");
const sessionsDir = path.join(root, "sessions");
mkdirSync(cwd, { recursive: true });
mkdirSync(sessionsDir, { recursive: true });
const sessionFile = path.join(sessionsDir, "20260103_session.jsonl");
writeFileSync(
@@ -3532,6 +3536,320 @@ describe("PiRpcAgentClient", () => {
});
});
describe("PiRpcAgentClient history resume", () => {
const BUILTIN_MCP_COMMAND = {
name: "mcp",
source: "extension",
sourceInfo: { path: "builtin:mcp" },
} as const;
const PASEO_MCP_SERVERS = {
paseo: { type: "http", url: "http://127.0.0.1:6767/mcp/agents" },
} as const;
interface ArchivedPiSession {
handle: {
provider: string;
sessionId: string;
nativeHandle: string;
metadata: { cwd: string; model?: string };
};
sessionFile: string;
header: Record<string, unknown>;
body: string;
originalBytes: Buffer;
}
function createDeletedFolder(): string {
const folder = mkdtempSync(path.join(tmpdir(), "paseo-pi-deleted-cwd-"));
rmSync(folder, { recursive: true, force: true });
return folder;
}
function createFileWhereFolderWas(): string {
const folder = createDeletedFolder();
writeFileSync(folder, "not a directory");
onTestFinished(() => rmSync(folder, { force: true }));
return folder;
}
function createExistingFolder(): string {
const folder = mkdtempSync(path.join(tmpdir(), "paseo-pi-existing-cwd-"));
onTestFinished(() => rmSync(folder, { recursive: true, force: true }));
return folder;
}
function writeArchivedSession(cwd: string, metadata: { model?: string } = {}): ArchivedPiSession {
const dir = mkdtempSync(path.join(tmpdir(), "paseo-pi-archived-session-"));
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
const sessionFile = path.join(dir, "2026-01-01_pi-session-1.jsonl");
const header = {
type: "session",
version: 3,
id: "pi-session-1",
timestamp: "2026-01-01T00:00:00.000Z",
cwd,
};
const body = [
JSON.stringify({
type: "message",
id: "entry-1",
parentId: null,
message: { role: "user", content: "Où est le café ☕ ?" },
}),
JSON.stringify({
type: "message",
id: "entry-2",
parentId: "entry-1",
message: { role: "assistant", content: [{ type: "text", text: "Dans la cuisine." }] },
}),
]
.map((line) => `${line}\n`)
.join("");
writeFileSync(sessionFile, `${JSON.stringify(header)}\n${body}`);
return {
handle: {
provider: "pi",
sessionId: "pi-session-1",
nativeHandle: sessionFile,
metadata: { cwd, ...metadata },
},
sessionFile,
header,
body,
originalBytes: readFileSync(sessionFile),
};
}
function readSessionCopy(sessionFile: string): { header: unknown; body: string } {
const content = readUtf8File(sessionFile);
const headerEnd = content.indexOf("\n");
return {
header: JSON.parse(content.slice(0, headerEnd)),
body: content.slice(headerEnd + 1),
};
}
function resumeArchived(
client: PiRpcAgentClient,
archived: ArchivedPiSession,
purpose: "history" | "interactive",
overrides: Partial<AgentSessionConfig> = {},
): Promise<AgentSession> {
return client.resumeSession(archived.handle, overrides, undefined, { purpose });
}
function writeFakePiProcess(): {
command: [string, ...string[]];
readLaunches: () => Array<{ cwd: string; session: string | null }>;
} {
const dir = mkdtempSync(path.join(tmpdir(), "paseo-pi-process-"));
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
const scriptPath = path.join(dir, "fake-pi.cjs");
const launchesPath = path.join(dir, "launches.jsonl");
writeFileSync(
scriptPath,
`
const fs = require("node:fs");
const readline = require("node:readline");
const [launchesPath, ...piArgs] = process.argv.slice(2);
const sessionFlag = piArgs.indexOf("--session");
const sessionFile = sessionFlag === -1 ? null : piArgs[sessionFlag + 1];
fs.appendFileSync(launchesPath, JSON.stringify({ cwd: process.cwd(), session: sessionFile }) + "\\n");
if (sessionFile) {
const header = JSON.parse(fs.readFileSync(sessionFile, "utf8").split("\\n", 1)[0]);
if (header.cwd && !fs.existsSync(header.cwd)) {
fs.writeSync(
2,
"Stored session working directory does not exist: " + header.cwd + "\\nSession file: " + sessionFile + "\\nCurrent working directory: " + process.cwd() + "\\n",
);
process.exit(1);
}
}
const models = [
{ provider: "openrouter", id: "a" },
{ provider: "openrouter", id: "b" },
];
const state = {
sessionId: "pi-session-1",
sessionFile,
model: models[0],
thinkingLevel: "medium",
isStreaming: false,
isCompacting: false,
messageCount: 0,
pendingMessageCount: 0,
};
const commands = [${JSON.stringify(BUILTIN_MCP_COMMAND)}];
function handle(request) {
switch (request.type) {
case "get_state":
return state;
case "get_commands":
return { commands };
case "get_available_models":
return { models };
case "set_model":
fs.appendFileSync(
sessionFile,
JSON.stringify({ type: "model_change", id: "model-1", provider: request.provider, modelId: request.modelId }) + "\\n",
);
state.model = models.find((model) => model.id === request.modelId);
return state.model;
default:
return {};
}
}
readline.createInterface({ input: process.stdin }).on("line", (line) => {
const request = JSON.parse(line);
process.stdout.write(
JSON.stringify({ id: request.id, type: "response", command: request.type, success: true, data: handle(request) }) + "\\n",
);
});
`,
);
return {
command: [process.execPath, scriptPath, launchesPath],
readLaunches: () =>
readUtf8File(launchesPath)
.trim()
.split("\n")
.map((line) => JSON.parse(line) as { cwd: string; session: string | null }),
};
}
test.each([
["is gone", createDeletedFolder],
["is now a file", createFileWhereFolderWas],
])(
"a history resume of an agent whose folder %s starts Pi from the home directory on a copy of its session",
async (_folderState, createFolder) => {
const archived = writeArchivedSession(createFolder());
const pi = new FakePi();
const session = await resumeArchived(createClient(pi), archived, "history");
onTestFinished(() => session.close());
expect(pi.recordedLaunches).toHaveLength(1);
const launch = pi.recordedLaunches[0]!;
expect(launch.cwd).toBe(homedir());
expect(launch.session).not.toBe(archived.sessionFile);
expect(readSessionCopy(launch.session!)).toEqual({
header: { ...archived.header, cwd: homedir() },
body: archived.body,
});
},
);
test("a history resume of an agent whose folder is gone removes its session copy on close and leaves the original unchanged", async () => {
const archived = writeArchivedSession(createDeletedFolder());
const pi = new FakePi();
const session = await resumeArchived(createClient(pi), archived, "history");
const sessionCopy = pi.recordedLaunches[0]!.session!;
expect(existsSync(sessionCopy)).toBe(true);
await session.close();
expect(existsSync(path.dirname(sessionCopy))).toBe(false);
expect(readFileSync(archived.sessionFile)).toEqual(archived.originalBytes);
});
test("a history resume of an agent whose folder is gone persists the original session file while Pi reports the copy", async () => {
const storedCwd = createDeletedFolder();
const archived = writeArchivedSession(storedCwd);
const pi = new FakePi();
const session = await resumeArchived(createClient(pi), archived, "history");
onTestFinished(() => session.close());
await session.getRuntimeInfo();
expect(pi.latestSession().state.sessionFile).toBe(pi.recordedLaunches[0]!.session);
expect(session.describePersistence()).toMatchObject({
nativeHandle: archived.sessionFile,
metadata: { cwd: storedCwd },
});
});
test("a history resume of an agent whose folder is gone removes its session copy when Pi fails to start", async () => {
const archived = writeArchivedSession(createDeletedFolder());
const pi = new FakePi();
pi.queueSessionSetup((session) => {
session.getStateError = new Error("Pi RPC process exited with code 1 and signal null");
});
await expect(resumeArchived(createClient(pi), archived, "history")).rejects.toThrow(
"Pi RPC process exited with code 1",
);
expect(pi.recordedLaunches).toHaveLength(1);
expect(pi.recordedLaunches[0]!.session).not.toBe(archived.sessionFile);
expect(existsSync(path.dirname(pi.recordedLaunches[0]!.session!))).toBe(false);
});
test("a history resume of an agent whose folder is gone checks MCP support from the home directory", async () => {
const archived = writeArchivedSession(createDeletedFolder());
const pi = new FakePi();
pi.queueCommands([BUILTIN_MCP_COMMAND]);
const session = await resumeArchived(createClient(pi), archived, "history", {
mcpServers: PASEO_MCP_SERVERS,
});
onTestFinished(() => session.close());
expect(pi.recordedLaunches.map((launch) => launch.cwd)).toEqual([homedir(), homedir()]);
expect(session.capabilities.supportsMcpServers).toBe(true);
});
test("an interactive resume of an agent whose folder is gone still fails in its stored folder", async () => {
const storedCwd = createDeletedFolder();
const archived = writeArchivedSession(storedCwd);
const pi = new FakePi();
await expect(resumeArchived(createClient(pi), archived, "interactive")).rejects.toThrow(
`Stored session working directory does not exist: ${storedCwd}`,
);
expect(pi.recordedLaunches).toMatchObject([{ cwd: storedCwd, session: archived.sessionFile }]);
});
test("a history resume of an agent whose folder exists keeps the stored folder and session file", async () => {
const storedCwd = createExistingFolder();
const archived = writeArchivedSession(storedCwd);
const pi = new FakePi();
const session = await resumeArchived(createClient(pi), archived, "history");
onTestFinished(() => session.close());
expect(pi.recordedLaunches).toMatchObject([{ cwd: storedCwd, session: archived.sessionFile }]);
});
test("a history resume of an agent whose folder is gone opens its session in a real Pi process started from the home directory", async () => {
const archived = writeArchivedSession(createDeletedFolder(), { model: "openrouter/b" });
const fakePiProcess = writeFakePiProcess();
const logger = pino({ level: "silent" });
const client = new PiRpcAgentClient({
logger,
runtime: new PiCliRuntime({ logger, command: fakePiProcess.command }),
});
const session = await resumeArchived(client, archived, "history", {
mcpServers: PASEO_MCP_SERVERS,
});
onTestFinished(() => session.close());
const launches = fakePiProcess.readLaunches();
const home = realpathSync.native(homedir());
expect(launches.map((launch) => realpathSync.native(launch.cwd))).toEqual([home, home]);
expect(session.capabilities.supportsMcpServers).toBe(true);
const sessionCopy = launches[1]!.session!;
expect(readUtf8File(sessionCopy)).toContain('"type":"model_change"');
expect(session.describePersistence()?.nativeHandle).toBe(archived.sessionFile);
await session.close();
expect(existsSync(path.dirname(sessionCopy))).toBe(false);
expect(readFileSync(archived.sessionFile)).toEqual(archived.originalBytes);
});
});
describe("transformPiModels", () => {
test("normalizes labels that include the upstream provider prefix", () => {
expect(
@@ -2,7 +2,7 @@ import { mapCustomMessageToToolCall } from "../custom-message.js";
import { randomUUID } from "node:crypto";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join, resolve as resolvePath } from "node:path";
import { basename, join, resolve as resolvePath } from "node:path";
import type { Logger } from "pino";
import stripAnsi from "strip-ansi";
import { z } from "zod";
@@ -22,6 +22,7 @@ import {
type AgentPersistenceHandle,
type AgentPromptInput,
type AgentProvider,
type AgentResumeSessionOptions,
type AgentRunOptions,
type AgentRunResult,
type AgentRuntimeInfo,
@@ -75,8 +76,13 @@ import {
piExtensionRuntimeBridge,
type PiExtensionEventOutput,
} from "./extensions/index.js";
import { resolveResumeProcessCwd } from "../resume-process-cwd.js";
import { revertPiConversation } from "./rewind.js";
import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js";
import {
copyPiSessionFileWithCwd,
listPiImportableSessions,
readPiImportSessionConfig,
} from "./session-descriptor.js";
import type { PiRuntime, PiRuntimeSession, PiStartSessionInput } from "./runtime.js";
import type {
PiAgentSessionEvent,
@@ -245,6 +251,7 @@ interface PiRpcAgentSessionOptions {
initialState: PiSessionState;
capabilities: AgentCapabilityFlags;
currentModeId?: string | null;
persistedSessionFile?: string;
cleanup?: () => void;
extensionTimeoutMs?: number;
logger: Logger;
@@ -492,13 +499,14 @@ function buildResumeConfig(
function buildResumeStartInput(input: {
resumeConfig: PiResumeConfig;
processCwd: string;
sessionFile: string;
launchContext: AgentLaunchContext | undefined;
mcpConfigFile: PiTempFile | null;
paseoExtension: PiTempFile | null;
}): PiStartSessionInput {
return {
cwd: input.resumeConfig.cwd,
cwd: input.processCwd,
env: input.launchContext?.env,
session: input.sessionFile,
thinkingOptionId: normalizePiThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined,
@@ -618,6 +626,19 @@ function createPiMcpConfigFile(
};
}
async function createPiSessionCopy(sessionFile: string, cwd: string): Promise<PiTempFile> {
const dir = mkdtempSync(join(tmpdir(), "paseo-pi-session-"));
const cleanup = () => rmSync(dir, { recursive: true, force: true });
const filePath = join(dir, basename(sessionFile));
try {
await copyPiSessionFileWithCwd({ sourceFile: sessionFile, targetFile: filePath, cwd });
} catch (error) {
cleanup();
throw error;
}
return { path: filePath, cleanup };
}
function createPiPaseoExtensionFile({
systemPrompt,
mcpServers = {},
@@ -1232,6 +1253,7 @@ export class PiRpcAgentSession implements AgentSession {
this.capabilities = options.capabilities;
this.provider = PI_PROVIDER;
this.currentModeId = options.currentModeId ?? null;
this.persistedSessionFile = options.persistedSessionFile;
this.cleanup = options.cleanup;
this.extensionTimeoutMs = options.extensionTimeoutMs ?? DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS;
this.logger = options.logger;
@@ -1262,6 +1284,7 @@ export class PiRpcAgentSession implements AgentSession {
private readonly runtimeSession: PiRuntimeSession;
private readonly config: AgentSessionConfig;
private readonly persistedSessionFile?: string;
private readonly cleanup?: () => void;
private readonly extensionTimeoutMs: number;
@@ -1496,7 +1519,7 @@ export class PiRpcAgentSession implements AgentSession {
return {
provider: this.provider,
sessionId: this.state.sessionId,
nativeHandle: this.state.sessionFile,
nativeHandle: this.persistedSessionFile ?? this.state.sessionFile,
metadata: {
cwd: this.config.cwd,
...(this.config.model ? { model: this.config.model } : {}),
@@ -2645,6 +2668,7 @@ export class PiRpcAgentClient implements AgentClient {
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
options?: AgentResumeSessionOptions,
): Promise<AgentSession> {
const sessionFile = handle.nativeHandle;
if (!sessionFile) {
@@ -2653,6 +2677,7 @@ export class PiRpcAgentClient implements AgentClient {
const persistenceMetadata = parsePersistenceMetadata(handle.metadata);
const resumeConfig = buildResumeConfig(persistenceMetadata, overrides, this.provider);
const processCwd = await resolveResumeProcessCwd(resumeConfig.cwd, options?.purpose);
const providerOptions = PiProviderOptionsSchema.parse(
resumeConfig.config.providerOptions ?? {},
@@ -2663,7 +2688,7 @@ export class PiRpcAgentClient implements AgentClient {
...launchContext?.env,
};
const mcp = await this.prepareMcpInjection(
resumeConfig.cwd,
processCwd,
resumeConfig.config.mcpServers,
mcpEnv,
runtime,
@@ -2676,20 +2701,29 @@ export class PiRpcAgentClient implements AgentClient {
),
mcpServers: mcp?.kind === "builtin" ? mcp.servers : undefined,
});
const tempFiles = mcpConfigFile ? [mcpConfigFile, paseoExtension] : [paseoExtension];
const cleanupTempFiles = () => {
for (const tempFile of tempFiles) tempFile.cleanup();
};
let sessionCopy: PiTempFile | null = null;
let runtimeSession: PiRuntimeSession;
try {
if (processCwd !== resumeConfig.cwd) {
sessionCopy = await createPiSessionCopy(sessionFile, processCwd);
tempFiles.push(sessionCopy);
}
runtimeSession = await runtime.startSession(
buildResumeStartInput({
resumeConfig,
sessionFile,
processCwd,
sessionFile: sessionCopy?.path ?? sessionFile,
launchContext,
mcpConfigFile,
paseoExtension,
}),
);
} catch (error) {
mcpConfigFile?.cleanup();
paseoExtension?.cleanup();
cleanupTempFiles();
throw error;
}
try {
@@ -2702,15 +2736,15 @@ export class PiRpcAgentClient implements AgentClient {
},
initialState,
capabilities: capabilitiesForSession(mcp !== null),
cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]),
persistedSessionFile: sessionCopy ? sessionFile : undefined,
cleanup: cleanupTempFiles,
extensionTimeoutMs: providerOptions.extensionTimeoutMs,
logger: this.logger,
usagePollScheduler: this.usagePollScheduler,
});
} catch (error) {
await runtimeSession.close().catch(() => undefined);
mcpConfigFile?.cleanup();
paseoExtension?.cleanup();
cleanupTempFiles();
throw error;
}
}
@@ -1,9 +1,13 @@
import { mkdtemp, mkdir, utimes, writeFile } from "node:fs/promises";
import { mkdtemp, mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "vitest";
import { expect, onTestFinished, test } from "vitest";
import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js";
import {
copyPiSessionFileWithCwd,
listPiImportableSessions,
readPiImportSessionConfig,
} from "./session-descriptor.js";
async function writeSession(root: string, lines: unknown[]): Promise<string> {
const sessionsDir = path.join(root, "sessions", "project");
@@ -216,3 +220,97 @@ test("Pi import config preserves thinking before a later model in large sessions
thinkingOptionId: "low",
});
});
test("a Pi session copy names the new cwd in its header and keeps every later byte", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const header = {
type: "session",
version: 3,
id: "pi-session-1",
timestamp: "2026-01-01T00:00:00.000Z",
cwd: path.join(root, "removed-worktree"),
};
const body = Buffer.from(
Array.from(
{ length: 2_000 },
(_, index) =>
`${JSON.stringify({ type: "message", id: `entry-${index}`, message: { role: "user", content: `café ☕ ${index}` } })}\n`,
).join(""),
);
expect(body.length).toBeGreaterThan(64 * 1024);
const sourceFile = path.join(root, "source.jsonl");
const targetFile = path.join(root, "copy.jsonl");
await writeFile(sourceFile, Buffer.concat([Buffer.from(`${JSON.stringify(header)}\n`), body]));
await copyPiSessionFileWithCwd({ sourceFile, targetFile, cwd: "/home/user" });
const copy = await readFile(targetFile);
const headerEnd = copy.indexOf(0x0a);
expect(JSON.parse(copy.subarray(0, headerEnd).toString("utf8"))).toEqual({
...header,
cwd: "/home/user",
});
expect(copy.subarray(headerEnd + 1).equals(body)).toBe(true);
});
test("a Pi session copy refuses a file without a session header", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const sourceFile = path.join(root, "source.jsonl");
await writeFile(sourceFile, `${JSON.stringify({ type: "message", id: "entry-1" })}\n`);
await expect(
copyPiSessionFileWithCwd({ sourceFile, targetFile: path.join(root, "copy.jsonl"), cwd: "/" }),
).rejects.toThrow(`Pi session file has no session header: ${sourceFile}`);
});
const COPY_BODY = [
{ type: "message", id: "entry-1", message: { role: "user", content: "café ☕" } },
{ type: "message", id: "entry-2", message: { role: "assistant", content: "Dans la cuisine." } },
]
.map((entry) => `${JSON.stringify(entry)}\n`)
.join("");
const COPY_HEADER = {
type: "session",
version: 3,
id: "pi-session-1",
timestamp: "2026-01-01T00:00:00.000Z",
cwd: "/removed-worktree",
};
async function copySessionContent(content: string): Promise<Buffer> {
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const sourceFile = path.join(root, "source.jsonl");
const targetFile = path.join(root, "copy.jsonl");
await writeFile(sourceFile, content);
await copyPiSessionFileWithCwd({ sourceFile, targetFile, cwd: "/home/user" });
return readFile(targetFile);
}
test.each([
["a header line longer than 64 KiB", "", { ...COPY_HEADER, padding: "x".repeat(70 * 1024) }],
["a header with an empty cwd", "", { ...COPY_HEADER, cwd: "" }],
["blank lines before the header", "\n\r\n", COPY_HEADER],
])("a Pi session copy accepts %s", async (_headerShape, prefix, header) => {
const copy = await copySessionContent(`${prefix}${JSON.stringify(header)}\n${COPY_BODY}`);
const headerEnd = copy.indexOf(0x0a);
expect(JSON.parse(copy.subarray(0, headerEnd).toString("utf8"))).toEqual({
...header,
cwd: "/home/user",
});
expect(copy.subarray(headerEnd + 1).equals(Buffer.from(COPY_BODY))).toBe(true);
});
test("a Pi session copy reports a missing session file", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const sourceFile = path.join(root, "missing.jsonl");
await expect(
copyPiSessionFileWithCwd({ sourceFile, targetFile: path.join(root, "copy.jsonl"), cwd: "/" }),
).rejects.toThrow(`Pi session file is missing: ${sourceFile}`);
});
@@ -1,7 +1,8 @@
import type { Dirent } from "node:fs";
import { open, readdir, readFile, stat } from "node:fs/promises";
import { createReadStream, createWriteStream, type Dirent } from "node:fs";
import { open, readdir, readFile, stat, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import type {
ImportableProviderSession,
@@ -18,6 +19,7 @@ const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
const HEAD_BYTES = 64 * 1024;
const TAIL_BYTES = 256 * 1024;
const FULL_SCAN_LINE_LIMIT = 2_000;
const PI_SESSION_HEADER_SCAN_BYTES = 1024 * 1024;
// Rank all discovered files cheaply, then parse only a bounded recent window.
const IMPORT_CANDIDATE_OVERSCAN = 40;
const IMPORT_CANDIDATE_MIN = 400;
@@ -100,6 +102,59 @@ export async function listPiImportableSessions(
);
}
export async function copyPiSessionFileWithCwd(input: {
sourceFile: string;
targetFile: string;
cwd: string;
}): Promise<void> {
const found = findSessionHeaderLine(
await readSessionFileStart(input.sourceFile, PI_SESSION_HEADER_SCAN_BYTES),
);
if (!found) {
throw new Error(`Pi session file has no session header: ${input.sourceFile}`);
}
await writeFile(input.targetFile, `${JSON.stringify({ ...found.header, cwd: input.cwd })}\n`, {
mode: 0o600,
});
await pipeline(
createReadStream(input.sourceFile, { start: found.bodyStart }),
createWriteStream(input.targetFile, { flags: "a" }),
);
}
function findSessionHeaderLine(
scanned: Buffer,
): { header: Record<string, unknown>; bodyStart: number } | null {
let lineStart = 0;
while (lineStart < scanned.length) {
const newline = scanned.indexOf(0x0a, lineStart);
const bodyStart = newline === -1 ? scanned.length : newline + 1;
const line = scanned.subarray(lineStart, bodyStart).toString("utf8").trim();
if (line) {
const header = parseJsonRecord(line);
return header?.type === "session" ? { header, bodyStart } : null;
}
lineStart = bodyStart;
}
return null;
}
async function readSessionFileStart(filePath: string, maxBytes: number): Promise<Buffer> {
const handle = await open(filePath, "r").catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error(`Pi session file is missing: ${filePath}`);
}
throw error;
});
try {
const buffer = Buffer.alloc(maxBytes);
const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
return buffer.subarray(0, bytesRead);
} finally {
await handle.close().catch(() => undefined);
}
}
export async function readPiImportSessionConfig(filePath: string): Promise<PiImportSessionConfig> {
const descriptor = await readPiSessionDescriptor(filePath);
if (!descriptor) return {};
@@ -1,3 +1,5 @@
import { existsSync, readFileSync } from "node:fs";
import type {
PiRuntime,
PiRuntimeLaunch,
@@ -75,6 +77,12 @@ export class FakePi implements PiRuntime {
const session = new FakePiSession(launch, this.removedModels);
session.commands = this.queuedCommands.shift() ?? [];
this.queuedSessionSetups.shift()?.(session);
const missingSessionCwd = launch.noSession ? null : readMissingSessionCwd(launch.session);
if (missingSessionCwd !== null) {
session.getStateError = new Error(
`Pi RPC process exited with code 1 and signal null\nStored session working directory does not exist: ${missingSessionCwd}\nSession file: ${launch.session}\nCurrent working directory: ${launch.cwd}`,
);
}
this.sessions.push(session);
return session;
}
@@ -101,6 +109,27 @@ export class FakePi implements PiRuntime {
}
}
function readMissingSessionCwd(sessionFile: string | undefined): string | null {
if (!sessionFile || !existsSync(sessionFile)) {
return null;
}
const [headerLine = ""] = readFileSync(sessionFile, "utf8").split("\n", 1);
let header: unknown;
try {
header = JSON.parse(headerLine);
} catch {
return null;
}
if (typeof header !== "object" || header === null || !("cwd" in header)) {
return null;
}
const { type, cwd } = header as { type?: unknown; cwd?: unknown };
if (type !== "session" || typeof cwd !== "string" || !cwd || existsSync(cwd)) {
return null;
}
return cwd;
}
export class FakePiSession implements PiRuntimeSession {
readonly environment: Record<string, string>;
readonly prompts: Array<{ message: string; imageCount: number }> = [];
@@ -0,0 +1,26 @@
import { stat } from "node:fs/promises";
import { homedir } from "node:os";
import type { AgentResumePurpose } from "../agent-sdk-types.js";
export async function resolveResumeProcessCwd(
cwd: string,
purpose: AgentResumePurpose | undefined,
): Promise<string> {
if (purpose !== "history" || (await isDirectory(cwd))) {
return cwd;
}
return homedir();
}
async function isDirectory(target: string): Promise<boolean> {
try {
return (await stat(target)).isDirectory();
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT" || code === "ENOTDIR") {
return false;
}
throw error;
}
}
@@ -1590,7 +1590,7 @@ function createToolCatalog(
{
title: "Create agent",
description:
"Create an agent. Agent-scoped creation defaults to your workspace and creates your subagent. Top-level creation without workspaceId creates a new local workspace. Requires provider/model (for example codex/gpt-5.4) and an initial prompt. Do not guess; call list_providers and list_models first if uncertain.",
"Create an agent. Agent-scoped creation defaults to your workspace and creates your subagent. Top-level creation without workspaceId creates a new local workspace. Requires provider/model (for example codex/gpt-5.4) and an initial prompt. For a different provider, set settings.modeId from inspect_provider; omission is accepted for targets with no modes and for unattended execution supported by the target. Do not guess; call list_providers and list_models first if uncertain.",
inputSchema: createAgentInputSchema,
outputSchema: {
agentId: z.string(),
@@ -14,6 +14,8 @@ import {
import type { ArchiveResult, ActiveWorkspaceRef } from "../workspace-archive-service.js";
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
import { createWorktree, type WorktreeConfig } from "../../utils/worktree.js";
import { createGitLabService } from "../../services/gitlab-service.js";
import { createGiteaService } from "../../services/gitea-service.js";
import type { ForgeService } from "../../../services/forge-service.js";
import type { StoredAgentRecord } from "../agent/agent-storage.js";
@@ -30,6 +32,7 @@ function createPullRequest(
state: "open",
baseRefName: "main",
headRefName: "feature",
headSha: "same-head",
isMerged: true,
...overrides,
};
@@ -46,6 +49,8 @@ function createSnapshot(overrides?: {
repoRoot: "/tmp/repo",
mainRepoRoot: "/tmp/repo",
currentBranch: "feature",
upstreamKnown: true,
headSha: "same-head",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: true,
isDirty: false,
@@ -346,6 +351,89 @@ afterEach(() => {
});
describe("archiveIfSafe", () => {
test("archives an exact-head GitLab merge using the adapter status", async () => {
const mr = {
iid: 123,
title: "Merge me",
web_url: "https://gitlab.example.com/acme/repo/-/merge_requests/123",
state: "merged",
source_branch: "feature",
target_branch: "main",
source_project_id: 1,
target_project_id: 1,
sha: "same-head",
references: { full: "acme/repo!123" },
};
const forge = createGitLabService({
resolveGlabPath: async () => "/test/glab",
resolveRemoteUrl: async () => "https://gitlab.example.com/acme/repo.git",
runner: async (args) => ({
stdout: JSON.stringify(args[1] === "list" ? [mr] : mr),
stderr: "",
}),
});
const status = await forge.getCurrentPullRequestStatus({ cwd: CWD, headRef: "feature" });
expect(status).toMatchObject({ isMerged: true, headSha: "same-head" });
const harness = createHarness({ snapshot: createSnapshot({ pullRequest: status }) });
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).toHaveBeenCalledTimes(1);
});
test("archives a Gitea merge when optional checks fail but selected head is known", async () => {
const pr = {
number: 123,
html_url: "https://gitea.com/acme/repo/pulls/123",
title: "Merge me",
state: "closed",
merged: true,
head: {
ref: "feature",
sha: "same-head",
repo: { id: 1, full_name: "acme/repo", owner: { login: "acme" } },
},
base: { ref: "main", repo: { id: 1, owner: { login: "acme" } } },
};
const forge = createGiteaService({
resolveTeaPath: async () => "/test/tea",
resolveRemoteUrl: async () => "https://gitea.com/acme/repo.git",
runner: async (args) => {
if (args[0] === "pr" && args[1] === "list") return { stdout: "[]", stderr: "" };
if (args[0] === "api" && args[1].includes("/pulls?"))
return { stdout: JSON.stringify([pr]), stderr: "" };
throw new Error("checks unavailable");
},
});
const status = await forge.getCurrentPullRequestStatus({ cwd: CWD, headRef: "feature" });
expect(status).toMatchObject({ isMerged: true, headSha: "same-head" });
const harness = createHarness({ snapshot: createSnapshot({ pullRequest: status }) });
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).toHaveBeenCalledTimes(1);
});
test("does not archive when the checkout and merged PR heads differ", async () => {
const harness = createHarness({
snapshot: createSnapshot({
git: { headSha: "checkout-head" } as Partial<WorkspaceGitRuntimeSnapshot["git"]>,
pullRequest: createPullRequest({ headSha: "remote-head" } as Partial<
NonNullable<WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"]>
>),
}),
});
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
});
test("does not archive when either head or the worktree state is unavailable", async () => {
for (const snapshot of [
createSnapshot({ git: { headSha: undefined } }),
createSnapshot({ pullRequest: createPullRequest({ headSha: undefined }) }),
createSnapshot({ git: { isDirty: null } }),
]) {
const harness = createHarness({ snapshot });
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
}
});
beforeEach(() => {
vi.clearAllMocks();
});
@@ -381,6 +469,26 @@ describe("archiveIfSafe", () => {
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
});
test("does not archive when the upstream lookup failed", async () => {
const harness = createHarness({
snapshot: createSnapshot({
git: { aheadOfOrigin: null, upstreamKnown: false } as Partial<
WorkspaceGitRuntimeSnapshot["git"]
>,
}),
});
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
});
test("does not archive when upstream safety provenance is missing", async () => {
const harness = createHarness({
snapshot: createSnapshot({ git: { aheadOfOrigin: 0, upstreamKnown: undefined } }),
});
await runArchiveIfSafe(harness);
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
});
test("archives when the PR is merged and the upstream branch was deleted", async () => {
const harness = createHarness({
snapshot: createSnapshot({ git: { aheadOfOrigin: null, behindOfOrigin: null } }),
@@ -47,6 +47,19 @@ const defaultDependencies: ArchiveIfSafeDependencies = {
killTerminalsForWorkspace,
};
function isEligibleForMergeArchive(snapshot: WorkspaceGitRuntimeSnapshot): boolean {
const pullRequest = snapshot.forge.pullRequest;
return Boolean(
pullRequest?.isMerged &&
!snapshot.forge.error &&
snapshot.git.headSha &&
snapshot.git.headSha === pullRequest.headSha &&
snapshot.git.isDirty === false &&
snapshot.git.upstreamKnown === true &&
(snapshot.git.aheadOfOrigin === 0 || snapshot.git.aheadOfOrigin === null),
);
}
export async function archiveIfSafe(input: {
workspaceId: string;
snapshot: WorkspaceGitRuntimeSnapshot;
@@ -59,13 +72,7 @@ export async function archiveIfSafe(input: {
const cwd = snapshot.cwd;
const pullRequest = snapshot.forge.pullRequest;
if (!pullRequest?.isMerged) {
return;
}
if (snapshot.git.isDirty === true) {
return;
}
if (typeof snapshot.git.aheadOfOrigin === "number" && snapshot.git.aheadOfOrigin > 0) {
if (!pullRequest || !isEligibleForMergeArchive(snapshot)) {
return;
}
@@ -30,8 +30,8 @@ interface PullRequestObservation {
const cleanupPaths: string[] = [];
const cleanupServices: WorkspaceGitServiceImpl[] = [];
function run(cwd: string, args: string[]): void {
execFileSync("git", args, { cwd, stdio: "pipe" });
function run(cwd: string, args: string[]): string {
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" });
}
async function settleObservation(): Promise<void> {
@@ -63,6 +63,7 @@ async function createWorkspaceFiles() {
async function createWorkspaceJourney() {
const { repoDir, worktree } = await createWorkspaceFiles();
const remoteHeadSha = run(worktree.worktreePath, ["rev-parse", "HEAD"]).trim();
const workspaceId = "workspace-under-test";
let active = true;
let currentPullRequest: PullRequestObservation | null = null;
@@ -81,6 +82,7 @@ async function createWorkspaceJourney() {
repoRoot: worktree.worktreePath,
mainRepoRoot: repoDir,
currentBranch: branch,
headSha: run(worktree.worktreePath, ["rev-parse", "HEAD"]).trim(),
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: true,
isDirty: false,
@@ -102,6 +104,7 @@ async function createWorkspaceJourney() {
state: currentPullRequest.state,
baseRefName: "main",
headRefName: currentPullRequest.headRefName,
headSha: remoteHeadSha,
isMerged: currentPullRequest.state === "merged",
}
: null,
@@ -161,9 +164,13 @@ async function createWorkspaceJourney() {
};
}
async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
async function createUnobservedWorkspace(
archiveBarrier?: Promise<void>,
changeBeforeArchiveRead = false,
) {
const { repoDir, paseoHome, worktree } = await createWorkspaceFiles();
const cwd = worktree.worktreePath;
let remoteHeadSha = run(cwd, ["rev-parse", "HEAD"]).trim();
run(repoDir, ["remote", "add", "origin", "https://github.com/acme/repo.git"]);
run(repoDir, ["update-ref", "refs/remotes/origin/workspace", "HEAD"]);
run(cwd, ["branch", "--set-upstream-to=origin/workspace"]);
@@ -176,38 +183,58 @@ async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
const reads: Array<string | undefined> = [];
const archives: string[] = [];
let active = true;
const service = new WorkspaceGitServiceImpl({
logger,
paseoHome,
deps: {
forgeOverrides: { github },
getPullRequestStatus: async (_cwd, _forge, options) => {
reads.push(options?.reason);
return {
status: observation && {
...observation,
title: observation.headRefName,
baseRefName: "main",
isMerged: observation.state === "merged",
},
authState: "authenticated",
featuresEnabled: true,
githubFeaturesEnabled: true,
};
function createService() {
const service = new WorkspaceGitServiceImpl({
logger,
paseoHome,
deps: {
forgeOverrides: { github },
getPullRequestStatus: async (_cwd, _forge, options) => {
reads.push(options?.reason);
return {
status: observation && {
...observation,
headSha: remoteHeadSha,
title: observation.headRefName,
baseRefName: "main",
isMerged: observation.state === "merged",
},
authState: "authenticated",
featuresEnabled: true,
githubFeaturesEnabled: true,
};
},
},
},
});
cleanupServices.push(service);
});
cleanupServices.push(service);
return service;
}
let service = createService();
const options = {
logger,
paseoHome,
daemonConfigStore: { get: () => ({ autoArchiveAfterMerge: true }) },
workspaceGitService: service,
workspaceGitService: {
onSnapshotUpdated: (listener: (snapshot: WorkspaceGitRuntimeSnapshot) => void) =>
service.onSnapshotUpdated(listener),
getSnapshot: async (
snapshotCwd: string,
readOptions?: { reason?: string; force?: boolean },
) => {
if (changeBeforeArchiveRead && readOptions?.reason === "auto-archive-on-merge") {
changeBeforeArchiveRead = false;
writeFileSync(path.join(cwd, "README.md"), "changed after notification\n");
run(cwd, ["add", "README.md"]);
run(cwd, ["-c", "commit.gpgsign=false", "commit", "-m", "after notification"]);
}
return service.getSnapshot(snapshotCwd, readOptions);
},
},
getAutoArchivedChangeRequestUrl: async () => null,
listActiveWorkspaces: async () =>
active ? [{ workspaceId: "workspace-under-test", cwd, kind: "worktree" }] : [],
} as unknown as AutoArchiveOnMergeOptions;
setupAutoArchiveOnMerge(options, {
const archiveDependencies: AutoArchiveOnMergeDependencies = {
resolvePath: path.resolve,
archiveIfSafe: async (input) =>
archiveIfSafe({
@@ -228,12 +255,22 @@ async function createUnobservedWorkspace(archiveBarrier?: Promise<void>) {
},
},
}),
});
};
let subscription = setupAutoArchiveOnMerge(options, archiveDependencies);
return {
archives,
reads,
metrics: () => service.getMetrics(),
isActive: () => active,
restartProducer: async () => {
subscription.unsubscribe();
await service.dispose();
service = createService();
subscription = setupAutoArchiveOnMerge(options, archiveDependencies);
},
advanceRemoteHead: () => {
remoteHeadSha = "a".repeat(40);
},
makeDirty: () => writeFileSync(path.join(cwd, "README.md"), "uncommitted work\n"),
commitUnpushed: () => {
writeFileSync(path.join(cwd, "README.md"), "unpushed work\n");
@@ -270,6 +307,24 @@ afterEach(async () => {
});
describe("workspace auto-archive behavior", () => {
test("rechecks a checkout changed after the merged notification", async () => {
const workspace = await createUnobservedWorkspace(undefined, true);
await workspace.observe("open");
await workspace.observe("merged");
expect(workspace.reads).toContain("auto-archive-on-merge");
expect(workspace.archives).toEqual([]);
expect(workspace.isActive()).toBe(true);
});
test("keeps a merged badge and an active workspace when the remote head advanced", async () => {
const workspace = await createUnobservedWorkspace();
await workspace.observe("open");
workspace.advanceRemoteHead();
const merged = await workspace.observe("merged");
expect(merged.forge.pullRequest).toMatchObject({ state: "merged", headSha: "a".repeat(40) });
expect(merged.git.headSha).not.toBe(merged.forge.pullRequest?.headSha);
expect(workspace.isActive()).toBe(true);
expect(workspace.archives).toEqual([]);
});
test("archives a clean owned workspace on an unobserved open-to-merged transition only once", async () => {
let releaseArchive!: () => void;
const archiveBarrier = new Promise<void>((resolve) => {
@@ -292,10 +347,11 @@ describe("workspace auto-archive behavior", () => {
expect(workspace.metrics().workspaceListenerCount).toBe(0);
expect(workspace.archives).toEqual(["workspace-under-test"]);
// Confirmation reads the completed snapshot cache; it does not shell out again.
// The archive decision rereads the checkout and forge after the transition.
expect(workspace.reads).toEqual([
"workspace-journey",
"workspace-journey",
"auto-archive-on-merge",
"workspace-journey",
]);
} finally {
@@ -306,11 +362,14 @@ describe("workspace auto-archive behavior", () => {
test("keeps an unobserved workspace active when its first snapshot is already merged", async () => {
const workspace = await createUnobservedWorkspace();
await workspace.observe("merged");
workspace.advanceRemoteHead();
expect((await workspace.observe("merged")).forge.pullRequest?.state).toBe("merged");
await workspace.restartProducer();
expect((await workspace.observe("merged")).forge.pullRequest?.state).toBe("merged");
expect(workspace.metrics().workspaceListenerCount).toBe(0);
expect(workspace.archives).toEqual([]);
expect(workspace.reads).toEqual(["workspace-journey"]);
expect(workspace.reads).toEqual(["workspace-journey", "workspace-journey"]);
});
test("keeps dirty work when an unobserved workspace PR merges", async () => {
@@ -90,6 +90,11 @@ test("fans one fresh observation out to every workspace attached to its exact cw
await finished;
expect(getSnapshot).toHaveBeenCalledTimes(1);
expect(getSnapshot).toHaveBeenCalledWith("/repo/worktree/.", {
force: true,
afterInFlight: true,
reason: "auto-archive-on-merge",
});
expect(calls).toEqual([
{ workspaceId: "workspace-a", pullRequest: freshSnapshot.forge.pullRequest },
{ workspaceId: "workspace-b", pullRequest: freshSnapshot.forge.pullRequest },
@@ -63,6 +63,8 @@ export function setupAutoArchiveOnMerge(
let freshSnapshot: WorkspaceGitRuntimeSnapshot | null;
try {
freshSnapshot = await options.workspaceGitService.getSnapshot(snapshot.cwd, {
force: true,
afterInFlight: true,
reason: "auto-archive-on-merge",
});
} catch (error) {
@@ -670,11 +670,11 @@ describe("paseo daemon bootstrap", () => {
test("rolls back standalone listener without starting plugins when main listen fails", async () => {
const mainPort = await findFreePort();
const standalonePort = await findFreePort();
const occupiedMain = http.createServer((_req, res) => {
res.end("occupied-main");
});
await new Promise<void>((resolve) => occupiedMain.listen(mainPort, "127.0.0.1", resolve));
const standalonePort = await findFreePort();
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-main-rollback-"));
const paseoHome = path.join(paseoHomeRoot, ".paseo");
@@ -183,7 +183,7 @@ test("ordinary routed APIs isolate scopes and enforce one-hop, disable and origi
await paseo.dispose();
});
test("routed authority is bounded by the caller, with isolated simultaneous sessions", async () => {
test("routed authority isolates simultaneous sessions and preserves ordinary permission RPC errors", async () => {
const b = await daemon();
const a = await daemon();
const owner = await api(a);
@@ -210,33 +210,16 @@ test("routed authority is bounded by the caller, with isolated simultaneous sess
code: "permission_denied",
});
expect((await owner.paseo.hosts.list()).hosts).toHaveLength(1);
await remote.dispose();
});
test("SDK host clients preserve ordinary permission RPC errors", async () => {
const b = await daemon();
const a = await daemon();
const owner = await api(a);
await owner.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
const serverId = (await online(owner.paseo)).hosts[0]!.serverId!;
const clients = [a, b].map((host) =>
createHostClient(`127.0.0.1:${host.port}`, {
clientId: crypto.randomUUID(),
sessionPermissions: ["daemon.read", "workspace.read"],
reconnect: { enabled: false },
}),
);
for (const client of clients) {
cleanup.push(() => client.close());
await client.connect();
}
const origin = createPaseoApi(clients[0]!);
const direct = createPaseoApi(clients[1]!);
const remote = await origin.hosts.client(serverId);
const directClient = createHostClient(`127.0.0.1:${b.port}`, {
clientId: crypto.randomUUID(),
sessionPermissions: ["daemon.read", "workspace.read"],
reconnect: { enabled: false },
});
await directClient.connect();
const direct = createPaseoApi(directClient);
cleanup.push(async () => {
await remote.dispose();
await direct.dispose();
await origin.dispose();
await directClient.close();
});
const input = { source: { kind: "directory" as const, path: b.staticDir } };
const directError = (await direct.workspaces.create(input).catch((error: unknown) => error)) as {
@@ -247,6 +230,7 @@ test("SDK host clients preserve ordinary permission RPC errors", async () => {
};
expect(directError.code).toBe("access_denied");
expect(remoteError.code).toBe(directError.code);
await remote.dispose();
});
test("distinct origin sessions cannot collide through a caller-selected route scope, including terminal binary output", async () => {
@@ -784,6 +768,211 @@ test("old capabilities fail before execution and authentication errors recover t
expect((await online(paseo)).hosts[0]!.serverId).toBe(b.daemon.getServerId());
});
async function updatableDestination() {
let features: { connectedHosts: boolean; hostMultiplexing: boolean } | undefined;
let holding = false;
const held: (() => void)[] = [];
let connections = 0;
const http = createServer();
const ws = new WebSocketServer({ server: http });
ws.on("connection", (socket) => {
connections++;
socket.on("message", (data) => {
const frame = JSON.parse(data.toString());
if (frame.type === "ping") socket.send(JSON.stringify({ type: "pong" }));
if (frame.type !== "hello") return;
const reply = () =>
socket.send(
JSON.stringify({
type: "session",
message: {
type: "status",
payload: {
status: "server_info",
serverId: "srv_updated",
hostname: "updated",
version: "0.12.0",
...(features ? { features } : {}),
},
},
}),
);
if (holding) held.push(reply);
else reply();
});
});
http.listen(0, "127.0.0.1");
await once(http, "listening");
cleanup.push(async () => {
for (const socket of ws.clients) socket.terminate();
await new Promise<void>((resolve) => ws.close(() => resolve()));
await new Promise<void>((resolve) => http.close(() => resolve()));
});
const address = http.address();
if (!address || typeof address === "string") throw new Error("Missing destination port");
return {
port: address.port,
connections: () => connections,
hold: () => {
holding = true;
},
update: () => {
features = { connectedHosts: true, hostMultiplexing: true };
holding = false;
for (const reply of held.splice(0)) reply();
},
};
}
async function capabilityRetry(paseo: ReturnType<typeof createPaseoApi>): Promise<number> {
let retryAt = "";
await expect
.poll(
async () => {
const row = (await paseo.hosts.list()).hosts[0];
retryAt = row?.nextRetryAt ?? "";
return row;
},
{ timeout: 5000 },
)
.toMatchObject({
status: "offline",
nextRetryAt: expect.any(String),
error: { code: "unsupported_capability" },
});
return Date.parse(retryAt);
}
test("a destination updated past unsupported_capability keeps its guidance while retrying and comes online, while duplicate_host stays terminal", async () => {
const destination = await updatableDestination();
const a = await daemon();
const { paseo } = await api(a);
const host = await paseo.hosts.add({ connection: `127.0.0.1:${destination.port}` });
await capabilityRetry(paseo);
destination.hold();
await expect
.poll(async () => (await paseo.hosts.list()).hosts[0], { timeout: 5000 })
.toMatchObject({ status: "connecting", error: { code: "unsupported_capability" } });
await expect(paseo.hosts.client("srv_updated")).rejects.toMatchObject({
code: "unsupported_capability",
});
destination.update();
expect((await online(paseo)).hosts[0]).toMatchObject({
registrationId: host.registrationId,
serverId: "srv_updated",
nextRetryAt: null,
error: null,
});
await paseo.hosts.add({ connection: `ws://127.0.0.1:${destination.port}/ws` });
await expect
.poll(async () => (await paseo.hosts.list()).hosts[1], { timeout: 5000 })
.toMatchObject({ status: "error", nextRetryAt: null, error: { code: "duplicate_host" } });
});
test("disabling or removing a host cancels its pending capability retry", async () => {
const destination = await updatableDestination();
const a = await daemon();
const { paseo } = await api(a);
const host = await paseo.hosts.add({ connection: `127.0.0.1:${destination.port}` });
let retryAt = await capabilityRetry(paseo);
let connections = destination.connections();
await paseo.hosts.update(host.registrationId, { enabled: false });
await new Promise((resolve) => setTimeout(resolve, retryAt - Date.now() + 500));
expect(destination.connections()).toBe(connections);
await paseo.hosts.update(host.registrationId, { enabled: true });
retryAt = await capabilityRetry(paseo);
connections = destination.connections();
await paseo.hosts.remove(host.registrationId);
await new Promise((resolve) => setTimeout(resolve, retryAt - Date.now() + 500));
expect(destination.connections()).toBe(connections);
});
import {
createDaemonChannel,
exportPublicKey,
generateKeyPair,
openDaemonSecureChannel,
type Transport,
} from "@getpaseo/relay/e2ee";
test("a capability retry over a relay keeps the protected endpoint security it negotiated", async () => {
const keys = generateKeyPair();
const legacyReceived: unknown[] = [];
let connections = 0;
let retryClosed = false;
const http = createServer();
const ws = new WebSocketServer({ server: http });
ws.on("connection", (socket) => {
connections++;
const wire: Transport = {
send: (data) => socket.send(data),
close: (code, reason) => socket.close(code, reason),
onmessage: null,
onclose: null,
onerror: null,
};
socket.on("message", (data, isBinary) => {
const bytes = Buffer.from(data as Buffer);
wire.onmessage?.({
data: isBinary
? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
: bytes.toString(),
isBinary,
});
});
socket.on("close", (code, reason) => wire.onclose?.(code, reason.toString()));
if (connections > 1) {
socket.on("close", () => {
retryClosed = true;
});
void createDaemonChannel(wire, keys, {
onmessage: (data) => legacyReceived.push(data),
}).catch(() => {});
return;
}
const channel = openDaemonSecureChannel(wire, {
daemonKeyPair: keys,
events: {
onmessage: (data) => {
if (typeof data !== "string" || JSON.parse(data).type !== "hello") return;
void channel.then((secure) =>
secure.send(
JSON.stringify({
type: "session",
message: {
type: "status",
payload: {
status: "server_info",
serverId: "srv_relayed",
hostname: "relayed",
version: "0.12.0",
},
},
}),
),
);
},
},
});
void channel.catch(() => {});
});
http.listen(0, "127.0.0.1");
await once(http, "listening");
cleanup.push(async () => {
for (const socket of ws.clients) socket.terminate();
await new Promise<void>((resolve) => ws.close(() => resolve()));
await new Promise<void>((resolve) => http.close(() => resolve()));
});
const address = http.address();
if (!address || typeof address === "string") throw new Error("Missing relay port");
const a = await daemon();
const { paseo } = await api(a);
await paseo.hosts.add({
connection: `relay://127.0.0.1:${address.port}/srv_relayed?key=${encodeURIComponent(exportPublicKey(keys.publicKey))}`,
});
await capabilityRetry(paseo);
await expect.poll(() => retryClosed, { timeout: 15000 }).toBe(true);
expect(legacyReceived).toEqual([]);
});
test("an unreachable saved target becomes online after its daemon starts without being added again", async () => {
const reservation = createServer();
reservation.listen(0, "127.0.0.1");
@@ -7,10 +7,12 @@ import { z } from "zod";
import {
createHostClient,
createHostChannelTransport,
createHostTransportFactory,
parseHostTarget,
HostTransportBudget,
} from "@getpaseo/client/node";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { DaemonTransportFactory } from "@getpaseo/client/internal/daemon-client-transport-types";
import {
HostSummarySchema,
HostAddSchema,
@@ -235,23 +237,30 @@ export class ConnectedHosts {
if (route.registrationId === id)
route.close(code, "Host access was disabled, replaced or removed");
}
private start(row: Saved): void {
private start(
row: Saved,
resume?: { failures: number; transports: DaemonTransportFactory },
): void {
if (!row.enabled || this.closed) return;
let stopped = false;
const client = createHostClient(
row.connection,
{
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
connectTimeoutMs: 10000,
const transports =
resume?.transports ??
createHostTransportFactory(row.connection, this.budget, {
webSocketAgent: webSocketProxyAgent,
},
this.budget,
);
});
let stopped = false;
let attempts = resume?.failures ?? 0;
let retry: ReturnType<typeof setTimeout> | undefined;
const client = createHostClient(row.connection, {
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
connectTimeoutMs: 10000,
transportFactory: transports,
});
const live: Live = {
client,
accepted: false,
stop: () => {
stopped = true;
clearTimeout(retry);
void client.close();
},
};
@@ -285,7 +294,7 @@ export class ConnectedHosts {
if (info.features?.connectedHosts !== true || info.features?.hostMultiplexing !== true)
throw new HostError(
"unsupported_capability",
"Update the destination daemon to use connected hosts.",
"Update the destination daemon to use connected hosts; retrying automatically.",
);
// Pin and persist under the same writer as management to prevent discovery races.
await this.mutate(async () => {
@@ -304,16 +313,22 @@ export class ConnectedHosts {
if (stopped || client.getLastServerInfoMessage() !== info || !client.isConnected) return;
Object.assign(row, next);
live.accepted = true;
attempts = 0;
this.changed();
});
} catch (error) {
if (stopped) return;
row.status = "error";
row.error = connectionFailure(error, client.authFailureReason !== null) ?? {
code: "host_offline",
message: "Host connection failed",
};
row.nextRetryAt = null;
if (terminalFailures.has(row.error.code)) {
row.status = "error";
row.nextRetryAt = null;
} else {
stopped = true;
retry = this.scheduleAcceptanceRetry(row, attempts, transports);
}
this.changed();
await client.close();
}
@@ -329,18 +344,17 @@ export class ConnectedHosts {
if (route.registrationId === row.registrationId) route.close();
row.status = state.status === "connecting" ? "connecting" : "offline";
row.nextRetryAt = state.status === "disconnected" ? (state.nextRetryAt ?? null) : null;
row.error =
state.status === "disconnected"
? (connectionFailure(
Object.assign(new Error("Check the SSH configuration and daemon credentials"), {
code: state.failureCode,
}),
client.authFailureReason !== null,
) ?? {
code: "host_offline",
message: "Cannot reach host. Check the connection; retrying automatically.",
})
: null;
if (state.status === "disconnected")
row.error = connectionFailure(
Object.assign(new Error("Check the SSH configuration and daemon credentials"), {
code: state.failureCode,
}),
client.authFailureReason !== null,
) ?? {
code: "host_offline",
message: "Cannot reach host. Check the connection; retrying automatically.",
};
else if (row.error?.code !== "unsupported_capability") row.error = null;
if (row.error?.code === "authentication_failed") {
row.status = "error";
row.nextRetryAt = null;
@@ -351,6 +365,18 @@ export class ConnectedHosts {
});
void client.connect().catch(() => {});
}
private scheduleAcceptanceRetry(
row: Saved,
failures: number,
transports: DaemonTransportFactory,
): ReturnType<typeof setTimeout> {
const delay = acceptanceRetryDelayMs(failures);
row.status = "offline";
row.nextRetryAt = new Date(Date.now() + delay).toISOString();
const retry = setTimeout(() => this.start(row, { failures: failures + 1, transports }), delay);
retry.unref();
return retry;
}
async openRoute(input: {
serverId: string;
scopeId: string;
@@ -531,6 +557,20 @@ export class ConnectedHosts {
}
}
const ACCEPTANCE_RETRY_BASE_MS = 1500;
const ACCEPTANCE_RETRY_MAX_MS = 30_000;
function acceptanceRetryDelayMs(failures: number): number {
return Math.min(ACCEPTANCE_RETRY_BASE_MS * 2 ** failures, ACCEPTANCE_RETRY_MAX_MS);
}
const terminalFailures = new Set([
"server_id_mismatch",
"self_host",
"duplicate_host",
"authentication_failed",
]);
function connectionFailure(error: unknown, authenticationFailed: boolean): HostSummary["error"] {
if (error instanceof HostError) return { code: error.code, message: error.message };
if (error instanceof Error && "code" in error && error.code === "authentication_failed")
@@ -88,13 +88,9 @@ test.each(["uncommitted", "base", "commit"] as const)(
60_000,
);
test.each(
(["uncommitted", "base"] as const).flatMap((mode) =>
(["single", "batch", "rename"] as const).map((shape) => ({ mode, shape })),
),
)(
"$mode $shape diff includes hunks from a repository subdirectory",
async ({ mode, shape }) => {
test.each(["single", "batch", "rename"] as const)(
"%s diff includes hunks from a repository subdirectory before and after commit",
async (shape) => {
const ctx = await createDaemonTestContext();
const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-subdirectory-"));
@@ -112,9 +108,7 @@ test.each(
}
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial");
if (mode === "base") {
git(cwd, "checkout", "-b", "feature");
}
git(cwd, "checkout", "-b", "feature");
const filename = shape === "rename" ? "renamed.txt" : "normal.txt";
if (shape === "rename") {
git(cwd, "mv", "sub/normal.txt", `sub/${filename}`);
@@ -123,36 +117,38 @@ test.each(
if (shape === "batch") {
writeFileSync(join(cwd, "sibling.txt"), "new sibling\n");
}
if (mode === "base") {
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
async function expectSubdirectoryDiff(mode: "uncommitted" | "base") {
const compare = { mode, baseRef: "main" };
const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare);
const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare);
const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare);
const liveDiff = await subscription.ready;
await subscription.release();
expect(rootDiff.error).toBeNull();
expect(subdirectoryDiff.error).toBeNull();
expect(liveDiff.error).toBeNull();
expect(liveDiff.files).toEqual(rootDiff.files);
expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1);
const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`);
expect(file).toMatchObject({
additions: 1,
deletions: 1,
...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}),
});
expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "remove", content: "before" }),
expect.objectContaining({ type: "add", content: "after" }),
]),
);
expect(subdirectoryDiff.files).toEqual(rootDiff.files);
}
const compare = { mode, baseRef: "main" };
const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare);
const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare);
const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare);
const liveDiff = await subscription.ready;
await subscription.release();
expect(rootDiff.error).toBeNull();
expect(subdirectoryDiff.error).toBeNull();
expect(liveDiff.error).toBeNull();
expect(liveDiff.files).toEqual(rootDiff.files);
expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1);
const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`);
expect(file).toMatchObject({
additions: 1,
deletions: 1,
...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}),
});
expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "remove", content: "before" }),
expect.objectContaining({ type: "add", content: "after" }),
]),
);
expect(subdirectoryDiff.files).toEqual(rootDiff.files);
await expectSubdirectoryDiff("uncommitted");
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
await expectSubdirectoryDiff("base");
} finally {
await ctx.cleanup();
rmSync(cwd, { recursive: true, force: true });
@@ -0,0 +1,67 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import path from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { expect, test } from "vitest";
import { createDaemonTestContext } from "../test-utils/index.js";
test("an attended MCP caller can discover and supply a cross-provider child mode", async () => {
const artifacts = path.join(process.cwd(), ".artifacts");
await mkdir(artifacts, { recursive: true });
const root = await mkdtemp(path.join(artifacts, "create-agent-mode-"));
const ctx = await createDaemonTestContext({ paseoHomeRoot: root, mcpEnabled: true });
const mcp = new Client({ name: "mode-contract-test", version: "1" });
try {
const parent = await ctx.client.createAgent({
provider: "claude",
cwd: root,
modeId: "default",
title: "Attended parent",
});
await mcp.connect(
new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${ctx.daemon.port}/mcp/agents?callerAgentId=${parent.id}`),
),
);
const { tools } = await mcp.listTools();
const create = tools.find((tool) => tool.name === "create_agent");
expect.soft(create?.description).toContain("settings.modeId");
const args = {
provider: "codex/gpt-5.4-mini",
title: "Cross-provider child",
initialPrompt: "Reply with OK",
};
const refused = await mcp.callTool({ name: "create_agent", arguments: args });
expect(refused.isError).toBe(true);
expect.soft(refused.content).toEqual([
expect.objectContaining({
type: "text",
text: expect.stringContaining("Pass an explicit mode in settings.modeId"),
}),
]);
expect((await ctx.client.fetchAgents({ subscribe: {} })).entries).toHaveLength(1);
const inspected = await mcp.callTool({
name: "inspect_provider",
arguments: { provider: "codex" },
});
expect(inspected.isError).not.toBe(true);
expect(inspected.structuredContent).toMatchObject({
modes: expect.arrayContaining([expect.objectContaining({ id: "auto" })]),
});
const created = await mcp.callTool({
name: "create_agent",
arguments: { ...args, settings: { modeId: "auto" } },
});
expect(created.isError).not.toBe(true);
expect(created.structuredContent).toMatchObject({ type: "codex", currentModeId: "auto" });
const childId = (created.structuredContent as { agentId: string }).agentId;
expect((await ctx.client.fetchAgent(childId))?.agent.currentModeId).toBe("auto");
expect((await ctx.client.waitForFinish(childId, 5000)).status).toBe("idle");
} finally {
await mcp.close();
await ctx.cleanup();
await rm(root, { recursive: true, force: true });
}
}, 30_000);
@@ -102,53 +102,52 @@ describe("daemon E2E", () => {
},
);
test.each([
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
{
fileName: "café.txt",
disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt",
},
{
fileName: "中文报告 (最终版).txt",
disposition:
"attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt",
},
{
fileName: "報告書.md",
disposition:
"attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md",
},
{
fileName: "보고서.txt",
disposition:
"attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt",
},
{
fileName: "report-📄.txt",
disposition:
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
},
])(
"issues token over WS and downloads $fileName via HTTP",
async ({ fileName, disposition }) => {
const cwd = tmpCwd();
try {
test("downloads ASCII and Unicode filenames through one workspace", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Download Token Test Agent",
});
expect(agent.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
for (const { fileName, disposition } of [
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
{
fileName: "café.txt",
disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt",
},
{
fileName: "中文报告 (最终版).txt",
disposition:
"attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt",
},
{
fileName: "報告書.md",
disposition:
"attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md",
},
{
fileName: "보고서.txt",
disposition:
"attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt",
},
{
fileName: "report-📄.txt",
disposition:
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
},
]) {
const filePath = path.join(cwd, fileName);
const fileContents = "download test payload";
const fileContents = `download test payload: ${fileName}`;
writeFileSync(filePath, fileContents, "utf-8");
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Download Token Test Agent",
});
expect(agent.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName);
expect(tokenResponse.error).toBeNull();
@@ -167,12 +166,11 @@ describe("daemon E2E", () => {
const body = await response.text();
expect(body).toBe(fileContents);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
60000,
);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 60000);
test("rejects invalid token", async () => {
const response = await fetch(
@@ -0,0 +1,129 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import path from "node:path";
import type { FormInfo } from "@opencode/client";
import { expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { OpenCodeV2AgentClient } from "../agent/providers/opencode/v2/agent.js";
import { V2Harness } from "../agent/providers/opencode/test-utils/v2-harness.js";
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
async function createQuestionHarness() {
const artifacts = path.resolve(".artifacts");
await mkdir(artifacts, { recursive: true });
const root = await mkdtemp(path.join(artifacts, "peer-permissions-"));
const runtime = new V2Harness();
runtime.autoComplete = false;
runtime.api.model.list = async () => ({ location: runtime.info.location, data: [] });
runtime.api.provider.list = async () => ({
location: runtime.info.location,
data: [{ id: "test", name: "Test", activation: "enabled", package: "test" }],
});
let form: FormInfo | null = null;
const answers: Array<Record<string, unknown>> = [];
runtime.api.session.form.list = async () => (form ? [form] : []);
runtime.api.session.form.cancel = async () => {
form = null;
runtime.finishExecution("interrupted");
};
runtime.api.session.form.reply = async ({ answer }) => {
answers.push(answer);
form = null;
runtime.finishExecution();
};
// A steer is admitted to the existing execution, rather than starting another one.
runtime.prompt = async (input) => {
runtime.prompts.push(input.text);
if (runtime.prompts.length === 1) runtime.startExecution();
};
const provider = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: runtime.runtime,
});
const daemon = await createTestPaseoDaemon({
agentClients: { ...createTestAgentClients(), opencode: provider },
paseoHomeRoot: path.join(root, "daemon"),
staticDir: path.join(root, "static"),
cleanup: false,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const recipient = await client.createAgent({
provider: "opencode",
cwd: root,
title: "Waiting for a human answer",
});
const sender = await client.createAgent({ provider: "claude", cwd: root });
await client.sendMessage(recipient.id, "Ask which color to use");
await expect.poll(() => runtime.prompts).toHaveLength(1);
form = {
id: "color-question",
sessionID: "session",
title: "Color",
fields: [{ key: "color", title: "Color", type: "string" }],
};
runtime.push({ id: "question-created", created: 2, type: "form.created", data: { form } });
await client.waitForAgentUpsert(recipient.id, (agent) =>
(agent.pendingPermissions ?? []).some((request) => request.id === "color-question"),
);
return {
client,
recipient,
sender,
answers,
async close() {
await client.close();
await daemon.close();
await rm(root, { recursive: true, force: true });
},
};
} catch (error) {
await client.close();
await daemon.close();
await rm(root, { recursive: true, force: true });
throw error;
}
}
test("an agent-sourced steer leaves the question pending and answerable", async () => {
const { client, recipient, sender, answers, close } = await createQuestionHarness();
try {
await client.sendAgentMessage(recipient.id, "Peer status check", {
sourceAgentId: sender.id,
});
// The public wait includes admitted message delivery before reporting permission status.
const waiting = await client.waitForFinish(recipient.id);
expect(waiting.status).toBe("permission");
expect(waiting.final?.pendingPermissions).toMatchObject([
{ id: "color-question", kind: "question" },
]);
await client.respondToPermission(recipient.id, "color-question", {
behavior: "allow",
updatedInput: { answers: { Color: "Blue" } },
});
await client.waitForAgentUpsert(
recipient.id,
(agent) => (agent.pendingPermissions ?? []).length === 0 && agent.status === "idle",
);
expect(answers).toEqual([{ color: "Blue" }]);
} finally {
await close();
}
});
test("a human steer still dismisses the question", async () => {
const { client, recipient, answers, close } = await createQuestionHarness();
try {
await client.sendAgentMessage(recipient.id, "Use red instead", { activeTurnBehavior: "steer" });
const finished = await client.waitForFinish(recipient.id);
expect(finished.status).toBe("idle");
expect(finished.final?.pendingPermissions ?? []).toEqual([]);
expect(answers).toEqual([]);
} finally {
await close();
}
});
@@ -20,6 +20,7 @@ import { DaemonConfigStore } from "../daemon-config-store.js";
import { PluginService } from "./index.js";
import { BuiltinPluginLoader } from "./builtin/index.js";
import { ManagedPluginSources } from "./managed-source.js";
import type { PluginProviderMetadata } from "./plugin-process-protocol.js";
import {
startNpmRegistry,
npmPluginPackages,
@@ -179,6 +180,55 @@ function createPluginSelectivePausedRuntime(pausedPluginId: string) {
return { runtime, started, releaseStart, starts };
}
function createGatedRuntime(providers: Record<string, readonly PluginProviderMetadata[]> = {}) {
const gates = new Map<string, PromiseWithResolvers<void>>();
const gate = (pluginId: string) => {
const existing = gates.get(pluginId);
if (existing) return existing;
const created = Promise.withResolvers<void>();
gates.set(pluginId, created);
return created;
};
const begun: string[] = [];
const running = new Set<string>();
const runtime: TestPluginRuntime = {
...emptyUsageRuntime,
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async () => undefined,
getLogs: () => [],
clearLogs: () => undefined,
getProviderRegistrations: (pluginId) =>
running.has(pluginId) ? (providers[pluginId] ?? []) : [],
startPlugin: async (pluginId) => {
begun.push(pluginId);
await gate(pluginId).promise;
running.add(pluginId);
},
stopPluginById: async (pluginId) => running.delete(pluginId),
stopAll: async () => {
running.clear();
},
subscribe: () => () => undefined,
bindPaseoSessionHost: () => undefined,
};
return {
runtime,
begun,
finish: (pluginId: string) => gate(pluginId).resolve(),
fail: (pluginId: string, error: Error) => gate(pluginId).reject(error),
};
}
function nextNotification(service: PluginService, pluginId: string): Promise<void> {
return new Promise((resolve) => {
const unsubscribe = service.subscribe((notified) => {
if (notified !== pluginId) return;
unsubscribe();
resolve();
});
});
}
describe("PluginService", () => {
it.each([
{ shadow: false, runtime: "subprocess" },
@@ -372,15 +422,127 @@ describe("PluginService", () => {
"startup-first": { source: "directory", path: first },
"startup-second": { source: "directory", path: second },
});
const snapshots: string[][] = [];
service.subscribe(() => snapshots.push(catalogIds(service)));
const notifications: { pluginId: string; running: boolean }[] = [];
service.subscribe((pluginId) =>
notifications.push({ pluginId, running: catalogIds(service).includes(pluginId) }),
);
await service.start();
expect(snapshots).toEqual([["startup-first"], ["startup-first", "startup-second"]]);
expect(notifications).toEqual([
{ pluginId: "startup-first", running: true },
{ pluginId: "startup-second", running: true },
]);
expect(catalogIds(service)).toEqual(["startup-first", "startup-second"]);
await service.stopAllPlugins();
}, 20_000);
it("starts configured plugins concurrently and publishes them in config order", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const gated = createGatedRuntime();
const service = createService(
home,
{
alpha: { source: "directory", path: "/plugins/alpha" },
beta: { source: "directory", path: "/plugins/beta" },
gamma: { source: "directory", path: "/plugins/gamma" },
},
{ runtime: gated.runtime },
);
const notified: string[] = [];
service.subscribe((pluginId) => notified.push(pluginId));
let started = false;
const starting = (async () => {
await service.start();
started = true;
})();
await expect.poll(() => gated.begun).toEqual(["alpha", "beta", "gamma"]);
gated.finish("gamma");
const alphaPublished = nextNotification(service, "alpha");
gated.finish("alpha");
await alphaPublished;
expect(notified).toEqual(["alpha"]);
expect(started).toBe(false);
gated.finish("beta");
await starting;
expect(notified).toEqual(["alpha", "beta", "gamma"]);
expect(catalogIds(service)).toEqual(["alpha", "beta", "gamma"]);
await service.stopAllPlugins();
});
it("starts the other configured plugins when one fails while they are still starting", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const gated = createGatedRuntime();
const service = createService(
home,
{
broken: { source: "directory", path: "/plugins/broken" },
healthy: { source: "directory", path: "/plugins/healthy" },
},
{ runtime: gated.runtime },
);
const notified: string[] = [];
service.subscribe((pluginId) => notified.push(pluginId));
const starting = service.start();
await expect.poll(() => gated.begun).toEqual(["broken", "healthy"]);
const brokenPublished = nextNotification(service, "broken");
gated.fail("broken", new Error("startup exploded"));
await brokenPublished;
gated.finish("healthy");
await starting;
expect(notified).toEqual(["broken", "healthy"]);
expect(catalogIds(service)).toEqual(["healthy"]);
expect(await service.listPlugins()).toMatchObject([
{ id: "broken", status: "failed", error: "startup exploded" },
{ id: "healthy", status: "running" },
]);
await service.stopAllPlugins();
});
it("keeps a provider ID with the plugin listed first when both start at once", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const gated = createGatedRuntime({
first: [{ id: "shared-agent", label: "First agent" }],
second: [{ id: "shared-agent", label: "Second agent" }],
});
const service = createService(
home,
{
first: { source: "directory", path: "/plugins/first" },
second: { source: "directory", path: "/plugins/second" },
},
{ runtime: gated.runtime },
);
const starting = service.start();
await expect.poll(() => gated.begun).toEqual(["first", "second"]);
gated.finish("second");
gated.finish("first");
await starting;
expect(service.getProviderRegistrations()).toMatchObject([
{ id: "shared-agent", label: "First agent" },
]);
expect(catalogIds(service)).toEqual(["first"]);
expect(await service.listPlugins()).toMatchObject([
{ id: "first", status: "running" },
{
id: "second",
status: "failed",
error: 'Plugin second cannot register provider ID "shared-agent" twice',
},
]);
await service.stopAllPlugins();
});
it("uses an explicit config key, exposes reload failure, and retries from disk", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
+20 -8
View File
@@ -230,11 +230,18 @@ export class PluginService {
}
});
if (config.pluginsEnabled === true) {
for (const [pluginId, source] of Object.entries(config.plugins ?? {})) {
if (source.enabled === false) continue;
await this.startConfigured(pluginId);
this.notify(pluginId);
}
let earlierPublished: Promise<unknown> = Promise.resolve();
await Promise.all(
Object.entries(config.plugins ?? {})
.filter(([, source]) => source.enabled !== false)
.map(async ([pluginId]) => {
const publishTurn = earlierPublished;
const starting = this.startConfigured(pluginId, publishTurn);
earlierPublished = Promise.all([publishTurn, starting]);
await starting;
this.notify(pluginId);
}),
);
}
this.configStore.onFieldChange("pluginsEnabled", (value) => {
this.handleGlobalSwitch(value === true);
@@ -560,12 +567,12 @@ export class PluginService {
}).catch((error) => this.logger.error({ err: error }, "Failed to enable plugins"));
}
private async startConfigured(pluginId: string): Promise<void> {
private async startConfigured(pluginId: string, publishTurn?: Promise<unknown>): Promise<void> {
const source = this.configStore.get().plugins?.[pluginId];
if (!source || source.enabled === false || !this.canPublish(pluginId)) return;
this.errors.delete(pluginId);
try {
await this.startPlugin(pluginId, source.path);
await this.startPlugin(pluginId, source.path, publishTurn);
} catch (error) {
if (this.canPublish(pluginId)) this.recordFailure(pluginId, error);
}
@@ -594,8 +601,13 @@ export class PluginService {
);
}
private async startPlugin(pluginId: string, sourcePath: string): Promise<void> {
private async startPlugin(
pluginId: string,
sourcePath: string,
publishTurn?: Promise<unknown>,
): Promise<void> {
await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId));
await publishTurn;
try {
await this.publishProviderRegistrations(pluginId, sourcePath);
this.publishUsageSources(pluginId);
@@ -61,10 +61,24 @@ describe("managed Git plugin sources", () => {
roots.push(home);
const sources = new ManagedPluginSources(home);
const failure = sources.prepareInstall({
source: "git:https://oauth2:super-secret@127.0.0.1:1/missing.git",
// Own the failure response instead of relying on a closed port and OS connect timing.
const server = createServer((_request, response) => {
response.writeHead(500, { "Content-Type": "text/plain" });
response.end("clone rejected for oauth2:super-secret\n");
});
await expect(failure).rejects.not.toThrow(/oauth2|super-secret/);
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as { port: number };
try {
const failure = sources.prepareInstall({
source: `git:http://oauth2:super-secret@127.0.0.1:${address.port}/missing.git`,
});
await expect(failure).rejects.toThrow("500");
await expect(failure).rejects.toThrow("clone rejected for [redacted]:[redacted]");
await expect(failure).rejects.not.toThrow(/oauth2|super-secret/);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("normalizes explicit GitHub and Git identifiers to the same Git source", async () => {
+246 -31
View File
@@ -1,7 +1,9 @@
import { type EventEmitter, once } from "node:events";
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import http from "node:http";
import net from "node:net";
import type { Duplex } from "node:stream";
import express from "express";
import { describe, expect, it } from "vitest";
import pino from "pino";
@@ -332,48 +334,39 @@ describe("service proxy subsystem shape", () => {
});
});
interface ForwardedFixture {
interface ProxiedServiceFixture {
upstreamPort: number;
daemonPort: number;
daemon: http.Server;
hostname: string;
close(): Promise<void>;
}
/**
* Runs a real workspace service behind a real daemon listener. The upstream
* echoes the headers it received so tests can assert what actually crossed the
* proxy, not what a helper returned.
* Runs a real workspace service behind a real daemon listener that serves the
* service proxy for both HTTP and upgrades.
*/
async function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise<ForwardedFixture> {
const upstream = http.createServer((req, res) => {
let body = "";
req.on("data", (chunk: Buffer) => {
body += chunk.toString();
async function startProxiedService(
upstream: http.Server,
options: { upstreamHost?: string; logger?: pino.Logger } = {},
): Promise<ProxiedServiceFixture> {
const { upstreamHost = "127.0.0.1", logger: proxyLogger = logger } = options;
// Upgraded sockets keep the server alive; close() alone would hang.
const sockets = new Set<net.Socket>();
function trackConnections(server: http.Server) {
server.on("connection", (socket: net.Socket) => {
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
});
req.on("end", () => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body }));
});
});
const upgradeSockets: net.Socket[] = [];
upstream.on("upgrade", (req, socket) => {
upgradeSockets.push(socket);
// The client resets this connection once it has the echo, so the reset
// reaches us as an 'error'. A raw upgrade socket has no default handler —
// without this the event goes unhandled and takes down the test process.
socket.on("error", () => socket.destroy());
const payload = JSON.stringify(req.headers);
socket.write(
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`,
);
});
}
trackConnections(upstream);
await new Promise<void>((resolve, reject) => {
upstream.once("error", reject);
upstream.listen(0, upstreamHost, resolve);
});
const upstreamPort = (upstream.address() as net.AddressInfo).port;
const serviceProxy = createServiceProxySubsystem({ logger });
const serviceProxy = createServiceProxySubsystem({ logger: proxyLogger });
const route = serviceProxy.registerWorkspaceService({
workspaceId: "workspace-a",
projectSlug: "repo",
@@ -390,24 +383,51 @@ async function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise
res.status(404).send("404 Not Found");
});
const daemon = http.createServer(app);
trackConnections(daemon);
daemon.on("upgrade", serviceProxy.upgradeHandler({ passthroughUnknown: false }));
await new Promise<void>((resolve) => daemon.listen(daemonPort, "127.0.0.1", resolve));
return {
upstreamPort,
daemonPort,
daemon,
hostname: route.hostname,
async close() {
// Upgraded sockets keep the server alive; close() alone would hang.
for (const socket of upgradeSockets) socket.destroy();
daemon.closeAllConnections();
for (const socket of sockets) socket.destroy();
await new Promise<void>((resolve) => daemon.close(() => resolve()));
upstream.closeAllConnections();
await new Promise<void>((resolve) => upstream.close(() => resolve()));
},
};
}
/**
* The upstream echoes the headers it received so tests can assert what
* actually crossed the proxy, not what a helper returned.
*/
function startForwardedHeadersFixture(upstreamHost = "127.0.0.1"): Promise<ProxiedServiceFixture> {
const upstream = http.createServer((req, res) => {
let body = "";
req.on("data", (chunk: Buffer) => {
body += chunk.toString();
});
req.on("end", () => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ...req.headers, method: req.method, url: req.url, body }));
});
});
upstream.on("upgrade", (req, socket) => {
// The client resets this connection once it has the echo, so the reset
// reaches us as an 'error'. A raw upgrade socket has no default handler —
// without this the event goes unhandled and takes down the test process.
socket.on("error", () => socket.destroy());
const payload = JSON.stringify(req.headers);
socket.write(
`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nX-Echo-Length: ${payload.length}\r\n\r\n${payload}`,
);
});
return startProxiedService(upstream, { upstreamHost });
}
function upgradeThroughProxy(
port: number,
host: string,
@@ -651,3 +671,198 @@ describe("service proxy forwarded headers", () => {
}
});
});
function closed(emitter: EventEmitter): Promise<void> {
return new Promise((resolve) => {
emitter.once("close", () => resolve());
});
}
async function within<T>(promise: Promise<T>, ms: number, failure: string): Promise<T> {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(`${failure} after ${ms}ms`)), ms);
});
try {
return await Promise.race([promise, timeout]);
} finally {
clearTimeout(timer);
}
}
function requestThroughProxy(
fixture: ProxiedServiceFixture,
requestPath: string,
): http.ClientRequest {
return http.get({
hostname: "127.0.0.1",
port: fixture.daemonPort,
path: requestPath,
headers: { host: fixture.hostname },
});
}
const STRAY_WARNING_GRACE_MS = 100;
function createUpgradeUpstream(onUpgrade: (socket: Duplex) => void): http.Server {
const upstream = http.createServer();
upstream.on("upgrade", (_req, socket) => {
socket.on("error", () => socket.destroy());
socket.on("end", () => socket.end());
socket.write(
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n",
);
onUpgrade(socket);
});
return upstream;
}
function openUpgradeThroughProxy(fixture: ProxiedServiceFixture): Promise<net.Socket> {
return new Promise((resolve, reject) => {
const socket = net.connect({ host: "127.0.0.1", port: fixture.daemonPort }, () => {
const lines = [
"GET /ws HTTP/1.1",
`Host: ${fixture.hostname}`,
"Upgrade: websocket",
"Connection: Upgrade",
"",
"",
];
socket.write(lines.join("\r\n"));
});
let raw = "";
socket.on("data", (chunk: Buffer) => {
raw += chunk.toString();
if (raw.includes("\r\n\r\n")) resolve(socket);
});
socket.on("close", () => reject(new Error("upgrade closed before the 101 arrived")));
socket.on("error", reject);
});
}
describe("service proxy connection teardown", () => {
it("closes the upstream connection when a client leaves an event stream", async () => {
const upstreamResponse = Promise.withResolvers<http.ServerResponse>();
const upstream = http.createServer((_req, res) => {
res.writeHead(200, { "content-type": "text/event-stream" });
res.write("data: open\n\n");
upstreamResponse.resolve(res);
});
const fixture = await startProxiedService(upstream);
try {
const request = requestThroughProxy(fixture, "/events");
const [response] = (await once(request, "response")) as [http.IncomingMessage];
await once(response, "data");
const upstreamClosed = closed(await upstreamResponse.promise);
request.destroy();
await within(upstreamClosed, 1000, "the service still holds the event stream");
} finally {
await fixture.close();
}
}, 5000);
it("aborts the upstream request when a client leaves before the response starts", async () => {
const warnings: unknown[] = [];
const proxyLogger = pino(
{ level: "warn" },
{ write: (line: string) => warnings.push(JSON.parse(line)) },
);
const upstreamResponse = Promise.withResolvers<http.ServerResponse>();
const upstream = http.createServer((_req, res) => {
upstreamResponse.resolve(res);
});
const fixture = await startProxiedService(upstream, { logger: proxyLogger });
try {
const request = requestThroughProxy(fixture, "/slow");
request.on("error", () => undefined);
const upstreamClosed = closed(await upstreamResponse.promise);
request.destroy();
await within(upstreamClosed, 1000, "the service still holds the abandoned request");
await new Promise((resolve) => setTimeout(resolve, STRAY_WARNING_GRACE_MS));
expect(warnings).toEqual([]);
} finally {
await fixture.close();
}
}, 5000);
it("lets a complete response finish without resetting the service", async () => {
const upstreamResponse = Promise.withResolvers<http.ServerResponse>();
const upstream = http.createServer((_req, res) => {
res.writeHead(200, { "content-type": "text/plain" });
res.write("first;");
upstreamResponse.resolve(res);
});
const fixture = await startProxiedService(upstream);
try {
const response = httpGet(fixture.daemonPort, fixture.hostname, { path: "/" });
const res = await upstreamResponse.promise;
const upstreamClosed = closed(res);
res.end("second");
await expect(response).resolves.toEqual({ status: 200, body: "first;second" });
await within(upstreamClosed, 1000, "the service response never closed");
expect(res.writableFinished).toBe(true);
} finally {
await fixture.close();
}
}, 5000);
it("closes the upstream WebSocket when the daemon drops the client socket", async () => {
const upstreamSocket = Promise.withResolvers<Duplex>();
const fixture = await startProxiedService(
createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)),
);
const daemonSide = Promise.withResolvers<Duplex>();
fixture.daemon.on("upgrade", (_req, socket) => daemonSide.resolve(socket));
try {
const client = await openUpgradeThroughProxy(fixture);
const upstreamClosed = closed(await upstreamSocket.promise);
(await daemonSide.promise).destroy();
await within(upstreamClosed, 1000, "the service still holds the WebSocket");
client.destroy();
} finally {
await fixture.close();
}
}, 5000);
it("closes the upstream WebSocket when the client disconnects", async () => {
const upstreamSocket = Promise.withResolvers<Duplex>();
const fixture = await startProxiedService(
createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)),
);
try {
const client = await openUpgradeThroughProxy(fixture);
const upstreamClosed = closed(await upstreamSocket.promise);
client.destroy();
await within(upstreamClosed, 1000, "the service still holds the WebSocket");
} finally {
await fixture.close();
}
}, 5000);
it("closes the client WebSocket when the service closes it", async () => {
const upstreamSocket = Promise.withResolvers<Duplex>();
const fixture = await startProxiedService(
createUpgradeUpstream((socket) => upstreamSocket.resolve(socket)),
);
try {
const client = await openUpgradeThroughProxy(fixture);
const clientClosed = closed(client);
(await upstreamSocket.promise).destroy();
await within(clientClosed, 1000, "the client WebSocket is still open");
} finally {
await fixture.close();
}
}, 5000);
});
@@ -342,7 +342,14 @@ function proxyHttpRequest({
proxyRes.pipe(res, { end: true });
},
);
let clientClosed = false;
res.on("close", () => {
if (res.writableFinished) return;
clientClosed = true;
proxyReq.destroy();
});
proxyReq.on("error", (err) => {
if (clientClosed) return;
logger.warn(
{ err, hostname: route.hostname, port: route.port },
"Service proxy: upstream unreachable",
@@ -399,6 +406,9 @@ function proxyUpgradeRequest({
socket.on("error", () => {
targetSocket.destroy();
});
socket.on("close", () => {
targetSocket.destroy();
});
}
function sameRouteOwner(left: ServiceProxyRouteEntry, right: ServiceProxyRouteEntry): boolean {
+1 -1
View File
@@ -8318,7 +8318,7 @@ export class Session {
},
activeTurnBehavior: msg.activeTurnBehavior ?? "steer",
sessionMode: msg.sessionMode,
clearPendingPermissions: true,
clearPendingPermissions: false,
});
deliveryStatus = "queued";
queued();
@@ -95,7 +95,13 @@ it("accepts terminalId and token reports through the route into the tracker", as
command: process.execPath,
args: [
"-e",
`require("node:fs").writeFileSync(${JSON.stringify(envPath)}, JSON.stringify({ terminalId: process.env.PASEO_TERMINAL_ID, token: process.env.PASEO_ACTIVITY_TOKEN, url: process.env.PASEO_TERMINAL_ACTIVITY_URL })); setInterval(() => {}, 1000);`,
`
const fs = require("node:fs");
const envPath = ${JSON.stringify(envPath)};
fs.writeFileSync(envPath + ".tmp", JSON.stringify({ terminalId: process.env.PASEO_TERMINAL_ID, token: process.env.PASEO_ACTIVITY_TOKEN, url: process.env.PASEO_TERMINAL_ACTIVITY_URL }));
fs.renameSync(envPath + ".tmp", envPath);
setInterval(() => {}, 1000);
`,
],
});
await waitForCondition(() => existsSync(envPath), 10000);
@@ -1,3 +1,4 @@
import { flushGitCommandTrace } from "../utils/git-command-trace.js";
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
@@ -730,3 +731,103 @@ test("Git event identity retains a symlink's literal target before canonicalizin
rmSync(root, { recursive: true, force: true });
}
});
test.skipIf(process.platform !== "linux")(
"missing folders stop degraded Git polling and explicit refreshes, then recover",
async () => {
const artifacts = path.resolve(".artifacts");
mkdirSync(artifacts, { recursive: true });
const root = mkdtempSync(path.join(artifacts, "workspace-git-missing-"));
const cwd = path.join(root, "repo");
const initializeRepo = (branch: string) => {
mkdirSync(cwd);
execFileSync("git", ["init", "-b", branch], { cwd, stdio: "pipe" });
writeFileSync(path.join(cwd, "README.md"), "fixture\n");
execFileSync("git", ["add", "."], { cwd, stdio: "pipe" });
execFileSync(
"git",
[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.invalid",
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"fixture",
],
{ cwd, stdio: "pipe" },
);
};
initializeRepo("main");
// Exceed the real Linux observer's directory limit to enter degraded polling.
// No watcher errors or Git responses are injected.
for (let index = 0; index < 5100; index++) {
mkdirSync(path.join(cwd, `generated-${index}`));
}
const trace = path.join(root, "git.jsonl");
writeFileSync(trace, "");
const previousTrace = process.env.PASEO_GIT_TRACE_FILE;
process.env.PASEO_GIT_TRACE_FILE = trace;
let degraded = false;
let suspended = false;
const logger = pino(
{ level: "warn" },
{
write(line) {
const message = JSON.parse(line).msg;
if (message === "Failed to start working tree watcher; using degraded polling")
degraded = true;
if (
message ===
"Workspace folder no longer exists; suspended git observation until it is back"
)
suspended = true;
},
},
);
const service = new WorkspaceGitServiceImpl({ logger, paseoHome: path.join(root, "home") });
const subscription = service.registerWorkspace({ cwd }, () => {});
const commandCount = async () => {
await flushGitCommandTrace();
return readFileSync(trace, "utf8").trim().split("\n").filter(Boolean).length;
};
try {
await expect
.poll(() => degraded && service.getMetrics().workspaceRefreshInFlightCount === 0, {
timeout: 15_000,
})
.toBe(true);
rmSync(cwd, { recursive: true, force: true });
await expect.poll(() => suspended, { timeout: 15_000 }).toBe(true);
await expect.poll(() => service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
const commandsWhileMissing = await commandCount();
await service.getSnapshot(cwd, {
force: true,
includeForge: false,
reason: "refresh-button",
});
await service.refresh(cwd);
expect(await commandCount()).toBe(commandsWhileMissing);
await new Promise((resolve) => setTimeout(resolve, 16_000));
expect(await commandCount()).toBe(commandsWhileMissing);
initializeRepo("restored");
const snapshot = await service.getSnapshot(cwd, {
force: true,
includeForge: false,
reason: "refresh-button",
});
expect(snapshot.git).toMatchObject({ isGit: true, currentBranch: "restored" });
await expect.poll(() => service.getMetrics().repositoryWorkspaceLinkCount).toBe(1);
} finally {
subscription.unsubscribe();
await service.dispose();
if (previousTrace === undefined) delete process.env.PASEO_GIT_TRACE_FILE;
else process.env.PASEO_GIT_TRACE_FILE = previousTrace;
rmSync(root, { recursive: true, force: true });
}
},
60_000,
);
@@ -244,6 +244,7 @@ function createService(
filterEvents: (events) => events,
verify: vi.fn(async () => {}),
})),
workspaceFolderExists: vi.fn(async () => true),
...overrides,
} as never,
});
@@ -150,6 +150,8 @@ function currentPullRequestJson(overrides: Record<string, unknown> = {}): string
isDraft: false,
baseRefName: "main",
headRefName: "feature",
headRepositoryOwner: { login: "acme" },
headRepository: { name: "repo" },
mergedAt: null,
statusCheckRollup: [],
reviewDecision: "REVIEW_REQUIRED",
@@ -802,6 +804,28 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
service.dispose();
});
test("an after-in-flight read refreshes again after an earlier forced read", async () => {
const forcedRefresh = createDeferred<CheckoutStatusGit>();
const getCheckoutStatus = vi
.fn<() => Promise<CheckoutStatusGit>>()
.mockImplementationOnce(async () => createCheckoutStatus(REPO_CWD))
.mockImplementationOnce(async () => forcedRefresh.promise)
.mockImplementationOnce(async () => createCheckoutStatus(REPO_CWD));
const service = createService({ getCheckoutStatus });
await service.getSnapshot(REPO_CWD);
const first = service.getSnapshot(REPO_CWD, { force: true, reason: "earlier" });
await flushPromises();
const second = service.getSnapshot(REPO_CWD, {
force: true,
afterInFlight: true,
reason: "archive",
});
forcedRefresh.resolve(createCheckoutStatus(REPO_CWD));
await Promise.all([first, second]);
expect(getCheckoutStatus).toHaveBeenCalledTimes(3);
service.dispose();
});
test("a forced GitHub-inclusive call during an in-flight forced git refresh queues a GitHub refresh", async () => {
const forcedGitRefresh = createDeferred<CheckoutStatusGit>();
const getCheckoutStatus = vi
@@ -852,7 +876,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
expect(getPullRequestStatus).toHaveBeenCalledWith(
REPO_CWD,
expect.anything(),
{ force: true, reason: "merge-pr-validation" },
{ force: true, reason: "merge-pr-validation", previousPullRequest: null },
expect.anything(),
);
@@ -942,7 +966,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
expect(getPullRequestStatus).toHaveBeenCalledWith(
REPO_CWD,
expect.anything(),
{ force: false, reason: "initial" },
{ force: false, reason: "initial", previousPullRequest: null },
expect.anything(),
);
@@ -1274,6 +1298,14 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
const service = createService({
getCheckoutSnapshotFacts,
getCheckoutStatus,
getPullRequestStatus: vi.fn(async () => ({
...createPullRequestStatusResult(),
status: {
...createPullRequestStatusResult().status!,
number: 2,
url: "https://forge-self-heal.test/acme/repo/pulls/2",
},
})),
});
const listener = vi.fn();
@@ -1288,6 +1320,11 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
cwd: REPO_CWD,
headRef: "feature",
headSha: "1111111111111111111111111111111111111111",
previousPullRequest: {
number: 2,
url: "https://forge-self-heal.test/acme/repo/pulls/2",
state: "open",
},
reason: "self-heal-forge-pr-status",
});
expect(listener).toHaveBeenCalledWith(
@@ -1308,6 +1345,176 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
}
});
test.each(["poll then refresh", "refresh then poll"] as const)(
"uses the current workspace association across %s",
async (direction) => {
const merged = createCurrentPullRequestStatus({
number: 1,
url: "https://forge-owner.test/acme/repo/pulls/1",
state: "merged",
isMerged: true,
});
const open = createCurrentPullRequestStatus({
number: 2,
url: "https://forge-owner.test/acme/repo/pulls/2",
state: "open",
});
let refreshFindsOpen = false;
const forge = {
...createGitHubServiceStub(),
retainCurrentPullRequestStatusPoll: undefined,
getCurrentPullRequestStatus: vi.fn(async (input) =>
direction === "poll then refresh" || input.previousPullRequest?.number === 2
? open
: merged,
),
};
const unregister = defaultForgeRegistry.register("forge-owner-test", {
createService: () => forge,
matchesHost: (host) => host === "forge-owner.test",
});
const getPullRequestStatus = vi.fn(async (_cwd, _forge, options) => ({
...createPullRequestStatusResult(),
status: refreshFindsOpen || options.previousPullRequest?.number === 2 ? open : merged,
}));
const service = createService({
getCheckoutSnapshotFacts: vi.fn(async (cwd) =>
createCheckoutFacts(cwd, {
currentBranch: "feature",
remoteUrl: "https://forge-owner.test/acme/repo.git",
pullRequestLookupTarget: { headRef: "feature" },
}),
),
getCheckoutStatus: vi.fn(async (cwd) =>
createCheckoutStatus(cwd, {
currentBranch: "feature",
remoteUrl: "https://forge-owner.test/acme/repo.git",
}),
),
getPullRequestStatus,
});
try {
expect((await service.getSnapshot(REPO_CWD)).forge.pullRequest?.number).toBe(1);
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await flushPromises();
if (direction === "poll then refresh") {
await vi.advanceTimersByTimeAsync(120_000);
await flushPromises();
expect((await service.getSnapshot(REPO_CWD)).forge.pullRequest?.number).toBe(2);
expect(
(await service.getSnapshot(REPO_CWD, { force: true, reason: "test" })).forge.pullRequest
?.number,
).toBe(2);
} else {
refreshFindsOpen = true;
expect(
(await service.getSnapshot(REPO_CWD, { force: true, reason: "test" })).forge.pullRequest
?.number,
).toBe(2);
refreshFindsOpen = false;
await vi.advanceTimersByTimeAsync(120_000);
await flushPromises();
expect((await service.getSnapshot(REPO_CWD)).forge.pullRequest?.number).toBe(2);
}
subscription.unsubscribe();
} finally {
service.dispose();
unregister();
}
},
);
test.each(["older poll", "older refresh"] as const)(
"does not publish an %s after a newer observation",
async (older) => {
const merged = createCurrentPullRequestStatus({
number: 1,
url: "https://forge-race.test/acme/repo/pulls/1",
state: "merged",
isMerged: true,
});
const open = createCurrentPullRequestStatus({
number: 2,
url: "https://forge-race.test/acme/repo/pulls/2",
state: "open",
});
const delayedPoll = createDeferred<CurrentPullRequestStatus | null>();
const delayedRefresh = createDeferred<PullRequestStatusResult>();
let delayRefresh = false;
const forge = {
...createGitHubServiceStub(),
retainCurrentPullRequestStatusPoll: undefined,
getCurrentPullRequestStatus: vi.fn(async () =>
older === "older poll" ? delayedPoll.promise : open,
),
};
const unregister = defaultForgeRegistry.register("forge-race-test", {
createService: () => forge,
matchesHost: (host) => host === "forge-race.test",
});
let first = true;
const getPullRequestStatus = vi.fn(async (_cwd, _forge, options) => {
if (first) {
first = false;
return { ...createPullRequestStatusResult(), status: merged };
}
if (delayRefresh) {
delayRefresh = false;
return delayedRefresh.promise;
}
return {
...createPullRequestStatusResult(),
status:
older === "older poll" || options.previousPullRequest?.number === 2 ? open : merged,
};
});
const service = createService({
getCheckoutSnapshotFacts: vi.fn(async (cwd) =>
createCheckoutFacts(cwd, {
currentBranch: "feature",
remoteUrl: "https://forge-race.test/acme/repo.git",
pullRequestLookupTarget: { headRef: "feature" },
}),
),
getCheckoutStatus: vi.fn(async (cwd) =>
createCheckoutStatus(cwd, {
currentBranch: "feature",
remoteUrl: "https://forge-race.test/acme/repo.git",
}),
),
getPullRequestStatus,
});
try {
await service.getSnapshot(REPO_CWD);
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await flushPromises();
if (older === "older poll") {
await vi.advanceTimersByTimeAsync(120_000);
await flushPromises();
expect(
(await service.getSnapshot(REPO_CWD, { force: true, reason: "test" })).forge.pullRequest
?.number,
).toBe(2);
delayedPoll.resolve(merged);
await flushPromises();
} else {
delayRefresh = true;
const refresh = service.getSnapshot(REPO_CWD, { force: true, reason: "test" });
await flushPromises();
await vi.advanceTimersByTimeAsync(120_000);
await flushPromises();
delayedRefresh.resolve({ ...createPullRequestStatusResult(), status: merged });
expect((await refresh).forge.pullRequest?.number).toBe(2);
}
expect((await service.getSnapshot(REPO_CWD)).forge.pullRequest?.number).toBe(2);
subscription.unsubscribe();
} finally {
service.dispose();
unregister();
}
},
);
test("generic forge self-heal uses the fast poll window while checks are pending", async () => {
const forge = {
...createGitHubServiceStub(),
@@ -1397,7 +1604,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
nowMs = 3_000;
await service.refresh(REPO_CWD);
expect(workspaceRuntimeStore.get(REPO_CWD)?.forge.pullRequest).toBeNull();
expect(workspaceRuntimeStore.get(REPO_CWD)?.forge.pullRequest?.title).toBe("Visible PR");
expect(forge.getCurrentPullRequestStatus).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(0);
@@ -1412,6 +1619,57 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
}
});
test("keeps the known open association when a checkout commit changes before forge refresh", async () => {
let headSha = "1111111111111111111111111111111111111111";
const open = createCurrentPullRequestStatus({
number: 2,
url: "https://forge-commit.test/acme/repo/pulls/2",
state: "open",
});
const merged = createCurrentPullRequestStatus({
number: 1,
url: "https://forge-commit.test/acme/repo/pulls/1",
state: "merged",
isMerged: true,
});
const forge = { ...createGitHubServiceStub(), retainCurrentPullRequestStatusPoll: undefined };
const unregister = defaultForgeRegistry.register("forge-commit-test", {
createService: () => forge,
matchesHost: (host) => host === "forge-commit.test",
});
let first = true;
const service = createService({
getCheckoutSnapshotFacts: vi.fn(async (cwd) =>
createCheckoutFacts(cwd, {
currentBranch: "feature",
remoteUrl: "https://forge-commit.test/acme/repo.git",
pullRequestLookupTarget: { headRef: "feature", headSha },
}),
),
getCheckoutStatus: vi.fn(async (cwd) =>
createCheckoutStatus(cwd, {
currentBranch: "feature",
remoteUrl: "https://forge-commit.test/acme/repo.git",
}),
),
getPullRequestStatus: vi.fn(async (_cwd, _forge, options) => {
const result = first || options.previousPullRequest?.number === 2 ? open : merged;
first = false;
return { ...createPullRequestStatusResult(), status: result };
}),
});
try {
expect((await service.getSnapshot(REPO_CWD)).forge.pullRequest?.number).toBe(2);
headSha = "2222222222222222222222222222222222222222";
const refreshed = await service.getSnapshot(REPO_CWD, { force: true, reason: "test" });
expect(refreshed.git.headSha).toBe(headSha);
expect(refreshed.forge.pullRequest).toMatchObject({ number: 2, state: "open" });
} finally {
service.dispose();
unregister();
}
});
test("subscription cancels generic forge PR status self-heal polling after unsubscribe", async () => {
const forge = {
...createGitHubServiceStub(),
@@ -1,5 +1,7 @@
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path, { join } from "node:path";
import type pino from "pino";
@@ -10,11 +12,14 @@ import type {
PullRequestStatusResult,
} from "../utils/checkout-git.js";
import {
BACKGROUND_GIT_FETCH_INTERVAL_MS,
WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS,
WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY,
WORKSPACE_GIT_REFRESH_CONCURRENCY,
WORKSPACE_GIT_WATCHER_SUBSCRIBE_TIMEOUT_MS,
WorkspaceGitServiceImpl,
type WorkspaceGitRuntimeSnapshot,
workspaceFolderExists,
} from "./workspace-git-service.js";
const REPO_CWD = path.resolve("/tmp/repo");
@@ -23,6 +28,7 @@ function createLogger() {
const logger = {
child: () => logger,
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
};
return logger;
@@ -251,7 +257,7 @@ function createGitHubServiceStub(): ForgeService {
interface CreateServiceTestOptions {
subscribe?: ReturnType<typeof vi.fn>;
getCheckoutStatus?: ReturnType<typeof vi.fn>;
getCheckoutSnapshotFacts?: ReturnType<typeof vi.fn>;
getCheckoutSnapshotFacts?: (cwd: string) => Promise<CheckoutSnapshotFacts>;
getCheckoutShortstat?: ReturnType<typeof vi.fn>;
getCheckoutWorktreeState?: ReturnType<typeof vi.fn>;
getPullRequestStatus?: ReturnType<typeof vi.fn>;
@@ -261,6 +267,7 @@ interface CreateServiceTestOptions {
runGitFetch?: ReturnType<typeof vi.fn>;
runGitCommand?: ReturnType<typeof vi.fn>;
getWorkspaceGitSelfHealPhaseMs?: (cwd: string) => number;
workspaceFolderExists?: (cwd: string) => Promise<boolean>;
now?: () => Date;
}
@@ -294,6 +301,7 @@ function buildDefaultTestServiceDeps() {
signal: null,
})),
getWorkspaceGitSelfHealPhaseMs: vi.fn(() => 30_000),
workspaceFolderExists: vi.fn(async () => true),
createWatcherLivenessCanary: vi.fn(() => ({
path: "",
filterEvents: (events) => events,
@@ -324,6 +332,145 @@ function createService(options?: CreateServiceTestOptions) {
});
}
type WatchCallback = (error: Error | null, events: Array<{ path: string; type: "update" }>) => void;
interface FolderBackedGitOptions {
facts?: (cwd: string) => CheckoutSnapshotFacts;
unwatchableDirectories?: string[];
heldWatch?: { directory: string; release: Promise<void> };
}
function createFolderBackedGit(options?: FolderBackedGitOptions) {
const watchCallbacks = new Map<string, WatchCallback>();
const subscribe = vi.fn(async (directory: string, callback: WatchCallback) => {
if (!existsSync(directory) || options?.unwatchableDirectories?.includes(directory)) {
throw new Error(`Cannot watch ${directory}`);
}
if (options?.heldWatch?.directory === directory) {
await options.heldWatch.release;
}
watchCallbacks.set(directory, callback);
return createAsyncSubscription();
});
const getCheckoutSnapshotFacts = vi.fn(
async (cwd: string): Promise<CheckoutSnapshotFacts> =>
existsSync(cwd) ? (options?.facts ?? createCheckoutSnapshotFacts)(cwd) : { isGit: false },
);
const getCheckoutStatus = vi.fn(async (cwd: string) =>
existsSync(cwd) ? createCheckoutStatus(cwd) : ({ isGit: false } as const),
);
const getCheckoutShortstat = vi.fn(async (_cwd: string) => ({ additions: 1, deletions: 0 }));
const getCheckoutWorktreeState = vi.fn(async (_cwd: string) => ({
isDirty: false,
diffStat: { additions: 1, deletions: 0 },
}));
const runGitCommand = vi.fn(async (args: string[], runOptions: { cwd: string }) => ({
stdout: args[0] === "rev-parse" ? `${runOptions.cwd}\n` : "",
stderr: "",
truncated: false,
exitCode: 0,
signal: null,
}));
const runGitFetch = vi.fn(async (_cwd: string) => ({ changes: [], error: null }));
const callsIn = (fake: ReturnType<typeof vi.fn>, cwd: string) =>
fake.mock.calls.filter(([calledCwd]) => calledCwd === cwd).length;
return {
deps: {
subscribe,
getCheckoutSnapshotFacts,
getCheckoutStatus,
getCheckoutShortstat,
getCheckoutWorktreeState,
runGitCommand,
runGitFetch,
workspaceFolderExists: async (cwd: string) => existsSync(cwd),
now: () => new Date(),
},
watchCallbackFor(directory: string): WatchCallback {
const callback = watchCallbacks.get(directory);
if (!callback) {
throw new Error(`No watcher was started for ${directory}`);
}
return callback;
},
watchAttemptsFor: (directory: string) => callsIn(subscribe, directory),
pollsOf: (cwd: string) => callsIn(getCheckoutShortstat, cwd),
fetchesIn: (cwd: string) => callsIn(runGitFetch, cwd),
gitCommandsIn(cwd: string): number {
const cwdCommands = [
getCheckoutSnapshotFacts,
getCheckoutStatus,
getCheckoutShortstat,
getCheckoutWorktreeState,
runGitFetch,
].reduce((count, fake) => count + callsIn(fake, cwd), 0);
const commandsRunInCwd = runGitCommand.mock.calls.filter(
([, runOptions]) => runOptions.cwd === cwd,
).length;
return cwdCommands + commandsRunInCwd;
},
};
}
function linkedWorktreeFacts(gitCommonDir: string) {
return (cwd: string): CheckoutSnapshotFacts => ({
...createCheckoutSnapshotFacts(cwd),
absoluteGitDir: join(gitCommonDir, "worktrees", path.basename(cwd)),
gitCommonDir,
});
}
const SETTLE_ATTEMPTS = 1_000;
async function settle(assertion: () => void): Promise<void> {
let lastFailure: unknown = null;
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
try {
assertion();
return;
} catch (failure) {
lastFailure = failure;
}
await vi.advanceTimersByTimeAsync(0);
}
throw lastFailure;
}
async function waitForRefreshesToSettle(service: WorkspaceGitServiceImpl): Promise<void> {
await settle(() => {
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
});
}
async function waitForObservationToSettle(
service: WorkspaceGitServiceImpl,
repositoryWorkspaceLinkCount: number,
): Promise<void> {
await settle(() => {
const metrics = service.getMetrics();
expect(metrics.repositoryWorkspaceLinkCount).toBe(repositoryWorkspaceLinkCount);
expect(metrics.workspaceObservationSetupInFlightCount).toBe(0);
expect(metrics.workspaceRefreshInFlightCount).toBe(0);
});
}
async function advanceThroughDegradedPolls(service: WorkspaceGitServiceImpl): Promise<void> {
for (const delayMs of [5_000, 10_000, 20_000, 40_000, 60_000, 60_000]) {
await vi.advanceTimersByTimeAsync(delayMs);
await waitForRefreshesToSettle(service);
}
}
async function advanceThroughWorkingTreeRecoveryAttempts(
service: WorkspaceGitServiceImpl,
): Promise<void> {
for (const delayMs of [30_000, 60_000, 120_000]) {
await vi.advanceTimersByTimeAsync(delayMs);
await waitForRefreshesToSettle(service);
}
}
describe("WorkspaceGitServiceImpl", () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -1553,3 +1700,460 @@ describe("WorkspaceGitServiceImpl", () => {
service.dispose();
});
});
describe("WorkspaceGitServiceImpl with workspace folders that no longer exist", () => {
const temporaryFolders: string[] = [];
async function createTemporaryFolder(): Promise<string> {
const folder = await mkdtemp(join(os.tmpdir(), "paseo-workspace-git-"));
temporaryFolders.push(folder);
return folder;
}
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(async () => {
vi.useRealTimers();
await Promise.all(
temporaryFolders.splice(0).map((folder) => rm(folder, { recursive: true, force: true })),
);
});
test("polling stops for a workspace whose folder no longer exists", async () => {
const workspace = await createTemporaryFolder();
const git = createFolderBackedGit();
const service = createService(git.deps);
const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
await rm(workspace, { recursive: true, force: true });
const gitCommandsAtDeletion = git.gitCommandsIn(workspace);
await advanceThroughDegradedPolls(service);
expect(git.gitCommandsIn(workspace)).toBe(gitCommandsAtDeletion);
expect(service.getMetrics()).toMatchObject({
repositoryTargetCount: 0,
workingTreeWatchTargetCount: 0,
});
expect(vi.getTimerCount()).toBe(1);
subscription.unsubscribe();
await service.dispose();
});
test("a deleted worktree leaves its shared repository polling and fetch to the surviving worktree", async () => {
const gitCommonDir = join(await createTemporaryFolder(), ".git");
const deletedWorktree = await createTemporaryFolder();
const survivingWorktree = await createTemporaryFolder();
const git = createFolderBackedGit({ facts: linkedWorktreeFacts(gitCommonDir) });
let survivingWorktreeEdits = 0;
git.deps.getCheckoutShortstat.mockImplementation(async (cwd: string) => ({
additions: cwd === survivingWorktree ? ++survivingWorktreeEdits : 1,
deletions: 0,
}));
const service = createService(git.deps);
const deletedSubscription = service.registerWorkspace({ cwd: deletedWorktree }, vi.fn());
await waitForObservationToSettle(service, 1);
const survivingSubscription = service.registerWorkspace({ cwd: survivingWorktree }, vi.fn());
await waitForObservationToSettle(service, 2);
await rm(deletedWorktree, { recursive: true, force: true });
await vi.advanceTimersByTimeAsync(60_000);
await waitForRefreshesToSettle(service);
const deletedWorktreeCommands = git.gitCommandsIn(deletedWorktree);
for (let tick = 0; tick < 3; tick += 1) {
const survivingWorktreePolls = git.pollsOf(survivingWorktree);
await vi.advanceTimersByTimeAsync(5_000);
await settle(() => {
expect(git.pollsOf(survivingWorktree)).toBe(survivingWorktreePolls + 1);
});
}
expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands);
const fetchesBefore = git.deps.runGitFetch.mock.calls.length;
await vi.advanceTimersByTimeAsync(2 * BACKGROUND_GIT_FETCH_INTERVAL_MS);
expect(git.deps.runGitFetch.mock.calls.slice(fetchesBefore).map(([cwd]) => cwd)).toEqual([
survivingWorktree,
survivingWorktree,
]);
expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands);
deletedSubscription.unsubscribe();
survivingSubscription.unsubscribe();
await service.dispose();
});
test("deleting every worktree of a repository stops its polling and background fetch", async () => {
const gitCommonDir = join(await createTemporaryFolder(), ".git");
const firstWorktree = await createTemporaryFolder();
const secondWorktree = await createTemporaryFolder();
const git = createFolderBackedGit({ facts: linkedWorktreeFacts(gitCommonDir) });
const service = createService(git.deps);
const firstSubscription = service.registerWorkspace({ cwd: firstWorktree }, vi.fn());
await waitForObservationToSettle(service, 1);
const secondSubscription = service.registerWorkspace({ cwd: secondWorktree }, vi.fn());
await waitForObservationToSettle(service, 2);
await rm(firstWorktree, { recursive: true, force: true });
await rm(secondWorktree, { recursive: true, force: true });
const commandsAtDeletion = [
git.gitCommandsIn(firstWorktree),
git.gitCommandsIn(secondWorktree),
];
await advanceThroughDegradedPolls(service);
expect([git.gitCommandsIn(firstWorktree), git.gitCommandsIn(secondWorktree)]).toEqual(
commandsAtDeletion,
);
expect(service.getMetrics()).toMatchObject({
repositoryTargetCount: 0,
workingTreeWatchTargetCount: 0,
});
expect(vi.getTimerCount()).toBe(2);
firstSubscription.unsubscribe();
secondSubscription.unsubscribe();
await service.dispose();
});
test("working tree fallback polling and watcher recovery stop for a workspace whose folder no longer exists", async () => {
const workspace = await createTemporaryFolder();
await mkdir(join(workspace, ".git"));
const git = createFolderBackedGit();
const service = createService(git.deps);
const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
await rm(workspace, { recursive: true, force: true });
git.watchCallbackFor(workspace)(new Error("watched directory was deleted"), []);
await vi.advanceTimersByTimeAsync(1_000);
await waitForRefreshesToSettle(service);
const commandsAfterWatcherError = git.gitCommandsIn(workspace);
const watchAttemptsAfterWatcherError = git.watchAttemptsFor(workspace);
await advanceThroughDegradedPolls(service);
expect(git.gitCommandsIn(workspace)).toBe(commandsAfterWatcherError);
expect(git.watchAttemptsFor(workspace)).toBe(watchAttemptsAfterWatcherError);
expect(vi.getTimerCount()).toBe(1);
subscription.unsubscribe();
await service.dispose();
});
test("a workspace whose folder comes back resumes polling on its next subscription", async () => {
const workspace = await createTemporaryFolder();
const git = createFolderBackedGit();
const service = createService(git.deps);
const firstSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
await rm(workspace, { recursive: true, force: true });
const commandsWhileMissing = git.gitCommandsIn(workspace);
await advanceThroughDegradedPolls(service);
expect(git.gitCommandsIn(workspace)).toBe(commandsWhileMissing);
await mkdir(workspace);
const secondSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
const pollsAfterResume = git.pollsOf(workspace);
await vi.advanceTimersByTimeAsync(5_000);
await waitForRefreshesToSettle(service);
expect(git.pollsOf(workspace)).toBe(pollsAfterResume + 1);
firstSubscription.unsubscribe();
secondSubscription.unsubscribe();
await service.dispose();
});
test("a suspended workspace costs no git while its folder is missing, even when a refresh is requested", async () => {
const workspace = await createTemporaryFolder();
const git = createFolderBackedGit();
const service = createService(git.deps);
const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
await rm(workspace, { recursive: true, force: true });
await advanceThroughDegradedPolls(service);
const commandsWhileMissing = git.gitCommandsIn(workspace);
for (let probe = 0; probe < 10; probe += 1) {
await vi.advanceTimersByTimeAsync(WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS);
await waitForRefreshesToSettle(service);
}
service.scheduleRefreshForCwd(workspace);
service.onWorkspaceStateMayHaveChanged(workspace);
await vi.advanceTimersByTimeAsync(5_000);
await waitForRefreshesToSettle(service);
expect(git.gitCommandsIn(workspace)).toBe(commandsWhileMissing);
expect(vi.getTimerCount()).toBe(1);
subscription.unsubscribe();
await service.dispose();
});
test("a suspended worktree resumes polling and its repository fetch once its folder is back, without a new subscription", async () => {
const gitCommonDir = join(await createTemporaryFolder(), ".git");
const worktree = await createTemporaryFolder();
const git = createFolderBackedGit({ facts: linkedWorktreeFacts(gitCommonDir) });
const service = createService(git.deps);
const subscription = service.registerWorkspace({ cwd: worktree }, vi.fn());
await waitForObservationToSettle(service, 1);
await rm(worktree, { recursive: true, force: true });
await advanceThroughDegradedPolls(service);
await waitForObservationToSettle(service, 0);
await mkdir(worktree);
const fetchesBeforeRestore = git.fetchesIn(worktree);
await vi.advanceTimersByTimeAsync(WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS);
await waitForObservationToSettle(service, 1);
expect(git.fetchesIn(worktree)).toBe(fetchesBeforeRestore + 1);
const pollsAfterRestore = git.pollsOf(worktree);
await vi.advanceTimersByTimeAsync(5_000);
await waitForRefreshesToSettle(service);
expect(git.pollsOf(worktree)).toBe(pollsAfterRestore + 1);
subscription.unsubscribe();
await service.dispose();
});
test("a forced snapshot resumes a suspended workspace whose folder is back", async () => {
const workspace = await createTemporaryFolder();
const git = createFolderBackedGit();
const service = createService(git.deps);
const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
await rm(workspace, { recursive: true, force: true });
await advanceThroughDegradedPolls(service);
await waitForObservationToSettle(service, 0);
await mkdir(workspace);
const snapshot = await service.getSnapshot(workspace, {
force: true,
reason: "refresh-button",
});
await waitForObservationToSettle(service, 1);
expect(snapshot.git.isGit).toBe(true);
subscription.unsubscribe();
await service.dispose();
});
test("a resumed workspace rebuilds observation from fresh facts, not the non-git facts cached while its folder was gone", async () => {
const workspace = await createTemporaryFolder();
const git = createFolderBackedGit();
const service = createService(git.deps);
const firstSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
await rm(workspace, { recursive: true, force: true });
service.onWorkspaceStateMayHaveChanged(workspace);
await vi.advanceTimersByTimeAsync(1_000);
await waitForRefreshesToSettle(service);
expect((await service.getSnapshot(workspace)).git.isGit).toBe(false);
await advanceThroughDegradedPolls(service);
await waitForObservationToSettle(service, 0);
await mkdir(workspace);
const secondSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
expect((await service.getSnapshot(workspace)).git.isGit).toBe(true);
firstSubscription.unsubscribe();
secondSubscription.unsubscribe();
await service.dispose();
});
test("resuming waits for an observation setup that was still running when the folder vanished", async () => {
const workspace = await createTemporaryFolder();
const gitDir = join(workspace, ".git");
await mkdir(gitDir);
const metadataWatchRelease = createDeferred<void>();
const git = createFolderBackedGit({
unwatchableDirectories: [workspace],
heldWatch: { directory: gitDir, release: metadataWatchRelease.promise },
});
const service = createService(git.deps);
const firstSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await settle(() => {
expect(git.watchAttemptsFor(gitDir)).toBe(1);
});
await rm(workspace, { recursive: true, force: true });
await vi.advanceTimersByTimeAsync(5_000);
await settle(() => {
expect(service.getMetrics()).toMatchObject({
repositoryTargetCount: 0,
workingTreeWatchTargetCount: 0,
});
});
await mkdir(workspace);
const secondSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
metadataWatchRelease.resolve();
await waitForObservationToSettle(service, 1);
expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1);
firstSubscription.unsubscribe();
secondSubscription.unsubscribe();
await service.dispose();
});
test("an open working tree watch neither notifies nor recovers while its folder is missing, and picks up once it is back", async () => {
const workspace = await createTemporaryFolder();
await mkdir(join(workspace, ".git"));
const git = createFolderBackedGit();
const service = createService(git.deps);
const workspaceSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
await waitForObservationToSettle(service, 1);
const diffListener = vi.fn();
const diffWatch = await service.requestWorkingTreeWatch(workspace, diffListener);
await rm(workspace, { recursive: true, force: true });
git.watchCallbackFor(workspace)(new Error("watched directory was deleted"), []);
await vi.advanceTimersByTimeAsync(1_000);
await waitForRefreshesToSettle(service);
const commandsAfterWatcherError = git.gitCommandsIn(workspace);
const notificationsAfterWatcherError = diffListener.mock.calls.length;
const watchAttemptsAfterWatcherError = git.watchAttemptsFor(workspace);
await advanceThroughWorkingTreeRecoveryAttempts(service);
expect(git.gitCommandsIn(workspace)).toBe(commandsAfterWatcherError);
expect(diffListener.mock.calls.length).toBe(notificationsAfterWatcherError);
expect(git.watchAttemptsFor(workspace)).toBe(watchAttemptsAfterWatcherError);
expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1);
await mkdir(join(workspace, ".git"), { recursive: true });
await vi.advanceTimersByTimeAsync(60_000);
await waitForRefreshesToSettle(service);
expect(diffListener.mock.calls.length).toBe(notificationsAfterWatcherError + 1);
await vi.advanceTimersByTimeAsync(240_000);
await waitForRefreshesToSettle(service);
expect(git.watchAttemptsFor(workspace)).toBe(watchAttemptsAfterWatcherError + 1);
const notificationsAfterResubscribe = diffListener.mock.calls.length;
await vi.advanceTimersByTimeAsync(120_000);
await waitForRefreshesToSettle(service);
expect(diffListener.mock.calls.length).toBe(notificationsAfterResubscribe);
diffWatch.unsubscribe();
workspaceSubscription.unsubscribe();
await service.dispose();
});
test("a refresh crossing suspension preserves the cached snapshot and drops queued work", async () => {
const workspace = await createTemporaryFolder();
const git = createFolderBackedGit();
const started = createDeferred<void>();
const release = createDeferred<void>();
let holdNextRead = false;
let factsReads = 0;
const service = createService({
...git.deps,
getCheckoutSnapshotFacts: async (cwd: string) => {
factsReads++;
if (holdNextRead) {
holdNextRead = false;
started.resolve();
await release.promise;
}
return git.deps.getCheckoutSnapshotFacts(cwd);
},
});
const updates: WorkspaceGitRuntimeSnapshot[] = [];
const subscription = service.registerWorkspace({ cwd: workspace }, (snapshot) =>
updates.push(snapshot),
);
await waitForObservationToSettle(service, 1);
const cached = await service.getSnapshot(workspace);
holdNextRead = true;
const active = service.getSnapshot(workspace, {
force: true,
includeForge: false,
reason: "overlapping-refresh",
});
await started.promise;
const queued = service.getSnapshot(workspace, {
force: true,
includeForge: true,
reason: "queued-refresh",
});
await rm(workspace, { recursive: true, force: true });
await vi.advanceTimersByTimeAsync(5_000);
await settle(() => expect(service.getMetrics().repositoryWorkspaceLinkCount).toBe(0));
const readsAtSuspension = factsReads;
const updatesAtSuspension = updates.length;
release.resolve();
expect(await active).toEqual(cached);
expect(await queued).toEqual(cached);
expect(await service.getSnapshot(workspace)).toEqual(cached);
expect(updates).toHaveLength(updatesAtSuspension);
expect(factsReads).toBe(readsAtSuspension);
subscription.unsubscribe();
await service.dispose();
});
test("restoration settles a suspended refresh before reloading facts", async () => {
const workspace = await createTemporaryFolder();
const git = createFolderBackedGit();
const staleFacts = createDeferred<CheckoutSnapshotFacts>();
const started = createDeferred<void>();
let holdNextRead = false;
const service = createService({
...git.deps,
getCheckoutSnapshotFacts: async (cwd: string) => {
if (holdNextRead) {
holdNextRead = false;
started.resolve();
return staleFacts.promise;
}
return git.deps.getCheckoutSnapshotFacts(cwd);
},
});
const updates: WorkspaceGitRuntimeSnapshot[] = [];
const subscription = service.registerWorkspace({ cwd: workspace }, (snapshot) =>
updates.push(snapshot),
);
await waitForObservationToSettle(service, 1);
const cached = await service.getSnapshot(workspace);
holdNextRead = true;
const active = service.getSnapshot(workspace, {
force: true,
includeForge: false,
reason: "overlapping-refresh",
});
await started.promise;
await rm(workspace, { recursive: true, force: true });
await vi.advanceTimersByTimeAsync(5_000);
await settle(() => expect(service.getMetrics().repositoryWorkspaceLinkCount).toBe(0));
const updatesAtSuspension = updates.length;
await mkdir(workspace);
const restored = service.getSnapshot(workspace, {
force: true,
includeForge: false,
reason: "folder-returned",
});
await vi.advanceTimersByTimeAsync(0);
staleFacts.resolve({ isGit: false });
expect(await active).toEqual(cached);
expect((await restored).git.isGit).toBe(true);
expect(updates.slice(updatesAtSuspension).every((snapshot) => snapshot.git.isGit)).toBe(true);
await waitForObservationToSettle(service, 1);
subscription.unsubscribe();
await service.dispose();
});
test("workspaceFolderExists reports a missing folder and a path under a file as missing", async () => {
const folder = await createTemporaryFolder();
const file = join(folder, "file.txt");
await writeFile(file, "");
expect(await workspaceFolderExists(folder)).toBe(true);
expect(await workspaceFolderExists(join(folder, "missing"))).toBe(false);
expect(await workspaceFolderExists(join(file, "nested"))).toBe(false);
});
});
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { readFile, stat } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import { LRUCache } from "lru-cache";
import { CheckoutDiffCache } from "./checkout-diff-cache.js";
@@ -23,6 +23,7 @@ import {
getPullRequestStatus,
forgeAuthStateFromError,
hasOriginRemote,
isPullRequestTargetOnForge,
listBranchSuggestions,
resolveRepositoryDefaultBranch,
resolveBranchCheckout,
@@ -74,7 +75,7 @@ import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js";
import { createWatcherLivenessCanary } from "./watcher-liveness-canary.js";
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
export const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
const FETCH_METADATA_ECHO_TTL_MS = 5_000;
export const WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS = 60_000;
const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000;
@@ -151,6 +152,8 @@ export const getWorkspaceGitSelfHealPhaseMs = getWorkspaceGitObservationReensure
export interface WorkspaceGitRuntimeSnapshot {
cwd: string;
git: {
headSha?: string;
upstreamKnown?: boolean;
isGit: boolean;
repoRoot: string | null;
mainRepoRoot: string | null;
@@ -176,6 +179,7 @@ export interface WorkspaceGitRuntimeSnapshot {
*/
forge?: string;
pullRequest: {
headSha?: string;
number?: number;
repoOwner?: string;
repoName?: string;
@@ -321,6 +325,7 @@ export type WorkspaceGitSnapshotOptions =
force: true;
includeForge?: boolean;
reason: string;
afterInFlight?: boolean;
};
interface WorkspaceGitRefreshRequest {
@@ -384,6 +389,7 @@ interface WorkspaceGitServiceDependencies {
runGitCommand: typeof runGitCommand;
getWorkspaceGitSelfHealPhaseMs: typeof getWorkspaceGitObservationReensurePhaseMs;
createWatcherLivenessCanary: typeof createWatcherLivenessCanary;
workspaceFolderExists: (cwd: string) => Promise<boolean>;
now: () => Date;
}
@@ -435,6 +441,8 @@ interface WorkspaceGitTarget {
repoGitRoot: string | null;
observationSetupPromise: Promise<void> | null;
observationSetupComplete: boolean;
folderMissing: boolean;
folderRestorePromise: Promise<void> | null;
closed: boolean;
}
@@ -547,6 +555,10 @@ interface WorkspaceForgePrStatusPollTarget {
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
headRepositoryPath?: string;
headRepositoryHost?: string;
headRepositoryEndpoint?: import("../services/forge-service.js").HeadRepositoryEndpoint;
changeRequestNumber?: number;
}
function buildDefaultWorkspaceGitServiceDeps(
@@ -571,10 +583,21 @@ function buildDefaultWorkspaceGitServiceDeps(
runGitCommand,
getWorkspaceGitSelfHealPhaseMs: getWorkspaceGitObservationReensurePhaseMs,
createWatcherLivenessCanary,
workspaceFolderExists,
now: () => new Date(),
};
}
export async function workspaceFolderExists(cwd: string): Promise<boolean> {
try {
await stat(cwd);
return true;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
return code !== "ENOENT" && code !== "ENOTDIR";
}
}
function resolveWorkspaceGitServiceDeps(
subscribe: SubscribeToFileChanges,
deps: Partial<WorkspaceGitServiceDependencies> | undefined,
@@ -660,6 +683,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const cwd = resolve(params.cwd);
const target = this.ensureWorkspaceTarget(cwd);
target.listeners.add(listener);
if (target.folderMissing) {
void this.resumeWorkspaceIfFolderRestored(target);
}
if (target.listeners.size === 1) {
this.startWorkspaceSubscriptionTimers(target);
}
@@ -751,6 +777,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
if (!request.force && target.latestSnapshot) {
return target.latestSnapshot;
}
if (target.folderMissing) {
await this.resumeWorkspaceIfFolderRestored(target);
}
return this.requestWorkspaceSnapshot(target, request);
}
@@ -972,6 +1001,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.assertNotDisposed();
cwd = resolve(cwd);
const target = this.ensureWorkspaceTarget(cwd);
if (target.folderMissing) {
await this.resumeWorkspaceIfFolderRestored(target);
}
await this.refreshWorkspaceTarget(target, {
force: false,
refreshStructure: true,
@@ -1005,9 +1037,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.assertNotDisposed();
cwd = resolve(cwd);
const target = this.workspaceTargets.get(cwd);
if (target) {
this.scheduleWorkspaceRefresh(target, { queueIfBusy: false });
if (!target) {
return;
}
if (target.folderMissing) {
void this.resumeWorkspaceIfFolderRestored(target);
return;
}
this.scheduleWorkspaceRefresh(target, { queueIfBusy: false });
}
onWorkspaceStateMayHaveChanged(cwd: string): void {
@@ -1018,6 +1055,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return;
}
this.invalidateForge(normalizedCwd);
if (target.folderMissing) {
void this.resumeWorkspaceIfFolderRestored(target);
return;
}
this.scheduleWorkspaceRefresh(target, {
force: true,
includeForge: true,
@@ -1227,6 +1268,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
repoGitRoot: null,
observationSetupPromise: null,
observationSetupComplete: false,
folderMissing: false,
folderRestorePromise: null,
closed: false,
};
@@ -1358,6 +1401,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private isActiveObservedWorkspaceTarget(target: WorkspaceGitTarget): boolean {
return (
!target.closed &&
!target.folderMissing &&
target.listeners.size > 0 &&
this.workspaceTargets.get(target.cwd) === target
);
@@ -1612,11 +1656,110 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
workspaceTarget: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
): Promise<boolean> {
if (await this.suspendWorkspaceIfFolderMissing(workspaceTarget)) {
return false;
}
const fingerprintBefore = workspaceTarget.latestFingerprint;
await this.refreshWorkspaceTarget(workspaceTarget, request);
return workspaceTarget.latestFingerprint !== fingerprintBefore;
}
private async suspendWorkspaceIfFolderMissing(target: WorkspaceGitTarget): Promise<boolean> {
if (target.folderMissing) {
return true;
}
if (await this.deps.workspaceFolderExists(target.cwd)) {
return false;
}
this.suspendWorkspaceWithMissingFolder(target);
return true;
}
private suspendWorkspaceWithMissingFolder(target: WorkspaceGitTarget): void {
if (target.folderMissing || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
return;
}
target.folderMissing = true;
if (target.refreshState.status === "in-flight") {
target.refreshState.queued = null;
}
this.detachWorkspaceFromRepoTarget(target);
target.repoGitRoot = null;
this.stopWorkspaceObservation(target);
target.observationSetupComplete = false;
this.scheduleMissingFolderProbe(target);
this.logger.warn(
{ cwd: target.cwd },
"Workspace folder no longer exists; suspended git observation until it is back",
);
}
private isSuspendedWorkspaceTarget(target: WorkspaceGitTarget): boolean {
return (
target.folderMissing && !target.closed && this.workspaceTargets.get(target.cwd) === target
);
}
private scheduleMissingFolderProbe(target: WorkspaceGitTarget): void {
if (
!this.isSuspendedWorkspaceTarget(target) ||
target.observationReensureTimer ||
target.folderRestorePromise
) {
return;
}
target.observationReensureTimer = setTimeout(() => {
target.observationReensureTimer = null;
void this.resumeWorkspaceIfFolderRestored(target);
}, WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS);
}
private resumeWorkspaceIfFolderRestored(target: WorkspaceGitTarget): Promise<void> {
if (!target.folderMissing) {
return Promise.resolve();
}
target.folderRestorePromise ??= this.resumeWorkspaceWithRestoredFolder(target).finally(() => {
target.folderRestorePromise = null;
this.scheduleMissingFolderProbe(target);
});
return target.folderRestorePromise;
}
private async resumeWorkspaceWithRestoredFolder(target: WorkspaceGitTarget): Promise<void> {
if (!(await this.deps.workspaceFolderExists(target.cwd))) {
return;
}
await target.observationSetupPromise;
if (target.refreshState.status === "in-flight") {
await target.refreshState.promise.catch(() => undefined);
}
if (!this.isSuspendedWorkspaceTarget(target)) {
return;
}
target.latestFacts = null;
target.folderMissing = false;
await this.refreshWorkspaceTarget(target, {
force: false,
refreshStructure: true,
refreshWorktree: true,
includeForge: false,
reason: "workspace-folder-restored",
queueIfBusy: true,
movedRemoteRefs: new Set(),
});
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
return;
}
target.observationSetupComplete = false;
if (target.observationReensureTimer) {
clearTimeout(target.observationReensureTimer);
target.observationReensureTimer = null;
}
this.logger.info({ cwd: target.cwd }, "Workspace folder is back; resumed git observation");
this.startWorkspaceSubscriptionTimers(target);
this.scheduleWorkspaceObservationSetup(target);
}
private startWorkingTreeWatchFallback(
target: WorkingTreeWatchTarget,
reason: WorkingTreeWatchFallbackReason,
@@ -1654,7 +1797,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return changed;
}),
);
this.notifyWorkingTreeConsumers(target);
if (await this.deps.workspaceFolderExists(target.watchPath)) {
this.notifyWorkingTreeConsumers(target);
}
if (!target.closed && (target.subscription === null || target.repoRoot === null)) {
target.fallbackPollQuietTickCount = changes.some(Boolean)
? 0
@@ -1731,6 +1876,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
if (target.closed || target.subscription) {
return;
}
if (!(await this.deps.workspaceFolderExists(target.watchPath))) {
this.scheduleWorkingTreeWatchRecovery(target);
return;
}
await this.refreshWorkingTreeIgnoredDirectories(target);
if (target.closed || target.subscription) {
return;
@@ -2786,7 +2935,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
private updateForgePrStatusPollForTarget(target: WorkspaceGitTarget): void {
if (target.listeners.size === 0) {
if (!this.isActiveObservedWorkspaceTarget(target)) {
this.stopForgePrStatusPollForTarget(target);
return;
}
@@ -2830,6 +2979,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
...(pollTarget.headRepositoryOwner
? { headRepositoryOwner: pollTarget.headRepositoryOwner }
: {}),
...(pollTarget.headRepositoryPath
? { headRepositoryPath: pollTarget.headRepositoryPath }
: {}),
...(pollTarget.headRepositoryEndpoint
? { headRepositoryEndpoint: pollTarget.headRepositoryEndpoint }
: {}),
...(pollTarget.changeRequestNumber
? { changeRequestNumber: pollTarget.changeRequestNumber }
: {}),
onStatus: (status) => {
if (!this.isActiveObservedWorkspaceTarget(target)) {
return;
@@ -2880,8 +3038,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}): { unsubscribe: () => void } {
let closed = false;
let timer: NodeJS.Timeout | null = null;
let latestStatus: WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"] =
target.latestForge?.pullRequest ?? null;
let consecutiveErrors = 0;
const schedule = (delayMs: number) => {
@@ -2898,6 +3054,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
if (closed || !this.isActiveObservedWorkspaceTarget(target)) {
return;
}
const previousForge = target.latestForge;
const previousStatus = previousForge?.pullRequest;
try {
const status = await service.getCurrentPullRequestStatus({
cwd: target.cwd,
@@ -2906,10 +3064,31 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
...(pollTarget.headRepositoryOwner
? { headRepositoryOwner: pollTarget.headRepositoryOwner }
: {}),
...(pollTarget.headRepositoryPath
? { headRepositoryPath: pollTarget.headRepositoryPath }
: {}),
...(pollTarget.headRepositoryEndpoint
? { headRepositoryEndpoint: pollTarget.headRepositoryEndpoint }
: {}),
...(pollTarget.changeRequestNumber
? { changeRequestNumber: pollTarget.changeRequestNumber }
: {}),
...(previousStatus?.state === "open"
? {
previousPullRequest: {
number: previousStatus.number,
url: previousStatus.url,
state: previousStatus.state,
},
}
: {}),
reason: "self-heal-forge-pr-status",
});
if (!closed && this.isActiveObservedWorkspaceTarget(target)) {
latestStatus = status;
if (
!closed &&
this.isActiveObservedWorkspaceTarget(target) &&
target.latestForge === previousForge
) {
consecutiveErrors = 0;
this.rememberForgePrStatusSnapshot(target, buildForgeSnapshotFromStatus(status, forge));
}
@@ -2927,7 +3106,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
"Failed to run forge PR status self-heal refresh",
);
} finally {
schedule(computeGenericForgeNextInterval(latestStatus, consecutiveErrors));
schedule(
computeGenericForgeNextInterval(
target.latestForge?.pullRequest ?? null,
consecutiveErrors,
),
);
}
};
@@ -2935,7 +3119,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
// changes. Revalidate that new identity immediately instead of leaving the
// PR panel empty for the full stable polling interval.
schedule(
pollImmediately ? 0 : computeGenericForgeNextInterval(latestStatus, consecutiveErrors),
pollImmediately
? 0
: computeGenericForgeNextInterval(
target.latestForge?.pullRequest ?? null,
consecutiveErrors,
),
);
return {
unsubscribe: () => {
@@ -2961,10 +3150,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
? target.latestFacts.pullRequestLookupTarget
: null;
if (target.latestFacts?.isGit && target.latestFacts.paseoWorktree.isPaseoOwnedWorktree) {
return lookupTarget;
return lookupTarget && isPullRequestTargetOnForge(git.remoteUrl, lookupTarget)
? lookupTarget
: null;
}
if (lookupTarget) {
return lookupTarget;
return isPullRequestTargetOnForge(git.remoteUrl, lookupTarget) ? lookupTarget : null;
}
return { headRef: git.currentBranch };
@@ -3049,6 +3240,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
): Promise<WorkspaceGitRuntimeSnapshot> {
if (target.folderMissing) {
return Promise.resolve(target.latestSnapshot ?? buildNotGitSnapshot(target.cwd));
}
if (target.refreshState.status === "in-flight") {
const active = target.refreshState.request;
const addsWork =
@@ -3099,7 +3293,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
refreshWorktree: true,
includeForge: options?.includeForge ?? true,
reason: options?.reason ?? defaultReason,
queueIfBusy: false,
queueIfBusy: options?.force === true && options.afterInFlight === true,
movedRemoteRefs: new Set(),
};
}
@@ -3191,11 +3385,19 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
try {
const runRefreshGitCommand = createRunGitCommand(`workspace-refresh:${request.reason}`);
const admittedSnapshot = await this.workspaceRefreshLimit(() => {
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
if (
target.closed ||
target.folderMissing ||
this.workspaceTargets.get(target.cwd) !== target
) {
return null;
}
return this.refreshSnapshot(target, request, runRefreshGitCommand);
});
if (target.folderMissing) {
failure = null;
break;
}
if (!admittedSnapshot) {
break;
}
@@ -3209,6 +3411,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
failure = { error };
}
if (target.folderMissing) {
failure = null;
break;
}
if (this.disposed || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
break;
}
@@ -3304,12 +3510,17 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
runGitCommand: runRefreshGitCommand,
},
);
target.latestFacts = { ...facts, upstreamStatus: derived.upstreamStatus };
target.latestFacts = {
...facts,
upstreamStatus: derived.upstreamStatus,
upstreamKnown: derived.upstreamStatus !== null,
};
target.latestGit = {
...latestGit,
aheadBehind: derived.aheadBehind,
diffStat: derived.diffStat,
upstreamRef: derived.upstreamStatus?.ref ?? null,
upstreamKnown: derived.upstreamStatus !== null,
aheadOfOrigin: derived.upstreamStatus?.aheadBehind.ahead ?? null,
behindOfOrigin: derived.upstreamStatus?.aheadBehind.behind ?? null,
};
@@ -3354,7 +3565,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target.lastShellOutAtMs = now.getTime();
const cwd = target.cwd;
const previousForgePrStatusPollKey = this.getForgePrStatusPollKey(target);
const previousForgePrAssociationKey = this.getForgePrAssociationKey(target);
const baseContext: CheckoutContext = {
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
@@ -3384,6 +3595,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target.latestGit = {
isGit: true,
headSha: facts.isGit ? facts.pullRequestLookupTarget?.headSha : undefined,
upstreamKnown: facts.isGit ? facts.upstreamKnown : false,
repoRoot: checkoutStatus.repoRoot,
mainRepoRoot: checkoutStatus.mainRepoRoot,
currentBranch: checkoutStatus.currentBranch,
@@ -3403,7 +3616,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const loadedAtMs = this.deps.now().getTime();
target.latestGitLoadedAtMs = loadedAtMs;
if (previousForgePrStatusPollKey !== this.getForgePrStatusPollKey(target)) {
if (previousForgePrAssociationKey !== this.getForgePrAssociationKey(target)) {
target.latestForge = buildForgeUnavailableSnapshot();
target.latestForgeLoadedAtMs = target.latestGitLoadedAtMs;
}
@@ -3433,20 +3646,28 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
forgeService.invalidate({ cwd: target.cwd });
}
const forgeSnapshot = await loadForgeSnapshot({
cwd: target.cwd,
forgeService,
now: this.deps.now(),
deps: this.deps,
force: forceForge,
reason: request.reason,
facts,
runGitCommand: runRefreshGitCommand,
});
// Carry the resolved forge (probe-aware) so the wire projection labels
// self-managed GitLab hosts correctly instead of falling back to "github".
target.latestForge = { ...forgeSnapshot, forge: resolution.forge };
target.latestForgeLoadedAtMs = this.deps.now().getTime();
for (let attempt = 0; attempt < 2; attempt += 1) {
const previousForge = target.latestForge;
const forgeSnapshot = await loadForgeSnapshot({
cwd: target.cwd,
forgeService,
now: this.deps.now(),
deps: this.deps,
force: forceForge,
reason: request.reason,
facts,
runGitCommand: runRefreshGitCommand,
previousForge,
});
if (target.latestForge !== previousForge) {
if (forceForge) continue;
return;
}
target.latestForge = { ...forgeSnapshot, forge: resolution.forge };
target.latestForgeLoadedAtMs = this.deps.now().getTime();
return;
}
throw new Error("Forge status changed during the forced workspace refresh");
}
private combineSnapshot(target: WorkspaceGitTarget): WorkspaceGitRuntimeSnapshot {
@@ -3461,7 +3682,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
};
}
private getForgePrStatusPollKey(target: WorkspaceGitTarget): string | null {
private getForgePrAssociationKey(target: WorkspaceGitTarget): string | null {
const git = target.latestGit;
if (!git?.currentBranch || !git.remoteUrl) {
return null;
@@ -3477,7 +3698,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return null;
}
return buildWorkspaceForgePrStatusPollKey({
return buildWorkspaceForgePrAssociationKey({
forge: resolution.forge,
remoteUrl: git.remoteUrl,
target: pollTarget,
@@ -3504,7 +3725,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
snapshot: WorkspaceGitRuntimeSnapshot,
options?: { forceEmit?: boolean },
): void {
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
if (target.closed || target.folderMissing || this.workspaceTargets.get(target.cwd) !== target) {
return;
}
target.latestSnapshot = snapshot;
@@ -3660,21 +3881,25 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
private removeWorkspaceTarget(target: WorkspaceGitTarget): void {
if (target.repoGitRoot) {
const repoTarget = this.repoTargets.get(target.repoGitRoot);
repoTarget?.workspaceKeys.delete(target.cwd);
if (repoTarget && repoTarget.workspaceKeys.size === 0) {
this.closeRepoTarget(repoTarget);
this.repoTargets.delete(target.repoGitRoot);
} else if (repoTarget?.cwd === target.cwd) {
repoTarget.cwd = repoTarget.workspaceKeys.values().next().value ?? repoTarget.cwd;
}
}
this.detachWorkspaceFromRepoTarget(target);
this.closeWorkspaceTarget(target);
this.workspaceTargets.delete(target.cwd);
}
private detachWorkspaceFromRepoTarget(target: WorkspaceGitTarget): void {
if (!target.repoGitRoot) {
return;
}
const repoTarget = this.repoTargets.get(target.repoGitRoot);
repoTarget?.workspaceKeys.delete(target.cwd);
if (repoTarget && repoTarget.workspaceKeys.size === 0) {
this.closeRepoTarget(repoTarget);
this.repoTargets.delete(target.repoGitRoot);
} else if (repoTarget?.cwd === target.cwd) {
repoTarget.cwd = repoTarget.workspaceKeys.values().next().value ?? repoTarget.cwd;
}
}
private removeWorkingTreeWatchListener(cwd: string, listener: () => void): void {
const target = this.workingTreeWatchTargets.get(cwd);
if (!target) {
@@ -3719,6 +3944,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private closeWorkspaceTarget(target: WorkspaceGitTarget): void {
target.closed = true;
this.stopWorkspaceObservation(target);
target.listeners.clear();
}
private stopWorkspaceObservation(target: WorkspaceGitTarget): void {
if (target.workingTreeWatchTarget) {
this.removeWorkspaceWorkingTreeLink(target.workingTreeWatchTarget, target.cwd);
target.workingTreeWatchTarget = null;
@@ -3727,12 +3957,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
clearTimeout(target.debounceTimer);
target.debounceTimer = null;
}
target.pendingDebounceRequest = null;
if (target.observationReensureTimer) {
clearTimeout(target.observationReensureTimer);
target.observationReensureTimer = null;
}
this.stopForgePrStatusPollForTarget(target);
target.listeners.clear();
}
private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): void {
@@ -3807,6 +4037,7 @@ async function loadForgeSnapshot(options: {
reason?: string;
facts?: CheckoutSnapshotFacts;
runGitCommand: RunGitCommand;
previousForge: WorkspaceGitRuntimeSnapshot["forge"] | null;
}): Promise<WorkspaceGitRuntimeSnapshot["forge"]> {
const forgeService = options.forgeService;
if (!forgeService) {
@@ -3832,6 +4063,7 @@ async function loadForgeSnapshot(options: {
{
force: options.force,
reason: options.reason,
previousPullRequest: options.previousForge?.pullRequest ?? null,
},
{ facts: options.facts, runGitCommand: options.runGitCommand },
);
@@ -3839,7 +4071,7 @@ async function loadForgeSnapshot(options: {
} catch (error) {
// The auth probe succeeded, so a failure here is a command error, not an
// auth problem — surface it as an error while keeping features enabled.
return buildForgeSnapshot("authenticated", null, {
return buildForgeSnapshot("authenticated", options.previousForge?.pullRequest ?? null, {
message: error instanceof Error ? error.message : String(error),
});
}
@@ -3956,13 +4188,31 @@ function buildWorkspaceForgePrStatusPollKey({
forge: string;
remoteUrl: string;
target: WorkspaceForgePrStatusPollTarget;
}): string {
return JSON.stringify([
buildWorkspaceForgePrAssociationKey({ forge, remoteUrl, target }),
target.headSha ?? null,
]);
}
function buildWorkspaceForgePrAssociationKey({
forge,
remoteUrl,
target,
}: {
forge: string;
remoteUrl: string;
target: WorkspaceForgePrStatusPollTarget;
}): string {
return JSON.stringify([
forge,
remoteUrl,
target.headRef,
target.headSha ?? null,
target.headRepositoryOwner ?? null,
target.headRepositoryPath ?? null,
target.headRepositoryHost ?? null,
target.headRepositoryEndpoint ?? null,
target.changeRequestNumber ?? null,
]);
}
@@ -1,4 +1,29 @@
import type { ForgeSearchKind } from "@getpaseo/protocol/messages";
import { parseGitRemoteLocation, type GitRemoteLocation } from "@getpaseo/protocol/git-remote";
/** A checkout endpoint that must be proven against the candidate's native clone URL. */
export type HeadRepositoryEndpoint = GitRemoteLocation;
/** The bounded discovery window could not establish a result. */
export class ForgeDiscoveryIncompleteError extends Error {}
export function matchesHeadRepositoryEndpoint(
endpoint: HeadRepositoryEndpoint | undefined,
urls: (string | null | undefined)[],
): boolean {
if (!endpoint) return true;
return urls.some((url) => {
const candidate = url ? parseGitRemoteLocation(url) : null;
if (!candidate) return false;
const transport = (value: GitRemoteLocation["transport"]) => (value === "scp" ? "ssh" : value);
return (
transport(candidate.transport) === transport(endpoint.transport) &&
candidate.host === endpoint.host &&
candidate.port === endpoint.port &&
candidate.path.toLowerCase() === endpoint.path.toLowerCase()
);
});
}
export type ForgeSearchRequestKind = ForgeSearchKind | "github-issue" | "github-pr" | "pr";
@@ -114,6 +139,8 @@ export type ForgeAuthState =
export type ForgeSpecificStatusFacts = { forge: string } & Record<string, unknown>;
export interface CurrentPullRequestStatus {
/** Commit recorded as the change request's head by the forge. */
headSha?: string;
number?: number;
repoOwner?: string;
repoName?: string;
@@ -472,6 +499,11 @@ export interface ForgeService {
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
headRepositoryPath?: string;
headRepositoryHost?: string;
headRepositoryEndpoint?: HeadRepositoryEndpoint;
previousPullRequest?: { number?: number; url: string; state: string } | null;
changeRequestNumber?: number;
} & ForgeReadOptions,
): Promise<CurrentPullRequestStatus | null>;
getPullRequestTimeline(options: GetPullRequestTimelineOptions): Promise<PullRequestTimeline>;
@@ -513,6 +545,9 @@ export interface ForgeService {
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
headRepositoryPath?: string;
headRepositoryEndpoint?: HeadRepositoryEndpoint;
changeRequestNumber?: number;
onStatus?: (status: CurrentPullRequestStatus | null) => void;
onError?: (error: unknown) => void;
}): { unsubscribe: () => void };
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { PullRequestCommandStatus, PullRequestTimelineItem } from "./forge-service.js";
import { ForgeDiscoveryIncompleteError } from "./forge-service.js";
import {
type CreateGiteaServiceOptions,
createGiteaService,
@@ -19,9 +20,40 @@ function ok(stdout: string): TeaCommandResult {
function makeService(responder: Responder, overrides: Partial<CreateGiteaServiceOptions> = {}) {
const calls: string[][] = [];
const listed = new Map<string, typeof OPEN_PR>();
const listedDetail = (args: string[]): TeaCommandResult | null => {
const match = args[0] === "api" ? args[1]?.match(/\/pulls\/(\d+)$/) : null;
const item = match ? listed.get(match[1]) : undefined;
if (!item) return null;
const detail = currentPullRequestApi({
number: Number(item.index),
state: "open",
headRef: item.head,
headSha: STATUS_PR_VIEW.headSha,
});
return ok(JSON.stringify({ ...detail, html_url: item.url, title: item.title }));
};
const runner: TeaCommandRunner = async (args) => {
calls.push(args);
return responder(args);
try {
const result = await responder(args);
if (args[0] === "pr" && args[1] === "list") {
const items = JSON.parse(result.stdout);
if (Array.isArray(items)) for (const item of items) listed.set(item.index, item);
}
if (args[0] === "api" && /\/pulls\/\d+$/.test(args[1] ?? "")) {
const data = JSON.parse(result.stdout);
if (!data?.number) return listedDetail(args) ?? result;
}
return result;
} catch (error) {
if (error instanceof Error && error.message.startsWith("unexpected call:")) {
if (args[0] === "api" && args[1] === "repos/example-user/sample-repo")
return ok(JSON.stringify({ full_name: "example-user/sample-repo" }));
return listedDetail(args) ?? Promise.reject(error);
}
throw error;
}
};
const service = createGiteaService({
runner,
@@ -94,7 +126,7 @@ function currentPullRequestApi(input: {
head: {
ref: input.headRef,
sha: input.headSha,
repo: { id: 1, owner: { login: "example-user" } },
repo: { id: 1, full_name: "example-user/sample-repo", owner: { login: "example-user" } },
},
base: {
ref: "main",
@@ -485,8 +517,9 @@ describe("createGiteaService", () => {
expect(calls[0]).toContain("--fields");
expect(calls[0]).toContain("-o");
expect(calls[0]).toContain("json");
expect(calls[1]).toEqual(["pr", "5", "-o", "json"]);
expect(calls[2]).toEqual([
expect(calls[1]).toEqual(["api", "repos/example-user/sample-repo/pulls/5"]);
expect(calls[2]).toEqual(["pr", "5", "-o", "json"]);
expect(calls[3]).toEqual([
"api",
"repos/example-user/sample-repo/commits/3333333333333333333333333333333333333333/status",
]);
@@ -1627,6 +1660,7 @@ describe("createGiteaService", () => {
"--page",
"1",
],
["api", "repos/example-user/sample-repo/pulls/5"],
["pr", "5", "-o", "json"],
["api", `repos/example-user/sample-repo/commits/${STATUS_PR_VIEW.headSha}/status`],
]);
@@ -2055,6 +2089,7 @@ describe("createGiteaService", () => {
cwd: "/repo",
headRef: "feat/recently-closed",
headSha,
headRepositoryPath: "example-user/sample-repo",
});
expect(status).toMatchObject({ number: 8, headRefName: "feat/recently-closed" });
@@ -2064,7 +2099,31 @@ describe("createGiteaService", () => {
]);
});
it("does not attach a stale Gitea-family PR after a same-name branch advances", async () => {
it("discovers the repository from tea when origin and a head path are unavailable", async () => {
const pr = currentPullRequestApi({
number: 8,
state: "closed",
headRef: "topic",
headSha: "1".repeat(40),
merged: true,
});
const { service } = makeService(
(args) => {
if (args[0] === "api" && args[1] === "repos/{owner}/{repo}")
return ok(JSON.stringify({ full_name: "example-user/sample-repo" }));
if (args[0] === "pr" && args[1] === "list") return ok("[]");
if (args[0] === "api" && args[1].includes("pulls?state=all"))
return ok(JSON.stringify([pr]));
throw new Error(`unexpected call: ${args.join(" ")}`);
},
{ resolveRemoteUrl: async () => null },
);
expect(
(await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "topic" }))?.number,
).toBe(8);
});
it("shows a merged PR when the checkout head differs", async () => {
const stale = currentPullRequestApi({
number: 8,
state: "closed",
@@ -2086,9 +2145,548 @@ describe("createGiteaService", () => {
headRef: "dev",
headSha: "2222222222222222222222222222222222222222",
}),
).resolves.toMatchObject({ number: 8, state: "merged", headSha: stale.head.sha });
});
it("does not scan beyond the bounded terminal window", async () => {
const unrelated = Array.from({ length: 50 }, (_, i) =>
currentPullRequestApi({
number: i + 100,
state: "closed",
headRef: `other-${i}`,
headSha: "1".repeat(40),
}),
);
const wanted = currentPullRequestApi({
number: 8,
state: "closed",
headRef: "topic",
headSha: "2".repeat(40),
merged: true,
});
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok("[]");
if (args[0] === "api" && args[1].includes("/pulls?state=all"))
return ok(JSON.stringify(args[1].includes("page=2") ? [wanted] : unrelated));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
await expect(
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "topic" }),
).rejects.toBeInstanceOf(ForgeDiscoveryIncompleteError);
expect(calls.filter((args) => args[1]?.includes("/pulls?state=all"))).toHaveLength(1);
});
it("does not associate a different repository owned by the same account", async () => {
const foreign = currentPullRequestApi({
number: 8,
state: "closed",
headRef: "dev",
headSha: "1".repeat(40),
merged: true,
});
foreign.head.repo.id = 2;
foreign.head.repo.full_name = "example-user/other";
const { service } = makeService((args) => {
if (args[0] === "api" && args[1] === "repos/example-user/sample-repo")
return ok(JSON.stringify({ full_name: "example-user/sample-repo" }));
if (args[0] === "pr" && args[1] === "list") return ok("[]");
if (args[0] === "api" && args[1].includes("/pulls?state=all"))
return ok(JSON.stringify([foreign]));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
await expect(
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "dev" }),
).resolves.toBeNull();
});
it.each(["open", "merged"] as const)(
"retains a %s PR when its repository was renamed",
async (state) => {
const original = currentPullRequestApi({
number: 8,
state: state === "open" ? "open" : "closed",
headRef: "topic",
headSha: "1".repeat(40),
merged: state === "merged",
});
const pr = {
...original,
html_url: "https://gitea.example/acme/renamed/pulls/8",
head: {
...original.head,
repo: { ...original.head.repo, full_name: "acme/renamed", owner: { login: "acme" } },
},
base: {
...original.base,
repo: { ...original.base.repo, full_name: "acme/renamed", owner: { login: "acme" } },
},
};
const { service, calls } = makeService(
(args) => {
if (args[0] === "pr" && args[1] === "list")
return ok(
JSON.stringify(
state === "open"
? [{ ...OPEN_PR, index: "8", head: "topic", url: pr.html_url }]
: [],
),
);
if (args[0] === "api" && args[1].endsWith("/pulls/8")) return ok(JSON.stringify(pr));
if (args[0] === "api" && args[1] === "repos/acme/original")
return ok(JSON.stringify({ full_name: "acme/renamed" }));
if (args[0] === "api" && args[1].includes("pulls?state=all"))
return ok(JSON.stringify([pr]));
if (args[0] === "pr" && args[1] === "8") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/"))
return ok(JSON.stringify({ state: "", statuses: [] }));
throw new Error(`unexpected call: ${args.join(" ")}`);
},
{ resolveRemoteUrl: async () => "https://gitea.example/acme/original.git" },
);
const input = { cwd: "/repo", headRef: "topic", headRepositoryPath: "acme/original" };
expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(8);
expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(8);
await service.getCurrentPullRequestStatus({ ...input, force: true, reason: "archive" });
expect(calls.filter((args) => args[1] === "repos/acme/original")).toHaveLength(2);
},
);
it("bounds canonical repository reads when many branch candidates differ", async () => {
const other = currentPullRequestApi({
number: 1,
state: "closed",
headRef: "topic",
headSha: "1".repeat(40),
});
other.head.repo.full_name = "example-user/other";
const candidates = Array.from({ length: 50 }, (_, i) => ({ ...other, number: i + 1 }));
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok("[]");
if (args[0] === "api" && args[1] === "repos/example-user/sample-repo")
return ok(JSON.stringify({ full_name: "example-user/sample-repo" }));
if (args[0] === "api" && args[1].includes("pulls?state=all"))
return ok(JSON.stringify(candidates));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
await expect(
service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "topic",
force: true,
reason: "archive",
}),
).rejects.toBeInstanceOf(ForgeDiscoveryIncompleteError);
expect(calls.filter((args) => args[1] === "repos/example-user/sample-repo")).toHaveLength(1);
});
it("isolates renamed repository identities when the forge remote changes", async () => {
let host = "first.example";
const original = currentPullRequestApi({
number: 8,
state: "closed",
headRef: "topic",
headSha: "1".repeat(40),
merged: true,
});
const { service, calls } = makeService(
(args) => {
if (args[0] === "pr" && args[1] === "list") return ok("[]");
if (args[0] === "api" && args[1] === "repos/example-user/sample-repo")
return ok(JSON.stringify({ full_name: `example-user/${host}` }));
if (args[0] === "api" && args[1].includes("pulls?state=all"))
return ok(
JSON.stringify([
{
...original,
head: {
...original.head,
repo: { ...original.head.repo, full_name: `example-user/${host}` },
},
},
]),
);
throw new Error(`unexpected call: ${args.join(" ")}`);
},
{ resolveRemoteUrl: async () => `https://${host}/example-user/sample-repo.git` },
);
const input = { cwd: "/repo", headRef: "topic" };
expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(8);
host = "second.example";
expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(8);
expect(calls.filter((args) => args[1] === "repos/example-user/sample-repo")).toHaveLength(2);
});
it("revalidates the source repository when the forge remote changes", async () => {
let host = "first.example";
const { service, calls } = makeService(
(args) => {
if (args[0] === "pr" && args[1] === "list")
return ok(JSON.stringify([{ ...OPEN_PR, index: "8", head: "topic" }]));
if (args[0] === "api" && args[1].endsWith("/pulls/8")) {
const pr = currentPullRequestApi({
number: 8,
state: "open",
headRef: "topic",
headSha: "1".repeat(40),
});
if (host === "second.example") pr.head.repo.full_name = "example-user/foreign";
return ok(JSON.stringify(pr));
}
if (args[0] === "api" && args[1] === "repos/example-user/sample-repo")
return ok(JSON.stringify({ full_name: "example-user/sample-repo" }));
if (args[0] === "api" && args[1].includes("pulls?state=all")) return ok("[]");
if (args[0] === "pr" && args[1] === "8") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/"))
return ok(JSON.stringify({ state: "", statuses: [] }));
throw new Error(`unexpected call: ${args.join(" ")}`);
},
{ resolveRemoteUrl: async () => `https://${host}/example-user/sample-repo.git` },
);
const input = { cwd: "/repo", headRef: "topic" };
expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(8);
host = "second.example";
expect(await service.getCurrentPullRequestStatus(input)).toBeNull();
expect(calls.filter((args) => args[1]?.endsWith("/pulls/8"))).toHaveLength(2);
});
it.each(["open", "merged"] as const)("matches a transferred fork for an %s PR", async (state) => {
const original = currentPullRequestApi({
number: 8,
state: state === "open" ? "open" : "closed",
headRef: "topic",
headSha: "1".repeat(40),
merged: state === "merged",
});
const pr = {
...original,
html_url: "https://gitea.example/base/repo/pulls/8",
head: {
...original.head,
repo: {
...original.head.repo,
id: 2,
full_name: "new-owner/repo",
owner: { login: "new-owner" },
},
},
base: { ...original.base, repo: { id: 1, owner: { login: "base" } } },
};
const { service } = makeService(
(args) => {
if (args[0] === "pr" && args[1] === "list")
return ok(
JSON.stringify(
state === "open"
? [{ ...OPEN_PR, index: "8", head: "new-owner:topic", url: pr.html_url }]
: [],
),
);
if (args[0] === "api" && args[1].endsWith("/pulls/8")) return ok(JSON.stringify(pr));
if (args[0] === "api" && args[1] === "repos/old-owner/repo")
return ok(JSON.stringify({ full_name: "new-owner/repo" }));
if (args[0] === "api" && args[1].includes("pulls?state=all"))
return ok(JSON.stringify([pr]));
if (args[0] === "pr" && args[1] === "8") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/"))
return ok(JSON.stringify({ state: "", statuses: [] }));
throw new Error(`unexpected call: ${args.join(" ")}`);
},
{ resolveRemoteUrl: async () => "https://gitea.example/base/repo.git" },
);
expect(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "topic",
headRepositoryOwner: "old-owner",
headRepositoryPath: "old-owner/repo",
}),
).toMatchObject({ number: 8, state });
});
it.each(["merged", "open"] as const)(
"finds a recent %s candidate after 200 unrelated open requests",
async (state) => {
const unrelated = Array.from({ length: 50 }, (_, i) => ({
...OPEN_PR,
index: String(i + 100),
head: `other-${i}`,
}));
const wanted = currentPullRequestApi({
number: 9,
state: state === "merged" ? "closed" : "open",
headRef: "topic",
headSha: "1".repeat(40),
merged: state === "merged",
});
const newerMerged = currentPullRequestApi({
number: 10,
state: "closed",
headRef: "topic",
headSha: "2".repeat(40),
merged: true,
});
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify(unrelated));
if (args[0] === "api" && args[1].includes("pulls?state=all"))
return ok(
JSON.stringify(
state === "open"
? [
{ ...newerMerged, updated_at: "2026-10-01T00:00:00Z" },
{ ...wanted, updated_at: "2026-01-01T00:00:00Z" },
]
: [wanted],
),
);
if (args[0] === "pr" && args[1] === "9") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/"))
return ok(JSON.stringify({ state: "", statuses: [] }));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
expect(
(await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "topic" }))?.number,
).toBe(9);
expect(calls.filter((args) => args[0] === "pr" && args[1] === "list")).toHaveLength(4);
expect(calls.filter((args) => args[1]?.includes("pulls?state=all"))).toHaveLength(1);
},
);
it("revalidates a known open PR before replacing it with a crowded-window terminal candidate", async () => {
const open = currentPullRequestApi({
number: 2,
state: "open",
headRef: "topic",
headSha: "1".repeat(40),
});
const oldMerged = currentPullRequestApi({
number: 1,
state: "closed",
headRef: "topic",
headSha: "2".repeat(40),
merged: true,
});
const unrelated = Array.from({ length: 50 }, (_, i) => ({
...OPEN_PR,
index: String(i + 100),
head: `other-${i}`,
}));
const fullRecentItems = Array.from({ length: 50 }, (_, i) => ({
...oldMerged,
number: i + 100,
head: { ...oldMerged.head, ref: `other-${i}` },
}));
let crowded = false;
let fullRecent = false;
let prior = open;
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list")
return ok(
JSON.stringify(
crowded ? unrelated : [{ ...OPEN_PR, index: "2", url: open.html_url, head: "topic" }],
),
);
if (args[0] === "api" && args[1].endsWith("/pulls/2")) return ok(JSON.stringify(prior));
if (args[0] === "api" && args[1].includes("pulls?state=all"))
return ok(JSON.stringify(fullRecent ? fullRecentItems : [oldMerged]));
if (args[0] === "pr" && args[1] === "2") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/"))
return ok(JSON.stringify({ state: "", statuses: [] }));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const input = { cwd: "/repo", headRef: "topic" };
const observed = await service.getCurrentPullRequestStatus(input);
expect(observed).toMatchObject({ number: 2, state: "open" });
crowded = true;
expect(
await service.getCurrentPullRequestStatus({ ...input, previousPullRequest: observed! }),
).toMatchObject({ number: 2, state: "open" });
prior = { ...open, state: "closed", merged: true };
expect(
await service.getCurrentPullRequestStatus({ ...input, previousPullRequest: observed! }),
).toMatchObject({ number: 2, state: "merged" });
prior = { ...open, state: "closed", merged: false };
expect(
await service.getCurrentPullRequestStatus({ ...input, previousPullRequest: observed! }),
).toMatchObject({ number: 2, state: "closed" });
fullRecent = true;
expect(
await service.getCurrentPullRequestStatus({ ...input, previousPullRequest: observed! }),
).toMatchObject({ number: 2, state: "closed" });
expect(calls.filter((args) => args[1]?.endsWith("/pulls/2"))).toHaveLength(5);
});
it("requires candidate clone URL proof for mixed ports and isolates cached identity", async () => {
const pr = currentPullRequestApi({
number: 5,
state: "open",
headRef: "topic",
headSha: "1".repeat(40),
});
let cloneUrl: string | undefined = "ssh://git@forge.example:2222/example-user/sample-repo.git";
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list")
return ok(JSON.stringify([{ ...OPEN_PR, head: "topic" }]));
if (args[0] === "api" && args[1].endsWith("/pulls/5"))
return ok(
JSON.stringify({
...pr,
head: { ...pr.head, repo: { ...pr.head.repo, ssh_url: cloneUrl } },
}),
);
if (args[0] === "api" && args[1].includes("pulls?state=all")) return ok("[]");
if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/"))
return ok(JSON.stringify({ state: "", statuses: [] }));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const endpoint = {
transport: "ssh" as const,
host: "forge.example",
port: "2222",
path: "example-user/sample-repo",
};
const input = { cwd: "/repo", headRef: "topic", headRepositoryEndpoint: endpoint };
expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(5);
expect(
await service.getCurrentPullRequestStatus({
...input,
headRepositoryEndpoint: { ...endpoint, port: "2223" },
}),
).toBeNull();
expect(
(await service.getCurrentPullRequestStatus({ ...input, force: true, reason: "archive" }))
?.number,
).toBe(5);
cloneUrl = undefined;
expect(
await service.getCurrentPullRequestStatus({ ...input, force: true, reason: "archive" }),
).toBeNull();
expect(calls.filter((args) => args[1]?.endsWith("/pulls/5"))).toHaveLength(4);
});
it("ignores an open PR from another repository of the same owner", async () => {
const foreign = currentPullRequestApi({
number: 5,
state: "open",
headRef: "dev",
headSha: "1".repeat(40),
});
foreign.head.repo.id = 2;
foreign.head.repo.full_name = "example-user/other";
const { service } = makeService((args) => {
if (args[0] === "api" && args[1] === "repos/example-user/sample-repo")
return ok(JSON.stringify({ full_name: "example-user/sample-repo" }));
if (args[0] === "pr" && args[1] === "list")
return ok(JSON.stringify([{ ...OPEN_PR, head: "dev" }]));
if (args[0] === "api" && args[1].endsWith("/pulls/5")) return ok(JSON.stringify(foreign));
if (args[0] === "api" && args[1].includes("/pulls?state=all")) return ok("[]");
throw new Error(`unexpected call: ${args.join(" ")}`);
});
await expect(
service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "dev",
headRepositoryPath: "example-user/sample-repo",
}),
).resolves.toBeNull();
});
it("uses a checkout hint to discover a renamed fork before finding the current PR", async () => {
const forkPr = currentPullRequestApi({
number: 42,
state: "open",
headRef: "topic",
headSha: "1".repeat(40),
});
forkPr.head.repo = { id: 2, full_name: "contributor/renamed", owner: { login: "contributor" } };
const { service } = makeService((args) => {
if (args[0] === "api" && args[1].endsWith("/pulls/41"))
return ok(JSON.stringify({ ...forkPr, number: 41 }));
if (args[0] === "pr" && args[1] === "list") return ok("[]");
if (args[0] === "api" && args[1].includes("/pulls?state=all"))
return ok(JSON.stringify([forkPr]));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "topic",
headRepositoryOwner: "contributor",
changeRequestNumber: 41,
});
expect(status?.number).toBe(42);
});
it("prefers an open PR before looking for terminal requests", async () => {
const open = { ...OPEN_PR, head: "topic" };
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([open]));
if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/"))
return ok(JSON.stringify({ state: "", statuses: [] }));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
expect(
await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "topic" }),
).toMatchObject({ number: 5, state: "open" });
expect(calls.some((args) => args[1]?.includes("pulls?state=all"))).toBe(false);
});
it("bounds repository identity reads when a branch is crowded", async () => {
const candidates = Array.from({ length: 4 }, (_, i) => ({
...OPEN_PR,
index: String(i + 20),
head: "topic",
}));
const { service, calls } = makeService((args) => {
if (args[0] === "api" && args[1] === "repos/example-user/sample-repo")
return ok(JSON.stringify({ full_name: "example-user/sample-repo" }));
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify(candidates));
if (args[0] === "api" && args[1].includes("pulls?state=all")) return ok("[]");
if (args[0] === "api" && /\/pulls\/\d+$/.test(args[1])) {
const pr = currentPullRequestApi({
number: Number(args[1].split("/").at(-1)),
state: "open",
headRef: "topic",
headSha: "1".repeat(40),
});
pr.head.repo.full_name = "example-user/other";
return ok(JSON.stringify(pr));
}
throw new Error(`unexpected call: ${args.join(" ")}`);
});
await expect(
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "topic" }),
).rejects.toBeInstanceOf(ForgeDiscoveryIncompleteError);
expect(calls.filter((args) => args[0] === "api" && /\/pulls\/\d+$/.test(args[1]))).toHaveLength(
3,
);
});
it("reuses the head repository identity on subsequent open PR polls", async () => {
const open = { ...OPEN_PR, head: "topic" };
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([open]));
if (args[0] === "pr" && args[1] === "5") return ok(JSON.stringify(STATUS_PR_VIEW));
if (args[0] === "api" && args[1].includes("/commits/"))
return ok(JSON.stringify({ state: "", statuses: [] }));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const input = { cwd: "/repo", headRef: "topic" };
expect((await service.getCurrentPullRequestStatus(input))?.headSha).toBe(
STATUS_PR_VIEW.headSha,
);
expect((await service.getCurrentPullRequestStatus(input))?.headSha).toBe(
STATUS_PR_VIEW.headSha,
);
expect(
calls.filter((args) => args[1] === "repos/example-user/sample-repo/pulls/5"),
).toHaveLength(1);
await service.getCurrentPullRequestStatus({ ...input, force: true, reason: "archive" });
expect(
calls.filter((args) => args[1] === "repos/example-user/sample-repo/pulls/5"),
).toHaveLength(2);
});
it("returns null when no PR matches the current branch", async () => {
const { service, calls } = makeService((args) => {
if (args[0] === "pr" && args[1] === "list") return ok(JSON.stringify([]));
@@ -2110,11 +2708,7 @@ describe("createGiteaService", () => {
]);
});
it("resolves an open cross-fork PR by head sha despite the owner gate", async () => {
// The PR lives in the base repo but its head is on a fork
// (contributor:feat/fork-change), so the checkout-owner gate excludes it.
// The head sha uniquely identifies it, so resolution must accept it — this
// mirrors GitLab, which resolves fork MRs from the base checkout.
it("resolves an open cross-fork PR for the specified head owner", async () => {
const forkHeadSha = "c4ce035f493f2c50f656e3e5bdba29b321a74042";
const forkPr = {
number: 6,
@@ -2129,7 +2723,7 @@ describe("createGiteaService", () => {
head: {
ref: "feat/fork-change",
sha: forkHeadSha,
repo: { id: 2, owner: { login: "contributor" } },
repo: { id: 2, full_name: "contributor/renamed", owner: { login: "contributor" } },
},
base: {
ref: "main",
@@ -2157,6 +2751,8 @@ describe("createGiteaService", () => {
cwd: "/repo",
headRef: "feat/fork-change",
headSha: forkHeadSha,
headRepositoryOwner: "contributor",
headRepositoryPath: "contributor/renamed",
});
expect(status?.number).toBe(6);
+281 -45
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { LRUCache } from "lru-cache";
import { CHECK_TRAIT_ACTION_REQUIRED, CHECK_TRAIT_WARNING } from "@getpaseo/protocol/check-traits";
import { mapGiteaCommitState } from "@getpaseo/protocol/gitea-status";
import pLimit from "p-limit";
@@ -27,6 +28,8 @@ import {
createUnavailableSearchResult,
normalizeForgeSearchKinds,
parseOptionalTime,
matchesHeadRepositoryEndpoint,
ForgeDiscoveryIncompleteError,
} from "./forge-service.js";
import type {
CheckDetails,
@@ -95,6 +98,7 @@ const ISSUE_LIST_FIELDS = "index,state,author,url,title,body,labels,comments,cre
const CURRENT_PR_LOOKUP_PAGE_SIZE = 50;
const CURRENT_PR_OPEN_LOOKUP_MAX_PAGES = 4;
const CURRENT_PR_IDENTITY_READ_LIMIT = 3;
const TIMELINE_PAGE_SIZE = 50;
const TIMELINE_MAX_PAGES_PER_BUCKET = 4;
@@ -160,6 +164,7 @@ const GiteaPrListItemSchema = z
head: z.string().optional(),
created: z.string().optional(),
updated: z.string().optional(),
headSha: z.string().optional(),
labels: z.string().optional(),
ci: z.string().optional(),
})
@@ -223,7 +228,9 @@ const GiteaPullRequestViewSchema = z
const GiteaPullRequestRepoSchema = z
.object({
id: z.number().optional(),
full_name: z.string().optional(),
ssh_url: z.string().nullable().optional(),
clone_url: z.string().nullable().optional(),
html_url: z.string().nullable().optional(),
owner: z.object({ login: z.string().optional() }).passthrough().nullable().optional(),
})
@@ -1010,6 +1017,7 @@ function currentPullRequestApiToListItem(item: GiteaCurrentPullRequestApi): Gite
...(item.mergeable != null ? { mergeable: String(item.mergeable) } : {}),
base: item.base.ref,
head,
headSha: item.head.sha,
updated: item.updated_at,
labels: item.labels.map((label) => label.name).join(","),
};
@@ -1082,6 +1090,7 @@ function toCurrentPullRequestStatus(item: GiteaPrListItem): CurrentPullRequestSt
state,
baseRefName: item.base ?? "",
headRefName: stripHeadOwner(item.head),
...(item.headSha ? { headSha: item.headSha } : {}),
isMerged: state === "merged",
mergeable: mapGiteaMergeable(item),
checks: [],
@@ -1394,6 +1403,11 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
const resolveCurrentBranch = options.resolveCurrentBranch ?? defaultResolveCurrentBranch;
const now = options.now ?? Date.now;
const currentPrHeadShaCache = new Map<string, { sha: string; expiresAt: number }>();
const pullRequestIdentityCache = new LRUCache<string, boolean>({ max: 512, ttl: 120_000 });
const canonicalRepositoryPaths = new LRUCache<string, { path: string | null }>({
max: 512,
ttl: 120_000,
});
async function run(args: string[], runOptions: TeaCommandRunnerOptions): Promise<string> {
const teaPath = await resolveTea();
@@ -1578,6 +1592,12 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
cwd: string;
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
headRepositoryPath?: string;
headRepositoryEndpoint?: import("./forge-service.js").HeadRepositoryEndpoint;
changeRequestNumber?: number;
force?: boolean;
previousPullRequest?: { number?: number; url: string; state: string } | null;
}): Promise<CurrentPullRequestStatus | null> {
const match = await findCurrentPullRequestForHeadRef(input);
if (!match) {
@@ -1606,21 +1626,199 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
cwd: string;
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
headRepositoryPath?: string;
headRepositoryEndpoint?: import("./forge-service.js").HeadRepositoryEndpoint;
changeRequestNumber?: number;
force?: boolean;
previousPullRequest?: { number?: number; url: string; state: string } | null;
}): Promise<GiteaPrListItem | null> {
const repoIdentity = await resolveCurrentRepoIdentity(input.cwd);
const expectedHeadOwner = repoIdentity?.owner ?? null;
const openMatch = await findOpenPullRequestForHeadRef(input, expectedHeadOwner);
if (openMatch) {
return openMatch;
}
// tea discovers repository context from any configured git remote, while
// the default remote resolver reads origin only. Preserve that broader
// discovery for repositories whose primary remote has another name.
const repoPath = repoIdentity
? `repos/${encodeURIComponent(repoIdentity.owner)}/${encodeURIComponent(repoIdentity.name)}`
: "repos/{owner}/{repo}";
const recentItems = await runJsonArray(
let expectedPath =
input.headRepositoryPath ??
(input.headRepositoryOwner
? null
: repoIdentity && `${repoIdentity.owner}/${repoIdentity.name}`);
if (!expectedPath && !input.headRepositoryOwner && !repoIdentity) {
const repository = await runJson(
["api", "repos/{owner}/{repo}"],
{ cwd: input.cwd },
GiteaPullRequestRepoSchema,
);
expectedPath = repository.full_name ?? null;
}
if (!expectedPath && input.headRepositoryOwner && input.changeRequestNumber) {
const hint = await runJson(
["api", `${repoPath}/pulls/${input.changeRequestNumber}`],
{ cwd: input.cwd },
GiteaCurrentPullRequestApiSchema,
);
const path = hint.head.repo?.full_name;
if (path?.split("/")[0]?.toLowerCase() === input.headRepositoryOwner.toLowerCase())
expectedPath = path;
}
if (!expectedPath) return null;
const expectedHeadOwner = expectedPath.split("/")[0] ?? null;
let canonicalLookup: Promise<string | null> | undefined;
const loadCanonicalPath = async (): Promise<string | null> => {
const key = JSON.stringify([
input.cwd,
await resolveRemoteUrl(input.cwd),
repoPath,
expectedPath.toLowerCase(),
]);
const cached = input.force ? undefined : canonicalRepositoryPaths.get(key);
if (cached !== undefined) return cached.path;
const path = expectedPath.split("/").map(encodeURIComponent).join("/");
const repository = await runJson(
["api", `repos/${path}`],
{ cwd: input.cwd },
GiteaPullRequestRepoSchema,
);
const result = repository.full_name ?? null;
canonicalRepositoryPaths.set(key, { path: result });
return result;
};
const matchesHead = async (item: GiteaPrListItem): Promise<boolean> => {
if (matchesCurrentHeadRef(item, input.headRef, expectedHeadOwner)) return true;
if (stripHeadOwner(item.head) !== input.headRef) return false;
canonicalLookup ??= loadCanonicalPath();
const canonicalPath = await canonicalLookup;
return canonicalPath
? matchesCurrentHeadRef(item, input.headRef, canonicalPath.split("/")[0] ?? null)
: false;
};
const matchesRepository = async (item: GiteaCurrentPullRequestApi): Promise<boolean> => {
const headPath =
item.head.repo?.full_name ??
(item.head.repo?.id !== undefined &&
item.head.repo.id === item.base.repo?.id &&
repoIdentity
? `${repoIdentity.owner}/${repoIdentity.name}`
: null);
if (!headPath) return false;
let canonicalPath = expectedPath;
if (headPath.toLowerCase() !== expectedPath.toLowerCase()) {
canonicalLookup ??= loadCanonicalPath();
const resolved = await canonicalLookup;
if (!resolved) return false;
canonicalPath = resolved;
}
return (
headPath.toLowerCase() === canonicalPath.toLowerCase() &&
matchesHeadRepositoryEndpoint(input.headRepositoryEndpoint, [
item.head.repo?.ssh_url,
item.head.repo?.clone_url,
item.head.repo?.html_url,
])
);
};
const open = await findOpenPullRequestForHeadRef(
input,
matchesHead,
repoPath,
matchesRepository,
expectedPath,
input.headRepositoryEndpoint,
);
if (open.match) return open.match;
// A full open window does not make the recent all-state window useless:
// Gitea does not expose a source-branch filter for this endpoint.
return resolveRecentPullRequest({
...input,
matchesHead,
repoPath,
matchesRepository,
exhausted: open.exhausted,
});
}
async function resolveRecentPullRequest(input: {
cwd: string;
headRef: string;
previousPullRequest?: { number?: number; url: string; state: string } | null;
matchesHead: (item: GiteaPrListItem) => Promise<boolean>;
repoPath: string;
matchesRepository: (item: GiteaCurrentPullRequestApi) => Promise<boolean>;
exhausted: boolean;
}): Promise<GiteaPrListItem | null> {
let recent: GiteaPrListItem | null;
try {
recent = await findRecentPullRequestForHeadRef(
input,
input.matchesHead,
input.repoPath,
input.matchesRepository,
);
} catch (error) {
if (!(error instanceof ForgeDiscoveryIncompleteError) || !input.exhausted) throw error;
const prior = await revalidatePreviousPullRequest({
...input,
recent: null,
});
if (prior) return prior;
throw error;
}
if (input.exhausted) {
const prior = await revalidatePreviousPullRequest({
...input,
recent,
});
if (prior) return prior;
}
if (!recent && input.exhausted)
throw new ForgeDiscoveryIncompleteError(
"Gitea pull request discovery exceeded its open candidate window",
);
return recent;
}
async function revalidatePreviousPullRequest(input: {
cwd: string;
headRef: string;
repoPath: string;
matchesHead: (item: GiteaPrListItem) => Promise<boolean>;
matchesRepository: (item: GiteaCurrentPullRequestApi) => Promise<boolean>;
previousPullRequest?: { number?: number; url: string; state: string } | null;
recent: GiteaPrListItem | null;
}): Promise<GiteaPrListItem | null> {
const previous = input.previousPullRequest;
if (previous?.state !== "open") return null;
if (
input.recent &&
(mapGiteaState(input.recent.state) === "open" || input.recent.url === previous.url)
)
return null;
if (!previous.number)
throw new ForgeDiscoveryIncompleteError(
"Cannot revalidate the previously observed open pull request",
);
const current = await runJson(
["api", `${input.repoPath}/pulls/${previous.number}`],
{ cwd: input.cwd },
GiteaCurrentPullRequestApiSchema,
);
const candidate = currentPullRequestApiToListItem(current);
return candidate.url === previous.url &&
(await input.matchesHead(candidate)) &&
(await input.matchesRepository(current))
? candidate
: null;
}
async function findRecentPullRequestForHeadRef(
input: { cwd: string; headRef: string },
matchesHead: (item: GiteaPrListItem) => Promise<boolean>,
repoPath: string,
matchesRepository: (item: GiteaCurrentPullRequestApi) => Promise<boolean>,
): Promise<GiteaPrListItem | null> {
// tea discovers repository context from any configured git remote, while
// the default remote resolver reads origin only. Preserve that broader
// discovery for repositories whose primary remote has another name.
const items = await runJsonArray(
[
"api",
`${repoPath}/pulls?state=all&sort=recentupdate&page=1&limit=${CURRENT_PR_LOOKUP_PAGE_SIZE}`,
@@ -1628,32 +1826,31 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
{ cwd: input.cwd },
GiteaCurrentPullRequestApiSchema,
);
const candidates = recentItems.filter((item) => {
const candidates: GiteaCurrentPullRequestApi[] = [];
for (const item of items) {
if (
matchesCurrentHeadRef(
currentPullRequestApiToListItem(item),
input.headRef,
expectedHeadOwner,
)
) {
return true;
}
// A cross-fork PR carries the fork owner in its head, so the checkout-owner
// gate excludes it even though the PR lives in this base repo. The head sha
// is an unambiguous identity, so accept a match on it regardless of owner —
// this mirrors the GitLab adapter, which resolves fork MRs without an
// owner gate.
return input.headSha !== undefined && item.head.sha === input.headSha;
});
const match =
candidates.find((item) => mapGiteaState(item.state) === "open") ??
candidates.find(
(item) =>
mapGiteaState(item.state) !== "open" &&
input.headSha !== undefined &&
item.head.sha === input.headSha,
) ??
null;
(await matchesHead(currentPullRequestApiToListItem(item))) &&
(await matchesRepository(item))
)
candidates.push(item);
}
candidates.sort(
(a, b) =>
Number(b.state === "open") - Number(a.state === "open") ||
parseOptionalTime(b.updated_at) - parseOptionalTime(a.updated_at) ||
b.number - a.number,
);
const match = candidates[0] ?? null;
const last = items.at(-1);
if (
items.length >= CURRENT_PR_LOOKUP_PAGE_SIZE &&
(!match?.updated_at ||
!last?.updated_at ||
parseOptionalTime(last.updated_at) >= parseOptionalTime(match.updated_at))
)
throw new ForgeDiscoveryIncompleteError(
"Gitea pull request discovery exceeded its recent candidate window",
);
return match ? currentPullRequestApiToListItem(match) : null;
}
@@ -1662,9 +1859,16 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
cwd: string;
headRef: string;
headSha?: string;
force?: boolean;
},
expectedHeadOwner: string | null,
): Promise<GiteaPrListItem | null> {
matchesHead: (item: GiteaPrListItem) => Promise<boolean>,
repoPath: string,
matchesRepository: (item: GiteaCurrentPullRequestApi) => Promise<boolean>,
expectedPath: string,
endpoint?: import("./forge-service.js").HeadRepositoryEndpoint,
): Promise<{ match: GiteaPrListItem | null; exhausted: boolean }> {
let identityReads = 0;
const remoteUrl = await resolveRemoteUrl(input.cwd);
for (let page = 1; page <= CURRENT_PR_OPEN_LOOKUP_MAX_PAGES; page += 1) {
const items = await listPullRequestItems({
cwd: input.cwd,
@@ -1672,17 +1876,36 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
limit: CURRENT_PR_LOOKUP_PAGE_SIZE,
page,
});
const match = items.find((item) =>
matchesCurrentHeadRef(item, input.headRef, expectedHeadOwner),
);
if (match) {
return match;
for (const item of items) {
if (!(await matchesHead(item))) continue;
const identityKey = JSON.stringify([
input.cwd,
remoteUrl,
repoPath,
item.index,
expectedPath.toLowerCase(),
endpoint ?? null,
]);
const cached = input.force ? undefined : pullRequestIdentityCache.get(identityKey);
if (cached === true) return { match: item, exhausted: false };
if (cached === false) continue;
if (identityReads >= CURRENT_PR_IDENTITY_READ_LIMIT)
return { match: null, exhausted: true };
identityReads += 1;
const detail = await runJson(
["api", `${repoPath}/pulls/${item.index}`],
{ cwd: input.cwd },
GiteaCurrentPullRequestApiSchema,
);
const matches = await matchesRepository(detail);
pullRequestIdentityCache.set(identityKey, matches);
if (matches) return { match: { ...item, headSha: detail.head.sha }, exhausted: false };
}
if (items.length < CURRENT_PR_LOOKUP_PAGE_SIZE) {
return null;
return { match: null, exhausted: false };
}
}
return null;
return { match: null, exhausted: true };
}
async function loadCurrentPullRequestChecks(
@@ -1718,7 +1941,12 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
combined,
actionTasks,
);
return applyGiteaChecks(status, combined, actionTasks, actionRequiredRunIds);
return applyGiteaChecks(
status.headSha ? status : { ...status, headSha: pr.headSha },
combined,
actionTasks,
actionRequiredRunIds,
);
} catch {
return status;
}
@@ -2003,6 +2231,12 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
cwd: input.cwd,
headRef: input.headRef,
headSha: input.headSha,
headRepositoryOwner: input.headRepositoryOwner,
headRepositoryPath: input.headRepositoryPath,
headRepositoryEndpoint: input.headRepositoryEndpoint,
previousPullRequest: input.previousPullRequest,
changeRequestNumber: input.changeRequestNumber,
force: input.force,
});
},
@@ -2325,6 +2559,8 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For
},
invalidate(input: { cwd: string }): void {
pullRequestIdentityCache.clear();
canonicalRepositoryPaths.clear();
const prefix = `${input.cwd}:`;
for (const key of currentPrHeadShaCache.keys()) {
if (key.startsWith(prefix)) {
@@ -18,14 +18,16 @@ import {
} from "./github-service.js";
import { isPlatform } from "../test-utils/platform.js";
import { CheckoutPrStatusResponseSchema } from "@getpaseo/protocol/messages";
import { ForgeDiscoveryIncompleteError } from "./forge-service.js";
const EXPECTED_GITHUB_FAST_POLL_MS = 20_000;
const EXPECTED_GITHUB_SLOW_POLL_MS = 120_000;
const EXPECTED_GITHUB_ERROR_BACKOFF_CAP_MS = 300_000;
const CURRENT_PR_STATUS_BASE_FIELDS =
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,updatedAt,mergedAt,reviewDecision,mergeable,headRepositoryOwner,headRepository";
const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
const FORK_PR_QUERY = "state=all&per_page=100&head=forkOwner%3Afeature%2Ffork";
const FORK_PR_QUERY =
"state=all&per_page=100&head=forkOwner%3Afeature%2Ffork&sort=updated&direction=desc";
interface RunnerCall {
args: string[];
@@ -202,6 +204,8 @@ function readFakeGitHubCliLog(logPath: string): string[] {
}
function currentPullRequestJson(overrides: Record<string, unknown> = {}): string {
const repoUrl = String(overrides.url ?? "https://github.com/parentOwner/parentRepo/pull/42");
const [, owner, name] = new URL(repoUrl).pathname.split("/");
return JSON.stringify({
number: 42,
url: "https://github.com/parentOwner/parentRepo/pull/42",
@@ -211,6 +215,8 @@ function currentPullRequestJson(overrides: Record<string, unknown> = {}): string
baseRefName: "main",
headRefName: "feature/fork",
headRefOid: "1111111111111111111111111111111111111111",
headRepositoryOwner: { login: owner },
headRepository: { name },
mergedAt: null,
statusCheckRollup: [],
reviewDecision: "REVIEW_REQUIRED",
@@ -244,6 +250,8 @@ function currentPullRequestGithubFactsJson(overrides: Record<string, unknown> =
}
function batchPollPrNodeJson(overrides: Record<string, unknown> = {}): Record<string, unknown> {
const repoUrl = String(overrides.url ?? "https://github.com/acme/widgets/pull/42");
const [, owner, name] = new URL(repoUrl).pathname.split("/");
return {
number: 42,
url: "https://github.com/acme/widgets/pull/42",
@@ -256,7 +264,8 @@ function batchPollPrNodeJson(overrides: Record<string, unknown> = {}): Record<st
mergedAt: "2026-08-01T00:00:00Z",
mergeable: "UNKNOWN",
reviewDecision: null,
headRepositoryOwner: { login: "acme" },
headRepositoryOwner: { login: owner },
headRepository: { name },
mergeStateStatus: "CLEAN",
autoMergeRequest: null,
viewerCanEnableAutoMerge: false,
@@ -888,6 +897,7 @@ describe("ForgeService", () => {
cwd: "/repo",
headRef: "open-button-targets-active-file",
headRepositoryOwner: "fork-owner",
headRepositoryPath: "fork-owner/parentRepo",
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
@@ -907,6 +917,50 @@ describe("ForgeService", () => {
service.dispose?.();
});
it("keeps mixed-port endpoint proof in retained polls", async () => {
const endpoint = {
transport: "ssh" as const,
host: "forge.example",
port: "2222",
path: "parentOwner/parentRepo",
};
const calls: string[][] = [];
const service = createGitHubService({
ttlMs: 0,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => null,
runner: async (args) => {
calls.push(args);
if (args[0] === "pr") return { stdout: currentPullRequestJson(), stderr: "" };
if (args[1] === "repos/parentOwner/parentRepo")
return {
stdout: JSON.stringify({
ssh_url: "ssh://git@forge.example:2222/parentOwner/parentRepo.git",
}),
stderr: "",
};
return { stdout: currentPullRequestGithubFactsJson(), stderr: "" };
},
});
const reads = recordCurrentPullRequestStatusReads(service);
const statuses: Array<CurrentPullRequestStatus | null> = [];
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryPath: endpoint.path,
headRepositoryEndpoint: endpoint,
onStatus: (status) => statuses.push(status),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(reads).toEqual([expect.objectContaining({ headRepositoryEndpoint: endpoint })]);
expect(statuses).toEqual([expect.objectContaining({ number: 42 })]);
expect(calls.filter((args) => args[1] === "repos/parentOwner/parentRepo")).toHaveLength(1);
subscription?.unsubscribe();
service.dispose?.();
});
it("polls PR status at slow cadence after stable checks", async () => {
let now = 0;
const runner = createRunner([
@@ -1711,10 +1765,10 @@ describe("ForgeService", () => {
batchPollPrNodeJson({
number: 100 + index,
url: `https://github.com/acme/widgets/pull/${100 + index}`,
state: "OPEN",
mergedAt: null,
state: index === 0 ? "MERGED" : "OPEN",
mergedAt: index === 0 ? "2026-08-01T00:00:00Z" : null,
headRefName: "feat-a",
headRepositoryOwner: { login: "stranger" },
headRepositoryOwner: { login: index === 0 ? "acme" : "stranger" },
}),
);
const runner = createScriptedRunner([
@@ -1740,8 +1794,7 @@ describe("ForgeService", () => {
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
// Ten same-named fork PRs filled the page, so absence of a self-owned PR
// is inconclusive and the target resolves through the legacy path.
// A full page can contain a terminal match while an older open PR is omitted.
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
expect(statuses).toEqual([expect.objectContaining({ number: 42, state: "open" })]);
@@ -1790,6 +1843,114 @@ describe("ForgeService", () => {
service.dispose?.();
});
it("uses a full fork path in batched polls without a separate owner hint", async () => {
const node = batchPollPrNodeJson({
state: "OPEN",
mergedAt: null,
headRepositoryOwner: { login: "fork" },
headRepository: { name: "renamed" },
});
const runner = createScriptedRunner([
batchPollStatusJson({ t0: batchPollRepositoryJson([node]) }),
batchPollStatusJson({ t0: batchPollChecksAliasJson([]) }),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => "acme/widgets",
});
const statuses: Array<CurrentPullRequestStatus | null> = [];
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "feat-a",
headRepositoryPath: "fork/renamed",
onStatus: (status) => statuses.push(status),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(statuses).toEqual([expect.objectContaining({ number: 42, state: "open" })]);
subscription?.unsubscribe();
service.dispose?.();
});
it("uses canonical repository identity for batched polls after a transfer", async () => {
const node = batchPollPrNodeJson({
state: "OPEN",
mergedAt: null,
headRepositoryOwner: { login: "new-owner" },
headRepository: { name: "repo" },
});
const runner = createScriptedRunner([
batchPollStatusJson({
t0: batchPollRepositoryJson([node], { owner: { login: "new-owner" }, name: "repo" }),
}),
batchPollStatusJson({ t0: batchPollChecksAliasJson([]) }),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => "old-owner/repo",
});
const statuses: Array<CurrentPullRequestStatus | null> = [];
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "feat-a",
headRepositoryPath: "old-owner/repo",
onStatus: (status) => statuses.push(status),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(statuses).toEqual([expect.objectContaining({ number: 42, state: "open" })]);
subscription?.unsubscribe();
service.dispose?.();
});
it("resolves a transferred fork during batched polling", async () => {
const node = batchPollPrNodeJson({
state: "OPEN",
mergedAt: null,
headRepositoryOwner: { login: "new-fork" },
headRepository: { name: "repo" },
});
const pr = currentPullRequestJson({
url: "https://github.com/base/repo/pull/42",
headRefName: "feat-a",
headRepositoryOwner: { login: "new-fork" },
headRepository: { name: "repo" },
});
const runner = createScriptedRunner([
batchPollStatusJson({ t0: batchPollRepositoryJson([node]) }),
pr,
JSON.stringify([JSON.parse(pr)]),
JSON.stringify({ full_name: "new-fork/repo" }),
currentPullRequestGithubFactsJson(),
]);
const service = createGitHubService({
ttlMs: 0,
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoHost: async () => null,
resolveRepoSlug: async () => "base/repo",
});
const statuses: Array<CurrentPullRequestStatus | null> = [];
const subscription = service.retainCurrentPullRequestStatusPoll?.({
cwd: "/ws-a",
headRef: "feat-a",
headRepositoryOwner: "old-fork",
headRepositoryPath: "old-fork/repo",
onStatus: (status) => statuses.push(status),
});
await vi.advanceTimersByTimeAsync(0);
await flushMicrotasks();
expect(statuses).toEqual([expect.objectContaining({ number: 42, state: "open" })]);
subscription?.unsubscribe();
service.dispose?.();
});
it("recovers healthy aliases from a partially failed batch response", async () => {
let now = 0;
const partialBody = batchPollStatusJson({
@@ -2718,6 +2879,8 @@ describe("ForgeService", () => {
isDraft: true,
baseRefName: "main",
headRefName: "feature/pr-pane",
headRepositoryOwner: { login: "acme" },
headRepository: { name: "repo" },
mergedAt: null,
reviewDecision: "REVIEW_REQUIRED",
statusCheckRollup: [
@@ -3219,14 +3382,18 @@ describe("ForgeService", () => {
});
});
it("keeps a merged PR only when headRefOid matches the checkout HEAD", async () => {
it("keeps a merged PR when headRefOid matches the checkout HEAD", async () => {
const headSha = "2222222222222222222222222222222222222222";
const runner = createRunner([
currentPullRequestJson({
state: "MERGED",
mergedAt: "2026-07-17T12:00:00Z",
headRefOid: headSha,
}),
const merged = currentPullRequestJson({
state: "MERGED",
mergedAt: "2026-07-17T12:00:00Z",
headRefOid: headSha,
headRepositoryOwner: { login: "parentOwner" },
});
const runner = createScriptedRunner([
merged,
JSON.stringify({ owner: { login: "parentOwner" }, name: "parentRepo", parent: null }),
JSON.stringify([JSON.parse(merged)]),
]);
const service = createGitHubService({
runner: runner.runner,
@@ -3243,11 +3410,12 @@ describe("ForgeService", () => {
expect(status?.state).toBe("merged");
});
it("does not attach a stale merged PR after a same-name branch advances", async () => {
it("shows a merged PR after a same-name branch advances", async () => {
const stale = currentPullRequestJson({
state: "MERGED",
mergedAt: "2026-07-17T12:00:00Z",
headRefOid: "1111111111111111111111111111111111111111",
headRepositoryOwner: { login: "parentOwner" },
});
const runner = createScriptedRunner([
stale,
@@ -3265,12 +3433,183 @@ describe("ForgeService", () => {
headRef: "feature/fork",
headSha: "2222222222222222222222222222222222222222",
}),
).resolves.toBeNull();
).resolves.toMatchObject({
number: 42,
state: "merged",
headSha: "1111111111111111111111111111111111111111",
});
expect(runner.calls.some((call) => call.args[0] === "pr" && call.args[1] === "list")).toBe(
true,
);
});
it.each(["OPEN", "MERGED"])(
"ignores a %s PR from another repository of the same owner",
async (state) => {
const foreign = JSON.parse(
currentPullRequestJson({
state,
headRepositoryOwner: { login: "same" },
headRepository: { name: "other" },
}),
);
const wanted = JSON.parse(
currentPullRequestJson({
number: 43,
state,
headRepositoryOwner: { login: "same" },
headRepository: { name: "wanted" },
}),
);
const runner = createScriptedRunner([
JSON.stringify(foreign),
JSON.stringify([foreign, wanted]),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "same",
headRepositoryPath: "same/wanted",
});
expect(status?.number).toBe(43);
},
);
it("uses the checkout hint only to discover its fork repository", async () => {
const hint = currentPullRequestJson({
number: 9,
state: "MERGED",
headRepositoryOwner: { login: "fork" },
headRepository: { name: "renamed" },
});
const open = currentPullRequestJson({
number: 10,
headRepositoryOwner: { login: "fork" },
headRepository: { name: "renamed" },
});
const runner = createScriptedRunner([hint, open]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
const status = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "fork",
changeRequestNumber: 9,
});
expect(status).toMatchObject({ number: 10, state: "open" });
});
it("uses the full head path when no separate owner hint is present", async () => {
const other = currentPullRequestJson({
url: "https://github.com/base/repo/pull/42",
state: "CLOSED",
headRepositoryOwner: { login: "base" },
headRepository: { name: "repo" },
});
const wanted = currentPullRequestJson({
number: 43,
url: "https://github.com/base/repo/pull/43",
headRepositoryOwner: { login: "fork" },
headRepository: { name: "renamed" },
});
const runner = createScriptedRunner([
other,
JSON.stringify({ owner: { login: "base" }, name: "repo", parent: null }),
JSON.stringify([JSON.parse(wanted)]),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
expect(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryPath: "fork/renamed",
}),
).toMatchObject({ number: 43, state: "open" });
});
it("finds the correct repository when the branch search is crowded", async () => {
const other = JSON.parse(
currentPullRequestJson({
headRepositoryOwner: { login: "same" },
headRepository: { name: "other" },
}),
);
const crowd = Array.from({ length: 10 }, (_, i) => ({ ...other, number: i + 100 }));
const wanted = currentPullRequestJson({
number: 42,
url: "https://github.com/base/project/pull/42",
headRepositoryOwner: { login: "same" },
headRepository: { name: "wanted" },
});
const runner = createScriptedRunner([
JSON.stringify(other),
JSON.stringify(crowd),
JSON.stringify({ full_name: "same/wanted" }),
JSON.stringify({ owner: { login: "base" }, name: "project", parent: null }),
JSON.stringify([{ number: 42, state: "open", head: { repo: { full_name: "same/wanted" } } }]),
wanted,
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
expect(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "same",
headRepositoryPath: "same/wanted",
}),
).toMatchObject({ number: 42, state: "open" });
});
it("selects the most recently updated terminal PR instead of the checkout SHA", async () => {
const older = JSON.parse(
currentPullRequestJson({
number: 41,
state: "MERGED",
mergedAt: "2026-07-01T00:00:00Z",
updatedAt: "2026-08-01T01:00:00+02:00",
headRefOid: "checkout",
}),
);
const newer = JSON.parse(
currentPullRequestJson({
number: 42,
state: "CLOSED",
mergedAt: null,
updatedAt: "2026-08-01T00:00:00Z",
headRefOid: "remote",
}),
);
const runner = createScriptedRunner([
JSON.stringify(older),
JSON.stringify({ owner: { login: "parentOwner" }, name: "parentRepo", parent: null }),
JSON.stringify([older, newer]),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
expect(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headSha: "checkout",
headRepositoryPath: "parentOwner/parentRepo",
}),
).toMatchObject({ number: 42, state: "closed" });
});
it("prefers an open PR over an exact-SHA merged PR for the same head", async () => {
const checkoutSha = "2222222222222222222222222222222222222222";
const owner = { login: "forkOwner" };
@@ -3308,6 +3647,7 @@ describe("ForgeService", () => {
headRef: "feature/fork",
headSha: checkoutSha,
headRepositoryOwner: "forkOwner",
headRepositoryPath: "forkOwner/parentRepo",
});
expect(status).toMatchObject({ number: 43, state: "open" });
@@ -3326,7 +3666,18 @@ describe("ForgeService", () => {
name: "parentRepo",
parent: { owner: { login: "parentOwner" }, name: "parentRepo" },
}),
JSON.stringify([{ number: 41 }, { number: 42 }]),
JSON.stringify([
{
number: 41,
updated_at: "2026-08-02",
head: { repo: { full_name: "forkOwner/parentRepo" } },
},
{
number: 42,
updated_at: "2026-08-01",
head: { repo: { full_name: "forkOwner/parentRepo" } },
},
]),
JSON.stringify({
number: 41,
url: "https://github.com/parentOwner/parentRepo/pull/41",
@@ -3339,6 +3690,7 @@ describe("ForgeService", () => {
statusCheckRollup: [],
reviewDecision: "REVIEW_REQUIRED",
headRepositoryOwner: { login: "otherFork" },
headRepository: { name: "parentRepo" },
}),
JSON.stringify({
number: 42,
@@ -3352,6 +3704,7 @@ describe("ForgeService", () => {
statusCheckRollup: [],
reviewDecision: "REVIEW_REQUIRED",
headRepositoryOwner: { login: "forkOwner" },
headRepository: { name: "parentRepo" },
}),
]);
const service = createGitHubService({
@@ -3381,7 +3734,7 @@ describe("ForgeService", () => {
]);
});
it("reads the fork PR carrying the checked-out commit past newer attempts on the branch", async () => {
it("selects the latest fork PR when a branch was reused", async () => {
const checkoutSha = "3333333333333333333333333333333333333333";
const viewed: number[] = [];
const runner: GitHubCommandRunner = async (args) => {
@@ -3399,10 +3752,19 @@ describe("ForgeService", () => {
// Newest first, and the checkout is sitting on the oldest attempt.
return {
stdout: JSON.stringify([
{ number: 50, head: { sha: "5".repeat(40) } },
{ number: 49, head: { sha: "4".repeat(40) } },
{ number: 48, head: { sha: "8".repeat(40) } },
{ number: 47, head: { sha: checkoutSha } },
{
number: 50,
head: { sha: "5".repeat(40), repo: { full_name: "forkOwner/parentRepo" } },
},
{
number: 49,
head: { sha: "4".repeat(40), repo: { full_name: "forkOwner/parentRepo" } },
},
{
number: 48,
head: { sha: "8".repeat(40), repo: { full_name: "forkOwner/parentRepo" } },
},
{ number: 47, head: { sha: checkoutSha, repo: { full_name: "forkOwner/parentRepo" } } },
]),
stderr: "",
};
@@ -3436,14 +3798,163 @@ describe("ForgeService", () => {
});
expect(status).toMatchObject({
number: 47,
number: 50,
repoOwner: "parentOwner",
repoName: "parentRepo",
});
expect(viewed).toContain(47);
expect(viewed).not.toContain(47);
expect(viewed).toHaveLength(3);
});
it("does not scan beyond the fork request budget when open status is unknown", async () => {
const refs = Array.from({ length: 100 }, (_, i) => ({
number: i + 1,
state: "closed",
head: { repo: { full_name: "forkOwner/parentRepo" } },
}));
const runner = createScriptedRunner([
{ error: noPullRequestError() },
JSON.stringify({
owner: { login: "forkOwner" },
name: "parentRepo",
parent: { owner: { login: "parentOwner" }, name: "parentRepo" },
}),
JSON.stringify(refs),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
await expect(
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "feature/fork" }),
).rejects.toBeInstanceOf(ForgeDiscoveryIncompleteError);
expect(runner.calls.filter((call) => call.args[0] === "api")).toHaveLength(1);
});
it("matches a repository transferred while the checkout retains its old origin URL", async () => {
const pr = currentPullRequestJson({
url: "https://github.com/new-owner/repo/pull/42",
headRepositoryOwner: { login: "new-owner" },
headRepository: { name: "repo" },
});
const runner = createScriptedRunner([
pr,
JSON.stringify({ owner: { login: "new-owner" }, name: "repo", parent: null }),
JSON.stringify([JSON.parse(pr)]),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
resolveRepoSlug: async () => "old-owner/repo",
});
expect(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryPath: "old-owner/repo",
}),
).toMatchObject({ number: 42 });
});
it("matches a transferred fork using its canonical repository identity", async () => {
const pr = currentPullRequestJson({
url: "https://github.com/base/repo/pull/42",
headRepositoryOwner: { login: "new-fork" },
headRepository: { name: "repo" },
});
const runner = createScriptedRunner([
pr,
JSON.stringify([JSON.parse(pr)]),
JSON.stringify({ full_name: "new-fork/repo" }),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
expect(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "old-fork",
headRepositoryPath: "old-fork/repo",
}),
).toMatchObject({ number: 42 });
});
it("finds a transferred fork when the branch candidate window is full", async () => {
const pr = JSON.parse(
currentPullRequestJson({
url: "https://github.com/base/repo/pull/42",
headRepositoryOwner: { login: "new-fork" },
headRepository: { name: "repo" },
}),
);
const unrelated = Array.from({ length: 9 }, (_, i) => ({
...pr,
number: i + 50,
headRepositoryOwner: { login: "other" },
}));
const runner = createScriptedRunner([
JSON.stringify(pr),
JSON.stringify([...unrelated, pr]),
JSON.stringify({ full_name: "new-fork/repo" }),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
expect(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "old-fork",
headRepositoryPath: "old-fork/repo",
}),
).toMatchObject({ number: 42, state: "open" });
});
it("uses the canonical fork in the bounded terminal fallback after a transfer", async () => {
const pr = JSON.parse(
currentPullRequestJson({
url: "https://github.com/base/repo/pull/42",
state: "MERGED",
mergedAt: "2026-01-01T00:00:00Z",
headRepositoryOwner: { login: "new-fork" },
headRepository: { name: "repo" },
}),
);
const unrelated = Array.from({ length: 9 }, (_, i) => ({
...pr,
number: i + 50,
headRepositoryOwner: { login: "other" },
}));
const runner = createScriptedRunner([
JSON.stringify(pr),
JSON.stringify([...unrelated, pr]),
JSON.stringify({ full_name: "new-fork/repo" }),
JSON.stringify({ owner: { login: "base" }, name: "repo", parent: null }),
JSON.stringify([
{ number: 42, state: "closed", head: { repo: { full_name: "new-fork/repo" } } },
]),
JSON.stringify(pr),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
});
expect(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "feature/fork",
headRepositoryOwner: "old-fork",
headRepositoryPath: "old-fork/repo",
}),
).toMatchObject({ number: 42, state: "merged" });
expect(
runner.calls.some((call) => call.args[1]?.includes("head=new-fork%3Afeature%2Ffork")),
).toBe(true);
});
it("propagates a failed fork PR lookup instead of reporting no pull request", async () => {
const rateLimitError = new GitHubCommandError({
args: ["api", "repos/parentOwner/parentRepo/pulls"],
@@ -3493,6 +4004,7 @@ describe("ForgeService", () => {
statusCheckRollup: [],
reviewDecision: "REVIEW_REQUIRED",
headRepositoryOwner: { login: headRepositoryOwner },
headRepository: { name: "parentRepo" },
};
}
const crowd = Array.from({ length: 10 }, (_, index) =>
@@ -3524,7 +4036,17 @@ describe("ForgeService", () => {
}
if (args[0] === "api" && args[1]?.startsWith("repos/")) {
expect(args[1]).toContain("head=forkOwner%3Amain");
return { stdout: JSON.stringify([{ number: 4984 }]), stderr: "" };
return {
stdout: JSON.stringify([
...[1, 2, 3, 4].map((number) => ({
number,
state: "open",
head: { repo: { full_name: "forkOwner/other" } },
})),
{ number: 4984, state: "open", head: { repo: { full_name: "forkOwner/parentRepo" } } },
]),
stderr: "",
};
}
return { stdout: "{}", stderr: "" };
};
@@ -3560,7 +4082,7 @@ describe("ForgeService", () => {
name: "parentRepo",
parent: { owner: { login: "parentOwner" }, name: "parentRepo" },
}),
JSON.stringify([{ number: 42 }]),
JSON.stringify([{ number: 42, head: { repo: { full_name: "forkOwner/parentRepo" } } }]),
{
error: statusCheckRollupPermissionError([
"pr",
@@ -3583,6 +4105,7 @@ describe("ForgeService", () => {
mergedAt: null,
reviewDecision: "REVIEW_REQUIRED",
headRepositoryOwner: { login: "forkOwner" },
headRepository: { name: "parentRepo" },
}),
]);
const service = createGitHubService({
@@ -3679,6 +4202,8 @@ describe("ForgeService", () => {
"all",
"--head",
"main",
"--search",
"sort:updated-desc",
"--limit",
"10",
"--json",
@@ -3716,6 +4241,7 @@ describe("ForgeService", () => {
statusCheckRollup: [],
reviewDecision: "REVIEW_REQUIRED",
headRepositoryOwner: { login: "chethanuk" },
headRepository: { name: "repo" },
},
]),
]);
@@ -3729,6 +4255,7 @@ describe("ForgeService", () => {
cwd: "/repo",
headRef: "main",
headRepositoryOwner: "chethanuk",
headRepositoryPath: "chethanuk/repo",
});
expect(status).toMatchObject({
@@ -3746,6 +4273,8 @@ describe("ForgeService", () => {
"all",
"--head",
"main",
"--search",
"sort:updated-desc",
"--limit",
"10",
"--json",
@@ -3762,7 +4291,7 @@ describe("ForgeService", () => {
name: "repo",
parent: { owner: { login: "parentOwner" }, name: "repo" },
}),
JSON.stringify([{ number: 88 }]),
JSON.stringify([{ number: 88, head: { repo: { full_name: "forkOwner/repo" } } }]),
JSON.stringify({
number: 88,
url: "https://github.com/parentOwner/repo/pull/88",
@@ -3775,6 +4304,7 @@ describe("ForgeService", () => {
statusCheckRollup: [],
reviewDecision: null,
headRepositoryOwner: { login: "forkOwner" },
headRepository: { name: "repo" },
}),
]);
const service = createGitHubService({
@@ -4420,6 +4950,49 @@ describe("ForgeService", () => {
expect(currentPullRequestStatusCalls(runner.calls)).toHaveLength(1);
});
it("requires forge clone URL proof for mixed ports and separates cache entries", async () => {
const calls: string[][] = [];
let cloneUrl: string | undefined = "ssh://git@forge.example:2222/parentOwner/parentRepo.git";
const service = createGitHubService({
resolveGhPath: async () => "/usr/bin/gh",
runner: async (args) => {
calls.push(args);
if (args[0] === "pr") return { stdout: currentPullRequestJson(), stderr: "" };
if (args[0] === "api" && args[1] === "repos/parentOwner/parentRepo")
return { stdout: JSON.stringify({ ssh_url: cloneUrl }), stderr: "" };
return { stdout: currentPullRequestGithubFactsJson(), stderr: "" };
},
});
const endpoint = {
transport: "ssh" as const,
host: "forge.example",
port: "2222",
path: "parentOwner/parentRepo",
};
const input = {
cwd: "/repo",
headRef: "feature/fork",
headRepositoryPath: endpoint.path,
headRepositoryEndpoint: endpoint,
};
expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(42);
expect(
await service.getCurrentPullRequestStatus({
...input,
headRepositoryEndpoint: { ...endpoint, port: "2223" },
}),
).toBeNull();
expect(
(await service.getCurrentPullRequestStatus({ ...input, force: true, reason: "archive" }))
?.number,
).toBe(42);
cloneUrl = undefined;
expect(
await service.getCurrentPullRequestStatus({ ...input, force: true, reason: "archive" }),
).toBeNull();
expect(calls.filter((args) => args[1] === "repos/parentOwner/parentRepo")).toHaveLength(4);
});
it("bypasses the warm PR status cache for forced reads", async () => {
const runner = createRunner([
currentPullRequestJson({ number: 41, title: "First" }),
+326 -89
View File
@@ -28,6 +28,8 @@ import {
createUnavailableSearchResult,
normalizeForgeSearchKinds,
parseOptionalTime,
matchesHeadRepositoryEndpoint,
ForgeDiscoveryIncompleteError,
} from "./forge-service.js";
import type {
CheckAnnotation,
@@ -346,6 +348,8 @@ const CurrentPullRequestStatusSchema = z.object({
baseRefName: z.string().catch(""),
headRefName: z.string().catch(""),
headRefOid: z.string().optional(),
headRepository: z.object({ name: z.string() }).nullable().optional(),
updatedAt: z.string().optional(),
mergedAt: z.string().nullable().optional(),
statusCheckRollup: z.unknown().optional(),
reviewDecision: z.unknown().optional(),
@@ -469,8 +473,13 @@ const GitHubRepoViewSchema = z.object({
const ForkPullRequestRefsSchema = z.array(
z.object({
number: z.number(),
state: z.string().optional(),
updated_at: z.string().optional(),
head: z
.object({ sha: z.string().catch("") })
.object({
sha: z.string().catch(""),
repo: z.object({ full_name: z.string() }).nullable().optional(),
})
.nullable()
.optional(),
}),
@@ -524,7 +533,7 @@ query PullRequestCheckoutTarget($owner: String!, $name: String!, $number: Int!)
}`;
const CURRENT_PR_STATUS_BASE_FIELDS =
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,mergedAt,reviewDecision,mergeable,headRepositoryOwner";
"number,url,title,state,isDraft,baseRefName,headRefName,headRefOid,updatedAt,mergedAt,reviewDecision,mergeable,headRepositoryOwner,headRepository";
const CURRENT_PR_STATUS_FIELDS = `${CURRENT_PR_STATUS_BASE_FIELDS},statusCheckRollup`;
const PULL_REQUEST_STATUS_FACTS_QUERY = `
@@ -640,6 +649,8 @@ const BatchPollPrNodeSchema = z.object({
baseRefName: z.string().catch(""),
headRefName: z.string().catch(""),
headRefOid: z.string().catch(""),
headRepository: z.object({ name: z.string() }).nullable().optional(),
updatedAt: z.string().optional(),
mergedAt: z.string().nullable().optional(),
mergeable: PullRequestMergeableSchema.optional().default("UNKNOWN"),
reviewDecision: z.unknown().optional(),
@@ -656,6 +667,7 @@ const BatchPollPrNodeSchema = z.object({
const BatchPollRepositorySchema = z.object({
owner: z.object({ login: z.string().optional() }).nullable().optional().catch(null),
name: z.string().optional(),
isFork: z.boolean().optional().catch(false),
parent: z
.object({ owner: z.object({ login: z.string() }), name: z.string() })
@@ -744,12 +756,14 @@ fragment PaseoPollPullRequest on PullRequest {
baseRefName
headRefName
headRefOid
updatedAt
mergedAt
mergeable
reviewDecision
headRepositoryOwner {
login
}
headRepository { name }
mergeStateStatus
autoMergeRequest {
enabledAt
@@ -785,6 +799,7 @@ function buildBatchPullRequestStatusQuery(
owner {
login
}
name
isFork
parent {
owner {
@@ -799,7 +814,7 @@ function buildBatchPullRequestStatusQuery(
viewerDefaultMergeMethod
pullRequests(headRefName: ${graphqlString(
entry.headRef,
)}, first: ${BATCH_PR_CANDIDATE_LIMIT}, orderBy: {field: CREATED_AT, direction: DESC}) {
)}, first: ${BATCH_PR_CANDIDATE_LIMIT}, orderBy: {field: UPDATED_AT, direction: DESC}) {
nodes {
...PaseoPollPullRequest
}
@@ -1071,6 +1086,9 @@ interface GitHubPollTarget {
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
headRepositoryPath?: string;
headRepositoryEndpoint?: import("./forge-service.js").HeadRepositoryEndpoint;
changeRequestNumber?: number;
retainCount: number;
/** Absolute due time on the shared poll grid; null while a poll is in flight. */
nextDueAt: number | null;
@@ -1093,6 +1111,7 @@ interface GitHubBatchPollEntry {
owner: string;
name: string;
headRepositoryOwner?: string;
canonicalHeadRepositoryPath?: string;
}
interface PendingBatchPullRequestChecks {
@@ -1116,8 +1135,9 @@ interface GitHubBatchDistribution {
interface ResolvedPullRequestCandidate {
status: CurrentPullRequestStatus;
headSha?: string;
updatedAt?: string;
headRepositoryOwner?: string;
headRepositoryPath?: string;
}
export function createGitHubService(options: CreateGitHubServiceOptions = {}): GitHubService {
@@ -1163,7 +1183,12 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
const frozenPullRequestChecksCache = new Map<string, unknown[]>();
const pollRepositoryRedirects = new Map<
string,
{ owner: string; name: string; headRepositoryOwner: string }
{
owner: string;
name: string;
headRepositoryOwner: string;
canonicalHeadRepositoryPath: string;
}
>();
const pollPausedUntilByHost = new Map<string, number>();
const rateLimitResetLoads = new Map<string, Promise<number | null>>();
@@ -1357,6 +1382,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
headRepositoryPath?: string;
headRepositoryEndpoint?: import("./forge-service.js").HeadRepositoryEndpoint;
changeRequestNumber?: number;
}): string {
return buildCacheKey({
cwd: target.cwd,
@@ -1365,6 +1393,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
headRef: target.headRef,
headSha: target.headSha,
headRepositoryOwner: target.headRepositoryOwner,
headRepositoryPath: target.headRepositoryPath,
headRepositoryEndpoint: target.headRepositoryEndpoint,
changeRequestNumber: target.changeRequestNumber,
},
});
}
@@ -1374,6 +1405,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
headRepositoryPath?: string;
headRepositoryEndpoint?: import("./forge-service.js").HeadRepositoryEndpoint;
changeRequestNumber?: number;
status: CurrentPullRequestStatus | null;
notify: boolean;
}): void {
@@ -1702,6 +1736,13 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
redirected: [],
};
for (const [index, entry] of input.entries.entries()) {
if (
entry.target.headRepositoryEndpoint ||
(entry.target.headRepositoryOwner && !entry.target.headRepositoryPath)
) {
distribution.legacyFallback.push(entry.target);
continue;
}
const aliasValue = input.aliases[batchAlias(index)];
const repository = aliasValue ? BatchPollRepositorySchema.safeParse(aliasValue) : null;
if (!repository?.success) {
@@ -1722,20 +1763,18 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
owner: repository.data.parent.owner.login,
name: repository.data.parent.name,
headRepositoryOwner: forkOwner,
canonicalHeadRepositoryPath: `${forkOwner}/${repository.data.name ?? entry.originName}`,
});
distribution.redirected.push(entry);
continue;
}
if (repository.data.pullRequests.nodes.length >= BATCH_PR_CANDIDATE_LIMIT) {
distribution.legacyFallback.push(entry.target);
continue;
}
const node = selectBatchPollNode(entry, repository.data);
if (!node) {
// Legacy fallback covers the two ways "no match" can be wrong: a fork
// whose PR lives in the parent repository, and a full candidate page —
// ten newer same-named fork PRs can crowd the checkout's own PR out of
// the first:N window, so absence is only conclusive on a partial page.
if (
repository.data.isFork ||
repository.data.pullRequests.nodes.length >= BATCH_PR_CANDIDATE_LIMIT
) {
if (shouldRetryUnmatchedBatch(entry, repository.data)) {
distribution.legacyFallback.push(entry.target);
} else {
finalizeBatchPollTarget({
@@ -1748,10 +1787,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
}
continue;
}
const frozenKey =
node.state !== "OPEN" && node.headRefOid.length > 0
? `${entry.owner}/${entry.name}#${node.number}@${node.headRefOid}`
: null;
const frozenKey = frozenPullRequestKey(entry, node);
const cachedRollup = frozenKey ? frozenPullRequestChecksCache.get(frozenKey) : undefined;
if (frozenKey && cachedRollup) {
rememberFrozenPullRequestChecks(frozenKey, cachedRollup);
@@ -1774,6 +1810,24 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
return distribution;
}
function shouldRetryUnmatchedBatch(
entry: GitHubBatchPollEntry,
repository: BatchPollRepository,
): boolean {
return Boolean(
repository.isFork ||
(entry.target.headRepositoryOwner &&
entry.target.headRepositoryPath &&
repository.pullRequests.nodes.length > 0),
);
}
function frozenPullRequestKey(entry: GitHubBatchPollEntry, node: BatchPollPrNode): string | null {
return node.state !== "OPEN" && node.headRefOid.length > 0
? `${entry.owner}/${entry.name}#${node.number}@${node.headRefOid}`
: null;
}
function addressGitHubBatchEntry(entry: GitHubBatchPollEntry): GitHubBatchPollEntry {
const redirect = pollRepositoryRedirects.get(batchRepositoryRedirectKey(entry));
return redirect
@@ -1782,6 +1836,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
owner: redirect.owner,
name: redirect.name,
headRepositoryOwner: redirect.headRepositoryOwner,
canonicalHeadRepositoryPath: redirect.canonicalHeadRepositoryPath,
}
: entry;
}
@@ -1812,7 +1867,11 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
// name. Without a fork-owner hint the workspace branch lives in this
// repository, so only self-owned heads are real candidates (mirrors
// `gh pr view` resolving the current branch's own PR).
if (!headRepositoryOwner && candidate.headRepositoryOwner?.toLowerCase() !== selfOwner) {
if (
!headRepositoryOwner &&
!entry.target.headRepositoryPath &&
candidate.headRepositoryOwner?.toLowerCase() !== selfOwner
) {
continue;
}
nodeByCandidate.set(candidate, node);
@@ -1821,8 +1880,14 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
const match = pickPullRequestCandidate({
candidates,
headRef: entry.target.headRef,
headSha: entry.target.headSha,
headRepositoryOwner,
headRepositoryPath:
!entry.target.headRepositoryOwner &&
entry.target.headRepositoryPath?.toLowerCase() ===
`${entry.originOwner}/${entry.originName}`.toLowerCase()
? (entry.canonicalHeadRepositoryPath ??
`${repository.owner?.login ?? entry.originOwner}/${repository.name ?? entry.originName}`)
: (entry.target.headRepositoryPath ?? `${entry.originOwner}/${entry.originName}`),
});
return match ? (nodeByCandidate.get(match) ?? null) : null;
}
@@ -1958,6 +2023,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
headRef: entry.target.headRef,
headSha: entry.target.headSha,
headRepositoryOwner: entry.target.headRepositoryOwner,
headRepositoryPath: entry.target.headRepositoryPath,
headRepositoryEndpoint: entry.target.headRepositoryEndpoint,
changeRequestNumber: entry.target.changeRequestNumber,
},
});
// A direct or forced read can finish between this batch becoming due and
@@ -1970,6 +2038,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
headRef: entry.target.headRef,
headSha: entry.target.headSha,
headRepositoryOwner: entry.target.headRepositoryOwner,
headRepositoryPath: entry.target.headRepositoryPath,
headRepositoryEndpoint: entry.target.headRepositoryEndpoint,
changeRequestNumber: entry.target.changeRequestNumber,
status: existing.value as CurrentPullRequestStatus | null,
notify: true,
});
@@ -1983,6 +2054,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
headRef: entry.target.headRef,
headSha: entry.target.headSha,
headRepositoryOwner: entry.target.headRepositoryOwner,
headRepositoryPath: entry.target.headRepositoryPath,
headRepositoryEndpoint: entry.target.headRepositoryEndpoint,
changeRequestNumber: entry.target.changeRequestNumber,
status,
notify: true,
});
@@ -1995,6 +2069,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
headRef: target.headRef,
headSha: target.headSha,
headRepositoryOwner: target.headRepositoryOwner,
headRepositoryPath: target.headRepositoryPath,
headRepositoryEndpoint: target.headRepositoryEndpoint,
changeRequestNumber: target.changeRequestNumber,
reason: "self-heal-github",
});
} catch (error) {
@@ -2161,6 +2238,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
headRef: input.headRef,
headSha: input.headSha,
headRepositoryOwner: input.headRepositoryOwner,
headRepositoryPath: input.headRepositoryPath,
headRepositoryEndpoint: input.headRepositoryEndpoint,
changeRequestNumber: input.changeRequestNumber,
},
readOptions: input,
load: async () => {
@@ -2169,8 +2249,33 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
headRef: input.headRef,
headSha: input.headSha,
headRepositoryOwner: input.headRepositoryOwner,
headRepositoryPath: input.headRepositoryPath,
originRepositoryPath:
input.headRepositoryPath && !input.headRepositoryOwner
? await resolveRepoSlugCached(input.cwd)
: null,
changeRequestNumber: input.changeRequestNumber,
run,
});
if (status && input.headRepositoryEndpoint) {
if (!input.headRepositoryPath) return null;
const repo = await runGhJson(
["api", `repos/${input.headRepositoryPath}`],
{ cwd: input.cwd },
z.object({
ssh_url: z.string().nullable().optional(),
clone_url: z.string().nullable().optional(),
}),
"{}",
);
if (
!matchesHeadRepositoryEndpoint(input.headRepositoryEndpoint, [
repo.ssh_url,
repo.clone_url,
])
)
return null;
}
return addCurrentPullRequestGithubFacts({ cwd: input.cwd, status, run });
},
}).then((status) => {
@@ -2179,6 +2284,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
headRef: input.headRef,
headSha: input.headSha,
headRepositoryOwner: input.headRepositoryOwner,
headRepositoryPath: input.headRepositoryPath,
headRepositoryEndpoint: input.headRepositoryEndpoint,
changeRequestNumber: input.changeRequestNumber,
status,
notify: input.reason === "self-heal-github",
});
@@ -2571,6 +2679,9 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
headRef: input.headRef,
headSha: input.headSha,
headRepositoryOwner: input.headRepositoryOwner,
headRepositoryPath: input.headRepositoryPath,
headRepositoryEndpoint: input.headRepositoryEndpoint,
changeRequestNumber: input.changeRequestNumber,
retainCount: 0,
nextDueAt: null,
pollCycleStartedAt: null,
@@ -2952,60 +3063,160 @@ function isGitHubRateLimitError(error: unknown): boolean {
);
}
async function resolveCurrentPullRequestView(options: {
interface CurrentPullRequestLookup {
cwd: string;
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
headRepositoryPath?: string;
originRepositoryPath?: string | null;
changeRequestNumber?: number;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<CurrentPullRequestStatus | null> {
const viewCandidate = await tryCurrentPullRequestView(options);
const viewMatch = viewCandidate
? pickPullRequestCandidate({
candidates: [viewCandidate],
headRef: options.headRef,
headSha: options.headSha,
headRepositoryOwner: options.headRepositoryOwner,
})
: null;
if (viewMatch) {
}
async function resolveCurrentPullRequestView(
options: CurrentPullRequestLookup,
): Promise<CurrentPullRequestStatus | null> {
const resolved = await resolveHintedHeadRepository(options);
return resolved ? resolveCurrentPullRequestForRepository(resolved) : null;
}
async function resolveHintedHeadRepository(
options: CurrentPullRequestLookup,
): Promise<CurrentPullRequestLookup | null> {
if (options.headRepositoryOwner && !options.headRepositoryPath) {
if (!options.changeRequestNumber) return null;
const hint = await tryCurrentPullRequestView({
...options,
args: ["pr", "view", String(options.changeRequestNumber)],
});
if (
!hint?.headRepositoryPath ||
hint.headRepositoryOwner?.toLowerCase() !== options.headRepositoryOwner.toLowerCase()
)
return null;
return { ...options, headRepositoryPath: hint.headRepositoryPath };
}
return options;
}
async function resolveCurrentPullRequestForRepository(
options: CurrentPullRequestLookup,
): Promise<CurrentPullRequestStatus | null> {
const viewMatch = await findDirectPullRequestMatch(options);
if (viewMatch?.status.state === "open") {
return viewMatch.status;
}
let headRepositoryOwner = options.headRepositoryOwner;
if (!options.headRepositoryOwner) return resolvePullRequestWithoutOwner(options, viewMatch);
return resolveListedPullRequest(options, viewMatch, options.headRepositoryOwner);
}
if (!headRepositoryOwner) {
const repo = await getGitHubRepoView(options);
const forkOwner = repo?.owner?.login;
const parentOwner = repo?.parent?.owner?.login;
const parentName = repo?.parent?.name;
if (!forkOwner) {
return null;
}
if (parentOwner && parentName) {
return resolveForkPullRequestView({
...options,
forkOwner,
repo: `${parentOwner}/${parentName}`,
});
}
headRepositoryOwner = forkOwner;
}
async function resolvePullRequestWithoutOwner(
options: CurrentPullRequestLookup,
viewMatch: ResolvedPullRequestCandidate | null,
): Promise<CurrentPullRequestStatus | null> {
const repo = await getGitHubRepoView(options);
const forkOwner = repo?.owner?.login;
const forkName = repo?.name;
const parentOwner = repo?.parent?.owner?.login;
const parentName = repo?.parent?.name;
if (!forkOwner || !forkName) return null;
const headRepositoryPath =
options.headRepositoryPath &&
options.headRepositoryPath.toLowerCase() !== options.originRepositoryPath?.toLowerCase()
? options.headRepositoryPath
: `${forkOwner}/${forkName}`;
options = { ...options, headRepositoryPath };
const expectedOwner = headRepositoryPath.split("/")[0];
if (!expectedOwner) return null;
if (parentOwner && parentName)
return resolveForkPullRequestView({
...options,
forkOwner: expectedOwner,
repo: `${parentOwner}/${parentName}`,
});
return resolveListedPullRequest(options, viewMatch, expectedOwner);
}
async function resolveListedPullRequest(
options: CurrentPullRequestLookup,
viewMatch: ResolvedPullRequestCandidate | null,
headRepositoryOwner: string,
): Promise<CurrentPullRequestStatus | null> {
const candidates = await listCurrentPullRequestCandidates({
cwd: options.cwd,
headRef: options.headRef,
run: options.run,
});
const match = pickPullRequestCandidate({
candidates,
let match = pickPullRequestCandidate({
candidates: viewMatch ? [viewMatch, ...candidates] : candidates,
headRef: options.headRef,
headSha: options.headSha,
headRepositoryOwner,
headRepositoryPath: options.headRepositoryPath,
});
if (
!match &&
options.headRepositoryOwner &&
options.headRepositoryPath &&
candidates.some((candidate) => isCandidateForHeadRef(candidate, options.headRef))
) {
const args = ["api", `repos/${options.headRepositoryPath}`];
const stdout = await options.run(args, { cwd: options.cwd });
const repository = parseGitHubJsonOutput(
stdout,
z.object({ full_name: z.string().optional() }),
{ args, cwd: options.cwd, emptyFallback: "{}" },
);
if (
repository.full_name &&
repository.full_name.toLowerCase() !== options.headRepositoryPath.toLowerCase()
) {
options = {
...options,
headRepositoryPath: repository.full_name,
headRepositoryOwner: repository.full_name.split("/")[0],
};
match = pickPullRequestCandidate({
candidates: viewMatch ? [viewMatch, ...candidates] : candidates,
headRef: options.headRef,
headRepositoryOwner: repository.full_name.split("/")[0],
headRepositoryPath: repository.full_name,
});
}
}
if (candidates.length >= BATCH_PR_CANDIDATE_LIMIT && options.headRepositoryPath) {
if (match?.status.state === "open") return match.status;
return resolveCrowdedPullRequests(options, options.headRepositoryPath);
}
return match?.status ?? null;
}
async function resolveCrowdedPullRequests(
options: CurrentPullRequestLookup,
headRepositoryPath: string,
): Promise<CurrentPullRequestStatus | null> {
const repo = await getGitHubRepoView(options);
const owner = repo?.parent?.owner?.login ?? repo?.owner?.login;
const name = repo?.parent?.name ?? repo?.name;
const forkOwner = headRepositoryPath.split("/")[0];
if (!owner || !name || !forkOwner) return null;
return resolveForkPullRequestView({ ...options, forkOwner, repo: `${owner}/${name}` });
}
async function findDirectPullRequestMatch(
options: CurrentPullRequestLookup,
): Promise<ResolvedPullRequestCandidate | null> {
const candidate = await tryCurrentPullRequestView(options);
if (!candidate) return null;
return pickPullRequestCandidate({
candidates: [candidate],
headRef: options.headRef,
headRepositoryOwner: options.headRepositoryOwner,
headRepositoryPath: options.headRepositoryPath,
});
}
// A fork checkout's pull request lives in the parent repository, where only
// its head repository tells it apart from every other fork's pull request on
// the same branch name. `gh pr list --head` cannot ask that question: it
@@ -3019,6 +3230,7 @@ async function resolveForkPullRequestView(options: {
headRef: string;
headSha?: string;
forkOwner: string;
headRepositoryPath?: string;
repo: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<CurrentPullRequestStatus | null> {
@@ -3038,22 +3250,19 @@ async function resolveForkPullRequestView(options: {
(candidate): candidate is ResolvedPullRequestCandidate => candidate !== null,
),
headRef: options.headRef,
headSha: options.headSha,
headRepositoryOwner: options.forkOwner,
headRepositoryPath: options.headRepositoryPath,
});
return match?.status ?? null;
}
// The endpoint answers with the fork branch's pull requests, newest first, so
// a branch reused for several attempts can carry the checkout's head commit on
// an older one. Those are read first, and the rest of the window is filled
// newest-first, so the full read stays bounded without dropping the attempt the
// checkout is actually sitting on.
// The endpoint answers with the fork branch's pull requests, newest first.
async function listForkPullRequestNumbers(options: {
cwd: string;
headRef: string;
headSha?: string;
forkOwner: string;
headRepositoryPath?: string;
repo: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<number[]> {
@@ -3061,6 +3270,8 @@ async function listForkPullRequestNumbers(options: {
state: "all",
per_page: String(FORK_PR_PAGE_SIZE),
head: `${options.forkOwner}:${options.headRef}`,
sort: "updated",
direction: "desc",
});
const args = ["api", `repos/${options.repo}/pulls?${query.toString()}`];
const stdout = await options.run(args, { cwd: options.cwd });
@@ -3069,12 +3280,22 @@ async function listForkPullRequestNumbers(options: {
cwd: options.cwd,
emptyFallback: "[]",
});
function carriesHeadSha(ref: (typeof refs)[number]): boolean {
return options.headSha !== undefined && ref.head?.sha === options.headSha;
}
return [...refs.filter(carriesHeadSha), ...refs.filter((ref) => !carriesHeadSha(ref))]
.slice(0, FORK_PR_VIEW_LIMIT)
.map((ref) => ref.number);
const matching = refs
.filter(
(ref) =>
ref.head?.repo?.full_name?.toLowerCase() === options.headRepositoryPath?.toLowerCase(),
)
.sort(
(a, b) =>
Number(b.state === "open") - Number(a.state === "open") ||
parseOptionalTime(b.updated_at) - parseOptionalTime(a.updated_at) ||
b.number - a.number,
);
if (refs.length >= FORK_PR_PAGE_SIZE && !matching.some((ref) => ref.state === "open"))
throw new ForgeDiscoveryIncompleteError(
"GitHub pull request discovery exceeded its candidate window",
);
return matching.slice(0, FORK_PR_VIEW_LIMIT).map((ref) => ref.number);
}
async function addCurrentPullRequestGithubFacts(options: {
@@ -3163,7 +3384,18 @@ async function listCurrentPullRequestCandidates(options: {
headRef: string;
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
}): Promise<ResolvedPullRequestCandidate[]> {
const args = ["pr", "list", "--state", "all", "--head", options.headRef, "--limit", "10"];
const args = [
"pr",
"list",
"--state",
"all",
"--head",
options.headRef,
"--search",
"sort:updated-desc",
"--limit",
String(BATCH_PR_CANDIDATE_LIMIT),
];
try {
const stdout = await runCurrentPullRequestStatusCommand({
cwd: options.cwd,
@@ -3337,11 +3569,13 @@ function toBatchCurrentPullRequestItem(
baseRefName: node.baseRefName,
headRefName: node.headRefName,
...(node.headRefOid ? { headRefOid: node.headRefOid } : {}),
...(node.updatedAt ? { updatedAt: node.updatedAt } : {}),
mergedAt: node.mergedAt ?? null,
...(statusCheckRollup ? { statusCheckRollup } : {}),
reviewDecision: node.reviewDecision,
mergeable: node.mergeable,
headRepositoryOwner: node.headRepositoryOwner,
headRepository: node.headRepository,
};
}
@@ -3354,11 +3588,15 @@ function toCurrentPullRequestCandidate(
return null;
}
const headRepositoryOwner = item.headRepositoryOwner?.login;
const headSha = item.headRefOid;
const headRepositoryPath =
headRepositoryOwner && item.headRepository?.name
? `${headRepositoryOwner}/${item.headRepository.name}`
: undefined;
return {
status,
...(headSha ? { headSha } : {}),
updatedAt: item.updatedAt,
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
...(headRepositoryPath ? { headRepositoryPath } : {}),
};
}
@@ -3373,55 +3611,53 @@ function hasResolvedRepoIdentity(status: CurrentPullRequestStatus): boolean {
function pickPullRequestCandidate(options: {
candidates: ResolvedPullRequestCandidate[];
headRef: string;
headSha?: string;
headRepositoryOwner?: string;
headRepositoryPath?: string;
}): ResolvedPullRequestCandidate | null {
const matching = options.candidates.filter((candidate) => {
if (!isCandidateForHeadRef(candidate, options.headRef)) {
return false;
}
if (
candidate.status.state !== "open" &&
(!options.headSha || candidate.headSha !== options.headSha)
) {
options.headRepositoryPath &&
candidate.headRepositoryPath?.toLowerCase() !== options.headRepositoryPath.toLowerCase()
)
return false;
}
if (!options.headRepositoryOwner) {
return true;
if (options.headRepositoryPath) return true;
return Boolean(
candidate.headRepositoryPath &&
candidate.headRepositoryPath.toLowerCase() ===
`${candidate.status.repoOwner}/${candidate.status.repoName}`.toLowerCase(),
);
}
return candidate.headRepositoryOwner === options.headRepositoryOwner;
return (
candidate.headRepositoryOwner?.toLowerCase() === options.headRepositoryOwner.toLowerCase()
);
});
matching.sort((left, right) =>
comparePullRequestCandidatePreference(left, right, options.headSha),
);
matching.sort(comparePullRequestCandidatePreference);
return matching[0] ?? null;
}
function comparePullRequestCandidatePreference(
left: ResolvedPullRequestCandidate,
right: ResolvedPullRequestCandidate,
headSha?: string,
): number {
const stateRank = getPullRequestStateRank(left.status) - getPullRequestStateRank(right.status);
if (stateRank !== 0) {
return stateRank;
}
const leftExact = headSha !== undefined && left.headSha === headSha;
const rightExact = headSha !== undefined && right.headSha === headSha;
if (leftExact !== rightExact) {
return leftExact ? -1 : 1;
}
return 0;
return (
parseOptionalTime(right.updatedAt) - parseOptionalTime(left.updatedAt) ||
(right.status.number ?? 0) - (left.status.number ?? 0)
);
}
function getPullRequestStateRank(status: CurrentPullRequestStatus): number {
if (status.state === "open" || status.isDraft) {
if (status.state === "open") {
return 0;
}
if (status.state === "merged") {
return 1;
}
return 2;
return 1;
}
type GitHubCloneProtocol = "https" | "ssh";
@@ -3963,6 +4199,7 @@ function toCurrentPullRequestStatus(
const checks = parseStatusCheckRollup(item.statusCheckRollup);
return {
...(typeof item.number === "number" ? { number: item.number } : {}),
...(item.headRefOid ? { headSha: item.headRefOid } : {}),
...(repoIdentity ? { repoOwner: repoIdentity.owner, repoName: repoIdentity.name } : {}),
url: item.url,
title: item.title,
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { PullRequestCommandStatus } from "./forge-service.js";
import { ForgeDiscoveryIncompleteError } from "./forge-service.js";
import { GITLAB_ACTIVE_PIPELINE_STATUS_SET } from "@getpaseo/protocol/gitlab-pipeline";
import type { GitLabStatusFacts } from "./gitlab-facts.js";
import {
@@ -261,6 +262,114 @@ const DISCUSSIONS = [
];
describe("createGitLabService", () => {
it.each([true, false])(
"discovers the project without an origin remote (head path supplied: %s)",
async (hasHeadPath) => {
const mr = { ...OPEN_MR, source_branch: "topic" };
const { service } = makeService(
(args) => {
if (args[1] === "list") return ok(JSON.stringify([mr]));
if (args[1] === "view") return ok(JSON.stringify(mr));
if (
args[1] === "projects/:fullpath" ||
args[1] === "projects/example-group%2Fexample-project"
)
return ok(
JSON.stringify({ id: 101, path_with_namespace: "example-group/example-project" }),
);
return ok("{}");
},
{ resolveRemoteUrl: async () => null },
);
expect(
(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "topic",
...(hasHeadPath ? { headRepositoryPath: "example-group/example-project" } : {}),
})
)?.number,
).toBe(14);
},
);
it("requires native clone URL proof for mixed transport ports and isolates cached endpoints", async () => {
const mr = { ...OPEN_MR, source_branch: "topic" };
let cloneUrl: string | undefined =
"ssh://git@gitlab.example.com:2222/example-group/example-project.git";
const { service, calls } = makeService((args) => {
if (args[1] === "list") return ok(JSON.stringify([mr]));
if (args[1] === "view") return ok(JSON.stringify(mr));
if (args[1] === "projects/example-group%2Fexample-project")
return ok(
JSON.stringify({
id: 101,
path_with_namespace: "example-group/example-project",
ssh_url_to_repo: cloneUrl,
}),
);
return ok("{}");
});
const endpoint = {
transport: "ssh" as const,
host: "gitlab.example.com",
port: "2222",
path: "example-group/example-project",
};
const input = {
cwd: "/repo",
headRef: "topic",
headRepositoryPath: endpoint.path,
headRepositoryEndpoint: endpoint,
};
expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(14);
expect(
await service.getCurrentPullRequestStatus({
...input,
headRepositoryEndpoint: { ...endpoint, port: "2223" },
}),
).toBeNull();
expect(
(await service.getCurrentPullRequestStatus({ ...input, force: true, reason: "archive" }))
?.number,
).toBe(14);
cloneUrl = undefined;
expect(
await service.getCurrentPullRequestStatus({ ...input, force: true, reason: "archive" }),
).toBeNull();
expect(
calls.filter((args) => args[1] === "projects/example-group%2Fexample-project"),
).toHaveLength(3);
});
it("finds the target fork without reading unrelated source projects", async () => {
const unrelated = Array.from({ length: 4 }, (_, i) => ({
...OPEN_MR,
iid: i + 30,
source_branch: "topic",
source_project_id: i + 200,
}));
const wanted = { ...OPEN_MR, iid: 44, source_branch: "topic", source_project_id: 300 };
const { service, calls } = makeService((args) => {
if (args[1] === "list") return ok(JSON.stringify([...unrelated, wanted]));
if (args[1] === "view") return ok(JSON.stringify(wanted));
if (args[1] === "projects/team%2Ffork")
return ok(JSON.stringify({ id: 300, path_with_namespace: "team/fork" }));
if (args[0] === "api" && /^projects\/\d+$/.test(args[1]))
return ok(
JSON.stringify({ id: Number(args[1].split("/")[1]), path_with_namespace: "other/repo" }),
);
return ok("{}");
});
expect(
(
await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "topic",
headRepositoryPath: "team/fork",
})
)?.number,
).toBe(44);
expect(calls.filter((args) => /^projects\/\d+$/.test(args[1] ?? ""))).toHaveLength(0);
});
it("maps a glab merge request view to the neutral current PR status", async () => {
const { service, calls } = makeService((args) =>
ok(JSON.stringify(args[1] === "list" ? [OPEN_MR] : OPEN_MR)),
@@ -321,7 +430,7 @@ describe("createGitLabService", () => {
expect(status).toBeNull();
});
it("selects a terminal merge request only when its head SHA matches the checkout", async () => {
it("selects the latest terminal merge request even when the checkout head differs", async () => {
const branch = "dev";
const checkoutSha = "2222222222222222222222222222222222222222";
const newestStale = {
@@ -342,7 +451,7 @@ describe("createGitLabService", () => {
};
const { service, calls } = makeService((args) => {
if (args[1] === "list") return ok(JSON.stringify([newestStale, exactOlder]));
if (args[1] === "view" && args[2] === "270") return ok(JSON.stringify(exactOlder));
if (args[1] === "view" && args[2] === "271") return ok(JSON.stringify(newestStale));
if (args[0] === "api") return ok("{}");
throw new Error(`unexpected call: ${args.join(" ")}`);
});
@@ -353,18 +462,159 @@ describe("createGitLabService", () => {
headSha: checkoutSha,
});
expect(status?.number).toBe(270);
expect(calls[1]).toEqual(["mr", "view", "270", "-F", "json"]);
expect(status?.number).toBe(271);
expect(status?.headSha).toBe(newestStale.sha);
expect(calls[1]).toEqual(["mr", "view", "271", "-F", "json"]);
});
it("does not attach the latest historical merge request after a reused branch advances", async () => {
it.each(["opened", "merged"])(
"matches a nested, renamed fork for a %s merge request",
async (state) => {
const wrong = { ...OPEN_MR, iid: 41, state, source_branch: "topic", source_project_id: 201 };
const right = { ...wrong, iid: 42, source_project_id: 202 };
const { service } = makeService((args) => {
if (args[1] === "list") return ok(JSON.stringify([wrong, right]));
if (args[1] === "view") return ok(JSON.stringify(right));
if (args[1] === "projects/team%2Fsubgroup%2Frenamed")
return ok(JSON.stringify({ id: 202, path_with_namespace: "team/subgroup/renamed" }));
if (args[1] === "projects/201")
return ok(JSON.stringify({ path_with_namespace: "team/subgroup/other" }));
if (args[1] === "projects/202")
return ok(JSON.stringify({ path_with_namespace: "team/subgroup/renamed" }));
if (args[0] === "api") return ok("{}");
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const result = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "topic",
headRepositoryPath: "team/subgroup/renamed",
});
expect(result?.number).toBe(42);
},
);
it("resolves a checkout hint to the full fork project before selecting the current request", async () => {
const hint = { ...OPEN_MR, iid: 41, source_branch: "topic", source_project_id: 202 };
const current = { ...hint, iid: 42, state: "opened" };
const { service } = makeService((args) => {
if (args[1] === "list") return ok(JSON.stringify([current]));
if (args[1] === "view") return ok(JSON.stringify(args[2] === "41" ? hint : current));
if (args[1] === "projects/202")
return ok(JSON.stringify({ path_with_namespace: "team/subgroup/renamed" }));
if (args[1] === "projects/team%2Fsubgroup%2Frenamed")
return ok(JSON.stringify({ id: 202, path_with_namespace: "team/subgroup/renamed" }));
if (args[0] === "api") return ok("{}");
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const result = await service.getCurrentPullRequestStatus({
cwd: "/repo",
headRef: "topic",
headRepositoryOwner: "team",
changeRequestNumber: 41,
});
expect(result?.number).toBe(42);
});
it("prefers an open merge request over a more recently updated terminal request", async () => {
const terminal = {
...OPEN_MR,
iid: 50,
state: "merged",
source_branch: "topic",
updated_at: "2026-08-01T00:00:00Z",
};
const open = {
...OPEN_MR,
iid: 49,
state: "opened",
source_branch: "topic",
updated_at: "2026-07-01T00:00:00Z",
};
const { service } = makeService((args) => {
if (args[1] === "list") return ok(JSON.stringify([terminal, open]));
if (args[1] === "view") return ok(JSON.stringify(open));
if (args[0] === "api") return ok("{}");
throw new Error(`unexpected call: ${args.join(" ")}`);
});
expect(
await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "topic" }),
).toMatchObject({ number: 49, state: "open" });
});
it("does not select a terminal request when the first page cannot establish whether an open one exists", async () => {
const terminal = Array.from({ length: 100 }, (_, i) => ({
...OPEN_MR,
iid: i + 100,
state: "merged",
source_branch: "topic",
}));
const open = { ...OPEN_MR, iid: 99, state: "opened", source_branch: "topic" };
const { service, calls } = makeService((args) => {
if (args[1] === "list")
return ok(JSON.stringify(args.includes("--page") ? [open] : terminal));
if (args[1] === "view") return ok(JSON.stringify(open));
if (args[0] === "api") return ok("{}");
throw new Error(`unexpected call: ${args.join(" ")}`);
});
await expect(
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "topic" }),
).rejects.toBeInstanceOf(ForgeDiscoveryIncompleteError);
expect(calls.filter((args) => args[1] === "list")).toHaveLength(1);
});
it("bounds source project identity reads when a branch is crowded", async () => {
const requests = Array.from({ length: 4 }, (_, i) => ({
...OPEN_MR,
iid: i + 20,
state: "merged",
source_branch: "topic",
source_project_id: i + 200,
}));
const { service, calls } = makeService((args) => {
if (args[1] === "list") return ok(JSON.stringify(requests));
if (args[0] === "api" && args[1].startsWith("projects/"))
return ok(JSON.stringify({ path_with_namespace: "elsewhere/repo" }));
throw new Error(`unexpected call: ${args.join(" ")}`);
});
expect(
await service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "topic" }),
).toBeNull();
expect(
calls.filter((args) => args[0] === "api" && /^projects\/\d+$/.test(args[1])),
).toHaveLength(0);
});
it("reuses a resolved source project identity on subsequent polls", async () => {
const mr = { ...OPEN_MR, iid: 44, source_branch: "topic", source_project_id: 202 };
const { service, calls } = makeService((args) => {
if (args[1] === "list") return ok(JSON.stringify([mr]));
if (args[1] === "view") return ok(JSON.stringify(mr));
if (args[1] === "projects/team%2Ffork")
return ok(JSON.stringify({ id: 202, path_with_namespace: "team/fork" }));
if (args[0] === "api") return ok("{}");
throw new Error(`unexpected call: ${args.join(" ")}`);
});
const input = { cwd: "/repo", headRef: "topic", headRepositoryPath: "team/fork" };
expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(44);
expect((await service.getCurrentPullRequestStatus(input))?.number).toBe(44);
expect(calls.filter((args) => args[1] === "projects/team%2Ffork")).toHaveLength(1);
await service.getCurrentPullRequestStatus({ ...input, force: true, reason: "archive" });
expect(calls.filter((args) => args[1] === "projects/team%2Ffork")).toHaveLength(2);
});
it("shows the latest historical merge request after a reused branch advances", async () => {
const stale = {
...OPEN_MR,
state: "merged",
source_branch: "dev",
sha: "1111111111111111111111111111111111111111",
};
const { service, calls } = makeService(() => ok(JSON.stringify([stale])));
const { service, calls } = makeService((args) => {
if (args[1] === "list") return ok(JSON.stringify([stale]));
if (args[1] === "view") return ok(JSON.stringify(stale));
if (args[0] === "api") return ok("{}");
throw new Error(`unexpected call: ${args.join(" ")}`);
});
await expect(
service.getCurrentPullRequestStatus({
@@ -372,8 +622,8 @@ describe("createGitLabService", () => {
headRef: "dev",
headSha: "2222222222222222222222222222222222222222",
}),
).resolves.toBeNull();
expect(calls).toEqual([currentMrListArgs("dev")]);
).resolves.toMatchObject({ number: stale.iid, state: "merged" });
expect(calls[0]).toEqual(currentMrListArgs("dev"));
});
it("looks up a numeric current branch through the source-branch list filter", async () => {
+121 -12
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { LRUCache } from "lru-cache";
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import { findExecutable } from "../executable-resolution/executable-resolution.js";
import {
@@ -17,6 +18,8 @@ import {
createUnavailableSearchResult,
normalizeForgeSearchKinds,
parseOptionalTime,
matchesHeadRepositoryEndpoint,
ForgeDiscoveryIncompleteError,
} from "./forge-service.js";
import type {
CheckDetails,
@@ -720,6 +723,7 @@ function toCurrentPullRequestStatus(
const projectPath = extractProjectPath(mr.references?.full);
return {
number: mr.iid,
...(mr.sha ? { headSha: mr.sha } : {}),
...(owner ? { repoOwner: owner } : {}),
...(name ? { repoName: name } : {}),
...(projectPath ? { projectPath } : {}),
@@ -911,6 +915,15 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
const runner = options.runner ?? runGlabCommand;
const resolveGlab = createCachedCliPathResolver(options.resolveGlabPath ?? resolveGlabPath);
const resolveRemoteUrl = options.resolveRemoteUrl ?? defaultResolveRemoteUrl;
const projectPaths = new LRUCache<
string,
{
id?: number;
path_with_namespace: string;
ssh_url_to_repo?: string | null;
http_url_to_repo?: string | null;
}
>({ max: 512, ttl: 120_000 });
async function run(args: string[], runOptions: GlabCommandRunnerOptions): Promise<string> {
const glabPath = await resolveGlab();
@@ -974,20 +987,114 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
);
}
async function resolveExpectedHeadProject(
input: Parameters<ForgeService["getCurrentPullRequestStatus"]>[0],
): Promise<string | null> {
if (input.headRepositoryPath) return input.headRepositoryPath;
if (!input.headRepositoryOwner) {
const remote = await resolveRemoteUrl(input.cwd);
return (
(remote ? parseGitRemoteLocation(remote)?.path : null) ??
(await readProject(input.cwd, ":fullpath", input.force === true)).path_with_namespace
);
}
if (!input.changeRequestNumber) return null;
const hint = await viewMergeRequest(input.cwd, String(input.changeRequestNumber));
if (hint.source_project_id == null) return null;
const path = (await readProject(input.cwd, hint.source_project_id, input.force === true))
.path_with_namespace;
return path.split("/")[0]?.toLowerCase() === input.headRepositoryOwner.toLowerCase()
? path
: null;
}
async function readProject(cwd: string, projectId: number | string, force = false) {
const key = JSON.stringify([cwd, await resolveRemoteUrl(cwd), projectId]);
const cached = force ? undefined : projectPaths.get(key);
if (cached) return cached;
const source = await runJson(
["api", `projects/${projectId === ":fullpath" ? projectId : encodeURIComponent(projectId)}`],
{ cwd },
z.object({
id: z.number().optional(),
path_with_namespace: z.string(),
ssh_url_to_repo: z.string().nullable().optional(),
http_url_to_repo: z.string().nullable().optional(),
}),
);
projectPaths.set(key, source);
return source;
}
async function resolveExpectedProjectId(input: {
cwd: string;
expectedPath: string;
mergeRequests: GitLabMergeRequest[];
force: boolean;
endpoint?: import("./forge-service.js").HeadRepositoryEndpoint;
}): Promise<number | null> {
const remote = await resolveRemoteUrl(input.cwd);
const originPath = remote ? parseGitRemoteLocation(remote)?.path : null;
const targetId =
!originPath || input.expectedPath.toLowerCase() === originPath.toLowerCase()
? input.mergeRequests.find(
(mr) =>
extractProjectPath(mr.references?.full)?.toLowerCase() ===
input.expectedPath.toLowerCase(),
)?.target_project_id
: undefined;
if (!input.endpoint && targetId != null) return targetId;
const project = await readProject(input.cwd, input.expectedPath, input.force);
if (
!matchesHeadRepositoryEndpoint(input.endpoint, [
project.ssh_url_to_repo,
project.http_url_to_repo,
])
)
return null;
return project.id ?? null;
}
async function resolveCurrentMergeRequest(
cwd: string,
headRef: string,
headSha?: string,
input: Parameters<ForgeService["getCurrentPullRequestStatus"]>[0],
): Promise<GitLabMergeRequest | null> {
const { cwd, headRef, headRepositoryEndpoint } = input;
const force = input.force === true;
const mergeRequests = await listMergeRequestsBySourceBranch(cwd, headRef);
const candidates = mergeRequests.filter((mr) => mr.source_branch === headRef);
if (mergeRequests.length === 0) return null;
const expectedPath = await resolveExpectedHeadProject(input);
if (!expectedPath) return null;
const expectedProjectId = await resolveExpectedProjectId({
cwd,
expectedPath,
mergeRequests,
force,
endpoint: headRepositoryEndpoint,
});
if (expectedProjectId == null) return null;
const candidates: GitLabMergeRequest[] = [];
for (const mr of mergeRequests) {
if (
mr.source_branch !== headRef ||
mr.source_project_id == null ||
mr.target_project_id == null
)
continue;
if (mr.source_project_id === expectedProjectId) {
if (mapMergeRequestState(mr.state) === "open") return viewMergeRequest(cwd, String(mr.iid));
candidates.push(mr);
}
}
candidates.sort(
(a, b) =>
parseGitLabTimestamp(b.updated_at) - parseGitLabTimestamp(a.updated_at) || b.iid - a.iid,
);
const match =
candidates.find((mr) => mapMergeRequestState(mr.state) === "open") ??
candidates.find(
(mr) =>
mapMergeRequestState(mr.state) !== "open" && headSha !== undefined && mr.sha === headSha,
) ??
null;
candidates.find((mr) => mapMergeRequestState(mr.state) === "open") ?? candidates[0] ?? null;
if (mergeRequests.length >= 100 && (!match || mapMergeRequestState(match.state) !== "open"))
throw new ForgeDiscoveryIncompleteError(
"GitLab merge request discovery exceeded its candidate window",
);
return match ? viewMergeRequest(cwd, String(match.iid)) : null;
}
@@ -1151,7 +1258,7 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
async getCurrentPullRequestStatus(input): Promise<CurrentPullRequestStatus | null> {
try {
const mr = await resolveCurrentMergeRequest(input.cwd, input.headRef, input.headSha);
const mr = await resolveCurrentMergeRequest(input);
if (!mr) {
return null;
}
@@ -1448,6 +1555,8 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
return { success: true };
},
invalidate(_input: { cwd: string }): void {},
invalidate(_input: { cwd: string }): void {
projectPaths.clear();
},
};
}
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it } from "vitest";
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
@@ -54,6 +55,7 @@ function makeSources(bundleSource = "export {};\n"): {
mkdirSync(native, { recursive: true });
writeFileSync(path.join(native, "pty.node"), "native");
writeFileSync(path.join(native, "spawn-helper"), "#!/bin/sh\n", { mode: 0o755 });
chmodSync(path.join(native, "spawn-helper"), 0o755);
// Another platform's build is not copied.
mkdirSync(path.join(nodePtyDirectory, "prebuilds", "other-platform"));
return { bundleDirectory, nodePtyDirectory };
@@ -410,7 +410,12 @@ describe("createTerminal", () => {
cwd: realpathSync(tmpdir()),
cols: 500,
command: process.execPath,
args: ["-e", "process.stdout.write(JSON.stringify(process.argv.slice(1)))", nastyPrompt],
// Keep the PTY alive until the screen assertion; exit disposes its parser.
args: [
"-e",
"process.stdout.write(JSON.stringify(process.argv.slice(1))); setInterval(() => {}, 100000)",
nastyPrompt,
],
}),
);
@@ -1623,13 +1623,26 @@ it.each([1, 4])(
async (version) => {
const paseoHome = createTemporaryHome();
const old = await leaveWorkerWithTerminal(paseoHome, version);
const [record] = await workerRecords(paseoHome);
const program = record!.terminals.find((entry) => entry.id === old.terminalId)!;
const inspector = createTerminalWorkerProcessInspector();
manager = await startRealWorkerManager({ paseoHome });
await waitForCondition(
() => !isProcessAlive(old.workerPid) && !isProcessAlive(old.programPid),
10000,
);
expect(await readUnconfirmedTerminalCleanup(paseoHome)).toEqual([]);
// Startup launches a replacement worker after cleanup. Either old PID may already
// belong to another process, so verify that the recorded birth identities are gone.
for (const original of [{ pid: record!.pid, ...record!.identity }, program]) {
await expect
.poll(
async () =>
!isProcessAlive(original.pid) ||
classifyTerminalProgramProcess(original, await inspector.inspect(original.pid))
.status === "unrelated",
{ timeout: 10000 },
)
.toBe(true);
}
expect(manager.getTerminal(old.terminalId)).toBeUndefined();
const workers = await workerRecords(paseoHome);
expect(workers).toHaveLength(1);

Some files were not shown because too many files have changed in this diff Show More