diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a66d9862a..5fddcf9aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,7 +95,9 @@ jobs: - name: Check the generated Swift contract fixture is current # The SwiftUI client mirrors packages/contracts by hand, so nothing else # makes a contract change fail its build. This is that signal. - run: node scripts/generate-swift-contract-fixtures.ts --check + run: | + node scripts/generate-swift-contract-fixtures.ts --check + node scripts/generate-swift-provider-settings.ts --check - name: Build desktop pipeline run: vp run build:desktop diff --git a/PATCH.md b/PATCH.md index bc82fb09f..20b59549e 100644 --- a/PATCH.md +++ b/PATCH.md @@ -29,10 +29,9 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera thread is unpinned; the fractional-key math itself is upstream's shared `@t3tools/client-runtime/state/thread-sort`, so web and mobile compute identical orders. The server keeps advertising the `threadPinReorder` capability. Mobile uses this end to end. The web - sidebar deliberately does not adopt upstream's pinned-block drag: it keeps the fork's - client-local whole-list manual order (`applyManualThreadOrderForSidebarV2`), which already lets - users arrange pinned threads and would otherwise fight upstream's DnD over the same - `DndContext`. Upstream's `animatePinnedLayoutChanges` (which stops dnd-kit replaying the + sidebar keeps the fork's whole-list drag surface, but persists pinned and active positions + through V2 metadata on capable servers. Client-local ordering remains only as a legacy + fallback; see Web durable sidebar order below. Upstream's `animatePinnedLayoutChanges` (which stops dnd-kit replaying the committed layout move after the pointer is released) is carried and applied to the fork's whole-list `SortableSidebarThreadRow` instead of upstream's pinned-block row. - Replaces upstream's web thread context menu stack (`threadActionMenu.logic.ts`, @@ -40,41 +39,58 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera items built inline in `Sidebar.tsx`. Upstream changes to those retired modules resolve to the fork: port the menu feature itself (new items, handlers) into `Sidebar.tsx`/`useThreadActions.ts` instead of merging the files. -- Does not carry upstream's three large web-client redesigns that are written against the V1 - activity model: "collapse tool activity into one line" (`4a9edff4c1`, the `work-toggle` row, - `deriveToolLifecycleCollapseKey`, and the `live-activity-focus` CSS) and "attach composer state - drawers" (`792a1404f6`, the shoulder tabs, `ComposerTasksBadge`, `chat-composer-*-drawer` - surfaces, and the micro approval actions). The fork's timeline renders orchestration V2 - `timelineEntries` and already collapses work rows through its own `work` group and - `collapseWorkEntriesKeepingLiveBackground`; its composer has diverged in the same places. - Follow-on upstream work on those files resolves to the fork, and upstream's companion fixes - (`490f48ed98`'s `AgentSpawnCtaRow` inset, `68966c1e66`'s shoulder-tab spacing) have no fork - counterpart. Carried out of those commits: `deriveActiveWorkStartedAt`'s - `latestUserMessageAt` fallback, ported onto the V2 run shell. The third is "unify activity logs - and composer banners" (`3d32797f6f`, reverted by `8dcb96314c`, re-landed as `30175a8af0`, then - `9842518c9a` and `3f62e6fa65`): it deletes `ThreadSyncStatusPill`, rewrites `ComposerBannerStack` - around a new `ComposerBanner`/`ComposerSurface`/`ComposerActivityStatus` trio, and rebuilds - `MessagesTimeline` and `session-logic` on the drawer surfaces the fork already declined. The - features that landed only inside those files are therefore not carried either: web video - attachments in chat (`ac4aae101d`), expanded-preview playback for agent images (`8f525af5af`), - the circle-alert treatment for failed tool calls (`8b817cbcaa`/`f1e6f0c9bb`), interim turn folding - (`17c48f7fc1`), and the smoothed worktree setup status (`ef84bc9873`). Upstream's separate web - file-attachment model (`bcb855a633`: a `files` array beside `images`, `composerFileNeedsReattach`, - per-chip upload progress) is likewise not carried — the fork's composer already models - image/file/pdf/video in one `images` array with its own upload queue. -- Does not carry upstream's Codex citations and artifact templates in web chat (`c1e70b5f8c`). - The shared halves are carried and exported from `@t3tools/client-runtime` - (`codexFileCitations`, `codexArtifactTemplates`, `codexMarkdownDirectives`), but the renderer - half is written against upstream's inline `useMemo` component map, while the fork builds its - through `createChatMarkdownComponents` and keeps the `MarkdownMedia` image path. The - client-runtime modules stay so a later port has them; they have no fork consumer yet. -- Does not carry upstream's provider-settings list/editor split (`e2d4d12a81`, `f276e632c5`, - `5e63aea2df`) or its `ProviderInstanceCard` `mode: "list" | "editor"` restructure. The fork keeps - `EnvironmentProviderSettings` inline in `SettingsPanels.tsx` with the card's own expand/collapse. -- Does not carry upstream's pinned-block drag-to-reorder in the web sidebar (the - `optimisticPinnedOrder` / `handlePinnedDragEnd` block) or its searchable project-filter combobox - (`48c176b3cf`, `filterSidebarProjectScopeItems`): both are written against upstream's sidebar - shell, and the fork keeps its client-local whole-list manual order. Upstream's toggleable unpin +- Ports upstream's composer attachment surfaces onto V2: notices, task progress/list, + approval and question drawers, plan follow-up and the stash shoulder tab share the + existing composer form. The shell and context strip use one surface model and the + fork's theme tokens. Tasks no longer occupy a notice slot that could obscure another + notice. Approval actions keep the V2 live/non-resumable response gate. Swift keeps + native task/approval surfaces outside its push-to-talk gesture hierarchy. Its stash tab + and list also remain outside that hierarchy; an atomic local queue preserves legacy + single-slot stashes and swaps unsent content into the queue during restoration. + Web interim response folding (`17c48f7fc1`) now uses V2 run folds, retaining + the final response, persistent resources and interruption evidence. Native + transcript-level interim folding and live-activity focus remain separate ports. + Ordinary failed tools use a muted circle alert on web and Swift, while typed runtime + failures retain their severe presentation. Workspace setup feedback remains visible + until a V2 run starts or fails; draft-route promotion waits for that same evidence. + Swift distinguishes preparing the workspace from starting the provider. V1 setup + activities and the continually repainting text shimmer are not imported. + The fork's unified image/file/pdf/video upload queue replaces upstream's separate + `files` array; per-attachment progress, retry and video playback are carried through it. +- Sidebar file drops are ported to both web sidebar layouts and search results using the + fork's unified attachment queue. Deferred drops are scoped by environment and thread, + survive repeated drops, and are cleared individually on navigation failure or when a + thread is missing. No V1 thread runtime or separate upstream file array is introduced. +- Codex file citations and artifact-template cards are now ported to web's stable + `createChatMarkdownComponents` wrappers, preserving the fork's MarkdownMedia/HTML embed paths. + Swift parses the two directives inside its existing block/inline render cache, routes cited + files into the native file viewer, and offers template prompts through the composer. Invalid + directives remain literal and code blocks are not interpreted as directives. + +- Selected assistant-text citations are now ported through V2 start and steering paths. + The durable message retains the origin-independent link; providers receive the decoded + quote and separately identified user comment. Web uses Lexical chips, bounded source + navigation and the fork's run/attempt folding (no V1 keyset pagination). Swift uses native + selection/comment sheets, draft chips outside the voice gesture surface, and its existing + V2 earlier-turn loader. Its recycled item ID stays distinct from the durable message ID + used by citations. The optional `assistantCitations` capability gates creation; Expo's + frozen feed receives only a readable quote/comment compatibility renderer. + +- Ports upstream's provider-settings list/editor split onto the fork's existing + `SettingsPanels.tsx` and `ProviderInstanceCard`, preserving dedicated provider environment + fields and the Hermes rollout gate. Environment tabs scope reads, writes, additions and + updates; read-only sessions retain account navigation. Native Agents uses account navigation + and per-environment model visibility editing. Custom model names and option descriptors are + ported through the existing V2 providers, including fork-only providers, without adopting + upstream's V1 Claude catalog. Built-in model IDs and capabilities remain authoritative. + Web and Swift offer local draft editors, copied options, provider presets and explicit saves; + the optional `customModelDefinitions` capability gates structured writes to older servers. + Swift preserves unknown account configuration and other accounts when editing custom models. + Setup terminals and the remaining native provider configuration controls are separate ports. + +- Ports web drag-to-reorder onto the fork's existing sortable rows and V2 metadata instead + of upstream's `optimisticPinnedOrder` / `handlePinnedDragEnd` implementation. The searchable project-filter + combobox is now ported, including keyboard project settings and query reset on close. Upstream's toggleable unpin confirmation (`22c311ddec`) _is_ carried — the setting, `requestThreadUnpinConfirmation`, and `useThreadActions` come across unchanged, and the fork's `Sidebar.tsx` `toggleThreadPin` gates its `thread.metadata.update` on it rather than upstream's `confirmAndUnpinThread`, which routes @@ -87,14 +103,13 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera because the union's open member is typed `type: string` and defeats literal narrowing. The composer's own attachment types are drawn from `ChatKnownAttachment`: composer attachments are always locally created and validated, so an unknown kind can never reach them. -- Keeps `PROVIDER_SEND_TURN_MAX_FILE_BYTES` at 20MB rather than upstream's 50MB (`8f49132214`). - Upstream raised the cap alongside a streaming upload path for generic files; on this fork the - composer still sends file/pdf/video attachments through the inline base64 path, whose - `PROVIDER_SEND_TURN_MAX_DATA_URL_CHARS` cap tops out around 21MB, so advertising 50MB would - promise a size the client cannot send. The signed-upload contract widening itself is carried - (`assets.ts` accepts `type: "file"` uploads, `AssetAccess` mints download disposition and - filename/mime claims, `attachmentStore` encodes the extension in the attachment id, and - `http.ts` serves range requests for inline video). +- Ports upstream's 50 MB file limit through signed HTTP uploads in web/desktop and Swift. + PDF/video/file discriminators are preserved in V2 message references. Images retain 10 MB; + the OpenCode native part limit remains 20 MB (larger files use workspace materialization). + Native share intake follows 50 MB, and older servers still enforce their advertised limit + or the 20 MB inline fallback. Inline base64 limits are not widened. Web chips display upload + progress and retry, preserving the fork's attachment keyboard and local-persistence behavior. + The frozen Expo picker/share intake explicitly retains its 20 MB inline transport limit. - Does not carry upstream's "retry failed thread bootstraps with a fresh id" (`8824f8f24f`). It reports a deleted bootstrap thread through the V1 `OrchestrationDispatchCommandError`, which the fork does not define; the fork launches threads through V2 `launchThread`, which keeps the @@ -136,7 +151,8 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera `040_ProjectionProjectFaviconPath` likewise targets the project aggregate and is carried as `053_ProjectionProjectFaviconPath`. Upstream's `041_AuthSessionClientConnection` targets `auth_sessions`, which the fork shares, and is carried as - `058_AuthSessionClientConnection`. Upstream's `042_ProjectionThreadLinkedPullRequest` and + `058_AuthSessionClientConnection`. Upstream’s `047_ProjectionProjectIcon` is carried as + `059_ProjectionProjectIcon` on the retained project aggregate. Upstream's `042_ProjectionThreadLinkedPullRequest` and `043_ProjectionThreadsUnsettledAt` are dropped: they add columns to the retired V1 `projection_threads`, and the fork carries linked pull requests and the un-settle re-entry stamp on the orchestration V2 thread JSON projection instead (see below). @@ -241,26 +257,23 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera uploads into the thread inside the V1 `Normalizer`; the fork claims them in `ws.ts` on the V2 `dispatchCommand` (`message.dispatch`) and `launchThread` handlers, releasing the claimed copies when the dispatch fails. `launchThread` can only claim when the caller named the thread id — a - server-allocated id has nothing to claim into yet. The upload contract accepts image mime types - only, so the composer's file/pdf/video attachments still ride the inline base64 path, and the - fork does not carry upstream's per-chip upload progress UI (it belongs to the composer drawer - redesign the fork already declined). -- Does not carry upstream's Codex MCP-elicitation approvals end to end (`7c6163c67`). The contract - widening (`ProviderRequestKind`'s `mcp-elicitation`, `ProviderApprovalDecision`'s `acceptAlways`, - `ProviderApprovalOption`) lives in the fork's `providerPolicy.ts` rather than upstream's - `orchestration.ts`, and `CodexSessionRuntime` carries upstream's handler — but that module is - V1 leftovers the fork's V2 stack does not run, and `CodexAdapterV2` registers no - `mcpServer/elicitation/request` handler. Codex app-access prompts therefore do not reach the - fork's clients yet; the approval panels only label the kind. `acceptAlways` collapses to - `acceptForSession` on the wire, which is the widest grant Codex's command/file-change approval - responses can carry. + server-allocated id has nothing to claim into yet. Signed uploads now handle all known attachment kinds, with per-chip progress and retry. + Pending claims remain at the V2 command boundary; no V1 normalizer is restored. +- Ports Codex MCP app-access approvals (`7c6163c67`) onto `CodexAdapterV2`, with + provider-labelled choices carried in the V2 JSON turn-item projection and web/Swift composers. + The live adapter rejects decisions the provider did not offer. Session/permanent grants are + returned as MCP form content and persistence metadata; unsupported input forms and URL + elicitations remain declined, matching upstream. This imports only the isolated form helpers, + never the V1 runtime. Ordinary Codex command/file approvals retain their session-only ceiling. - Keeps the fork's `MarkdownMedia` path for chat markdown images instead of upstream's `classifyMarkdownImageSource` renderer (`77c9d1eb5`, `5a7a7cf29`, `55c909334`). The fork's path already resolves workspace files through signed asset URLs and additionally handles browser artifacts and video, which upstream's image-only renderer does not. -- Does not carry upstream's `useThreadActionMenu`-based "double-click chat header title to rename" - (`837f6b871`): the fork replaced that module with `useThreadActions.ts` plus inline menu items, - and its `ChatHeader.tsx` is a presentational breadcrumb with no menu of its own. +- Ports upstream's chat-header title menu and double-click rename (`837f6b871`) through + `useHeaderThreadActions`, using the fork's V2 `useThreadActions` mutations and capability + checks. Settlement uses the same cached PR identity and age policy as the sidebar. Rename + state is scoped to both environment and thread. Compact headers keep project tools in the + details panel, preserving room for the fork's linked-PR and panel controls. - Runs CI as one self-hosted `verify` job, so upstream's test sharding, split Rust job, and macOS-gated `apps/mobile` native lint (`d7b9a689f`, `8f7da3b99`) have no fork counterpart. The PR-assets guard from `9f12eab38` is carried. `release.yml` adopts upstream's split @@ -357,6 +370,49 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera ownership, ordinary failures do not abort the batch, and failed/unprocessed threads stay selected. Navigation and worktree-cleanup failures are reported separately from a completed deletion, including the fork's archived-thread deletion path. +- The 2026-09-10 sync (`e16b8b059c..0f602b3372`, 13 upstream commits) carries seven + independent changes and retains the following boundaries: + - Linked-PR search (`f0401c6290`) runs on the existing V2 `linkedPullRequest` in web's + sidebar and command palette. The shared `threadPullRequestSearchTerms` exposes the PR + number, repository and URL without host reads. It does not introduce the upstream + multi-link projection, title snapshots or frozen Expo changes. + - Image zoom/pan (`8d8189e67d`) uses the fork's `ExpandedImageDialog` gallery, keeping its + original download handling. Zoom resets on navigation, arrow keys pan while zoomed, + and modal presence blocks type-to-focus. The standalone image component tolerates SSR. + - Composer model labels use available width (`b7b3ef1e6f`, web half); touch devices expose + user-message copy controls (`385cc0a4c6`, assistant controls were already visible). + PR list diff counts move to the title's trailing edge (`addfb1390e`) within the existing + row layout; review/check metadata already lives on the second line. + - Linux/BSD middle-click pastes the terminal's own selection (`d1eeb16247`) through the + existing paste race/bracketed-paste path. VT mouse reporting keeps priority; the fork's + modifier-click links, native copy, selection and split-pane activation remain intact. + Zed remote SSH links (`0f602b3372`) use the shared editor catalog and the fork's Electron + external-link validator. No new runtime capability or migration is needed. + - Duplicate-command expansion (`50f918c57a`) is already covered by V2's + `buildToolCallExpandedBody` / projected-item disclosure; the fork has no + `commandMatchesVisibleLabel` expansion guard. Android feed positioning (`75e4ceb964`) + and glass backing (`383cc40f4d`) remain excluded under the Expo freeze. + - The approved native parity follow-up now supports multiple explicit PR links on V2: + `thread.metadata.update` adds/removes one link atomically, with a 50-link limit and + host/repository/number identity. The JSON projection carries `linkedPullRequests` while + `linkedPullRequest` remains the primary for older clients. Legacy edits preserve other links. + Swift gates collection editing on `threadPullRequestsV2`, searches every link, and requires + every linked PR to read as terminal before settling. Link changes restart its observations. + Web/Expo still render the primary and conservatively avoid automatic settlement for collections. + Automatic discovery/linking after creation, stack-dismissal tombstones, cached snapshots and + credential-scoped MCP link tools remain unported. Upstream's `threadPullRequests` flag and V1 + commands stay excluded; `050_ProjectionThreadPullRequests` is dropped, with no new migration. + - GitHub stack navigation/merge/rebase (`de37964db2`) is now available to Swift through + `pullRequests.stack` and `pullRequestStackActions`. The standalone GitHub action implementation + retains reviewed-head checks, per-branch permissions, partial-rebase reporting and remote-only + operations. Confirmation holds the reviewed stack immutable; mutations invalidate every + reviewed PR's cached reads even after partial failure. Web stack controls now use the same V2 endpoint. Expo stack controls remain unported. + - Restart-persistent PR summary/stack reads (`33242d0164`) remain excluded. Stack reads are + on demand; the earlier V2 background PR-discovery/summary service is still missing. Carry a + durable read cache with that service, including expiry and mutation/in-flight invalidation. + Advancing this sync marker records review of deferred work, not full upstream feature support. + - Swift's existing image galleries now support pinch/pan, double-tap zoom and an accessible + fit action while retaining original-byte export and current/adjacent-page loading. - The 2026-09-09 sync (`223ff4490f..e16b8b059c`, 185 upstream commits) manually carries independent correctness fixes while retaining the boundaries above: - `thread.stop` (`09e8de9c65`) uses web/desktop's existing V2 `interruptThreadTurn` path. @@ -404,10 +460,10 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera current pinned dependency versions and Electron 43 remain; Electron 44's drag-region fix is therefore not applied. - New minimap turn navigation, PR merge defaults/videos/link routing, sidebar file drops, - project-icon propagation, composer focus/multiline/footer transitions, usage account layout, + composer focus/multiline/footer transitions, usage account layout, and their follow-ups need dedicated adaptation to the fork's timeline, inline settings, - unified attachments and panel stores. Previously deferred onboarding, shared settings, - auto-balancing, citations, galleries, reset credits and browser-profile import stay deferred. + unified attachments and panel stores. Previously deferred shared settings, + auto-balancing, galleries, reset credits and browser-profile import stay deferred. The removed settings section-navigation machinery was never adopted by the fork. - Expo-only UI, outbox, drag handles and Android appearance/notification changes stay excluded under the freeze. Fork release artwork, marketing, review workflows, public security-policy @@ -417,12 +473,14 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera dedicated human-reviewed ports. Advancing the squash-sync marker does not mean these features are supported. Do not import their client flags or schemas without implementing the matching V2/server behavior and auditing the hand-maintained Swift contracts: - - Custom model names/option descriptors and imported custom-provider selection - (`5a433244d0`, `d92dca74eb`) need a V2 provider-option/adapter audit. - - Shared project defaults and scoped overrides (`9f40b2f563`) need the retained project - aggregate, fork-owned migration numbers, and Swift settings parity. Connection load balancing - (`420fd76f60`) needs V2 launch selection and an explicit multi-machine workspace policy. - - The welcome wizard (`09aac71563`) depends on upstream's V1 importer/provider setup. + - Custom model names/option descriptors (`5a433244d0`, `d92dca74eb`) are now ported + through the fork's existing provider snapshots and V2 option selection, as detailed above. + Imported custom-provider selection still needs a dedicated importer audit. + - Shared project defaults and scoped overrides (`9f40b2f563`) are ported through the retained + project aggregate and Swift settings. Connection load balancing (`420fd76f60`) is ported + through V2 draft selection and native execution targets; see the policy below. + - The welcome wizard (`09aac71563`) is now ported onto the V2 importer and account-specific + setup terminals, as detailed below. Server-side PR discovery (`223ff4490f`) and actual PR terminal timestamps (`050690d1bc`) need a V2 background service and JSON settlement projection. Upstream migration `048_ProjectionThreadBranchPullRequest` is not carried; existing client-driven V2 PR @@ -432,16 +490,16 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera static HTML cache correctness from the latter are carried. Its V1 stream/Expo changes are excluded; its worker and animation changes remain with their deferred prerequisites. - Lazy diff workers and Pierre editor fixes (`b3e1d88590`, `df8e0eb46b`, `6270a6f88b`, - `2fa5ef4c7b`, `6b87ce3a0b`) need integration with the fork's file/media panels and existing + `2fa5ef4c7b`, `6b87ce3a0b`) are ported with the fork's file/media panels and existing dependency patches. Terminal stream cursors, hidden surfaces and keyboard focus (`da7e46d08e`, `5eab021a51`, `896fe82f2f`) need joint adaptation to fork replay/selection handling. The independent bounded server history and terminal metadata cache are carried. - PR hover cards, hydration, project-filter choices and panel precedence (`95103905f5`, `91c66ac43d`, `110bbe6b55`, `a0eb23993a`, `931d41f933`, `e5a87e8b9c`) need adaptation to the fork's PR data and panel stores. Header project settings, keyboard-accessible project - actions and navigation motion (`cbe93e8dfb`, `7f8cf30ca4`, `cd713679bb`) likewise need - the fork's inline menus/sidebar layout. The fork has no upstream proactive-panels controller, - so its empty-diff and manual-choice follow-ups (`d115a96763`, `bccad27046`) are excluded. + actions (`cbe93e8dfb`, `7f8cf30ca4`) are now adapted to the fork's inline menus; navigation + motion (`cd713679bb`) is covered by the opt-in panel-motion port below. The later V2 proactive-panels port supersedes + the earlier deferral of empty-diff/manual-choice follow-ups (`d115a96763`, `bccad27046`). - Connect HTTP credential refresh and network-blocking diagnosis (`363cde4114`, `2dca7a1edd`) need the fork's auth/reconnect lifecycle. Installer ownership and mise shims (`2fb99a7a66`, `c7dc3cbd06`, `2271a27dad`) need a Cursor SDK/native-updater audit. @@ -464,10 +522,11 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera The native parity follow-up ports the read-only part of `19d8ab2ae9` through optional per-instance `usageLimits` snapshots: bounded Codex/Claude probes, explicit failures, and Swift account pooling. It does not restore V1 ingestion or turn-driven quota events; Limits refreshes the existing - provider registry and timestamps its reports. No migration is needed. Other clients tolerate - the optional snapshot field but do not gain a new Limits UI from this native change; + provider registry and timestamps its reports. No migration is needed. Web and desktop now expose the same reports in Usage → Limits, including + account deduplication, equal-weight pools separated by window kind, reset ordering, + aligned account columns, and explicit unavailable/stale reports; automatic clean-default-branch pulls (`ba3cb07738`) and customizable - project icons (`f6c04c552c`) need project-aggregate ports plus fork-owned migration numbers; + project icons (`f6c04c552c`) are carried on the project aggregate as described below; desktop browser-profile import (`134d51096e`, `39449e53e3`, `ff5843410d`, `498ab9c399`) conflicts with the fork's desktop/browser shell; and media-preview consolidation (`beae2147a9`, `922bd69225`) crosses the frozen Expo client and the fork's existing `MarkdownMedia` path. The range's V1 @@ -551,3 +610,699 @@ This fork stays close to `pingdotgg/t3code` and carries only the following opera exposes a public TestFlight invitation after Apple's initial Beta App Review. The fork does not consume the upstream Expo project's OTA updates; TestFlight distributes signed updates to opted-in testers. + +- Carries upstream diff navigation and appearance controls: a changed-file tree and path copy + buttons in web review, plus red/green or blue/orange diff roles on web/desktop and native Swift. + Native stores the preference in FeatureSettings and applies it through T3ThemeStore; semantic + success/error colors remain independent of the diff palette. +- Web prompt recall is adapted to the fork's unified attachment queue and V2 message projection. + Up/down only recall when the composer has no attached context and its caret is at the visual + edge; generated plan/attachment prompts and appended context are excluded. Native already + implements its own prompt recall. +- Desktop release notes use the upstream focusable popover and bounded newest-first excerpts. + Omission counts cross IPC as optional fields for older consumers. Links point at the fork's + release feed, and the fork's automatic download/install state machine remains intact. + +- Web/desktop now edit explicit PR collections through V2 `linkPullRequest` / + `unlinkPullRequest`, including command-palette entry and cross-project links resolved within + the same environment. A host read validates additions; unlinking uses the saved identity + without requiring the host or project to remain available. Stack detail controls use the + native parity RPC and capture immutable reviewed heads before submitting merge/rebase. + V2 background discovery, source/snapshot metadata, stack tombstones and MCP linking are now + ported, as described in the V2 PR tracking entry below. + +- GitHub PR labels are editable in web/desktop and Swift detail screens. Optional + host capability and viewer permission flags gate lazy candidate reads and mutations; + the server checks triage access and invalidates detail/list caches after attempted + updates, including partial failures. This host API port has no V1 runtime dependency. + +- Web PR code review also carries the paged changed-file tree; chat PR links load hover + details on demand and use atomic V2 collection actions from their context menu. +- Inline video recovery uses the fork's asset URLs and range streaming. Web previews + preserve an active playhead across URL renewal, prepare a first frame only while idle, + and provide explicit save/copy actions. Swift offers retry and pauses when backgrounded. + +- Custom usage prices are server-authoritative, keyed by exact model ID, with sparse + per-model replacement/deletion. Usage scans capture price settings and reprice cached + transcript records, including provider-reported costs. Web supports multi-environment + edits with per-environment retry; Swift exposes per-environment model-price editing. + +- Composer task summaries derive only from the current V2 run's todo list. Web's + attached banner stack keeps task activity in front and retains urgent fork notices; + Swift adds an expandable task row beside, outside, the voice composer's gesture tree. + Approval controls use compact web styling while preserving V2 non-resumable guards. + +- Web minimap previous/next-turn navigation uses the V2 timeline row positions. + Keep the fork’s transform/opacity strip animations and skip unchanged scroll + attributes; upstream width/background animations would regress scroll performance. + +- Environment machine icons use upstream's best-effort hardware detection and nullable + server setting, with a capability gate for older servers. Web/desktop and Swift show the + resolved glyph in environment selectors and thread context. Swift has a per-environment + settings screen; Automatic deletes the override. V2 thread state and migrations are untouched. + +- Terminal replay uses upstream's bounded chunk buffer and per-attach cursors in + client-runtime/web; Swift carries local byte cursors through its existing capped + buffer. Repeated output and history rollover append without resetting live VT state. + Hidden web surfaces continue parsing/replying but stop canvas work and cursor timers. + Preserve the fork's narrow-pane font fitting, middle-click selection, and V2 terminal + routing. Expo materializes text only at its existing native-surface boundary. + +- Web pull-request list controls are remembered independently from the selected review. + Merge-readiness sorting preserves involvement groups; duplicate repository checkouts + collapse only within one environment. Review-tab identity includes the host when known, + while the server still resolves reads through the selected project's repository identity. + +- Native composers accept file representations dropped from other apps through the existing + attachment processors and upload queue. Preparation reserves attachment slots, reads temporary + provider files before their callback expires, and discards results after composer navigation. + This does not yet add drop-to-open behavior to native sidebar rows. + +- Ports upstream panel user-choice revisions (`bccad27046`) while retaining the fork’s + independent thread-details visibility. V2 automatic plan panels respect choices made during + their run; resource reconciliation never counts as a manual choice. The proactive panel API + gives linked PRs precedence over automatic plan/diff panels. V2 linked-PR and completed-run + diff opening are now integrated behind `proactivePanelsEnabled`; background PR discovery + now runs on V2 and keeps its branch candidate separate from explicit links. + +- Ports lazy diff workers (`b3e1d88590`, readiness follow-up `ce4712d5b0`) at code-view + boundaries instead of wrapping the entire chat. Concurrent views share a pool, quick reopen + retains its cache, and the last closed pool expires after 30 seconds. File reveals retain the + fork’s mount-aware callbacks. All highlighter creation paths use upstream’s Oniguruma WASM + preference (`feb3ea7ebf`) to avoid JavaScript-regex backtracking freezes. + +- Ports clickable file breadcrumbs (`47a95332a2`) onto the fork’s scoped project-file queries. + Folder menus refresh when opened, since V1 workspace-mutation notifications are not carried. + Swift file previews offer a containing-folder menu using their existing native directory + navigation, including the workspace root. + +- Swift now folds completed-run interim responses and work behind a stable duration row, + retaining terminal replies, attachment-bearing messages, lifecycle cards and live work. + Expansion changes recycled collection rows instead of mounting a whole run in one cell, + preserves the viewport anchor, and source-citation navigation expands the owning run first. + Failed/interrupted/unknown runs remain fully visible; the always-expand preference wins. + +- Custom project icons (`f6c04c552c`) and automatic colored defaults (`4e89d74436`) are + carried through the retained project aggregate, its events/read models, and fork migration + `059_ProjectionProjectIcon`. `projectIcons` advertises support; omitted update fields preserve + the icon, explicit null clears it, and selecting a file clears the custom override. Web settings + apply edits across every member of the selected project group only when all hosts support them. + The native project-filter menu exposes the same searchable Lucide catalogue, emoji picker, + colors and reset. Native rows use the full canonical project title and preserve cross-environment + identity. Lucide vectors are converted at development time to cached native paths; no per-row + SVG web view is used. Regenerate with `scripts/generate-swift-project-icons.mjs` after changing + the pinned web Lucide version; see the contributor notes for its Python prerequisite. + +- Returning to a desktop-sized browser or Electron window refocuses the composer (`ecf3716fd1`) + after native focus restoration settles. Text fields, terminals, dialogs and popup controls retain + deliberate focus; mobile viewports do not raise the software keyboard. The effect is keyed by + the fork’s environment/thread identity and cleans up pending animation frames on navigation. + +- The native Code sidebar now opens a cross-environment pull-request workspace using existing + `pullRequests.list`/`listStats` contracts. It persists filters/sort, assigns shared repositories + to one environment, distinguishes hosts, paginates with opaque cursors, and fetches authored and + review-requested partitions independently. Four concurrent listing requests bound fan-out; + diff counts enrich visible results without treating an unavailable count as zero. Stale responses + cannot replace a newer search. Native detail, label and stack screens accept project context + without creating a thread, and reject a project whose repository changed since the list was read. + Responsive label pills, update times and diff palette roles use native theme tokens. Native PR + checkout actions remain separate parity work. + +- Native PR details expose a capability-gated Code tab from both thread links and the global + workspace. Host-backed slices retain opaque cursors, per-commit scope, reported omitted-file + counts and partial-read errors. A native collapsible file tree, path filter/copy and lazy line + diff use the fork’s diff color tokens. The shared unified-patch parser now decodes Git C-quoted + paths and distinguishes file metadata from code beginning with `---`/`+++`. Local working-tree + hydration and agent-prompt comments are not used for host PR code. Full-file context uses the host-backed comparison described below. + +- Native PR reviews use `pullRequests.submitReview` with private persisted summary/line drafts. + Offered verdicts intersect host capabilities with viewer permissions; line comments are available + only against the whole PR, never a commit-specific diff. Added/deleted/context coordinates and + renamed paths follow the contracts. Submission clears only the exact snapshot acknowledged by + the host, preserving failed drafts and concurrent edits. These are host reviews, separate from + working-tree comments sent to the coding agent. Existing review conversations support paginated replies and reversible resolution. + +- Native host review conversations are shown in Timeline and on matching Code lines. Placement + requires the same file, side and line in the whole-PR diff; outdated, withheld and commit-scoped + conversations stay separate. Replies and resolution intersect host/viewer permissions and + revalidate repository identity. Failed replies retain their text; refreshing merges loaded comment + pages and preserves other unsent replies. Repeated cursors stop rather than looping. + +- Native PR full-file context calls `pullRequests.diffFileContents` with the selected commit and + rename sides after repository-identity validation. Expanded contents must match the visible hunk + coordinates before they are joined; a changed revision asks for refresh. Files without supplied + hunks show labelled old/new snapshots instead of fabricated unchanged lines. Added context does + not acquire review targets that were absent from the original patch. No local checkout reads. + +- Native PR management offers merge, ready/draft, close/reopen, branch update and auto-merge only + where host capabilities and viewer permissions agree. Merge methods are narrowed by repository + settings, including reviewed stack merges; branch-update methods also intersect viewer access. + Conflicts, base freshness and auto-merge state are visible. Unknown auto-merge state is not treated + as off. Merge/close/update/auto-merge use concrete review sheets; reverse actions remain reachable. + Explicit workspace/detail/Code refreshes invalidate the appropriate server cache, while normal + browsing retains cache sharing. Identity preflight invalidates before reading current host metadata. + +- Native PR title/description editing follows upstream host/ownership rules; each save sends only + the opened field, and an empty description explicitly clears it. Comment editing requires the + viewer’s own issue/review remark and never rewrites a review summary. Timeline and paginated + review-conversation comments share the editor; loaded-page edits survive the subsequent refresh. + New top-level comments use the host comment endpoint. Failed saves keep editor text and show the + error, and every write validates current repository identity before submission. + +- Native reaction pills cover PR descriptions, timeline remarks and review-conversation comments. + Eight host reaction types have a native picker, selected state, counts and accessible actor names. + Optimistic changes are scoped per reaction; failures restore the preceding acknowledged state, + and in-flight presses cannot race the same reaction. Description requests omit `subjectId`; + comment requests carry the host ID. Mutation availability follows the host reaction capability. + +- Native reviewer management lazily reads host candidates, searches their displayed login/name, + renders avatars/team identity and distinguishes user/team rows even when their opaque IDs match. + Requests and withdrawals send the host ID and kind unchanged; failures keep the previous state, + and a failed candidate refresh retains the acknowledged request. Truncated lists explicitly limit + search to loaded candidates. Host/viewer permissions gate mutations; hosts without candidate + listing direct reviewer management to the host. Editors and method pickers lock during writes. + +- Native PR checkout and agent handoff use the existing host Git operation and V2 thread metadata. + The thread is created before worktree preparation so setup scripts have an owner; failed + preparation/attachment keeps an empty recoverable thread and never stages or sends an agent task. + Ask/Explain avoid checkout, existing-thread tasks preserve the live composer, and stale reused + worktrees retain their changes with an explicit warning. No V1 launch path is imported. + +- Live tool focus is derived from V2 run ownership and projected item status on web and Swift. + Running foreground work wins over concurrent completions; the last success remains between + messages. Background processes keep separate visible rows, and failures/compaction cannot claim + the live focus. Native running rows remain visible before a result arrives. + +- Composer loading polish reads the V2 shell/projection, rather than V1 sessions or activities. + Provider discovery carries the optional `reportsContextWindow` presentation hint (Codex/Claude + true; unspecified drivers remain unknown). A started thread reserves its meter while loading, + and session-local Git identity prevents non-repository branch-strip flashes. Multiline browser + drafts stay expanded; the collapsed editor remains inert but measurable for restored wrapping. + +- Opt-in proactive panels (`fb93902ee2`, `8588d7f63b`) observe V2 run completion and checkpoint + summaries, never V1 turns. They defer until checkpoint/Git state is definitive, ignore empty, + failed and stale diffs, retain the user's revision across loading, and refresh the revision once + per new run. A selected linked PR can follow its replacement, but unrelated/manual selections + win. The setting is searchable, resettable, off by default and restricted to inline desktop + panels; native/compact clients keep explicit navigation rather than opening modal sheets. + +### Native provider account configuration + +The native Agents settings support add/edit/disable/remove for provider accounts, +including server connection fields, account colors and redacted environment credentials. +`generate-swift-provider-settings.ts` generates the native form catalogue from the same +pure field annotations and driver definitions as web; CI checks freshness. Native writes +re-read server settings, preserve unknown fields and other accounts, and reject conflicts +in edited fields. Built-in account IDs remain stable. The custom-model-definition capability +conservatively gates this editor on older paired servers, matching the native provider-map +write boundary. No V1 runtime behavior is introduced. + +### V2 historical tool-group summaries + +Web and native Swift summarize successful completed tool groups with V2 item types, +unique edited-file counts and bounded expanded histories. Single tools keep their own +labels; failed/declined calls form separate groups, and live work, compaction and persistent +resource cards stay visible. This ports upstream completed-group presentation without +V1 work-log ingestion or subagent observability. Full upstream integration-specific group +labels remain separate work; bounded history persistence is described below. + +### Native targeted PR handoffs + +Native comments/checks and selected diff ranges can be handed to the existing V2 +agent-thread preparation flow. Selected findings do not pull in unrelated review items; +original side/line, outdated/resolved state and optional revision remain explicit, and +bounded excerpts disclose truncation. Tasks stay unsent and preserve existing composer +text and attachments. Native checkout-command copy uses the reported source-control +provider, with no host guessing. Structured review-context chips now preserve these findings +through draft, stash, send, and transcript rendering; see Native PR review context below. + +### Native citation source highlighting + +Native source navigation resolves the shared normalized UTF-16 quote selector back to +rendered text and briefly marks the exact inline/code ranges without mutating cached +Markdown documents. List/table separators match native citation capture. Ambiguous +repeated text is never guessed; unique text can recover after offsets shift. Highlights +expire after three seconds without continuous animation and remain scoped to the source +message and transcript coordinator. + +### Web durable sidebar order + +Web carries `activeOrderKey` through its V2 shell projection and writes pinned/active positions +with `thread.metadata.update`, using existing `threadPinReorder`/`threadActiveOrderV2` capabilities. +The shared base-26 order preserves Swift's keyless-active-first and keyed-pin-first behavior; +first-time materialization supports large histories and reserves hidden thread keys. Optimistic +keys remain until projection acknowledgement, with concurrent-change/failure release. Resetting +an active position sends null without changing lifecycle state. Legacy keyless servers retain +local ordering; mixed durable/legacy sections require upgrading before a reorder. Pin entry +points share the existing top-of-pinned-run placement. No V1 reorder command or migration is used. + +### Tool-history reading state + +Web retains per-group/per-entry expansion and measured row-plus-offset anchors outside +virtualized rows, scoped to a conversation and bounded to 1,000 entries. Only appended calls +follow an already-visible end; status/output replacements do not re-arm following. Native +transcript coordinators own a bounded observable history cache and restore a stable tool-row +anchor with SwiftUI scroll targets, preserving expansion across recycled cells. Native +intra-row text offsets are not yet restored. This uses V2 item IDs, not the retired V1 work log. + +### Native sidebar file drops + +Native Home's UIKit collection accepts external file drops into thread rows across Code, +Work, Chat and search, opens the scoped thread, and queues providers until draft restoration. +The composer acknowledges each prepared file only after appending it; cancellation retains +unconsumed providers for that destination, while late callbacks cannot skip files. Existing +text/attachments and signed upload behavior are preserved. Eight-file batches, at most eight +pending destinations, file-size validation and visible overflow/errors bound the handoff. +Archived rows, links and selection mode reject drops. Queue state is session-local; received +attachments use the existing persisted composer draft. No provider or V1 runtime changes. + +### Native PR review-context chips + +Native PR identity, selected code and inline findings use the existing web `review_comment` +message format. Draft/stash/send keep the original readable blocks; the composer separates them +from editable prompt text into inspect/edit/remove chips, and sent messages render context cards. +Ask leaves an empty question field. Original diff sides, revision, outdated status and excerpt +bounds are preserved; fileless findings/checks stay in general context. Bounded parsing leaves +malformed blocks visible, escaping reserved host tags prevents nested forged chips, and longer +backtick fences preserve code. Swift↔web serialization was exercised in both directions. No new +server capability, schema or V1 runtime is required. + +### Desktop resting composer + +Deliberate wheel and timeline keyboard reading gestures compact eligible single-line composers. +The Lexical editor stays mounted; model/mode controls move into the existing context strip, +attachment/voice/send controls stay reachable, and measured footer width reserves editor space. +The expanded timeline inset is retained to avoid covering the last message on reopening. +Explicit editing, logical-end arrival, voice, requests/errors and thread changes restore the +expanded layout; trackpad momentum cannot immediately undo explicit expansion. The shared +`composerCollapseOnScroll` preference defaults on and is exposed in desktop General settings. +Native voice gestures and mobile layouts are unchanged. Upstream's full geometry animation +is not yet ported; the layout change currently has no motion interpolation. + +### PR reads across restarts + +Upstream `33242d0164` persistence is adapted to the fork's PR detail and stack APIs rather +than its V1-linked summary reader. Details retain their 15-second freshness limit and stacks +expire after 60 seconds, measured from the original host read across restarts. Keys include +operation, provider, host, repository, project and workspace identity; persisted filenames +are hashes. Schema validation, corrupt-file fallback, failed-read exclusion and unavailable +storage fallback keep the host authoritative. Refreshes and mutations clear persisted entries; +partial failed writes invalidate memory epochs too. Clearing waits for in-flight readers, +and a failed clear disables persistence for the process. No migration or V1 service is used. + +### Opt-in web panel motion + +Ports upstream `91c8d4771c`/`cd713679bb` panel duration (0–400ms, default zero), appearance +preview/reset/search, reduced-motion suppression and first-painted-route restoration. Retained +closing content is scoped to the thread or PR workspace and becomes inert immediately; terminal +visibility still stops painting when closed. The fork's resizable shell and V2 plan/agent panels +are retained, rather than importing upstream V1 panel data. Sidebar, terminal drawer, right +panel, sheet, header/footer breakpoint fades and PR workspace use the shared setting. Resize +and maximize changes suppress width transitions. This is a web/desktop layout preference; +native Swift keeps its own platform transitions and push-to-talk gesture tree. + +### Welcome setup and CLI history import (2026-09-10 parity port) + +Web/desktop now offer the reviewed upstream three-step welcome wizard, available again in +Settings. Fresh-workspace detection uses explicit startup provenance and waits for durable +client settings and live V2 shells; an existing workspace is never inferred to be fresh from +its folder name alone. Failed settings reads and completion writes remain retryable. +Swift has a native Computers / Agents / Projects setup flow after first pairing and in Settings, +using its existing navigation shell and terminal renderer. Neither client runs install/login +commands until the user presses Enter. Terminals resolve the selected provider account's +current environment and home on the server, including secret-backed values and Codex shadow +homes. Each wizard terminal owns and cleans up only its unique terminal ID. + +The bounded upstream Codex/Claude transcript scanner is carried with project lookup adapted +to the retained project aggregate. History import is implemented directly with V2 events and +provider native references, without starting a provider or importing V1 runtime services. +Imported conversations begin settled and resume through V2. A native session already owned +by another thread/project/account is never reassigned. Retries and copied transcript files +reuse the original conversation rather than overwrite history the user has continued. +Fork migration **060** stores only import ownership and source fingerprints; conversation +state remains the V2 JSON projection. Import receipts, events and fingerprints commit atomically. +The migration-journal reconciliation marker is registered alongside this fork-owned number. + +`agentSessionImport` and `providerTerminalEnvironment` advertise these implementations; +clients gate the new operations against older servers. Swift decodes real schema-generated +fixtures for scan results, import counts and capability flags. Expo remains on its existing +onboarding UI and can decode the additive contracts; its V1 importer is not introduced. + +### Markdown image gallery (2026-09-10 parity port) + +The upstream lazy, document-ordered Markdown gallery is adapted to the fork's `MarkdownMedia` +renderer and existing zoom dialog. PR descriptions share a gallery across their Markdown/media +segments. Linked badges remain links; remote image links open their full-size target. Weak DOM +keys release signed URLs when images unmount. Repeated images retain the clicked occurrence, +and navigation wraps correctly after any number of backward steps. The native Markdown +renderer already supplies a paged gallery with adjacent-page loading and needs no duplicate UI. + +### File and document previews (2026-09-10 parity port) + +The reviewed upstream file-preview behavior is integrated with the fork's file surfaces and +unified image/pdf/video/file attachments. Web/desktop show PDF/HTML attachments in distinct +ID-keyed tabs, even without an active workspace, and render workspace videos, PDF/HTML pages +and source toggles. Absolute host paths are read-only. Markdown images resolve relative to +the file's directory. Native iOS gains video/document file previews, HTML/source switching, +a document-attachment sheet, load/retry states, and signed-URL renewal, using its existing +navigation shell and video renderer. Native file previews refresh explicitly with Reload. + +Host-media access uses an exact, canonical file capability bound to device/inode and an open +descriptor. Renaming/replacing a path cannot change an in-flight full or ranged response; +atomic replacements require a fresh URL. Existing workspace sibling-asset capabilities, +V2 purged-worktree checks and the fork's browser-artifact route remain. Explicit absolute +text-file reads are bounded to 1 MiB and never enable out-of-workspace writes. Inline document +attachments require a stored PDF/HTML extension; a supplied MIME type cannot make an archive +render as HTML. Served HTML receives an opaque-origin sandbox policy. + +`fileDocumentPreviews` gates the new host/document operations and is mirrored in Swift with +schema-generated wire fixtures. V2 completed file-change/command items drive web refreshes, +including failed commands and out-of-order completions. Local pending saves defer refreshes; +no V1 activity import or observer is used. Existing native media galleries are retained. + +## Automatic project pulls + +Upstream clean-default-branch pulling is ported through the fork's VCS broadcaster +and ProjectService, with no V1 thread query or migration. Server settings carry an +off-by-default machine preference and sparse project overrides; null removes one +override without replacing others. The fork’s project aggregate supplies the policy. +Canonical-workspace locks serialize refreshes and pulls, and existing fast-forward-only +Git operations preserve local commits. Web/desktop and Swift expose defaults plus +on/off/inherit, guarded by `projectAutoPull` on older servers. Upstream's turn-end +PR refresh and discovery remain separate ports. Startup refreshes enabled projects +once after managed-update activation and before the V2 effect worker drains recovered +provider work; no checkout mutation runs in an uncommitted update trial. + +## Project browser access overrides + +Upstream project-scoped agent browser access resolves through V2 thread shells and +fresh server settings. Missing project identity or unreadable settings cannot bypass +an override. The session manager receives a thread-scoped policy and retains its MCP +credential reuse/rotation checks; only preview access changes. No V1 ProviderService +or snapshot query is imported. Sparse null patches restore inheritance. Web/desktop +project settings and Swift Project defaults expose on/off/inherit, guarded by +`projectBrowserAccess`; the machine-wide preference is preserved. + +## Machine-scoped project defaults + +Server settings carry a nullable default model selection, replaced atomically so +options from a previous model cannot leak into a new selection. The `projectDefaults` +capability gates machine-scoped editing on web/desktop and Swift. New threads resolve +explicit choices, project defaults, then the destination machine's default; legacy +servers retain their existing fallback. Existing draft reuse and navigation-race +guards remain fork-owned. Settings → Projects supports project and machine scope, +with grouped settings rows; project model fan-out checks target catalog availability. +Shared action defaults are a separate port and are not advertised by this capability. + +## Shared project action defaults + +Machine actions and sparse project action overrides resolve through the shared +project-script helper. Missing overrides preserve nonempty legacy/t3.json actions; +null explicitly inherits machine actions; an empty array disables them. Writes replace +one array instead of merging removed entries. V2 launch, MCP worktree creation and +Git PR preparation share the standalone setup runner; fork teardown resolves the +same settings through ProjectService. No V1 thread query or migration is introduced. +Web/desktop settings and action controls, and native action editing/execution lists, +use the effective actions behind `projectActionDefaults`. Inherited actions are never +written to t3.json. Native preserves teardown and single-run wire flags; its existing +terminal runner still does not provide desktop single-run toggle semantics. + +## Provider picker setup paths + +Web/desktop picker and banner setup links preserve target environment/account IDs +through provider settings. Account setup reuses the reviewed Codex/Claude terminal +flow and `providerTerminalEnvironment`; it pretypes without submitting and owns +cleanup/retry. Swift model pickers and account settings push a machine-scoped setup +screen using the same native terminal protocol, preserving the composer voice +gesture surface. Small picker controls and floating-layer event scope match upstream. +Antigravity setup is not advertised until its V2 adapter exists. + +## Remote model catalogs on V2 + +The provider manifest now carries model presentation, aliases, new badges, defaults, +CLI compatibility gates, and allowlisted Claude runtime profiles. Invalid references +or adapter metadata retain the last good catalog; an older disk manifest cannot hide +newer bundled models. Claude discovery, V2 turns, and structured text generation read +that same cached source. Each turn keeps its compiled prompt options through steering. +Custom model aliases stay opaque and use their own descriptors. + +The fork deliberately transforms upstream's Claude `contextWindow` descriptor into +its real `autoCompactWindow` slider. Models run at their largest supported window; +known natively-1M models retain bare identifiers, while suffix-based profiles select +the largest window. Provider-level and thread-level compaction ceilings still compose +in the V2 SDK settings. This preserves the existing working Context control instead +of restoring upstream's ineffective 200k selector on natively-1M models. No V1 runtime +or migration is introduced. Web/desktop and Swift render manifest-driven New badges; +the frozen Expo client accepts the additive contract fields. + +## Explicit draft model choices + +Web/desktop draft persistence distinguishes a human model/trait choice from a +project, machine, or sticky seed. Reopening an empty draft refreshes seeds without +replacing explicit picks, including picks made while workspace defaults load. +Moving typed draft content between projects carries an explicit model choice. +The browser storage migration removes only model seeds on empty local draft +sessions; invested drafts, real threads, and instance-scoped sticky preferences +remain intact. V2 persisted thread selections still outrank browser composer state. +Swift already records `selectionIsExplicit` and persists only explicit draft model +choices, so this port aligns web behavior without changing its native composer. + +## Settings availability and navigation + +The grouped settings shell now uses upstream's tighter page spacing, explicit +scroll targets, optional destination highlighting, and a common unavailable-group +presentation. Primary-server controls are inert with a keyboard-reachable explanation +on hosted clients that have no primary. Attempted primary-only writes report that +nothing was saved. The fork's machine-targeted settings continue to write to their +explicit environment; no arbitrary remote becomes the primary fallback. The same +rule covers fork-owned worktree-retention controls. Legacy auto-settlement preferences remain client-local for old servers; capable V2 servers own +their environment settings and persisted automatic decisions. + +### Image header metadata (2026-09-11 parity port) + +Signed asset responses include optional PNG/GIF/WebP/JPEG dimensions from at most 256 KiB +of header data. Host-file dimensions use the same identity-checked descriptor as URL minting; +malformed, unreadable or unsupported headers omit metadata without breaking previews. The fork's +MarkdownMedia renderer reserves the natural aspect ratio within its existing height cap, with +authored HTML dimensions taking precedence. Host-file previews are capability-gated. Swift +mirrors the optional contract and keeps its fixed transcript media frame to avoid collection-view +remeasurement. No V1 runtime or database migration is involved. + +### Live subscription limits through V2 (2026-09-11 parity port) + +Codex account/rateLimits/updated and Claude rate_limit_event now feed instance-owned provider +snapshots directly from V2 adapters. Sparse updates preserve other windows and reset metadata; +unchanged notifications do not broadcast. Claude scoped events reuse the first model bucket +reported by that account's probe, never a guessed model name. Cached Claude probes retain their +original sample timestamp. Failed or older probes do not erase newer live bars; unsupported +accounts remain unsupported. This uses the existing provider snapshot contract on web/desktop, +Expo and Swift, with no V1 provider bridge or migration. + +### Native tool-history reading offset (2026-09-11 parity port) + +Swift retains an offset within the first visible tool row in its bounded, thread/group-scoped +history cache. Restoring a recycled or reopened group first materializes the lazy row, then uses +its measured frame to restore the reading position; changed row heights clamp safely. A passive +iOS 17-compatible observer reads only the inner scroll view and leaves SwiftUI's delegate intact. +User dragging cancels pending restoration. Measurements are transient and do not invalidate the +whole transcript on every scroll. No simulator or browser was launched for verification. + +- T3 MCP activity intent labels, PR/browser icons and completed-group summaries are ported + onto projected V2 items on web and native Swift. Labels follow item status, including + failure/cancellation, and recognize provider namespace aliases. The shared URL parser + supports GitHub, GitLab, Bitbucket and Azure change-request routes; Swift mirrors those + rules for tool labels. This presentation does not advertise new PR MCP capabilities. + +- Codex browser/computer tool-source metadata is normalized at the V2 adapter boundary + into independent `toolActivity` contracts and optional V2 item fields. It survives JSON + projection persistence and reaches web/Swift icons and source-aware group summaries. + V1 item-lifecycle events are not reintroduced. Native app references resolve through + signed asset URLs and a bounded macOS icon cache; clients never receive application + bundle paths. Other host platforms retain glyph fallbacks, as upstream does. Swift + decodes older items without metadata and scopes native-icon requests by environment. + +### V2 pull-request discovery and persisted link snapshots + +Upstream PR discovery/snapshot workers and MCP linking are ported to V2 metadata commands, +not the retired V1 decider or `projection_threads`. `pullRequests` stores host-level identity, +source, snapshots, native stacks and dismissal tombstones; legacy fields stay derived and +preserve Azure selectors. Atomic link-version/anchor and branch/worktree guards reject stale +reads. Background-only updates do not create activity. The fork keeps explicit links authoritative. + +Workers start after recovery, share branch/status caches and host-error backoff, and use the +fork's persistent read cache for cheap summaries and separate lightweight/hydrated stack reads. +MCP PR tools are credential-thread-scoped. Web linked badges and the web/Swift collection views +use cached snapshots and ordered chains, with source/check/diff signals. Native fixtures mirror +the optional wire fields. Full cross-repository detail routing remains follow-up parity work; no V1 thread imports or new migrations are introduced. + +### PR indicator parity + +Saved V2 branch candidates feed web sidebar/chat/command-palette status and native thread details +and sidebar observations without becoming explicit links. Native explicit links use pushed V2 +snapshots; legacy and branch-only references retain scoped host reads. Web draft/closed/merged +icons share the detail presentation, and cached draft changes invalidate row snapshots. + +PR stack counts now use the fork’s web collection popover from sidebar/header controls and +native sidebar chain presentation. Native draft metadata survives both VCS/detail and pushed +V2 snapshot mappings. The upstream standalone hydrated collection panel remains unported. + +### Automatic settlement on V2 (2026-09-11 parity port) + +Upstream server-side settlement is implemented with V2 shell state and serialized dispatch, not +V1 deciders/projectors. The worker evaluates persisted PR snapshots and inactivity before host +lookups, preserves activity timestamps and pins, and rejects stale decisions using the thread event +sequence. Runs, blocking requests, queued messages, V2 background work, snoozes and explicit active +state govern eligibility. Actual merge/close timestamps prevent re-settling resumed work. + +Environment preferences and `threadAutoSettlement` keep web, Expo compatibility and Swift on the +same persisted classification. Legacy servers keep device-local rules. Native Thread organization +settings expose machine selection, merge opt-out and nullable inactivity periods. Pending request +summaries now distinguish message-mode questions; blocking requests win summary selection. No +SQLite migration or retired V1 runtime import is needed. Confirmed host merges immediately +request linked snapshot refresh and invalidate matching checkout caches before settlement. Queued +merges remain open, and host timestamps are preserved. Cross-machine shared preference propagation +remains separate parity work; periodic sweeps run each minute. + +### Created PR links on V2 (2026-09-11 parity port) + +Git stacked-action thread attribution is carried from web, Expo compatibility and Swift. The +upstream created-link hook is implemented through V2 metadata, with originating-checkout and +serialized context guards; it does not import V1 snapshot queries or thread dispatch. PR URLs +identify fork-target repositories, with a validated configured-host fallback for self-hosted GitHub. +Existing links retain their source and failures preserve the successful Git result. No migration +is required. Full hosted-reference detail routing remains separate parity work. + +### Codex resume metadata on V2 (2026-09-11 parity port) + +The metadata-only resume fix from upstream `1abc717f0d` is carried in `CodexAdapterV2`. +Resume requests exclude historical turns and validate only the native thread identity and optional +update timestamp used by V2. Unknown historical error enums cannot prevent a valid resume; malformed +identity metadata remains an error and never starts a fresh thread silently. Automatic restart +continuation itself remains separate work. + +### Opt-in restart continuation on V2 (2026-09-11 parity port) + +The restart preference and lifecycle intent from `5b7d72aad1`, `b906ce2d73` and `1abc717f0d` are +ported to V2 persisted run markers and serialized continuation commands. V1 provider directories, +adapters and projectors are not carried. Current native provider references and the latest V2 run +own recovery; duplicates, completed/archived/settled work, newer messages and changed contexts do +not resume. Explicit stop/organization changes retire pending markers. Codex resumes without a +synthetic prompt, other adapters receive a continuation instruction, and recovery never creates a +fresh provider conversation as a fallback. No SQLite migration is required. + +Web and native machine preferences use `threadRestartContinuation`, default false; Swift mirrors +settings, capabilities and optional run/message fields. All newly added PR/settlement/recovery roots +are registered before update activation. Cross-environment shared preferences and per-update +handoff integration remain distinct parity work; the persisted machine opt-in covers normal updates. + +### Linked pull-request collection panel + +The upstream hydrated collection panel uses V2 shell snapshots and the fork's metadata +link/unlink commands. It opens from the thread header/sidebar collection and right-panel +launcher, retains independent PR detail tabs, and shows stack structure, host checks, +review decisions, conflicts, author, branch direction and sync age. Unknown host fields +stay unknown. The native linked-PR sheet already consumes the same snapshots. + +### Native tool logos + +Swift tool rows render HTTP(S) and inline raster/SVG logos through a bounded bitmap cache, +with theme-specific URLs and existing signed native-app icons. SVGView is pinned to revision +`fd9f0303bc2da37e5d1ea98f42835c8273361c7b`; parsing has no external-resource linker and +rejects excessive depth, size and recursive references. Transcript rows retain only a 48-pixel +raster; they do not embed web views or repeatedly parse SVG while scrolling. + +### Shared server preferences + +Web and Swift share restart continuation, automatic settlement, worktree origin, generated-text +model and source-control writing preferences across connected, capable environments. Other +server settings keep their explicit machine scope. The V2 capability gates retain legacy-client +fallbacks; account selections propagate only when the target enables the same instance/driver. +Disconnected targets are not overwritten with defaults. Mismatch notices offer an explicit +apply-to-all action using loaded settings; Swift reports individual failed target names. + +### Whole-host resource sampling + +`server.getHostResources` measures machine CPU and available memory independently of V2 provider +or process history. Sampling is on demand, deduplicates concurrent callers and expires after +five seconds; no idle polling is introduced. Shared TypeScript and Swift routing policies use +client receipt timestamps, reject saturated/stale machines and apply user weights. The RPC uses +orchestration-read authorization. Automatic draft UI integration is a separate client concern. + +### Automatic selection for new tasks + +Web/desktop and Swift expose client-local automatic balancing and per-machine preferences +(default off, weight 50; zero means manual only). Only connected machines with the same +repository identity, ready matching provider instance/driver, and selected model can compete. +Fresh whole-host capacity decides once per draft; busy, unknown, stale, or unreachable hosts +cannot win. Failure leaves an explicit retry/manual path and blocks automatic submission. +Queries mount only while selecting; native capacity requests time out after five seconds. + +Web persists the choice on its V2 draft session and carries the selected model/options when +changing project scope. Branch selection overrides Auto. Attachments and machine-scoped context +prevent a fresh automatic move; late query responses recheck live draft state before moving it. +Native keeps the draft's storage key anchored to its original project and persists a separate +execution project, so routing cannot overwrite another machine's draft. Branches, uploads, file +search and provider controls follow the execution project. Manual workspace choices freeze that +project. Neither implementation imports a V1 thread runtime, and no migration is needed. +The frozen Expo client retains compatible defaulted contracts without a new balancing screen. + +### Native file refresh after agent edits + +Swift's open file browser and previews now use the same V2 terminal file-change/command +revision policy as web (`e09b88b6a5` intent). The revision is computed only for the open Files +surface, includes inherited source identity, and invalidates directory, text and media reads. +Reload and revision changes cancel/ignore older loads. Previous content stays visible with an +explicit refresh error; images bypass old cached bodies and document URLs retain signed query +bytes while carrying a cache revision. Document retries cannot resurrect an old signed URL after +navigation or agent edits. This is event-driven; no additional polling or V1 subscriptions. + +### Editable file highlighting and wrapped geometry + +The Pierre dependency patch retains the fork's controlled comment selection and exports while +adding upstream stale-worker/cache eviction (`df8e0eb46b`), editor grammar readiness +(`2fa5ef4c7b`), and measured-prefix preservation during edits (`6270a6f88b`). Width changes and +line-number digit boundaries still invalidate affected measurements; hidden zero-width layouts +wait for a visible resize. The reviewed tests execute the actual installed tokenizer, workers, +editor and virtualizer. Native source previews use Swift's read-only highlighting/layout and do +not instantiate this editor. Reselect-to-reveal in the diff tree was already present. + +### Loading feedback and live activity motion + +The loading/refresh treatment from `f12d39359f` keeps refresh glyphs stable while requests +run and uses a shared spinner throughout web/desktop. Usage history refresh waits for the +selected machines' rescans; disconnects abort waiting without hanging healthy machines. +Pricing refresh was subsequently ported below. Live V2 activity uses the masked text +highlight from `ce4712d5b0`; one shared observer pauses web motion offscreen, in hidden tabs +and for reduced motion. Swift uses a native masked highlight that stops when its row leaves +the view, the app becomes inactive, or reduced motion is enabled. No V1 runtime is imported. + +### Usage history and current native contracts + +Reviewed `2b745efe57` rate refresh and `394e8470c8` settings consistency now run independently +of V1: rate loads are single-flight with a one-minute manual floor and daily normal TTL; +custom prices and transcript homes use one settings snapshot. Failed fetches retain cached +rates. Web/desktop and Swift refresh pricing before rescanning, with older-server fallback. +The read-authorized RPC changes no provider credentials or transcript contents. + +Web's permanent environment filter and incremental totals follow `7ee52b0773`; preferences +follow `add8c3a55a` while preserving the fork's separate Limits screen. Swift now decodes usage +v5, accepts compatible v4, excludes older/future versions, and carries hourly bucket starts. +It adds exact rolling 24-hour charts, selected-machine histories, progressive coverage and the +full model list. A generated server-schema fixture checks current native decoding and totals. +Expo remains frozen; its existing daily/hourly usage RPC is backward compatible. + +### Codex reset credits on V2 provider instances + +`1641b4aba5` is ported onto the fork's account-owning provider instances, retaining the V2 +adapter and no V1 setup/runtime services. The scoped probe and reset action share app-server +initialization. An account-directory coordinator serializes attempts, keeps the idempotency key +on failure and bounds the request to 20 seconds. Registry hydration owns the coordinator across +instance rebuilds. Disabled/missing/non-Codex instances cannot redeem. The operate-authorized +RPC returns a warning, rather than an error inviting another redemption, if only the subsequent +limits probe fails. Live V2 window notifications retain banked credits (`f1a08116f9` intent). + +Web/desktop and native Swift show banked counts/expiry and require explicit confirmation. The +redemption target follows the instance that supplied the displayed account snapshot, including +deduplicated multi-environment accounts. Web additionally respects known operate permissions; +the server is authoritative for every client. No real credit was redeemed during verification. +External quota hubs remain a separate port; the frozen Expo client ignores optional credit data. diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 9ae6f502b..a4ad375bc 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -52,6 +52,33 @@ describe("ElectronShell", () => { }).pipe(Effect.provide(ElectronShell.layer)), ); + it.effect("opens Zed's ssh deep link", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const result = yield* electronShell.openExternal("zed://ssh/example.com/home/user/project"); + + assert.equal(result, true); + assert.deepEqual(openExternalMock.mock.calls, [["zed://ssh/example.com/home/user/project"]]); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + + it.effect("does not open editor URLs that mix up link shapes", () => + Effect.gen(function* () { + openExternalMock.mockResolvedValue(undefined); + + const electronShell = yield* ElectronShell.ElectronShell; + const results = yield* Effect.all([ + electronShell.openExternal("zed://extension/attacker"), + electronShell.openExternal("vscode://ssh/example.com/home/user/project"), + ]); + + assert.deepEqual(results, [false, false]); + assert.equal(openExternalMock.mock.calls.length, 0); + }).pipe(Effect.provide(ElectronShell.layer)), + ); + it.effect("does not open remote editor URLs with userinfo", () => Effect.gen(function* () { openExternalMock.mockResolvedValue(undefined); @@ -64,9 +91,10 @@ describe("ElectronShell", () => { electronShell.openExternal( "vscode://:secret@vscode-remote/ssh-remote+example.com/home/user/project", ), + electronShell.openExternal("zed://ssh/user@example.com/home/user/project"), ]); - assert.deepEqual(results, [false, false]); + assert.deepEqual(results, [false, false, false]); assert.equal(openExternalMock.mock.calls.length, 0); }).pipe(Effect.provide(ElectronShell.layer)), ); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 2ed13bfeb..756dfce2e 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -6,8 +6,8 @@ import * as Option from "effect/Option"; import * as Electron from "electron"; -// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`) -// must reach the OS handler; every other non-web scheme stays blocked. +// Remote editor links use VS Code’s vscode-remote shape or Zed’s ssh shape. +// Other non-web schemes stay blocked. const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]); const REMOTE_EDITOR_PROTOCOLS = new Set( REMOTE_CAPABLE_EDITOR_IDS.flatMap((id) => { @@ -16,13 +16,18 @@ const REMOTE_EDITOR_PROTOCOLS = new Set( }), ); +// Zed's host sits in the first path segment, so it needs its own userinfo ban. +const ZED_SSH_PATHNAME = /^\/[^/@:]+\/.+$/; + const isRemoteEditorUrl = (url: URL) => REMOTE_EDITOR_PROTOCOLS.has(url.protocol) && url.username.length === 0 && url.password.length === 0 && - url.host === "vscode-remote" && - url.pathname.startsWith("/ssh-remote+") && - url.pathname.length > "/ssh-remote+".length; + (url.protocol === "zed:" + ? url.host === "ssh" && ZED_SSH_PATHNAME.test(url.pathname) + : url.host === "vscode-remote" && + url.pathname.startsWith("/ssh-remote+") && + url.pathname.length > "/ssh-remote+".length); export function parseSafeExternalUrl(rawUrl: unknown): Option.Option { if (typeof rawUrl !== "string") { diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ce11a5212..39621609b 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -13,8 +13,15 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { + onboardingCompletedAt: null, + composerCollapseOnScroll: true, + panelAnimationDurationMs: 0, + loadBalancingEnabled: false, + loadBalancingWeights: { "environment-1": 75, "environment-2": 0 }, + diffColorScheme: "red-green", alwaysExpandActivity: false, autoOpenPlanSidebar: false, + proactivePanelsEnabled: false, appearanceContrast: 100, browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" }, browserDefaultZoomFactor: 1.25, diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 2ed4104b1..21548e456 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -722,9 +722,18 @@ export const make = Effect.gen(function* () { } const checkedAt = yield* currentIsoTimestamp; - const releaseNotes = normalizeDesktopUpdateReleaseNotes(info.releaseNotes, info.version); + const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes( + info.releaseNotes, + info.version, + ); yield* setState( - reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt, releaseNotes), + reduceDesktopUpdateStateOnUpdateAvailable( + state, + info.version, + checkedAt, + releaseNotes, + omittedReleaseCount, + ), ); yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); yield* logUpdaterInfo("update available", { diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 78ea56e75..3ba2444dc 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -3,38 +3,137 @@ import { describe, expect, it } from "vite-plus/test"; import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; describe("normalizeDesktopUpdateReleaseNotes", () => { - it("splits a plain string note into items under the fallback version", () => { - const notes = normalizeDesktopUpdateReleaseNotes( - "## What's changed\n- First fix\n- Second fix", + it("shows the newest changes and counts all real changes", () => { + const result = normalizeDesktopUpdateReleaseNotes( + [ + "- feat: first change", + "- fix: second change", + "- fix: third change", + "- fix: fourth change", + "- fix: fifth change", + "- fix: sixth change", + "- fix: seventh change", + "- fix: eighth change", + "- fix(web): keep long task drawers usable on small screens by @human in #8313", + "- fix(opencode): handle child approvals, stops, and model catalogs by @human in #8480", + "## New Contributors", + "- @human made their first contribution in #8435", + "**Full Changelog**: https://github.com/pingdotgg/t3code/compare/old...new", + ].join("\n"), + "0.0.36-nightly.20260828.1213", + ); + + expect(result).toEqual({ + releaseNotes: [ + { + version: "0.0.36-nightly.20260828.1213", + items: [ + "fix(opencode): handle child approvals, stops, and model catalogs by @human in #8480", + "fix(web): keep long task drawers usable on small screens by @human in #8313", + "fix: eighth change", + "fix: seventh change", + "fix: sixth change", + "fix: fifth change", + "fix: fourth change", + "fix: third change", + ], + totalItems: 10, + }, + ], + omittedReleaseCount: 0, + }); + }); + + it("excludes a GitHub HTML contributor section", () => { + const result = normalizeDesktopUpdateReleaseNotes( + "

What's Changed

" + + "

New Contributors

" + + "

Full Changelog

", "1.2.3", ); - expect(notes).toEqual([{ version: "1.2.3", items: ["First fix", "Second fix"] }]); + + expect(result).toEqual({ + releaseNotes: [{ version: "1.2.3", items: ["Newer fix", "Older fix"], totalItems: 2 }], + omittedReleaseCount: 0, + }); + }); + + it("does not count Markdown or HTML section headings as changes", () => { + const changes = Array.from({ length: 8 }, (_, index) => `Change ${index + 1}`); + const result = normalizeDesktopUpdateReleaseNotes( + [ + { version: "1.2.4", note: ["### Features", ...changes].join("\n- ") }, + { + version: "1.2.3", + note: `

Fixes

`, + }, + ], + "1.2.4", + ); + + expect(result.releaseNotes).toEqual([ + { version: "1.2.4", items: changes.toReversed(), totalItems: 8 }, + { version: "1.2.3", items: changes.toReversed(), totalItems: 8 }, + ]); }); - it("keeps per-version groups and drops empty ones", () => { - const notes = normalizeDesktopUpdateReleaseNotes( + it("keeps per-version order and drops empty groups", () => { + const result = normalizeDesktopUpdateReleaseNotes( [ - { version: "1.2.3", note: "- Newer change" }, + { version: "1.2.3", note: "- Newer release" }, { version: "1.2.2", note: "Full changelog: https://example.com/compare/x...y" }, - { version: "1.2.1", note: "- Older change" }, + { version: "1.2.1", note: "- Older release" }, ], "1.2.3", ); - expect(notes).toEqual([ - { version: "1.2.3", items: ["Newer change"] }, - { version: "1.2.1", items: ["Older change"] }, + + expect(result).toEqual({ + releaseNotes: [ + { version: "1.2.3", items: ["Newer release"], totalItems: 1 }, + { version: "1.2.1", items: ["Older release"], totalItems: 1 }, + ], + omittedReleaseCount: 0, + }); + }); + + it("counts valid groups before applying the six-release limit", () => { + const releaseNotes = [ + { version: "1.3.9", note: "- Change 9" }, + { version: "1.3.8", note: "Full changelog: https://example.com/compare/x...y" }, + { version: "1.3.7", note: "- Change 7" }, + { version: "1.3.6", note: "- Change 6" }, + { version: "1.3.5", note: "- Change 5" }, + { version: "1.3.4", note: "- Change 4" }, + { version: "1.3.3", note: "- Change 3" }, + { version: "1.3.2", note: "- Change 2" }, + ]; + + const result = normalizeDesktopUpdateReleaseNotes(releaseNotes, "1.3.9"); + + expect(result.releaseNotes.map(({ version }) => version)).toEqual([ + "1.3.9", + "1.3.7", + "1.3.6", + "1.3.5", + "1.3.4", + "1.3.3", ]); + expect(result.omittedReleaseCount).toBe(1); }); it("decodes valid HTML entities", () => { - const notes = normalizeDesktopUpdateReleaseNotes("- Fix & polish 😀", "1.0.0"); - expect(notes).toEqual([{ version: "1.0.0", items: ["Fix & polish 😀"] }]); + const result = normalizeDesktopUpdateReleaseNotes("- Fix & polish 😀", "1.0.0"); + expect(result).toEqual({ + releaseNotes: [{ version: "1.0.0", items: ["Fix & polish 😀"], totalItems: 1 }], + omittedReleaseCount: 0, + }); }); - it("ignores malformed entries instead of throwing", () => { - const notes = normalizeDesktopUpdateReleaseNotes( + it("ignores malformed and empty entries instead of throwing", () => { + const result = normalizeDesktopUpdateReleaseNotes( [ { version: "1.2.3", note: "- Valid change" }, + { version: "1.2.2", note: "" }, { version: 42, note: "- Bad version type" }, { version: "1.2.1", note: { html: "

object note

" } }, "not an object", @@ -42,23 +141,25 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { ], "1.2.3", ); - expect(notes).toEqual([{ version: "1.2.3", items: ["Valid change"] }]); + + expect(result).toEqual({ + releaseNotes: [{ version: "1.2.3", items: ["Valid change"], totalItems: 1 }], + omittedReleaseCount: 0, + }); }); - it("returns non-empty groups even when preceded by many boilerplate-only groups", () => { - const boilerplate = Array.from({ length: 7 }, (_, index) => ({ - version: `1.3.${9 - index}`, - note: "Full changelog: https://example.com/compare/x...y", - })); - const notes = normalizeDesktopUpdateReleaseNotes( - [...boilerplate, { version: "1.3.2", note: "- Older but real change" }], - "1.3.9", - ); - expect(notes).toEqual([{ version: "1.3.2", items: ["Older but real change"] }]); + it("returns an empty result for an invalid payload", () => { + expect(normalizeDesktopUpdateReleaseNotes({ note: "- Invalid" }, "1.0.0")).toEqual({ + releaseNotes: [], + omittedReleaseCount: 0, + }); }); it("does not throw on out-of-range numeric entities and keeps the literal", () => { - const notes = normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"); - expect(notes).toEqual([{ version: "1.0.0", items: ["Broken entity �"] }]); + const result = normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"); + expect(result).toEqual({ + releaseNotes: [{ version: "1.0.0", items: ["Broken entity �"], totalItems: 1 }], + omittedReleaseCount: 0, + }); }); }); diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 69857c92b..3b2f32e64 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -59,6 +59,7 @@ function stripMarkup(input: string): string { input .replace(//gi, "\n") .replace(/]*>/gi, "\n- ") + .replace(/]*>/gi, (_, level: string) => `\n${"#".repeat(Number(level))} `) .replace(/<\/(?:p|div|li|h[1-6]|ul|ol|blockquote)>/gi, "\n") .replace(/<[^>]*>/g, "") .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") @@ -71,43 +72,60 @@ function truncateReleaseNoteItem(item: string): string { return `${item.slice(0, MAX_RELEASE_NOTE_ITEM_LENGTH - 3).trimEnd()}...`; } -function isIgnoredReleaseNoteLine(line: string): boolean { - const normalized = line +function normalizeReleaseNoteLine(line: string): string { + return line .toLowerCase() .replace(/[*_`#]/g, "") .trim(); +} + +function isIgnoredReleaseNoteLine(line: string): boolean { + const normalized = normalizeReleaseNoteLine(line); return ( normalized === "" || normalized === "what's changed" || normalized === "whats changed" || - normalized === "full changelog" || - normalized === "new contributors" || normalized.startsWith("compare: ") || normalized.includes("/compare/") ); } -function extractReleaseNoteItems(note: string | null | undefined): ReadonlyArray { - if (!note) return []; +interface ExtractedReleaseNoteItems { + readonly items: ReadonlyArray; + readonly totalItems: number; +} + +function extractReleaseNoteItems(note: string | null | undefined): ExtractedReleaseNoteItems { + if (!note) return { items: [], totalItems: 0 }; const items: string[] = []; + let totalItems = 0; for (const rawLine of stripMarkup(note).split("\n")) { const item = rawLine .trim() .replace(/^[-*]\s+/, "") .replace(/^\d+[.)]\s+/, "") .replace(/\s+/g, " "); + const normalized = normalizeReleaseNoteLine(item); + if (normalized === "new contributors" || normalized === "full changelog") break; + if (/^#{1,6}\s+/.test(item)) continue; if (isIgnoredReleaseNoteLine(item)) continue; + totalItems += 1; items.push(truncateReleaseNoteItem(item)); - if (items.length >= MAX_RELEASE_NOTE_ITEMS_PER_GROUP) break; + if (items.length > MAX_RELEASE_NOTE_ITEMS_PER_GROUP) items.shift(); } - return items; + return { items: items.toReversed(), totalItems }; +} + +interface NormalizedDesktopUpdateReleaseNotes { + readonly releaseNotes: ReadonlyArray; + readonly omittedReleaseCount: number; } export function normalizeDesktopUpdateReleaseNotes( releaseNotes: unknown, fallbackVersion: string, -): ReadonlyArray { +): NormalizedDesktopUpdateReleaseNotes { const rawNotes = typeof releaseNotes === "string" ? [{ version: fallbackVersion, note: releaseNotes }] @@ -115,11 +133,20 @@ export function normalizeDesktopUpdateReleaseNotes( ? releaseNotes.filter(isElectronReleaseNoteInfo) : []; - return rawNotes - .map((entry) => ({ - version: entry.version, - items: extractReleaseNoteItems(entry.note), - })) - .filter((entry) => entry.items.length > 0) - .slice(0, MAX_RELEASE_NOTE_GROUPS); + const normalizedNotes = rawNotes.flatMap((entry) => { + const { items, totalItems } = extractReleaseNoteItems(entry.note); + if (totalItems === 0) return []; + return [ + { + version: entry.version, + items, + totalItems, + }, + ]; + }); + + return { + releaseNotes: normalizedNotes.slice(0, MAX_RELEASE_NOTE_GROUPS), + omittedReleaseCount: Math.max(0, normalizedNotes.length - MAX_RELEASE_NOTE_GROUPS), + }; } diff --git a/apps/desktop/src/updates/updateMachine.ts b/apps/desktop/src/updates/updateMachine.ts index 97d192be6..4514ac9d5 100644 --- a/apps/desktop/src/updates/updateMachine.ts +++ b/apps/desktop/src/updates/updateMachine.ts @@ -33,6 +33,7 @@ export function createInitialDesktopUpdateState( availableVersion: null, downloadedVersion: null, releaseNotes: [], + omittedReleaseCount: 0, downloadPercent: null, checkedAt: null, message: null, @@ -52,6 +53,7 @@ export function reduceDesktopUpdateStateOnCheckStart( autoInstallPending: false, checkedAt, releaseNotes: hasDownloadedUpdate ? state.releaseNotes : [], + omittedReleaseCount: hasDownloadedUpdate ? (state.omittedReleaseCount ?? 0) : 0, message: null, downloadPercent: hasDownloadedUpdate ? 100 : null, errorContext: null, @@ -92,6 +94,7 @@ export function reduceDesktopUpdateStateOnUpdateAvailable( version: string, checkedAt: string, releaseNotes: ReadonlyArray = [], + omittedReleaseCount = 0, ): DesktopUpdateState { const isDownloadedVersion = state.downloadedVersion === version; const nextReleaseNotes = @@ -103,6 +106,10 @@ export function reduceDesktopUpdateStateOnUpdateAvailable( availableVersion: version, downloadedVersion: isDownloadedVersion ? version : null, releaseNotes: nextReleaseNotes, + omittedReleaseCount: + isDownloadedVersion && releaseNotes.length === 0 + ? (state.omittedReleaseCount ?? 0) + : omittedReleaseCount, downloadPercent: isDownloadedVersion ? 100 : null, checkedAt, message: null, @@ -135,6 +142,7 @@ export function reduceDesktopUpdateStateOnNoUpdate( availableVersion: null, downloadedVersion: null, releaseNotes: [], + omittedReleaseCount: 0, downloadPercent: null, checkedAt, message: null, diff --git a/apps/mobile/src/features/sharing/incoming-share-model.ts b/apps/mobile/src/features/sharing/incoming-share-model.ts index b68548fd1..cef341555 100644 --- a/apps/mobile/src/features/sharing/incoming-share-model.ts +++ b/apps/mobile/src/features/sharing/incoming-share-model.ts @@ -1,8 +1,8 @@ +import { validateInlineComposerAttachment } from "../../lib/composerAttachmentKinds"; import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; import { inferExtensionFromMimeType, inferMimeTypeFromFileName, - validateComposerAttachment, } from "@t3tools/shared/composerAttachments"; import * as Schema from "effect/Schema"; import type { ResolvedSharePayload, SharePayload } from "expo-sharing"; @@ -183,7 +183,11 @@ export async function buildIncomingShareDraft(input: { } const declaredSize = resolved?.contentSize ?? null; if (declaredSize !== null) { - const preflight = validateComposerAttachment({ name, sizeBytes: declaredSize, mimeType }); + const preflight = validateInlineComposerAttachment({ + name, + sizeBytes: declaredSize, + mimeType, + }); if (!preflight.accepted) { // Reject before reading: no reason to pull 30 MB into memory first. warnings.push(preflight.message); @@ -195,7 +199,7 @@ export async function buildIncomingShareDraft(input: { try { const base64 = await input.fileReader.readBase64(uri); const sizeBytes = declaredSize ?? estimateBase64ByteSize(base64); - const validation = validateComposerAttachment({ name, sizeBytes, mimeType }); + const validation = validateInlineComposerAttachment({ name, sizeBytes, mimeType }); if (!validation.accepted) { warnings.push(validation.message); continue; diff --git a/apps/mobile/src/features/terminal/terminalMenu.test.ts b/apps/mobile/src/features/terminal/terminalMenu.test.ts index 966312270..3f9a39237 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.test.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; -import { type KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; +import { + EMPTY_TERMINAL_SESSION_STATE, + type KnownTerminalSession, +} from "@t3tools/client-runtime/state/terminal"; import { DEFAULT_TERMINAL_ID, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; @@ -40,6 +43,7 @@ function makeKnownSession(input: { terminalId: input.terminalId, }, state: { + ...EMPTY_TERMINAL_SESSION_STATE, summary: input.cwd ? { threadId: "thread-1", @@ -55,7 +59,6 @@ function makeKnownSession(input: { updatedAt: input.updatedAt ?? "2026-04-15T20:00:00.000Z", } : null, - buffer: "", status: input.status, error: null, hasRunningSubprocess: false, diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index cb4382c46..945e3a879 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1,3 +1,4 @@ +import { renderAssistantCitationsAsText } from "@t3tools/shared/assistantCitations"; import * as Haptics from "expo-haptics"; import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; import { type LegendListRef } from "@legendapp/list/react-native"; @@ -1142,6 +1143,7 @@ function renderFeedEntry( if (entry.type === "message") { const { message } = entry; + const renderedText = renderAssistantCitationsAsText(message.text); const isUser = message.role === "user"; const styles = isUser ? markdownStyles.user : markdownStyles.assistant; const timestampLabel = formatMessageTime(isUser ? message.createdAt : message.updatedAt); @@ -1184,7 +1186,7 @@ function renderFeedEntry( > {message.text.trim().length > 0 ? ( 0 ? ( {message.text.trim().length > 0 ? ( 0 ? ( 0 ? ( { it("splits kinds the way the attachment contract does", () => { @@ -55,3 +59,20 @@ describe("documentAttachmentKind agreement with web", () => { expect(documentAttachmentKind("", "itinerary.pdf")).toBe("pdf"); }); }); + +it("keeps inline Expo files within the transport limit after the shared file cap increases", () => { + expect( + validateInlineComposerAttachment({ + name: "large.pdf", + mimeType: "application/pdf", + sizeBytes: 25 * 1024 * 1024, + }), + ).toMatchObject({ accepted: false, message: expect.stringContaining("20 MB") }); + expect( + validateInlineComposerAttachment({ + name: "small.pdf", + mimeType: "application/pdf", + sizeBytes: 20 * 1024 * 1024, + }), + ).toMatchObject({ accepted: true, type: "pdf" }); +}); diff --git a/apps/mobile/src/lib/composerAttachmentKinds.ts b/apps/mobile/src/lib/composerAttachmentKinds.ts index 44325a87c..371f59469 100644 --- a/apps/mobile/src/lib/composerAttachmentKinds.ts +++ b/apps/mobile/src/lib/composerAttachmentKinds.ts @@ -1,5 +1,8 @@ import type { UploadChatAttachment, UploadChatImageAttachment } from "@t3tools/contracts"; -import { classifyComposerAttachment } from "@t3tools/shared/composerAttachments"; +import { + classifyComposerAttachment, + validateComposerAttachment, +} from "@t3tools/shared/composerAttachments"; /** * Pure attachment-kind helpers, kept free of native imports so they stay @@ -63,3 +66,17 @@ export function toUploadChatAttachments( /** @deprecated Use toUploadChatAttachments — kept for the document-only path. */ export const toUploadChatDocumentAttachments = toUploadChatAttachments; + +/** The frozen Expo composer still sends inline base64, capped below 28M characters. */ +export function validateInlineComposerAttachment( + input: Parameters[0], +): ReturnType { + const result = validateComposerAttachment(input); + if (result.accepted && result.type !== "image" && input.sizeBytes > 20 * 1024 * 1024) { + return { + accepted: false, + message: `'${result.name}' exceeds this client's 20 MB attachment limit.`, + }; + } + return result; +} diff --git a/apps/mobile/src/lib/composerDocuments.ts b/apps/mobile/src/lib/composerDocuments.ts index 7383dc38d..8f02954f3 100644 --- a/apps/mobile/src/lib/composerDocuments.ts +++ b/apps/mobile/src/lib/composerDocuments.ts @@ -1,12 +1,10 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "@t3tools/contracts"; -import { - inferExtensionFromMimeType, - validateComposerAttachment, -} from "@t3tools/shared/composerAttachments"; +import { inferExtensionFromMimeType } from "@t3tools/shared/composerAttachments"; import { estimateBase64ByteSize } from "./base64"; import { documentAttachmentKind, + validateInlineComposerAttachment, type DraftComposerDocumentAttachment, } from "./composerAttachmentKinds"; import { uuidv4 } from "./uuid"; @@ -79,7 +77,7 @@ export async function pickComposerDocuments(input: { readonly existingCount: num } const sizeBytes = asset.size ?? estimateBase64ByteSize(base64); - const validation = validateComposerAttachment({ name, sizeBytes, mimeType }); + const validation = validateInlineComposerAttachment({ name, sizeBytes, mimeType }); if (!validation.accepted) { error = validation.message; continue; diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 92ac0df3d..992e6b316 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -304,7 +304,11 @@ function itemIcon(item: OrchestrationV2TurnItem): ThreadFeedActivity["icon"] { return "message"; case "dynamic_tool": // Read-style tool calls (a file/notebook path argument) present as reads. - return dynamicToolInputPreview(item.input)?.kind === "path" ? "eye" : "wrench"; + return item.toolSurface === "browser" + ? "globe" + : dynamicToolInputPreview(item.input)?.kind === "path" + ? "eye" + : "wrench"; case "subagent": return "hammer"; case "run_interrupt_request": @@ -329,7 +333,10 @@ function itemToolPresentation(item: OrchestrationV2TurnItem): T3McpToolPresentat if (item.type !== "dynamic_tool") { return null; } - return resolveT3McpToolPresentation(item.toolName) ?? resolveT3McpToolPresentation(item.title); + return ( + resolveT3McpToolPresentation(item.toolName, item.status, item.input) ?? + resolveT3McpToolPresentation(item.title, item.status, item.input) + ); } function itemSummary( diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index ed62fc176..98375cbe7 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -1,3 +1,4 @@ +import { environmentServerConfigsAtom } from "./server"; import { useAtomValue } from "@effect/atom-react"; import { createEnvironmentThreadDetailAtoms, @@ -29,6 +30,13 @@ export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( export const environmentThreadShells = createEnvironmentThreadShellAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, snapshotAtom: environmentSnapshotAtom, + autoSettlementAtom: Atom.family((environmentId: EnvironmentId) => + Atom.make( + (get) => + get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadAutoSettlement === true, + ), + ), }); const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe( diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts index f320e9da7..020e0d702 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -325,6 +325,7 @@ export function useSelectedThreadGitActions() { "Running source control action", async ({ thread, cwd }) => { const result = await runStackedAction({ + threadId: thread.id, actionId, action: input.action, ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), diff --git a/apps/mobile/src/state/use-terminal-session.ts b/apps/mobile/src/state/use-terminal-session.ts index 328557a20..1bc269ee4 100644 --- a/apps/mobile/src/state/use-terminal-session.ts +++ b/apps/mobile/src/state/use-terminal-session.ts @@ -1,5 +1,6 @@ import { combineTerminalSessionState, + terminalOutputText, EMPTY_TERMINAL_BUFFER_STATE, EMPTY_TERMINAL_SESSION_STATE, type KnownTerminalSession, @@ -14,7 +15,7 @@ import { terminalEnvironment } from "./terminal"; export function useAttachedTerminalSession(input: { readonly environmentId: EnvironmentId | null; readonly terminal: TerminalAttachInput | null; -}): TerminalSessionState { +}): TerminalSessionState & { readonly buffer: string } { const attach = useEnvironmentQuery( input.environmentId !== null && input.terminal !== null ? terminalEnvironment.attach({ @@ -34,7 +35,7 @@ export function useAttachedTerminalSession(input: { return useMemo(() => { if (input.environmentId === null || input.terminal === null) { - return EMPTY_TERMINAL_SESSION_STATE; + return { ...EMPTY_TERMINAL_SESSION_STATE, buffer: "" }; } const summary = metadata.data?.find( @@ -42,7 +43,11 @@ export function useAttachedTerminalSession(input: { terminal.threadId === input.terminal?.threadId && terminal.terminalId === input.terminal?.terminalId, ) ?? null; - const state = combineTerminalSessionState(summary, attach.data ?? EMPTY_TERMINAL_BUFFER_STATE); + const session = combineTerminalSessionState( + summary, + attach.data ?? EMPTY_TERMINAL_BUFFER_STATE, + ); + const state = { ...session, buffer: terminalOutputText(session.output) }; return attach.error === null ? state : { ...state, error: attach.error, status: "error" }; }, [attach.data, attach.error, input.environmentId, input.terminal, metadata.data]); } diff --git a/apps/server/package.json b/apps/server/package.json index c04be029f..8ebdeaeb0 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -39,6 +39,8 @@ "effect": "catalog:", "msgpackr-extract": "3.0.4", "node-pty": "^1.1.0", + "stream-chain": "4.2.5", + "stream-json": "3.6.0", "yaml": "catalog:" }, "devDependencies": { diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 7e5c0c80b..a0fbe519e 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1,3 +1,12 @@ +import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; +// @effect-diagnostics nodeBuiltinImport:off - tests inject swaps at the native open boundary. +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; +import * as NodeFSP from "node:fs/promises"; +import { HttpServerResponse } from "effect/unstable/http"; +import { vi } from "vite-plus/test"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import { assetFileResponse } from "../http.ts"; +import { openMediaFile } from "./MediaFile.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { AssetPreviewTypeValidationError, ThreadId } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; @@ -17,10 +26,19 @@ import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, open: vi.fn(actual.open), realpath: vi.fn(actual.realpath) }; +}); + const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-asset-access-test-", }); const testLayer = Layer.mergeAll( + Layer.succeed(NativeAppIconResolver.NativeAppIconResolver, { + resolve: () => Effect.succeed(null), + }), + NodeHttpPlatform.layer, configLayer, WorkspacePaths.layer, ProjectFaviconResolver.layer.pipe( @@ -31,6 +49,479 @@ const testLayer = Layer.mergeAll( ).pipe(Layer.provideMerge(NodeServices.layer)); describe("AssetAccess", () => { + it.effect("signs native app references and resolves only the icon route", () => + Effect.gen(function* () { + const app = { _tag: "display-name", displayName: "Google Chrome" } as const; + const issued = yield* issueAssetUrl({ resource: { _tag: "native-app-icon", app } }); + const suffix = issued.relativeUrl.slice(ASSET_ROUTE_PREFIX.length + 1); + const separator = suffix.indexOf("/"); + const token = suffix.slice(0, separator); + const resolve = (tokenValue: string, name: string) => + resolveAsset(tokenValue, name).pipe( + Effect.provideService(NativeAppIconResolver.NativeAppIconResolver, { + resolve: (requested) => + Effect.sync(() => { + expect(requested).toEqual(app); + return "/cache/opaque-icon.png"; + }), + }), + ); + expect(yield* resolve(token, "native-app-icon.png")).toEqual({ + kind: "file", + path: "/cache/opaque-icon.png", + }); + expect(yield* resolve(token, "different.png")).toBeNull(); + expect(yield* resolve(`${token}tampered`, "native-app-icon.png")).toBeNull(); + expect(yield* resolveAsset(token, "native-app-icon.png")).toBeNull(); + yield* TestClock.adjust("3 hours"); + expect(yield* resolve(token, "native-app-icon.png")).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect( + "returns bounded header dimensions for workspace and host images, with a malformed-header fallback", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-image-dimensions-" }); + const image = path.join(root, "preview.png"); + const header = new Uint8Array(24); + header.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0); + header.set([0x49, 0x48, 0x44, 0x52], 12); + const view = new DataView(header.buffer); + view.setUint32(16, 1600); + view.setUint32(20, 900); + yield* fs.writeFile(image, header); + for (const tag of ["workspace-file", "media-file"] as const) { + const input = { + resource: { _tag: tag, threadId: ThreadId.make("thread-1"), path: image }, + workspaceRoot: root, + }; + expect((yield* issueAssetUrl(input)).imageDimensions).toEqual({ + width: 1600, + height: 900, + }); + yield* fs.writeFileString(image, "not an image"); + const malformed = yield* issueAssetUrl(input); + expect(malformed.imageDimensions).toBeUndefined(); + expect(malformed.relativeUrl).toContain(ASSET_ROUTE_PREFIX); + yield* fs.writeFile(image, header); + } + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("serves document attachments inline when a viewer requests it", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001-pdf"; + const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.pdf`); + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3])); + + const result = yield* issueAssetUrl({ + resource: { + _tag: "attachment", + attachmentId, + fileName: "report.pdf", + mimeType: "application/pdf", + disposition: "inline", + }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + + expect( + yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)), + ).toEqual({ + kind: "file", + path: attachmentPath, + fileName: "report.pdf", + mimeType: "application/pdf", + }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("keeps inline requests for other attachment types as downloads", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const attachmentId = "thread-1-00000000-0000-4000-8000-000000000002-zip"; + const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.zip`); + yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true }); + yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3])); + + const result = yield* issueAssetUrl({ + resource: { + _tag: "attachment", + attachmentId, + fileName: "archive.zip", + mimeType: "text/html", + disposition: "inline", + }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + + expect( + yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)), + ).toMatchObject({ kind: "file", path: attachmentPath, download: true }); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("issues exact URLs for media and browser documents outside the workspace", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-root-" }); + const outside = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-outside-" }); + for (const [name, mimeType] of [ + ["screenshot.png", "image/png"], + ["recording.mp4", "video/mp4"], + ["recording.webm", "video/webm"], + ["report.html", "text/html"], + ["report.pdf", "application/pdf"], + ] as const) { + const filePath = path.join(outside, name); + yield* fs.writeFileString(filePath, "media"); + const canonicalFile = yield* fs.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const token = suffix.slice(0, separator); + expect(yield* resolveAsset(token, suffix.slice(separator + 1))).toMatchObject({ + kind: "file", + path: canonicalFile, + mimeType, + }); + yield* fs.writeFileString(path.join(outside, "sibling.png"), "private sibling"); + expect(yield* resolveAsset(token, "sibling.png")).toBeNull(); + expect(yield* resolveAsset(token, `../${name}`)).toBeNull(); + expect(yield* resolveAsset(`${token}tampered`, name)).toBeNull(); + } + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("resolves relative media paths from the thread workspace, including outside it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-relative-" }); + const root = path.join(directory, "workspace"); + yield* fs.makeDirectory(root); + for (const relativePath of ["screenshot.png", "../recording.mp4"]) { + const filePath = path.resolve(root, relativePath); + yield* fs.writeFileString(filePath, "media"); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: relativePath }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + expect( + yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)), + ).toMatchObject({ + kind: "file", + path: yield* fs.realPath(filePath), + }); + } + }).pipe(Effect.provide(testLayer)), + ); + + it.effect.skipIf(!symlinksSupported)( + "rejects non-previewable files, disguised targets, and directories", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-validation-" }); + for (const name of ["report.md", "secret.txt", "secret.%70ng", "secret.png#private.txt"]) { + const filePath = path.join(root, name); + yield* fs.writeFileString(filePath, "not media"); + const error = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + }).pipe(Effect.flip); + expect(error).toBeInstanceOf(AssetPreviewTypeValidationError); + } + const disguisedPath = path.join(root, "disguised.png"); + yield* fs.symlink(path.join(root, "secret.txt"), disguisedPath); + const disguisedError = yield* issueAssetUrl({ + resource: { + _tag: "media-file", + threadId: ThreadId.make("thread-1"), + path: disguisedPath, + }, + }).pipe(Effect.flip); + expect(disguisedError).toBeInstanceOf(AssetPreviewTypeValidationError); + const directoryPath = path.join(root, "directory.png"); + yield* fs.makeDirectory(directoryPath); + const directoryError = yield* issueAssetUrl({ + resource: { + _tag: "media-file", + threadId: ThreadId.make("thread-1"), + path: directoryPath, + }, + }).pipe(Effect.flip); + expect(directoryError._tag).toBe("AssetWorkspaceAssetNotFoundError"); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect.skipIf(!symlinksSupported)( + "binds media URLs to the canonical target and rejects symlink substitution", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-symlink-" }); + const filePath = path.join(root, "actual.svg"); + const aliasPath = path.join(root, "alias.png"); + const replacementPath = path.join(root, "other.svg"); + yield* fs.writeFileString(filePath, ""); + yield* fs.writeFileString(replacementPath, "private"); + yield* fs.symlink(filePath, aliasPath); + const canonicalFile = yield* fs.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: aliasPath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const token = suffix.slice(0, separator); + const name = suffix.slice(separator + 1); + const expected = { kind: "file", path: canonicalFile, mimeType: "image/svg+xml" }; + expect(yield* resolveAsset(token, name)).toMatchObject(expected); + yield* fs.remove(aliasPath); + yield* fs.symlink(replacementPath, aliasPath); + expect(yield* resolveAsset(token, name)).toMatchObject(expected); + yield* fs.remove(filePath); + yield* fs.symlink(replacementPath, filePath); + expect(yield* resolveAsset(token, name)).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect.skipIf(!symlinksSupported)( + "keeps full and partial responses bound to the file opened during resolution", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-file-" }); + const filePath = path.join(root, "recording.mp4"); + const savedPath = path.join(root, "saved.mp4"); + const secretPath = path.join(root, "secret.txt"); + yield* fs.writeFileString(filePath, "0123456789"); + yield* fs.writeFileString(secretPath, "private information"); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + for (const [range, expected, status] of [ + [undefined, "0123456789", 200], + ["bytes=2-5", "2345", 206], + ] as const) { + const asset = yield* resolveAsset( + suffix.slice(0, separator), + suffix.slice(separator + 1), + ); + if (!asset || asset.kind !== "file") throw new Error("Expected a resolved media file"); + + yield* fs.rename(filePath, savedPath); + yield* fs.symlink(secretPath, filePath); + const response = HttpServerResponse.toWeb(yield* assetFileResponse(asset, range)); + expect(response.status).toBe(status); + expect(response.headers.get("content-length")).toBe(String(expected.length)); + expect(yield* Effect.promise(() => response.text())).toBe(expected); + yield* fs.remove(filePath); + yield* fs.rename(savedPath, filePath); + } + }).pipe(Effect.provide(testLayer)), + ); + + it.effect.skipIf(!symlinksSupported)( + "rejects a symlink swapped in after canonical validation but before open", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-race-" }); + const filePath = path.join(root, "recording.mp4"); + const secretPath = path.join(root, "secret.txt"); + yield* fs.writeFileString(filePath, "video"); + yield* fs.writeFileString(secretPath, "secret"); + const canonicalPath = yield* fs.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const swappingFileSystem = FileSystem.FileSystem.of({ + ...fs, + stat: Effect.fn(function* (requestedPath) { + const info = yield* fs.stat(requestedPath); + if (requestedPath === canonicalPath) { + yield* fs.remove(filePath); + yield* fs.symlink(secretPath, filePath); + } + return info; + }), + }); + expect( + yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)).pipe( + Effect.provideService(FileSystem.FileSystem, swappingFileSystem), + ), + ).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect.skipIf(!symlinksSupported)( + "closes a descriptor rejected when its path changes during open", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-open-rejected-" }); + const filePath = path.join(root, "recording.mp4"); + const secretPath = path.join(root, "secret.txt"); + yield* fs.writeFileString(filePath, "video"); + yield* fs.writeFileString(secretPath, "secret"); + const canonicalPath = yield* fs.realPath(filePath); + const originalOpen = (yield* Effect.promise(() => + vi.importActual("node:fs/promises"), + )).open; + let opened: NodeFSP.FileHandle | undefined; + const openSpy = vi.mocked(NodeFSP.open).mockImplementation(async (target, flags, mode) => { + const handle = await originalOpen(target, flags, mode); + if (target === canonicalPath) { + opened = handle; + await NodeFSP.unlink(filePath); + await NodeFSP.symlink(secretPath, filePath); + } + return handle; + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => openSpy.mockImplementation(originalOpen)), + ); + expect(yield* openMediaFile(canonicalPath)).toBeNull(); + expect(opened).toBeDefined(); + expect(opened?.fd).toBe(-1); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect.skipIf(!symlinksSupported)( + "rejects an ancestor symlink race even when canonical path rechecks would pass", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-parent-race-" }); + const publicDirectory = path.join(root, "public"); + const privateDirectory = path.join(root, "private"); + yield* fs.makeDirectory(publicDirectory); + yield* fs.makeDirectory(privateDirectory); + const filePath = path.join(publicDirectory, "recording.mp4"); + yield* fs.writeFileString(filePath, "public video"); + yield* fs.writeFileString(path.join(privateDirectory, "recording.mp4"), "private video"); + const canonicalPath = yield* fs.realPath(filePath); + const result = yield* issueAssetUrl({ + resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: filePath }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const native = yield* Effect.promise(() => + vi.importActual("node:fs/promises"), + ); + const savedDirectory = path.join(root, "saved"); + const realpathSpy = vi.mocked(NodeFSP.realpath).mockImplementationOnce(async () => { + // A pathname-only guard can see the original parents during realpath, + // but the private file during both lstat calls and open. + await native.unlink(publicDirectory); + await native.rename(savedDirectory, publicDirectory); + const canonical = await native.realpath(canonicalPath); + await native.rename(publicDirectory, savedDirectory); + await native.symlink(privateDirectory, publicDirectory, "junction"); + return canonical; + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => realpathSpy.mockReset().mockImplementation(native.realpath)), + ); + const swappingFileSystem = FileSystem.FileSystem.of({ + ...fs, + realPath: Effect.fn(function* (requestedPath) { + const canonical = yield* fs.realPath(requestedPath); + if (requestedPath === canonicalPath) { + yield* fs.rename(publicDirectory, savedDirectory); + yield* Effect.promise(() => + NodeFSP.symlink(privateDirectory, publicDirectory, "junction"), + ); + } + return canonical; + }), + }); + expect( + yield* resolveAsset(suffix.slice(0, separator), suffix.slice(separator + 1)).pipe( + Effect.provideService(FileSystem.FileSystem, swappingFileSystem), + ), + ).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("keeps in-place edits readable but requires a new URL after atomic replacement", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-replacement-" }); + const filePath = path.join(root, "recording.mp4"); + yield* fs.writeFileString(filePath, "original"); + const input = { + resource: { + _tag: "media-file" as const, + threadId: ThreadId.make("thread-1"), + path: filePath, + }, + }; + const original = yield* issueAssetUrl(input); + const suffix = original.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const token = suffix.slice(0, separator); + const name = suffix.slice(separator + 1); + yield* fs.writeFileString(filePath, "in-place edit"); + const edited = yield* resolveAsset(token, name); + if (!edited || edited.kind !== "file") throw new Error("Expected the edited media file"); + const editedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(edited)); + expect(yield* Effect.promise(() => editedResponse.text())).toBe("in-place edit"); + + const replacement = path.join(root, "replacement.mp4"); + yield* fs.writeFileString(replacement, "replacement"); + yield* fs.rename(replacement, filePath); + expect(yield* resolveAsset(token, name)).toBeNull(); + + const renewed = yield* issueAssetUrl(input); + const renewedSuffix = renewed.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const renewedSeparator = renewedSuffix.indexOf("/"); + const renewedAsset = yield* resolveAsset( + renewedSuffix.slice(0, renewedSeparator), + renewedSuffix.slice(renewedSeparator + 1), + ); + if (!renewedAsset || renewedAsset.kind !== "file") + throw new Error("Expected the replacement media file"); + const renewedResponse = HttpServerResponse.toWeb(yield* assetFileResponse(renewedAsset)); + expect(yield* Effect.promise(() => renewedResponse.text())).toBe("replacement"); + yield* fs.remove(filePath); + expect( + yield* resolveAsset( + renewedSuffix.slice(0, renewedSeparator), + renewedSuffix.slice(renewedSeparator + 1), + ), + ).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("issues workspace URLs that resolve the entry file and sibling assets", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 24dbd7f79..6dbee3bde 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -1,3 +1,10 @@ +import { ToolActivityNativeAppReference } from "@t3tools/contracts"; +import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; +import { + IMAGE_DIMENSIONS_HEADER_BYTES, + readImageDimensions, + type ImageDimensions, +} from "@t3tools/shared/imageDimensions"; import Mime from "@effect/platform-node/Mime"; import type { AssetResource } from "@t3tools/contracts"; import { @@ -16,6 +23,7 @@ import { AssetWorkspaceRootNormalizationError, } from "@t3tools/contracts"; import { + hostPreviewMimeTypeFromExtension, isWorkspaceImagePreviewPath, isWorkspacePreviewEntryPath, isWorkspaceVideoPreviewPath, @@ -49,6 +57,25 @@ import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts"; + +const HEADER_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]); + +const imageDimensionsFromOpenFile = (filePath: string, file: OpenMediaFile) => + readMediaFileHeader(filePath, file, IMAGE_DIMENSIONS_HEADER_BYTES).pipe( + Effect.map(readImageDimensions), + Effect.orElseSucceed(() => null), + ); + +const imageDimensionsFromHeader = (filePath: string) => + openMediaFile(filePath).pipe( + Effect.flatMap((file) => + file === null ? Effect.succeed(null) : imageDimensionsFromOpenFile(filePath, file), + ), + Effect.scoped, + Effect.orElseSucceed(() => null), + ); + export const ASSET_ROUTE_PREFIX = "/api/assets"; const SIGNING_SECRET_NAME = "asset-access-signing-key"; @@ -56,6 +83,14 @@ const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000; const PROJECT_FAVICON_VERSION_PREFIX = "v"; const INLINE_VIDEO_MIME_TYPE_PATTERN = /^video\/[\w!#$&^.+-]+$/i; +// Extensions a document viewer may request inline. The extension comes from +// the attachment id the server assigned, never from the client's mime type. +const INLINE_DOCUMENT_EXTENSIONS = new Set(["pdf", "html", "htm"]); +const INLINE_DOCUMENT_MIME_TYPES: Record = { + pdf: "application/pdf", + html: "text/html", + htm: "text/html", +}; const PREVIEW_ASSET_EXTENSIONS = new Set([ ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, @@ -70,6 +105,12 @@ const PREVIEW_ASSET_EXTENSIONS = new Set([ ]); const AssetClaimsSchema = Schema.Union([ + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("native-app-icon"), + app: ToolActivityNativeAppReference, + expiresAt: Schema.Number, + }), Schema.Struct({ version: Schema.Literal(1), kind: Schema.Literal("workspace-file"), @@ -84,6 +125,14 @@ const AssetClaimsSchema = Schema.Union([ relativePath: Schema.String, expiresAt: Schema.Number, }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("media-file-exact"), + filePath: Schema.String, + device: Schema.String, + inode: Schema.String, + expiresAt: Schema.Number, + }), Schema.Struct({ version: Schema.Literal(1), kind: Schema.Literal("attachment"), @@ -136,6 +185,7 @@ export type ResolvedAsset = | { readonly kind: "file"; readonly path: string; + readonly file?: OpenMediaFile; readonly download?: boolean; readonly fileName?: string; readonly mimeType?: string; @@ -295,8 +345,71 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i let claims: AssetClaims; let fileName: string; let sourcePath: string | undefined; + let imageDimensions: ImageDimensions | null = null; switch (input.resource._tag) { + case "media-file": { + let requestedPath = input.resource.path; + if (!path.isAbsolute(requestedPath)) { + if (!input.workspaceRoot) { + return yield* new AssetWorkspaceContextNotFoundError({ resource: input.resource }); + } + const workspaceRoot = yield* workspacePaths + .normalizeWorkspaceRoot(input.workspaceRoot) + .pipe( + Effect.mapError( + (cause) => + new AssetWorkspaceRootNormalizationError({ resource: input.resource, cause }), + ), + ); + requestedPath = path.resolve(workspaceRoot, requestedPath); + } + const canonicalFile = yield* resolveCanonicalFile(requestedPath).pipe( + Effect.mapError( + (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }), + ), + ); + if (!canonicalFile) { + return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource }); + } + if (hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null) { + return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); + } + const opened = yield* openMediaFile(canonicalFile).pipe( + Effect.flatMap((file) => + file === null + ? Effect.succeed(null) + : (HEADER_IMAGE_EXTENSIONS.has(path.extname(canonicalFile).toLowerCase()) + ? imageDimensionsFromOpenFile(canonicalFile, file) + : Effect.succeed(null) + ).pipe( + Effect.map((dimensions) => ({ + identity: { device: file.info.dev.toString(), inode: file.info.ino.toString() }, + dimensions, + })), + ), + ), + Effect.scoped, + Effect.mapError( + (cause) => new AssetWorkspaceAssetInspectionError({ resource: input.resource, cause }), + ), + ); + if (!opened) { + return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource }); + } + const identity = opened.identity; + imageDimensions = opened.dimensions; + sourcePath = canonicalFile; + claims = { + version: 1, + kind: "media-file-exact", + filePath: canonicalFile, + ...identity, + expiresAt, + }; + fileName = path.basename(canonicalFile); + break; + } case "workspace-file": { if (!input.workspaceRoot) { return yield* new AssetWorkspaceContextNotFoundError({ @@ -374,6 +487,9 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i baseRelativePath: path.dirname(resolved.relativePath), expiresAt, }; + if (HEADER_IMAGE_EXTENSIONS.has(path.extname(canonicalFile).toLowerCase())) { + imageDimensions = yield* imageDimensionsFromHeader(canonicalFile); + } fileName = path.basename(resolved.relativePath); break; } @@ -390,19 +506,32 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i } // Generic files carry their extension inside the attachment id (that // shape resolves the on-disk path); images do not. Videos and images - // render inline; other generic files download. - const isGenericFile = parseAttachmentFileExtension(input.resource.attachmentId) !== null; + // render inline. Other generic files download, unless a document viewer + // asked for inline and the stored extension is one a browser can show. + const extension = parseAttachmentFileExtension(input.resource.attachmentId); + const isGenericFile = extension !== null; + if (!isGenericFile) imageDimensions = yield* imageDimensionsFromHeader(attachmentPath); const videoMimeType = input.resource.mimeType?.split(";", 1)[0]?.trim() ?? ""; const isVideo = INLINE_VIDEO_MIME_TYPE_PATTERN.test(videoMimeType); + const inlineDocumentMimeType = + input.resource.disposition === "inline" && + extension !== null && + INLINE_DOCUMENT_EXTENSIONS.has(extension) + ? INLINE_DOCUMENT_MIME_TYPES[extension] + : undefined; claims = { version: 1, kind: "attachment", attachmentId: input.resource.attachmentId, - ...(isGenericFile && !isVideo ? { download: true } : {}), - ...(input.resource.fileName !== undefined ? { fileName: input.resource.fileName } : {}), - ...(input.resource.mimeType !== undefined - ? { mimeType: isVideo ? videoMimeType : input.resource.mimeType } + ...(isGenericFile && !isVideo && inlineDocumentMimeType === undefined + ? { download: true } : {}), + ...(input.resource.fileName !== undefined ? { fileName: input.resource.fileName } : {}), + ...(inlineDocumentMimeType !== undefined + ? { mimeType: inlineDocumentMimeType } + : input.resource.mimeType !== undefined + ? { mimeType: isVideo ? videoMimeType : input.resource.mimeType } + : {}), expiresAt, }; fileName = input.resource.fileName ?? path.basename(attachmentPath); @@ -430,6 +559,11 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i fileName = artifactFileName; break; } + case "native-app-icon": { + claims = { version: 1, kind: "native-app-icon", app: input.resource.app, expiresAt }; + fileName = "native-app-icon.png"; + break; + } case "project-favicon": { const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.resource.cwd).pipe( Effect.mapError( @@ -546,7 +680,11 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); - if (claims.kind === "project-favicon" || claims.kind === "project-favicon-external") { + if ( + claims.kind === "project-favicon" || + claims.kind === "project-favicon-external" || + claims.kind === "native-app-icon" + ) { const issuedAt = yield* Clock.currentTimeMillis; expiresAt = (Math.floor(issuedAt / PROJECT_FAVICON_TOKEN_BUCKET_MS) + 2) * @@ -559,6 +697,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i relativeUrl: `${ASSET_ROUTE_PREFIX}/${token}/${encodeURIComponent(fileName)}`, expiresAt, ...(sourcePath !== undefined ? { sourcePath } : {}), + ...(imageDimensions !== null ? { imageDimensions } : {}), }; }); @@ -580,6 +719,13 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( const claims = decodeClaims(encodedPayload); if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; + if (claims.kind === "native-app-icon") { + if (relativePath !== "native-app-icon.png") return null; + const resolver = yield* NativeAppIconResolver.NativeAppIconResolver; + const iconPath = yield* resolver.resolve(claims.app); + return iconPath ? ({ kind: "file", path: iconPath } satisfies ResolvedAsset) : null; + } + if (claims.kind === "attachment") { const config = yield* ServerConfig.ServerConfig; const attachmentPath = resolveAttachmentPathById({ @@ -646,6 +792,30 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( const decodedPath = decodeRelativePath(relativePath); if (decodedPath === null) return null; const path = yield* Path.Path; + if (claims.kind === "media-file-exact") { + if (decodedPath !== path.basename(claims.filePath)) return null; + const canonicalFile = yield* resolveCanonicalFile(claims.filePath).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to resolve canonical media path.", { + filePath: claims.filePath, + cause, + }), + ), + Effect.orElseSucceed(() => null), + ); + if (canonicalFile !== claims.filePath) return null; + const mimeType = hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)); + if (!mimeType) return null; + const file = yield* openMediaFile(canonicalFile, claims).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to open canonical media file.", { filePath: canonicalFile, cause }), + ), + Effect.orElseSucceed(() => null), + ); + return file + ? ({ kind: "file", path: canonicalFile, mimeType, file } satisfies ResolvedAsset) + : null; + } if (claims.kind === "workspace-file-exact") { if (decodedPath !== path.basename(claims.relativePath)) return null; const exactWorkspaceFile = yield* resolveCanonicalWorkspaceFileForRequest({ diff --git a/apps/server/src/assets/MediaFile.ts b/apps/server/src/assets/MediaFile.ts new file mode 100644 index 000000000..7fb0c1135 --- /dev/null +++ b/apps/server/src/assets/MediaFile.ts @@ -0,0 +1,145 @@ +// @effect-diagnostics nodeBuiltinImport:off - FileSystem does not expose no-follow +// or non-blocking open flags, and the response must keep the validated descriptor. +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; + +import * as NodeStream from "@effect/platform-node/NodeStream"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +class MediaFileOpenError extends Schema.TaggedErrorClass()( + "MediaFileOpenError", + { + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to open media file '${this.path}'.`; + } +} + +class MediaFileReadError extends Schema.TaggedErrorClass()( + "MediaFileReadError", + { + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read media file '${this.path}'.`; + } +} + +class MediaFileStatError extends Schema.TaggedErrorClass()( + "MediaFileStatError", + { + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to read metadata for media file '${this.path}'.`; + } +} + +/** Holds the file identity and descriptor for one HTTP request, never a copy of its bytes. */ +export interface OpenMediaFile { + readonly handle: NodeFSP.FileHandle; + readonly info: NodeFS.BigIntStats; +} + +const realpathLikeFileSystem = (filePath: string) => + new Promise((resolve, reject) => { + NodeFS.realpath(filePath, (error, resolved) => (error ? reject(error) : resolve(resolved))); + }); + +/** Opens a canonical media path once. Replacements cannot change the response's source. */ +export const openMediaFile = Effect.fn("openMediaFile")(function* ( + filePath: string, + identity?: { readonly device: string; readonly inode: string }, +) { + return yield* Effect.acquireRelease( + Effect.tryPromise({ + try: async () => { + const before = await NodeFSP.lstat(filePath, { bigint: true }); + if (!before.isFile() || before.ino === 0n) return null; + if ( + identity && + (before.dev.toString() !== identity.device || before.ino.toString() !== identity.inode) + ) { + return null; + } + + // Windows lacks these flags; the descriptor/path identity checks still apply. + const handle = await NodeFSP.open( + filePath, + NodeFS.constants.O_RDONLY | + (NodeFS.constants.O_NOFOLLOW ?? 0) | + (NodeFS.constants.O_NONBLOCK ?? 0), + ); + let accepted = false; + try { + const info = await handle.stat({ bigint: true }); + if (!info.isFile() || info.dev !== before.dev || info.ino !== before.ino) return null; + if ( + identity && + (info.dev.toString() !== identity.device || info.ino.toString() !== identity.inode) + ) { + return null; + } + // Callers canonicalise with Effect's FileSystem.realPath, which is + // Node's JS realpath. fs/promises.realpath is the native binding and + // on Windows also expands 8.3 short names, so a path that is already + // canonical by the caller's rules would still look swapped here. + if ((await realpathLikeFileSystem(filePath)) !== filePath) return null; + const after = await NodeFSP.lstat(filePath, { bigint: true }); + if (!after.isFile() || info.dev !== after.dev || info.ino !== after.ino) return null; + accepted = true; + return { handle, info } satisfies OpenMediaFile; + } finally { + if (!accepted) await handle.close(); + } + }, + catch: (cause) => new MediaFileOpenError({ path: filePath, cause }), + }), + (file) => (file ? Effect.promise(() => file.handle.close()) : Effect.void), + ); +}); + +/** Reads the leading bytes of an already-validated media file, never past the end. */ +export const readMediaFileHeader = (filePath: string, file: OpenMediaFile, byteCount: number) => + Effect.tryPromise({ + try: async () => { + const buffer = new Uint8Array(byteCount); + const { bytesRead } = await file.handle.read(buffer, 0, byteCount, 0); + return buffer.subarray(0, bytesRead); + }, + catch: (cause) => new MediaFileReadError({ path: filePath, cause }), + }); + +export const statMediaFile = Effect.fn("statMediaFile")(function* ( + filePath: string, + file: OpenMediaFile, +) { + return yield* Effect.tryPromise({ + try: () => file.handle.stat({ bigint: true }), + catch: (cause) => new MediaFileStatError({ path: filePath, cause }), + }); +}); + +export const streamMediaFile = (file: OpenMediaFile, offset: bigint, bytesToRead: bigint) => { + const start = Number(offset); + const end = Number(offset + bytesToRead - 1n); + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start) { + return null; + } + return NodeStream.fromReadable({ + evaluate: () => + file.handle.createReadStream({ + autoClose: false, + start, + end, + }), + }); +}; diff --git a/apps/server/src/assets/NativeAppIconResolver.test.ts b/apps/server/src/assets/NativeAppIconResolver.test.ts new file mode 100644 index 000000000..206f5e952 --- /dev/null +++ b/apps/server/src/assets/NativeAppIconResolver.test.ts @@ -0,0 +1,75 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import * as ServerConfig from "../config.ts"; +import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; + +function emptyProcessHandle() { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +describe("resolveNativeAppIcon", () => { + it.effect("escapes Spotlight wildcards and caches misses", () => { + const commands: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + const input = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + commands.push(input); + return emptyProcessHandle(); + }), + ); + const configLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { + prefix: "t3-native-app-icon-test-", + }); + const dependencies = Layer.mergeAll( + configLayer, + Layer.succeed(HostProcessPlatform, "darwin"), + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + ).pipe(Layer.provideMerge(NodeServices.layer)); + const testLayer = NativeAppIconResolver.layer.pipe(Layer.provide(dependencies)); + const app = { _tag: "display-name", displayName: "Review * App" } as const; + + return Effect.gen(function* () { + const resolver = yield* NativeAppIconResolver.NativeAppIconResolver; + expect(yield* resolver.resolve(app)).toBeNull(); + expect(yield* resolver.resolve(app)).toBeNull(); + + expect(commands).toHaveLength(1); + expect(commands[0]).toMatchObject({ command: "/usr/bin/mdfind" }); + expect(commands[0]?.args[0]).toContain("Review \\* App"); + + for (let index = 0; index < 256; index += 1) { + expect( + yield* resolver.resolve({ + _tag: "display-name", + displayName: `Missing Review App ${index}`, + }), + ).toBeNull(); + } + expect(commands).toHaveLength(257); + expect(yield* resolver.resolve(app)).toBeNull(); + expect(commands).toHaveLength(258); + }).pipe(Effect.provide(testLayer)); + }); +}); diff --git a/apps/server/src/assets/NativeAppIconResolver.ts b/apps/server/src/assets/NativeAppIconResolver.ts new file mode 100644 index 000000000..89a6d0636 --- /dev/null +++ b/apps/server/src/assets/NativeAppIconResolver.ts @@ -0,0 +1,269 @@ +import * as NodeCrypto from "node:crypto"; +import type { ToolActivityNativeAppReference } from "@t3tools/contracts"; +import * as Cache from "effect/Cache"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Semaphore from "effect/Semaphore"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import * as ServerConfig from "../config.ts"; + +const ICON_SIZE = 64; +const COMMAND_TIMEOUT = "5 seconds"; +const RESOLUTION_CACHE_TTL = Duration.hours(1); +const RESOLUTION_CACHE_MAX_ENTRIES = 256; + +/** Resolves and caches macOS application icons without exposing host paths to clients. */ +export class NativeAppIconResolver extends Context.Service< + NativeAppIconResolver, + { + /** Returns a cached PNG path for the application, or `null` when no icon is available. */ + readonly resolve: (app: ToolActivityNativeAppReference) => Effect.Effect; + } +>()("t3/assets/NativeAppIconResolver") {} + +function appCacheKey(app: ToolActivityNativeAppReference): string { + return JSON.stringify(app); +} + +function appFromCacheKey(key: string): ToolActivityNativeAppReference { + return JSON.parse(key) as ToolActivityNativeAppReference; +} + +const existingFile = Effect.fn("NativeAppIconResolver.existingFile")(function* (filePath: string) { + const fileSystem = yield* FileSystem.FileSystem; + const info = yield* fileSystem.stat(filePath).pipe( + Effect.map(Option.some), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(error), + }), + ); + return Option.isSome(info) && info.value.type === "File" ? filePath : null; +}); + +const commandOutput = Effect.fn("NativeAppIconResolver.commandOutput")(function* ( + command: string, + args: ReadonlyArray, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* spawner + .string(ChildProcess.make(command, args, { stdin: "ignore", stderr: "ignore" })) + .pipe(Effect.timeout(COMMAND_TIMEOUT)); +}); + +const plistValue = Effect.fn("NativeAppIconResolver.plistValue")(function* ( + infoPlistPath: string, + key: string, +) { + return yield* commandOutput("/usr/bin/plutil", [ + "-extract", + key, + "raw", + "-o", + "-", + infoPlistPath, + ]).pipe( + Effect.map((value) => value.trim()), + Effect.orElseSucceed(() => ""), + ); +}); + +function escapeSpotlightString(value: string): string { + return value.replace(/([\\'*?])/gu, "\\$1"); +} + +function containsControlCharacter(value: string): boolean { + return [...value].some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 31 || codePoint === 127; + }); +} + +const resolveApplicationPath = Effect.fn("NativeAppIconResolver.resolveApplicationPath")(function* ( + app: ToolActivityNativeAppReference, +) { + const path = yield* Path.Path; + const query = + app._tag === "app-id" + ? `kMDItemCFBundleIdentifier == '${app.appId}'` + : `kMDItemContentType == 'com.apple.application-bundle' && kMDItemDisplayName == '${escapeSpotlightString(app.displayName)}'`; + const spotlightOutput = yield* commandOutput("/usr/bin/mdfind", [query]); + const candidates = spotlightOutput + .split(/\r?\n/u) + .map((value) => value.trim()) + .filter((value) => value.endsWith(".app")); + const matchingCandidates = + app._tag === "app-id" + ? candidates + : candidates.filter( + (value) => + path.basename(value, ".app").toLocaleLowerCase() === + app.displayName.toLocaleLowerCase(), + ); + const rankedCandidates = matchingCandidates.length > 0 ? matchingCandidates : candidates; + let mostRecentlyUsed: { readonly path: string; readonly lastUsed: string } | null = null; + for (const candidate of rankedCandidates) { + const lastUsed = yield* commandOutput("/usr/bin/mdls", [ + "-raw", + "-name", + "kMDItemLastUsedDate", + candidate, + ]).pipe( + Effect.map((value) => value.trim()), + Effect.orElseSucceed(() => ""), + ); + if (!mostRecentlyUsed || lastUsed > mostRecentlyUsed.lastUsed) { + mostRecentlyUsed = { path: candidate, lastUsed }; + } + } + return mostRecentlyUsed?.path ?? null; +}); + +const resolveNativeAppIconUncached = Effect.fn("NativeAppIconResolver.resolveUncached")(function* ( + app: ToolActivityNativeAppReference, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const appPath = yield* resolveApplicationPath(app); + if (!appPath) return null; + + const canonicalAppPath = yield* fileSystem.realPath(appPath); + const infoPlistPath = path.join(canonicalAppPath, "Contents", "Info.plist"); + const resourcesDirectory = path.join(canonicalAppPath, "Contents", "Resources"); + const iconName = + (yield* plistValue(infoPlistPath, "CFBundleIconFile")) || + (yield* plistValue(infoPlistPath, "CFBundleIconName")); + if (iconName && path.basename(iconName) !== iconName) return null; + const iconFileName = iconName ? (path.extname(iconName) ? iconName : `${iconName}.icns`) : null; + const resourceEntries = yield* fileSystem + .readDirectory(resourcesDirectory) + .pipe(Effect.orElseSucceed(() => [])); + const sourceIconCandidate = + (iconFileName ? yield* existingFile(path.join(resourcesDirectory, iconFileName)) : null) ?? + (yield* existingFile(path.join(resourcesDirectory, "AppIcon.icns"))) ?? + (resourceEntries.find((entry) => entry.toLowerCase().endsWith(".icns")) + ? yield* existingFile( + path.join( + resourcesDirectory, + resourceEntries.find((entry) => entry.toLowerCase().endsWith(".icns"))!, + ), + ) + : null); + if (!sourceIconCandidate) return null; + const sourceIconPath = yield* fileSystem.realPath(sourceIconCandidate); + const relativeSource = path.relative(resourcesDirectory, sourceIconPath); + if ( + relativeSource === ".." || + relativeSource.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeSource) + ) { + return null; + } + + const appVersion = + (yield* plistValue(infoPlistPath, "CFBundleVersion")) || + (yield* plistValue(infoPlistPath, "CFBundleShortVersionString")); + const cacheKey = NodeCrypto.createHash("sha256") + .update(`${canonicalAppPath}\0${appVersion}\0${sourceIconPath}`) + .digest("hex"); + const cacheDirectory = path.join(config.providerStatusCacheDir, "native-app-icons"); + const cachePath = path.join(cacheDirectory, `${cacheKey}.png`); + if (yield* existingFile(cachePath)) return cachePath; + + yield* fileSystem.makeDirectory(cacheDirectory, { recursive: true }); + const temporaryPath = path.join( + cacheDirectory, + `.${cacheKey}-${process.pid}-${(yield* Clock.currentTimeMillis).toString(36)}-${NodeCrypto.randomUUID()}.png`, + ); + yield* commandOutput("/usr/bin/sips", [ + "-z", + String(ICON_SIZE), + String(ICON_SIZE), + "-s", + "format", + "png", + sourceIconPath, + "--out", + temporaryPath, + ]).pipe( + Effect.tap(() => fileSystem.rename(temporaryPath, cachePath)), + Effect.ensuring( + fileSystem.remove(temporaryPath).pipe(Effect.catchTags({ PlatformError: () => Effect.void })), + ), + ); + return yield* existingFile(cachePath); +}); + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const hostPlatform = yield* HostProcessPlatform; + const resolutionSemaphore = yield* Semaphore.make(2); + const resolutionCache: Cache.Cache< + string, + string | null, + PlatformError.PlatformError | Cause.TimeoutError + > = yield* Cache.makeWith( + (key: string) => + resolutionSemaphore.withPermits(1)(resolveNativeAppIconUncached(appFromCacheKey(key))), + { + capacity: RESOLUTION_CACHE_MAX_ENTRIES, + timeToLive: Exit.match({ + onSuccess: () => RESOLUTION_CACHE_TTL, + onFailure: () => Duration.zero, + }), + }, + ); + + const cachedFileExists = (filePath: string) => + fileSystem.stat(filePath).pipe( + Effect.map((info) => info.type === "File"), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed(false) : Effect.fail(error), + }), + ); + + const resolveAttempt = Effect.fn("NativeAppIconResolver.resolve")(function* ( + app: ToolActivityNativeAppReference, + ) { + if ( + hostPlatform !== "darwin" || + (app._tag === "display-name" && containsControlCharacter(app.displayName)) + ) { + return null; + } + + const key = appCacheKey(app); + const cached = yield* Cache.get(resolutionCache, key); + if (cached === null) return null; + if (yield* cachedFileExists(cached)) return cached; + + yield* Cache.invalidate(resolutionCache, key); + return yield* Cache.get(resolutionCache, key); + }); + + const resolve: NativeAppIconResolver["Service"]["resolve"] = (app) => + resolveAttempt(app).pipe( + Effect.tapError((cause) => + Effect.logDebug("Failed to resolve native application icon.", { app, cause }), + ), + Effect.orElseSucceed(() => null), + ); + + return NativeAppIconResolver.of({ resolve }); +}); + +export const layer = Layer.effect(NativeAppIconResolver, make); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 429d6be6e..de4af711c 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -47,10 +47,13 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, + [WS_METHODS.providerConsumeResetCredit]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetUsageSummary]: AuthOrchestrationReadScope, + [WS_METHODS.serverRefreshUsageRates]: AuthOrchestrationReadScope, [WS_METHODS.serverSignalProcess]: AuthOrchestrationOperateScope, [WS_METHODS.serverReportClientActivity]: AuthOrchestrationReadScope, [WS_METHODS.serverReportHostPowerState]: AuthOrchestrationOperateScope, @@ -77,6 +80,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsThreadComments]: AuthOrchestrationReadScope, @@ -96,6 +100,8 @@ export const RPC_REQUIRED_SCOPES = { // write like every other one. [WS_METHODS.pullRequestsReviewerCandidates]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsRequestReviewers]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsLabelCandidates]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsSetLabels]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, @@ -106,6 +112,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, + [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, + [WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope, [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, [WS_METHODS.assetsPersistChatAttachments]: AuthOrchestrationOperateScope, [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/claudeModelOptions.ts b/apps/server/src/claudeModelOptions.ts index b560a1f3b..845bc6ae7 100644 --- a/apps/server/src/claudeModelOptions.ts +++ b/apps/server/src/claudeModelOptions.ts @@ -7,13 +7,15 @@ import { } from "@t3tools/shared/model"; import { - getClaudeModelCapabilities, - isClaudeUltracodeEffort, - normalizeClaudeCliEffort, - resolveClaudeApiModelId, - resolveClaudeAutoCompactTokens, - resolveClaudeEffort, -} from "./provider/Layers/ClaudeProvider.ts"; + BUNDLED_CLAUDE_MODEL_CATALOG, + type ClaudeModelCatalog, + getClaudeCatalogModelCapabilities, + resolveClaudeCatalogEffort, + normalizeClaudeCatalogEffort, + isClaudeCatalogUltracodeEffort, + resolveClaudeCatalogApiModelId, + resolveClaudeCatalogAutoCompactTokens, +} from "./provider/ClaudeModelCatalog.ts"; export interface CompiledClaudeModelSelection { readonly apiModelId: string; @@ -31,14 +33,15 @@ export interface CompiledClaudeModelSelection { /** Compile every Claude model option at the provider boundary. */ export function compileClaudeModelSelection( selection: ModelSelection, + catalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, ): CompiledClaudeModelSelection { - const capabilities = getClaudeModelCapabilities(selection.model); + const capabilities = getClaudeCatalogModelCapabilities(catalog, selection.model); const descriptors = getProviderOptionDescriptors({ caps: capabilities }); const supportsBoolean = (id: string) => descriptors.some((descriptor) => descriptor.type === "boolean" && descriptor.id === id); const rawEffort = getModelSelectionStringOptionValue(selection, "effort"); - const resolvedEffort = resolveClaudeEffort(capabilities, rawEffort); - const effort = normalizeClaudeCliEffort(resolvedEffort, selection.model); + const resolvedEffort = resolveClaudeCatalogEffort(catalog, selection.model, rawEffort); + const effort = normalizeClaudeCatalogEffort(catalog, resolvedEffort, selection.model); const fastMode = supportsBoolean("fastMode") ? getModelSelectionBooleanOptionValue(selection, "fastMode") : undefined; @@ -48,11 +51,11 @@ export function compileClaudeModelSelection( const settings = { ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), ...(typeof fastMode === "boolean" ? { fastMode } : {}), - ...(isClaudeUltracodeEffort(resolvedEffort) ? { ultracode: true } : {}), + ...(isClaudeCatalogUltracodeEffort(resolvedEffort) ? { ultracode: true } : {}), }; - const apiModelId = resolveClaudeApiModelId(selection); + const apiModelId = resolveClaudeCatalogApiModelId(catalog, selection); const promptEffort = resolvePromptInjectedEffort(capabilities, rawEffort) ?? undefined; - const autoCompactWindow = resolveClaudeAutoCompactTokens(selection); + const autoCompactWindow = resolveClaudeCatalogAutoCompactTokens(catalog, selection); return { apiModelId, effort, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 903ae11d1..9dc1a8eb5 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -91,10 +91,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.repositoryIdentity).toBe(true); expect(second.capabilities.connectionProbe).toBe(true); expect(second.capabilities.attachmentUploads).toBe(true); - // 20MB, not upstream's 50MB: this fork's composer still sends - // file/pdf/video attachments through the inline base64 path, whose - // PROVIDER_SEND_TURN_MAX_DATA_URL_CHARS cap tops out around 21MB. - expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 20 * 1024 * 1024 }); + expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); expect(second.capabilities.pullRequests).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); expect(second.capabilities.threadPullRequestLinking).toBe(true); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 432d8df4e..a89ed32ff 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -1,3 +1,4 @@ +import { detectServerEnvironmentMachineKind } from "./ServerEnvironmentMachine.ts"; import { EnvironmentId, PROVIDER_SEND_TURN_MAX_FILE_BYTES, @@ -139,12 +140,14 @@ export const make = Effect.gen(function* () { launcherManaged: launcher.managed, }); + const machine = yield* detectServerEnvironmentMachineKind(); const descriptor: ExecutionEnvironmentDescriptor = { environmentId, label, platform: { os: platformOs(hostPlatform), arch: platformArch(hostArchitecture), + ...(machine === null ? {} : { machine }), }, serverVersion: packageJson.version, capabilities: { @@ -154,15 +157,31 @@ export const make = Effect.gen(function* () { fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, pullRequests: true, threadSettlement: true, + threadAutoSettlement: true, + threadRestartContinuation: true, threadSnooze: true, threadVisitedTracking: true, environmentThemes: true, + environmentIcon: true, + customModelDefinitions: true, + projectIcons: true, + agentSessionImport: true, + projectBrowserAccess: true, + projectActionDefaults: true, + projectDefaults: true, + projectAutoPull: true, + fileDocumentPreviews: true, + providerTerminalEnvironment: true, + assistantCitations: true, threadPinning: true, threadActiveOrderV2: true, threadQuestionActionsV2: true, threadPinReorder: true, threadTitleRegeneration: true, threadPullRequestLinking: true, + threadPullRequestsV2: true, + pullRequestStackActions: true, + usagePriceOverrides: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), }, diff --git a/apps/server/src/environment/ServerEnvironmentMachine.test.ts b/apps/server/src/environment/ServerEnvironmentMachine.test.ts new file mode 100644 index 000000000..2ae098922 --- /dev/null +++ b/apps/server/src/environment/ServerEnvironmentMachine.test.ts @@ -0,0 +1,245 @@ +import { afterEach, describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { vi } from "vite-plus/test"; + +import * as ProcessRunner from "../processRunner.ts"; +import { + detectServerEnvironmentMachineKind, + machineKindFromAppleProductName, + machineKindFromDmi, +} from "./ServerEnvironmentMachine.ts"; + +const runMock = vi.fn(); + +const ProcessRunnerTest = Layer.succeed( + ProcessRunner.ProcessRunner, + ProcessRunner.ProcessRunner.of({ run: (input) => runMock(input) }), +); + +const processOutput = (stdout: string, code = 0) => + Effect.succeed({ + stdout, + stderr: "", + code: ChildProcessSpawner.ExitCode(code), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }); + +const dmiFileSystem = (files: Readonly>) => + FileSystem.layerNoop({ + readFileString: (path) => { + const name = path.slice(path.lastIndexOf("/") + 1); + return name in files + ? Effect.succeed(files[name]!) + : Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: path, + cause: new Error("ENOENT"), + }), + ); + }, + }); + +const withPlatform = (platform: NodeJS.Platform, fileSystem = FileSystem.layerNoop({})) => + Layer.mergeAll(ProcessRunnerTest, fileSystem, Layer.succeed(HostProcessPlatform, platform)); + +afterEach(() => { + runMock.mockReset(); +}); + +describe("machineKindFromAppleProductName", () => { + it("maps marketing names and model identifiers", () => { + expect(machineKindFromAppleProductName("Mac mini (2024)")).toBe("mac-mini"); + expect(machineKindFromAppleProductName("Macmini8,1")).toBe("mac-mini"); + expect(machineKindFromAppleProductName("Mac Studio (2023)")).toBe("mac-studio"); + expect(machineKindFromAppleProductName("MacBook Pro (14-inch, 2024)")).toBe("laptop"); + expect(machineKindFromAppleProductName("MacBookAir10,1")).toBe("laptop"); + expect(machineKindFromAppleProductName("iMac (24-inch, 2024)")).toBe("desktop"); + expect(machineKindFromAppleProductName("Mac Pro (2023)")).toBe("desktop"); + }); + + it("returns null for Apple silicon model identifiers, which carry no product family", () => { + expect(machineKindFromAppleProductName("Mac16,10")).toBeNull(); + }); +}); + +describe("machineKindFromDmi", () => { + it("prefers virtualization markers over chassis type", () => { + expect( + machineKindFromDmi({ chassisType: "1", sysVendor: "QEMU", productName: "Standard PC" }), + ).toBe("cloud"); + expect( + machineKindFromDmi({ + chassisType: "3", + sysVendor: "Microsoft Corporation", + productName: "Virtual Machine", + }), + ).toBe("cloud"); + expect( + machineKindFromDmi({ chassisType: "1", sysVendor: "Amazon EC2", productName: "t3.large" }), + ).toBe("cloud"); + }); + + it("does not treat Microsoft hardware as a VM", () => { + expect( + machineKindFromDmi({ + chassisType: "9", + sysVendor: "Microsoft Corporation", + productName: "Surface Laptop 5", + }), + ).toBe("laptop"); + }); + + it("maps SMBIOS chassis codes", () => { + expect( + machineKindFromDmi({ chassisType: "3", sysVendor: "GMKtec", productName: "NucBox K8 Plus" }), + ).toBe("desktop"); + expect( + machineKindFromDmi({ chassisType: "10", sysVendor: "LENOVO", productName: "ThinkPad X1" }), + ).toBe("laptop"); + expect( + machineKindFromDmi({ chassisType: "23", sysVendor: "Supermicro", productName: "X11" }), + ).toBe("server"); + expect(machineKindFromDmi({ chassisType: "1", sysVendor: null, productName: null })).toBeNull(); + expect( + machineKindFromDmi({ chassisType: null, sysVendor: null, productName: null }), + ).toBeNull(); + }); + + it("recognizes Apple hardware running Linux", () => { + expect( + machineKindFromDmi({ chassisType: "3", sysVendor: "Apple", productName: "Mac Studio" }), + ).toBe("mac-studio"); + }); +}); + +describe("detectServerEnvironmentMachineKind", () => { + it.effect("reads the IOKit product name on macOS", () => + Effect.gen(function* () { + runMock.mockReturnValueOnce( + processOutput( + '+-o product \n {\n "product-name" = <"Mac mini (2024)">\n }\n', + ), + ); + + const result = yield* detectServerEnvironmentMachineKind().pipe( + Effect.provide(withPlatform("darwin")), + ); + + expect(result).toBe("mac-mini"); + expect(runMock).toHaveBeenCalledTimes(1); + expect(runMock).toHaveBeenCalledWith( + expect.objectContaining({ command: "ioreg", args: ["-rd1", "-n", "product"] }), + ); + }), + ); + + it.effect("falls back to hw.model when IOKit has no product node", () => + Effect.gen(function* () { + runMock.mockReturnValueOnce(processOutput("", 1)); + runMock.mockReturnValueOnce(processOutput("MacBookPro16,1\n")); + + const result = yield* detectServerEnvironmentMachineKind().pipe( + Effect.provide(withPlatform("darwin")), + ); + + expect(result).toBe("laptop"); + expect(runMock).toHaveBeenLastCalledWith( + expect.objectContaining({ command: "sysctl", args: ["-n", "hw.model"] }), + ); + }), + ); + + it.effect("returns null when both macOS probes fail", () => + Effect.gen(function* () { + runMock.mockImplementation((input) => + Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: input.command, + argumentCount: input.args.length, + cause: new Error("ENOENT"), + }), + ), + ); + + const result = yield* detectServerEnvironmentMachineKind().pipe( + Effect.provide(withPlatform("darwin")), + ); + + expect(result).toBeNull(); + expect(runMock).toHaveBeenCalledTimes(2); + }), + ); + + it.effect("reads DMI on Linux", () => + Effect.gen(function* () { + const result = yield* detectServerEnvironmentMachineKind().pipe( + Effect.provide( + withPlatform( + "linux", + dmiFileSystem({ + chassis_type: "3\n", + sys_vendor: "GMKtec\n", + product_name: "NucBox K8 Plus\n", + }), + ), + ), + ); + + expect(result).toBe("desktop"); + expect(runMock).not.toHaveBeenCalled(); + }), + ); + + it.effect("recognizes WSL before its Hyper-V DMI identity", () => + Effect.gen(function* () { + const result = yield* detectServerEnvironmentMachineKind().pipe( + Effect.provide( + withPlatform( + "linux", + dmiFileSystem({ + osrelease: "5.15.153.1-microsoft-standard-WSL2\n", + chassis_type: "3\n", + sys_vendor: "Microsoft Corporation\n", + product_name: "Virtual Machine\n", + }), + ), + ), + ); + + expect(result).toBe("linux"); + }), + ); + + it.effect("returns null on Linux without DMI (containers, ARM boards)", () => + Effect.gen(function* () { + const result = yield* detectServerEnvironmentMachineKind().pipe( + Effect.provide(withPlatform("linux", dmiFileSystem({}))), + ); + + expect(result).toBeNull(); + }), + ); + + it.effect("skips detection on other platforms", () => + Effect.gen(function* () { + const result = yield* detectServerEnvironmentMachineKind().pipe( + Effect.provide(withPlatform("win32")), + ); + + expect(result).toBeNull(); + expect(runMock).not.toHaveBeenCalled(); + }), + ); +}); diff --git a/apps/server/src/environment/ServerEnvironmentMachine.ts b/apps/server/src/environment/ServerEnvironmentMachine.ts new file mode 100644 index 000000000..9d11a1ef5 --- /dev/null +++ b/apps/server/src/environment/ServerEnvironmentMachine.ts @@ -0,0 +1,174 @@ +import type { EnvironmentMachineKind } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +import * as ProcessRunner from "../processRunner.ts"; + +/** + * Best-effort hardware detection for the environment icon. Every probe is + * allowed to fail: a null result means "no signal", and the client draws a + * generic server until the user picks something in Settings → Connections. + */ + +const DMI_ROOT = "/sys/class/dmi/id"; +const KERNEL_RELEASE_PATH = "/proc/sys/kernel/osrelease"; + +// SMBIOS 3.x System Enclosure types (table 17). Codes that describe a shape +// rather than a machine (docking stations, blades enclosures, IoT gateways) +// fall through to null on purpose. +const DMI_CHASSIS_KINDS: Readonly> = { + "3": "desktop", // Desktop + "4": "desktop", // Low Profile Desktop + "5": "desktop", // Pizza Box + "6": "desktop", // Mini Tower + "7": "desktop", // Tower + "8": "laptop", // Portable + "9": "laptop", // Laptop + "10": "laptop", // Notebook + "13": "desktop", // All in One + "14": "laptop", // Sub Notebook + "15": "desktop", // Space-saving + "16": "desktop", // Lunch Box + "17": "server", // Main Server Chassis + "18": "server", // Expansion Chassis + "19": "server", // SubChassis + "20": "server", // Bus Expansion Chassis + "21": "server", // Peripheral Chassis + "22": "server", // RAID Chassis + "23": "server", // Rack Mount Chassis + "24": "server", // Sealed-case PC + "28": "server", // Blade + "31": "laptop", // Convertible + "32": "laptop", // Detachable + "35": "desktop", // Mini PC +}; + +// Hypervisors and cloud providers write themselves into the DMI vendor or +// product strings; any hit means the box is a VM, and a VM reads as "cloud" +// regardless of the chassis type the hypervisor fakes. Hyper-V is matched on +// its "Virtual Machine" product, not the "Microsoft Corporation" vendor that +// physical Surface devices share. +const VIRTUALIZATION_MARKERS = [ + "qemu", + "kvm", + "bochs", + "vmware", + "virtualbox", + "innotek", + "xen", + "parallels", + "amazon ec2", + "google compute engine", + "digitalocean", + "hetzner", + "linode", + "vultr", + "scaleway", + "openstack", + "cloud", + "virtual machine", +]; + +function normalize(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : null; +} + +/** Marketing names and Intel-era model identifiers share these prefixes. */ +export function machineKindFromAppleProductName(name: string): EnvironmentMachineKind | null { + const normalized = name.trim().toLowerCase().replaceAll(/\s+/g, ""); + if (normalized.startsWith("macmini")) return "mac-mini"; + if (normalized.startsWith("macstudio")) return "mac-studio"; + if (normalized.startsWith("macbook")) return "laptop"; + if (normalized.startsWith("imac") || normalized.startsWith("macpro")) return "desktop"; + return null; +} + +export function machineKindFromDmi(input: { + readonly chassisType: string | null; + readonly sysVendor: string | null; + readonly productName: string | null; +}): EnvironmentMachineKind | null { + const productName = input.productName ?? ""; + const vendorAndProduct = `${input.sysVendor ?? ""} ${productName}`.toLowerCase(); + if (VIRTUALIZATION_MARKERS.some((marker) => vendorAndProduct.includes(marker))) { + return "cloud"; + } + // Apple hardware booting Linux (Asahi) still reports the Apple product name. + const appleKind = machineKindFromAppleProductName(productName); + if (appleKind !== null) { + return appleKind; + } + return input.chassisType === null ? null : (DMI_CHASSIS_KINDS[input.chassisType] ?? null); +} + +const readOptionalFile = Effect.fn("readOptionalFile")(function* (path: string) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.readFileString(path).pipe( + Effect.map(normalize), + Effect.catch(() => Effect.succeed(null)), + ); +}); + +const runProbe = Effect.fn("runMachineProbe")(function* (input: { + readonly command: string; + readonly args: ReadonlyArray; +}) { + const processRunner = yield* ProcessRunner.ProcessRunner; + return yield* processRunner + .run({ + command: input.command, + args: input.args, + timeout: "5 seconds", + timeoutBehavior: "timedOutResult", + }) + .pipe( + Effect.map((result) => (result.code === 0 ? normalize(result.stdout) : null)), + Effect.catch(() => Effect.succeed(null)), + ); +}); + +// IOKit's `product` node carries the marketing name ("Mac mini (2024)") on +// Apple silicon; Intel Macs lack it, so `hw.model` ("Macmini8,1") is the +// fallback. Both are single-digit-millisecond calls. +const detectDarwinMachineKind = Effect.fn("detectDarwinMachineKind")(function* () { + const ioreg = yield* runProbe({ command: "ioreg", args: ["-rd1", "-n", "product"] }); + const productName = ioreg?.match(/"product-name"\s*=\s*<"([^"]+)">/)?.[1] ?? null; + const fromProductName = + productName === null ? null : machineKindFromAppleProductName(productName); + if (fromProductName !== null) { + return fromProductName; + } + const model = yield* runProbe({ command: "sysctl", args: ["-n", "hw.model"] }); + return model === null ? null : machineKindFromAppleProductName(model); +}); + +const detectLinuxMachineKind = Effect.fn("detectLinuxMachineKind")(function* () { + const [kernelRelease, chassisType, sysVendor, productName] = yield* Effect.all([ + readOptionalFile(KERNEL_RELEASE_PATH), + readOptionalFile(`${DMI_ROOT}/chassis_type`), + readOptionalFile(`${DMI_ROOT}/sys_vendor`), + readOptionalFile(`${DMI_ROOT}/product_name`), + ]); + // WSL exposes Microsoft in its kernel release on both WSL 1 and WSL 2. + // Check it before DMI because WSL 2 presents as a Hyper-V VM. + if (kernelRelease?.toLowerCase().includes("microsoft")) { + return "linux"; + } + return machineKindFromDmi({ chassisType, sysVendor, productName }); +}); + +export const detectServerEnvironmentMachineKind = Effect.fn("detectServerEnvironmentMachineKind")( + function* () { + const platform = yield* HostProcessPlatform; + switch (platform) { + case "darwin": + return yield* detectDarwinMachineKind(); + case "linux": + return yield* detectLinuxMachineKind(); + default: + return null; + } + }, +); diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 55a7c70d0..0a3b43848 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -523,7 +523,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => JSON.parse(result.stdout) as unknown[]), @@ -567,7 +567,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "view", input.reference, "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary), @@ -694,6 +694,352 @@ const GitManagerTestLayer = GitVcsDriver.layer.pipe( ); it.layer(GitManagerTestLayer)("GitManager", (it) => { + it.effect("branch PR lookup returns null when the repository has no remotes", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const { manager, ghCalls } = yield* makeManager(); + + const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" }); + + expect(pullRequest).toBeNull(); + expect(ghCalls).toHaveLength(0); + }), + ); + + it.effect("branch PR lookup uses a saved tracked branch without changing checkout", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/saved-branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/saved-branch"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 216, + title: "Saved branch PR", + url: "https://github.com/pingdotgg/t3code/pull/216", + baseRefName: "main", + headRefName: "feature/saved-branch", + state: "OPEN", + updatedAt: "2026-04-03T15:00:00Z", + }, + ]), + ], + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/saved-branch", + }); + + expect(pullRequest).toMatchObject({ + number: 216, + title: "Saved branch PR", + url: "https://github.com/pingdotgg/t3code/pull/216", + baseRef: "main", + headRef: "feature/saved-branch", + state: "open", + closedAt: null, + mergedAt: null, + updatedAt: "2026-04-03T15:00:00.000Z", + }); + expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); + }), + ); + + it.effect("branch PR lookup uses the default branch from a non-origin remote", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "upstream", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "upstream", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "develop"]); + yield* runGit(repoDir, ["push", "-u", "upstream", "develop"]); + yield* runGit(remoteDir, ["symbolic-ref", "HEAD", "refs/heads/develop"]); + yield* runGit(repoDir, ["remote", "set-head", "upstream", "develop"]); + + const { manager } = yield* makeManager({ + ghScenario: { + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 221, + title: "Merged main PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/221", + baseRefName: "develop", + headRefName: "main", + state: "MERGED", + mergedAt: "2026-04-07T15:00:00Z", + updatedAt: "2026-04-08T15:00:00Z", + }, + ]), + ], + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" }); + + expect(pullRequest).toMatchObject({ + state: "merged", + closedAt: null, + mergedAt: "2026-04-07T15:00:00Z", + updatedAt: "2026-04-08T15:00:00.000Z", + }); + }), + ); + + it.effect("branch PR lookup uses the saved name after the local branch is deleted", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/deleted-local-branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/deleted-local-branch"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/deleted-local-branch"]); + yield* runGit(repoDir, ["branch", "feature/deleted-local-branch/child"]); + yield* runGit(repoDir, [ + "branch", + "--set-upstream-to", + "origin/main", + "feature/deleted-local-branch/child", + ]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 217, + title: "Deleted local branch PR", + url: "https://github.com/pingdotgg/t3code/pull/217", + baseRefName: "main", + headRefName: "feature/deleted-local-branch", + state: "MERGED", + updatedAt: "2026-04-04T15:00:00Z", + }, + ]), + ], + }, + }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/deleted-local-branch", + }); + + expect(pullRequest).toMatchObject({ + state: "merged", + closedAt: null, + mergedAt: null, + updatedAt: "2026-04-04T15:00:00.000Z", + }); + expect(ghCalls.some((call) => call.includes("--head feature/deleted-local-branch"))).toBe( + true, + ); + }), + ); + + it.effect("branch PR lookup rejects ambiguous deleted-branch remote refs", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/ambiguous-remote"]); + yield* runGit(repoDir, ["push", "origin", "feature/ambiguous-remote"]); + yield* runGit(repoDir, ["push", "fork", "feature/ambiguous-remote"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/ambiguous-remote"]); + const { manager, ghCalls } = yield* makeManager(); + + const error = yield* manager + .branchPullRequest({ cwd: repoDir, branch: "feature/ambiguous-remote" }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "GitManagerError", + detail: "Multiple remotes track feature/ambiguous-remote. Its pull request is ambiguous.", + }); + expect(ghCalls).toHaveLength(0); + }), + ); + + it.effect("branch PR lookup does not reuse a cached PR after the remote is repointed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originalRemoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originalRemoteDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/repointed-lookup"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/repointed-lookup"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:old-owner/old-repository.git", + originalRemoteDir, + ); + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 219, + title: "Old repository PR", + url: "https://github.com/old-owner/old-repository/pull/219", + baseRefName: "main", + headRefName: "feature/repointed-lookup", + state: "MERGED", + updatedAt: "2026-04-06T15:00:00Z", + }, + ]), + "[]", + ], + }, + }); + + const first = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/repointed-lookup", + }); + expect(first?.state).toBe("merged"); + + const replacementRemoteDir = yield* createBareRemote(); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:new-owner/new-repository.git", + replacementRemoteDir, + ); + + const second = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/repointed-lookup", + }); + + expect(second).toBeNull(); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); + }), + ); + + it.effect("branch PR lookup shares the status cache for the same repository identity", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/shared-pr-cache"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/shared-pr-cache"]); + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 220, + title: "Shared cache PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/220", + baseRefName: "main", + headRefName: "feature/shared-pr-cache", + state: "MERGED", + updatedAt: "2026-04-07T15:00:00Z", + }, + ]), + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 221, + title: "New PR on the same branch", + url: "https://github.com/pingdotgg/codething-mvp/pull/221", + baseRefName: "main", + headRefName: "feature/shared-pr-cache", + state: "OPEN", + updatedAt: "2026-04-08T15:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/shared-pr-cache", + }); + + expect(status.pr?.state).toBe("merged"); + expect(pullRequest?.state).toBe("merged"); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); + const refreshed = yield* manager.branchPullRequest( + { cwd: repoDir, branch: "feature/shared-pr-cache" }, + { refresh: true }, + ); + expect(refreshed).toMatchObject({ + number: 221, + state: "open", + repositoryKey: "github.com/pingdotgg/codething-mvp", + }); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); + }), + ); + + it.effect("branch PR lookup propagates provider failures", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/lookup-failure"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/lookup-failure"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + failWith: new GitHubCli.GitHubCliUnavailableError({ + command: "gh", + cwd: repoDir, + cause: new Error("gh is not available on PATH"), + }), + }, + }); + + const error = yield* manager + .branchPullRequest({ cwd: repoDir, branch: "feature/lookup-failure" }) + .pipe(Effect.flip); + + expect(error._tag).toBe("SourceControlProviderError"); + const refreshError = yield* manager + .branchPullRequest({ cwd: repoDir, branch: "feature/lookup-failure" }, { refresh: true }) + .pipe(Effect.flip); + expect(refreshError._tag).toBe("SourceControlProviderError"); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); + }), + ); + it.effect("status includes PR metadata when branch already has an open PR", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1214,7 +1560,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( - "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, @@ -1280,7 +1626,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( - "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index f2da1cb3b..5dd3ec1d8 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -77,6 +77,18 @@ export interface GitRunStackedActionOptions { readonly progressReporter?: GitActionProgressReporter; } +export interface GitRemoteStatusOptions extends GitVcsDriver.GitRemoteStatusOptions { + /** Retry a cached missing PR without clearing known PRs or failed lookup backoff. */ + readonly refreshMissingPullRequest?: boolean; +} + +export type GitBranchPullRequest = NonNullable & { + readonly repositoryKey: string | null; + readonly updatedAt: string | null; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; +}; + interface SourceControlTextGenerationSettings { readonly modelSelection: ModelSelection; readonly style: SourceControlWritingStyleSettings; @@ -93,8 +105,13 @@ export class GitManager extends Context.Service< ) => Effect.Effect; readonly remoteStatus: ( input: VcsStatusInput, - options?: GitVcsDriver.GitRemoteStatusOptions, + options?: GitRemoteStatusOptions, ) => Effect.Effect; + /** Resolve the PR for a saved branch without changing the current checkout. */ + readonly branchPullRequest: ( + input: { readonly cwd: string; readonly branch: string }, + options?: { readonly refresh?: boolean }, + ) => Effect.Effect; readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; readonly invalidateStatus: (cwd: string) => Effect.Effect; @@ -121,7 +138,13 @@ const SHORT_SHA_LENGTH = 7; const TOAST_DESCRIPTION_MAX = 72; const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); const STATUS_RESULT_CACHE_CAPACITY = 2_048; -const PR_LOOKUP_CACHE_TTL = Duration.minutes(2); +// Matches the automatic settlement sweep cadence so every background sweep +// reads fresh branch state: an external merge settles within about a minute +// instead of waiting out a longer cache. Unpublished branches never reach the +// host (a local probe answers first), and failed lookups still back off +// exponentially via prLookupFailureTtl, so throttling pressure still drops +// under 429s instead of amplifying it. +const PR_LOOKUP_CACHE_TTL = Duration.seconds(60); const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20); const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15); const PR_LOOKUP_CACHE_CAPACITY = 2_048; @@ -160,6 +183,9 @@ interface OpenPrInfo { interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { state: "open" | "closed" | "merged"; + isDraft?: boolean; + closedAt?: string | null; + mergedAt?: string | null; updatedAt: Option.Option; } @@ -190,14 +216,32 @@ interface BranchHeadContext { preferredHeadSelector: string; remoteName: string | null; headRemoteUrlKey: string | null; + targetRemoteUrlKey: string | null; headRepositoryNameWithOwner: string | null; headRepositoryOwnerLogin: string | null; isCrossRepository: boolean; } +export function pullRequestRepositoryKey(value: string): string | null { + try { + const url = new URL(value); + const match = + /^(.*)(?:\/pull\/|\/-\/merge_requests\/|\/pull-requests\/|\/pullrequest\/)\d+(?:\/.*)?$/iu.exec( + url.pathname, + ); + if (match?.[1] === undefined) return null; + url.pathname = match[1]; + url.search = ""; + url.hash = ""; + return normalizeGitRemoteUrl(url.toString()); + } catch { + return null; + } +} + function parseRepositoryNameFromPullRequestUrl(url: string): string | null { const trimmed = url.trim(); - const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); + const match = /^https?:\/\/[^/]+\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); const repositoryName = match?.[1]?.trim() ?? ""; return repositoryName.length > 0 ? repositoryName : null; } @@ -235,14 +279,14 @@ function resolvePullRequestWorktreeLocalBranchName( return `t3code/pr-${pullRequest.number}/${suffix}`; } -function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { +function parseRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { const trimmed = url?.trim() ?? ""; if (trimmed.length === 0) { return null; } const match = - /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( + /^(?:[^@/\s]+@[^:/\s]+:|(?:ssh|https?|git):\/\/[^/]+\/)((?:[^/\s]+\/)+[^/\s]+?)(?:\.git)?\/?$/iu.exec( trimmed, ); const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; @@ -254,6 +298,7 @@ function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null if (trimmed.length === 0) { return null; } + // GitLab reports the top-level group as owner. The full path distinguishes subgroups. const [ownerLogin] = trimmed.split("/"); const normalizedOwnerLogin = ownerLogin?.trim() ?? ""; return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null; @@ -393,6 +438,9 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state ?? "open", + ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt, ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } @@ -547,6 +595,7 @@ function toStatusPr(pr: PullRequestInfo): { baseRef: string; headRef: string; state: "open" | "closed" | "merged"; + isDraft?: boolean; updatedAt: string | null; } { return { @@ -556,6 +605,7 @@ function toStatusPr(pr: PullRequestInfo): { baseRef: pr.baseRefName, headRef: pr.headRefName, state: pr.state, + ...(pr.isDraft === true ? { isDraft: true } : {}), updatedAt: Option.match(pr.updatedAt, { onNone: () => null, onSome: (updatedAt) => DateTime.formatIso(updatedAt), @@ -966,15 +1016,16 @@ export const make = Effect.gen(function* () { prLookupEpochByCwd.set(cacheKey, prLookupEpoch(cacheKey) + 1); }), ); - // Cache keys are NUL-joined [cwd, branch, upstreamRef, defaultBranch, epoch] — none of the - // segments can contain a NUL byte, and refs are never empty, so "" decodes - // back to a null ref. + // Cache keys are NUL-joined. Automatic settlement validates repository URLs + // against the cached value before it uses a pull request decision. const prLookupCacheKey = ( cwd: string, details: { branch: string; upstreamRef: string | null; defaultBranch: string | null; + localBranchExists?: boolean; + remoteName?: string | null; }, ) => [ @@ -982,6 +1033,8 @@ export const make = Effect.gen(function* () { details.branch, details.upstreamRef ?? "", details.defaultBranch ?? "", + details.localBranchExists === false ? "0" : "1", + details.remoteName ?? "", String(prLookupEpoch(cwd)), ].join("\u0000"); // Consecutive failures per cache key, so a branch that keeps failing waits @@ -1003,32 +1056,33 @@ export const make = Effect.gen(function* () { }; const prLookupCache = yield* Cache.makeWith( (key: string) => { - const [cwd = "", branch = "", upstreamRef = "", defaultBranch = ""] = key.split("\u0000"); + const [ + cwd = "", + branch = "", + upstreamRef = "", + defaultBranch = "", + branchExists = "1", + remoteName = "", + ] = key.split("\u0000"); const details = { branch, upstreamRef: upstreamRef.length > 0 ? upstreamRef : null, defaultBranch: defaultBranch.length > 0 ? defaultBranch : null, + localBranchExists: branchExists !== "0", + ...(remoteName.length > 0 ? { remoteName } : {}), }; return Effect.gen(function* () { - const headContext = yield* resolveBranchHeadContext(cwd, details); - const upstreamHeadIsDefault = - headContext.headBranch === details.defaultBranch || - (details.defaultBranch === null && - (headContext.headBranch === "main" || headContext.headBranch === "master")); - // `git worktree add -b feature origin/main` makes the new local branch - // track origin/main. That upstream is the branch's base, not its - // published PR head. Looking up PRs for it can attach an old reverse - // merge from main and auto-settle an unrelated feature thread. - if ( - headContext.headBranch !== details.branch && - upstreamHeadIsDefault && - !headContext.isCrossRepository - ) { + const { headContext, lookup } = yield* resolveLookupHeadContext(cwd, details); + if (!lookup) { return { latest: null, headContext }; } // Only skip when the branch is untracked as well: anything carrying an // upstream keeps the old behaviour. - if (details.upstreamRef === null && (yield* isUnpublishedBranch(cwd, headContext))) { + if ( + details.localBranchExists && + details.upstreamRef === null && + (yield* isUnpublishedBranch(cwd, headContext)) + ) { return { latest: null, headContext }; } const latest = yield* findLatestPrForHeadContext(cwd, headContext); @@ -1112,11 +1166,21 @@ export const make = Effect.gen(function* () { defaultBranch: string | null; isDefaultBranch: boolean; }, + refreshMissingPullRequest = false, ) { // Keyed by (cwd, branch) only: the upstream ref changing (e.g. a first // `push -u`) must not orphan the fallback value for the same branch. const branchKey = `${cwd}\u0000${details.branch}`; - return yield* Cache.get(prLookupCache, prLookupCacheKey(cwd, details)).pipe( + const cacheKey = prLookupCacheKey(cwd, details); + if (refreshMissingPullRequest) { + const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe( + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isSome(cached) && cached.value.latest === null) { + yield* Cache.invalidate(prLookupCache, cacheKey); + } + } + return yield* Cache.get(prLookupCache, cacheKey).pipe( Effect.map(({ latest, headContext }) => { if (!latest) return { pr: null, headContext }; // On the default branch, only surface open PRs. @@ -1156,8 +1220,8 @@ export const make = Effect.gen(function* () { } : {}), }), - Effect.andThen(resolveBranchHeadContext(cwd, details)), - Effect.map((headContext) => + Effect.andThen(resolveLookupHeadContext(cwd, details)), + Effect.map(({ headContext }) => resolveLastKnownPr(branchKey, { upstreamRef: details.upstreamRef, headBranch: headContext.headBranch, @@ -1171,7 +1235,7 @@ export const make = Effect.gen(function* () { }); const readRemoteStatus = Effect.fn("readRemoteStatus")(function* ( cwd: string, - options?: GitVcsDriver.GitRemoteStatusOptions, + options?: GitRemoteStatusOptions, ) { const details = yield* gitCore .statusDetailsRemote(cwd, options) @@ -1182,12 +1246,16 @@ export const make = Effect.gen(function* () { const pr = details.branch !== null - ? yield* lookupStatusPr(cwd, { - branch: details.branch, - upstreamRef: details.upstreamRef, - defaultBranch: details.defaultBranch, - isDefaultBranch: details.isDefaultBranch, - }) + ? yield* lookupStatusPr( + cwd, + { + branch: details.branch, + upstreamRef: details.upstreamRef, + defaultBranch: details.defaultBranch, + isDefaultBranch: details.isDefaultBranch, + }, + options?.refreshMissingPullRequest, + ) : null; return { @@ -1238,7 +1306,7 @@ export const make = Effect.gen(function* () { } const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); - const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); + const repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); return { remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null, repositoryNameWithOwner, @@ -1246,11 +1314,33 @@ export const make = Effect.gen(function* () { }; }); + const resolvePrLookupRepositoryIdentity = Effect.fn("resolvePrLookupRepositoryIdentity")( + function* (cwd: string, branch: string, remoteNameOverride?: string) { + const remoteName = + remoteNameOverride ?? (yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)); + const [headRemote, targetRemote] = yield* Effect.all( + [ + resolveRemoteRepositoryContext(cwd, remoteName), + resolveRemoteRepositoryContext(cwd, "origin"), + ], + { concurrency: "unbounded" }, + ); + return { + remoteName, + headRemoteUrlKey: + headRemote.remoteUrlKey ?? (remoteName === null ? targetRemote.remoteUrlKey : null), + targetRemoteUrlKey: targetRemote.remoteUrlKey, + }; + }, + ); + const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* ( cwd: string, - details: { branch: string; upstreamRef: string | null }, + details: { branch: string; upstreamRef: string | null; remoteName?: string }, ) { - const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`); + const remoteName = + details.remoteName ?? + (yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`)); const headBranchFromUpstream = details.upstreamRef ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName }) : ""; @@ -1314,12 +1404,103 @@ export const make = Effect.gen(function* () { headRemoteUrlKey: remoteRepository.remoteUrlKey ?? (remoteName === null ? originRepository.remoteUrlKey : null), + targetRemoteUrlKey: originRepository.remoteUrlKey, headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, headRepositoryOwnerLogin: remoteRepository.ownerLogin, isCrossRepository, } satisfies BranchHeadContext; }); + // The remote that holds a ref named after the local branch, or null when + // none does. Remote names may contain slashes, so refs are matched literally + // per remote instead of with a glob. When several remotes hold the name, the + // preferred remote wins, then origin, then the first configured remote. + const findRemoteTrackingRemote = Effect.fn("findRemoteTrackingRemote")(function* ( + cwd: string, + branch: string, + preferredRemoteName: string | null, + ) { + if (branch.length === 0) return null; + return yield* Effect.gen(function* () { + const remoteNames = (yield* gitCore.execute({ + operation: "GitManager.findRemoteTrackingRemote.remotes", + cwd, + args: ["remote"], + timeoutMs: 5_000, + })).stdout + .split("\n") + .map((name) => name.trim()) + .filter((name) => name.length > 0); + if (remoteNames.length === 0) return null; + const refs = new Set( + (yield* gitCore.execute({ + operation: "GitManager.findRemoteTrackingRemote.refs", + cwd, + args: [ + "for-each-ref", + "--format=%(refname)", + ...remoteNames.map((name) => `refs/remotes/${name}/${branch}`), + ], + timeoutMs: 5_000, + })).stdout + .split("\n") + .map((ref) => ref.trim()) + .filter((ref) => ref.length > 0), + ); + const matching = remoteNames.filter((name) => refs.has(`refs/remotes/${name}/${branch}`)); + if (preferredRemoteName !== null && matching.includes(preferredRemoteName)) { + return preferredRemoteName; + } + if (matching.includes("origin")) return "origin"; + return matching[0] ?? null; + }).pipe(Effect.orElseSucceed(() => null)); + }); + + // `git worktree add -b feature origin/main` makes the new local branch track + // origin/main. That upstream is the branch's base, not its published PR + // head. Looking up PRs for it can attach an old reverse merge from main and + // auto-settle an unrelated feature thread. + // + // The branch may still have been pushed under its own name by a plain + // `git push feature` that never moved the upstream. When a remote + // holds a ref for the local name, look the PR up by that name on that + // remote. Without such a ref there is nothing to ask the host about, so + // `lookup` is false and no API call is spent. Both the cached lookup and the + // failure fallback resolve through here so the last-known PR compares + // against the same head branch. + const resolveLookupHeadContext = Effect.fn("resolveLookupHeadContext")(function* ( + cwd: string, + details: { + branch: string; + upstreamRef: string | null; + defaultBranch: string | null; + remoteName?: string; + }, + ) { + const headContext = yield* resolveBranchHeadContext(cwd, details); + const upstreamHeadIsDefault = + headContext.headBranch === details.defaultBranch || + (details.defaultBranch === null && + (headContext.headBranch === "main" || headContext.headBranch === "master")); + if ( + headContext.headBranch === details.branch || + !upstreamHeadIsDefault || + headContext.isCrossRepository + ) { + return { headContext, lookup: true }; + } + const remoteName = yield* findRemoteTrackingRemote(cwd, details.branch, headContext.remoteName); + if (remoteName === null) { + return { headContext, lookup: false }; + } + const ownNameContext = yield* resolveBranchHeadContext(cwd, { + branch: details.branch, + upstreamRef: null, + remoteName, + }); + return { headContext: ownNameContext, lookup: true }; + }); + /** * Whether git has no record of this branch on any remote, so a change request * cannot exist for it and asking the provider is a guaranteed-empty API call. @@ -1395,11 +1576,7 @@ export const make = Effect.gen(function* () { ); if (firstPullRequest) { return { - number: firstPullRequest.number, - title: firstPullRequest.title, - url: firstPullRequest.url, - baseRefName: firstPullRequest.baseRefName, - headRefName: firstPullRequest.headRefName, + ...firstPullRequest, state: "open", updatedAt: Option.none(), } satisfies PullRequestInfo; @@ -1903,7 +2080,7 @@ export const make = Effect.gen(function* () { const remoteStatus: GitManager["Service"]["remoteStatus"] = Effect.fn("remoteStatus")( function* (input, options) { const cacheKey = yield* normalizeStatusCacheKey(input.cwd); - if (options?.refreshUpstream === false) { + if (options?.refreshUpstream === false || options?.refreshMissingPullRequest) { return yield* readRemoteStatus(cacheKey, options); } return yield* Cache.get(remoteStatusResultCache, cacheKey); @@ -1915,6 +2092,159 @@ export const make = Effect.gen(function* () { }); return mergeGitStatusParts(local, remote); }); + const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn( + "branchPullRequest", + )(function* ({ cwd, branch }, options) { + const cacheCwd = yield* normalizeStatusCacheKey(cwd); + const remotes = yield* gitCore.execute({ + operation: "GitManager.branchPullRequest.remotes", + cwd: cacheCwd, + args: ["remote"], + }); + const remoteNames = remotes.stdout + .split("\n") + .map((remoteName) => remoteName.trim()) + .filter((remoteName) => remoteName.length > 0); + const [firstRemoteName] = remoteNames; + if (firstRemoteName === undefined) return null; + const branchRef = yield* gitCore.execute({ + operation: "GitManager.branchPullRequest.branchRef", + cwd: cacheCwd, + args: [ + "for-each-ref", + "--format=%(refname)%00%(upstream:short)%00%(upstream:remotename)%00%(upstream:remoteref)", + `refs/heads/${branch}`, + ], + }); + const expectedRefName = `refs/heads/${branch}`; + const exactBranch = branchRef.stdout + .split("\n") + .find((line) => line.split("\u0000", 1)[0] === expectedRefName); + const [refName = "", savedUpstream = "", savedRemoteName = "", savedRemoteRef = ""] = + exactBranch?.split("\u0000") ?? []; + const localBranchExists = refName.length > 0; + let upstreamRef: string | null = null; + let remoteName: string | null = null; + if (savedUpstream.length > 0) { + if (savedRemoteName.length === 0 || savedRemoteRef.length === 0) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Saved upstream for ${branch} is incomplete.`, + }); + } + remoteName = savedRemoteName; + const upstreamBranch = savedRemoteRef.replace(/^refs\/heads\//, ""); + upstreamRef = `${remoteName}/${upstreamBranch}`; + } else if (!localBranchExists) { + const trackingRefs = yield* gitCore.execute({ + operation: "GitManager.branchPullRequest.remoteTrackingRefs", + cwd: cacheCwd, + args: ["for-each-ref", "--format=%(refname)", "refs/remotes"], + }); + const refNames = new Set( + trackingRefs.stdout + .split("\n") + .map((remoteRef) => remoteRef.trim()) + .filter((remoteRef) => remoteRef.length > 0), + ); + const matchingRemoteNames = remoteNames.filter((candidate) => + refNames.has(`refs/remotes/${candidate}/${branch}`), + ); + if (matchingRemoteNames.length > 1) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Multiple remotes track ${branch}. Its pull request is ambiguous.`, + }); + } + remoteName = matchingRemoteNames[0] ?? null; + if (remoteName !== null) { + upstreamRef = `${remoteName}/${branch}`; + } + } + const defaultRemoteName = remoteNames.includes("origin") ? "origin" : firstRemoteName; + const defaultBranch = yield* gitCore + .resolveDefaultBranchName(cacheCwd, defaultRemoteName) + .pipe(Effect.orElseSucceed(() => null)); + const cacheKey = prLookupCacheKey(cacheCwd, { + branch, + upstreamRef, + defaultBranch, + localBranchExists, + ...(localBranchExists ? {} : { remoteName }), + }); + if (options?.refresh) { + // A completed turn can create a PR or reuse a merged PR's branch. + // Refresh successful answers, but keep failed lookups' retry backoff. + const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe( + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isSome(cached)) yield* Cache.invalidate(prLookupCache, cacheKey); + } + let cached = yield* Cache.get(prLookupCache, cacheKey); + // The cached head context may have resolved on a different remote than + // the saved upstream: a branch tracking origin/main but pushed to a fork + // is looked up on the fork. Verify against the remote the lookup used. + const identityRemoteName = (headContext: BranchHeadContext) => + headContext.remoteName ?? remoteName ?? undefined; + const currentIdentity = yield* resolvePrLookupRepositoryIdentity( + cacheCwd, + branch, + identityRemoteName(cached.headContext), + ); + const canVerifyIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) => + !( + (headContext.headRemoteUrlKey !== null && identity.headRemoteUrlKey === null) || + (headContext.targetRemoteUrlKey !== null && identity.targetRemoteUrlKey === null) + ); + const hasSameIdentity = (headContext: BranchHeadContext, identity: typeof currentIdentity) => + headContext.headRemoteUrlKey === identity.headRemoteUrlKey && + headContext.targetRemoteUrlKey === identity.targetRemoteUrlKey; + if (!canVerifyIdentity(cached.headContext, currentIdentity)) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Repository identity for ${branch} could not be verified.`, + }); + } + if (!hasSameIdentity(cached.headContext, currentIdentity)) { + yield* Cache.invalidate(prLookupCache, cacheKey); + cached = yield* Cache.get(prLookupCache, cacheKey); + const refreshedIdentity = yield* resolvePrLookupRepositoryIdentity( + cacheCwd, + branch, + identityRemoteName(cached.headContext), + ); + if ( + !canVerifyIdentity(cached.headContext, refreshedIdentity) || + !hasSameIdentity(cached.headContext, refreshedIdentity) + ) { + return yield* new GitManagerError({ + operation: "branchPullRequest", + cwd: cacheCwd, + detail: `Repository identity for ${branch} changed during pull request lookup.`, + }); + } + } + const { latest } = cached; + if (latest === null) return null; + if ( + (branch === defaultBranch || + (defaultBranch === null && (branch === "main" || branch === "master"))) && + latest.state !== "open" + ) { + return null; + } + return { + ...toStatusPr(latest), + closedAt: latest.closedAt ?? null, + mergedAt: latest.mergedAt ?? null, + // Hosting CLIs can select an upstream repository instead of origin. + // The returned PR URL names the repository that actually owns it. + repositoryKey: pullRequestRepositoryKey(latest.url), + }; + }); const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( "invalidateLocalStatus", )(function* (cwd) { @@ -2584,6 +2914,7 @@ export const make = Effect.gen(function* () { localStatus, remoteStatus, status, + branchPullRequest, invalidateLocalStatus, invalidateRemoteStatus, invalidateStatus, diff --git a/apps/server/src/git/linkCreatedPullRequest.test.ts b/apps/server/src/git/linkCreatedPullRequest.test.ts new file mode 100644 index 000000000..11ddc5c25 --- /dev/null +++ b/apps/server/src/git/linkCreatedPullRequest.test.ts @@ -0,0 +1,157 @@ +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + ProjectId, + ThreadId, + type Project, + type OrchestrationV2ThreadShell, + type OrchestrationV2Command, + type GitRunStackedActionResult, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { updateLinkedPullRequests } from "@t3tools/shared/threadPullRequests"; +import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; +import { ProjectService } from "../project/ProjectService.ts"; +import { createdPullRequestKey, withCreatedPullRequestLink } from "./linkCreatedPullRequest.ts"; + +const project: Project = { + id: ProjectId.make("project"), + title: "Fork", + workspaceRoot: "/checkout", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-09-11T00:00:00Z", + updatedAt: "2026-09-11T00:00:00Z", + deletedAt: null, +}; +const result: GitRunStackedActionResult = { + action: "create_pr", + branch: { status: "skipped_not_requested" }, + commit: { status: "skipped_not_requested" }, + push: { status: "skipped_not_requested" }, + pr: { status: "created", number: 42, url: "https://github.com/upstream/repo/pull/42" }, + toast: { title: "Created PR", cta: { kind: "none" } }, +}; + +it("uses the target URL identity and rejects inconsistent numbers and non-PR actions", () => { + expect(createdPullRequestKey(result.pr, project)).toEqual({ + host: "github.com", + repository: "upstream/repo", + number: 42, + }); + expect(createdPullRequestKey({ ...result.pr, number: 43 }, project)).toBeNull(); + expect( + createdPullRequestKey({ ...result.pr, status: "skipped_not_requested" }, project), + ).toBeNull(); +}); + +it("supports a configured self-hosted GitHub URL without accepting another host or repository", () => { + const enterprise = { + ...project, + repositoryIdentity: { + canonicalKey: "code.example.com/acme/repo", + provider: "github", + owner: "acme", + name: "repo", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "https://code.example.com/acme/repo.git", + }, + }, + }; + expect( + createdPullRequestKey( + { ...result.pr, url: "https://code.example.com/acme/repo/pull/42" }, + enterprise, + ), + ).toEqual({ host: "code.example.com", repository: "acme/repo", number: 42 }); + for (const url of [ + "https://other.example.com/acme/repo/pull/42", + "https://code.example.com/wrong/repo/pull/42", + "javascript:alert(42)", + ]) { + expect(createdPullRequestKey({ ...result.pr, url }, enterprise)).toBeNull(); + } +}); + +for (const change of [ + "none", + "new-branch", + "moved", + "branch-changed", + "missing", + "dispatch-failure", +] as const) { + it.effect(`keeps the git result and links only the original thread context: ${change}`, () => + Effect.gen(function* () { + let thread = { + id: ThreadId.make("thread"), + projectId: project.id, + worktreePath: null, + branch: "feature", + archivedAt: null, + deletedAt: null, + pullRequests: [], + } as unknown as OrchestrationV2ThreadShell; + const commands: OrchestrationV2Command[] = []; + const layer = Layer.mergeAll( + Layer.mock(ThreadManagementService)({ + getThreadShell: () => Effect.succeed(change === "missing" ? null : thread), + dispatch: (command) => + Effect.sync(() => { + commands.push(command); + if (change === "dispatch-failure") throw new Error("Thread was concurrently deleted"); + if (command.type === "thread.metadata.update") + thread = { + ...thread, + ...updateLinkedPullRequests(thread, command, project.updatedAt), + }; + return { sequence: commands.length, storedEvents: [] }; + }), + }), + Layer.mock(ProjectService)({ + snapshot: Effect.succeed({ projects: [project], updatedAt: project.updatedAt }), + }), + NodeServices.layer, + ); + const completed = + change === "new-branch" + ? { ...result, branch: { status: "created" as const, name: "feature/new" } } + : result; + const action = Effect.sync(() => { + if (change === "moved") thread = { ...thread, worktreePath: "/elsewhere" }; + if (change === "branch-changed") thread = { ...thread, branch: "unrelated" }; + if (change === "new-branch") thread = { ...thread, branch: "feature/new" }; + return completed; + }); + const input = { + actionId: "create-42", + action: "create_pr" as const, + threadId: thread.id, + cwd: project.workspaceRoot, + }; + expect(yield* withCreatedPullRequestLink(input, action).pipe(Effect.provide(layer))).toBe( + completed, + ); + if (change === "none" || change === "new-branch" || change === "dispatch-failure") { + expect(commands[0]).toMatchObject({ + type: "thread.metadata.update", + expectedProjectId: project.id, + expectedWorktreePath: null, + expectedBranch: change === "new-branch" ? "feature/new" : "feature", + linkPullRequestSource: "created", + linkPullRequest: { repository: "upstream/repo", number: 42 }, + }); + if (change !== "dispatch-failure") { + yield* withCreatedPullRequestLink(input, Effect.succeed(completed)).pipe( + Effect.provide(layer), + ); + expect(thread.pullRequests).toHaveLength(1); + expect(thread.pullRequests?.[0]?.source).toBe("created"); + } + } else expect(commands).toEqual([]); + }), + ); +} diff --git a/apps/server/src/git/linkCreatedPullRequest.ts b/apps/server/src/git/linkCreatedPullRequest.ts new file mode 100644 index 000000000..5deb25a41 --- /dev/null +++ b/apps/server/src/git/linkCreatedPullRequest.ts @@ -0,0 +1,125 @@ +import { + CommandId, + pullRequestHostOf, + type Project, + type GitRunStackedActionInput, + type GitRunStackedActionResult, +} from "@t3tools/contracts"; +import { changeRequestUrlFor, parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { sourceControlRepositorySelector } from "@t3tools/shared/sourceControl"; +import * as Cause from "effect/Cause"; +import * as Path from "effect/Path"; +import * as Effect from "effect/Effect"; +import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; +import { ProjectService } from "../project/ProjectService.ts"; + +/** Prefer the host URL over the checkout remote, including pull requests opened against an upstream fork. */ +export function createdPullRequestKey(pr: GitRunStackedActionResult["pr"], project: Project) { + if ((pr.status !== "created" && pr.status !== "opened_existing") || !pr.url) return null; + const parsed = parseChangeRequestUrl(pr.url); + if (parsed !== null) + return pr.number === undefined || pr.number === parsed.number ? parsed : null; + const identity = project.repositoryIdentity; + const kind = identity?.provider; + const repository = sourceControlRepositorySelector(identity); + if ( + !identity || + !repository || + pr.number === undefined || + (kind !== "github" && kind !== "gitlab" && kind !== "bitbucket" && kind !== "azure-devops") + ) + return null; + const host = pullRequestHostOf(identity, kind); + const expected = changeRequestUrlFor(kind, host, repository, pr.number); + if (expected === null) return null; + try { + const url = new URL(pr.url); + const expectedUrl = new URL(expected); + if ( + (url.protocol !== "https:" && url.protocol !== "http:") || + url.hostname.toLowerCase() !== expectedUrl.hostname.toLowerCase() || + url.pathname.replace(/\/$/u, "").toLowerCase() !== expectedUrl.pathname.toLowerCase() + ) + return null; + return { + host: url.hostname.toLowerCase(), + repository: repository.toLowerCase(), + number: pr.number, + }; + } catch { + return null; + } +} + +/** Link a successful create/open action without turning a bookkeeping failure into a failed git action. */ +export const withCreatedPullRequestLink = ( + input: GitRunStackedActionInput, + action: Effect.Effect, +) => + Effect.gen(function* () { + const threadId = input.threadId; + if (threadId === undefined) return yield* action; + const threads = yield* ThreadManagementService; + const projects = yield* ProjectService; + const path = yield* Path.Path; + const recover = (cause: Cause.Cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.logWarning("created pull request could not be linked", { + threadId: input.threadId, + cause: Cause.pretty(cause), + }).pipe(Effect.as(null)); + const before = yield* Effect.gen(function* () { + const thread = yield* threads.getThreadShell(threadId); + if (thread === null || thread.deletedAt !== null || thread.archivedAt !== null) return null; + const project = (yield* projects.snapshot).projects.find( + (project) => project.id === thread.projectId, + ); + if ( + project === undefined || + path.resolve(thread.worktreePath ?? project.workspaceRoot) !== path.resolve(input.cwd) + ) + return null; + return { thread, project }; + }).pipe(Effect.catchCause(recover)); + + const result = yield* action; + if ( + before === null || + (result.pr.status !== "created" && result.pr.status !== "opened_existing") || + !result.pr.url + ) + return result; + const key = createdPullRequestKey(result.pr, before.project); + if (key === null || (result.pr.number !== undefined && key.number !== result.pr.number)) + return result; + const url = result.pr.url; + yield* Effect.gen(function* () { + const current = yield* threads.getThreadShell(before.thread.id); + if ( + current === null || + current.deletedAt !== null || + current.archivedAt !== null || + current.projectId !== before.thread.projectId || + current.worktreePath !== before.thread.worktreePath + ) + return; + const project = (yield* projects.snapshot).projects.find( + (project) => project.id === current.projectId, + ); + if (project === undefined || project.workspaceRoot !== before.project.workspaceRoot) return; + // A feature-branch action may have already updated the client's saved branch. + if (current.branch !== before.thread.branch && current.branch !== result.branch.name) return; + yield* threads.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`pr-created-link:${input.actionId}`), + threadId: before.thread.id, + expectedProjectId: current.projectId, + expectedWorktreePath: current.worktreePath, + expectedBranch: current.branch, + linkPullRequest: { ...key, projectId: current.projectId, url }, + linkPullRequestSource: "created", + }); + }).pipe(Effect.catchCause(recover)); + return result; + }); diff --git a/apps/server/src/hermes/HermesHistoryNormalization.test.ts b/apps/server/src/hermes/HermesHistoryNormalization.test.ts index da73b15d7..5a2876ea8 100644 --- a/apps/server/src/hermes/HermesHistoryNormalization.test.ts +++ b/apps/server/src/hermes/HermesHistoryNormalization.test.ts @@ -236,7 +236,7 @@ describe("Hermes imported history normalization", () => { NodeFS.writeFileSync(arbitraryTemp, PNG_BYTES); NodeFS.writeFileSync(unsupported, "not media"); NodeFS.writeFileSync(oversized, PNG_BYTES); - NodeFS.truncateSync(oversized, 20 * 1024 * 1024 + 1); + NodeFS.truncateSync(oversized, 50 * 1024 * 1024 + 1); NodeFS.symlinkSync(outside, symlink); const persist = (sourcePath: string, expectedKind: "image" | "video" = "image") => diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 881b61f3f..163a7216e 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -49,9 +49,12 @@ import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManag import * as ThreadManagementService from "./orchestration-v2/ThreadManagementService.ts"; import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts"; +import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts"; + const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; +const HTML_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups allow-modals"; const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; // Types a browser may render as a document if a proxy strips the disposition @@ -63,6 +66,9 @@ const isSafeDownloadMimeType = (mimeType: string): boolean => const isSafeInlineVideoMimeType = (mimeType: string): boolean => DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && mimeType.toLowerCase().startsWith("video/"); +const isSafeInlineDocumentMimeType = (mimeType: string): boolean => + mimeType.toLowerCase() === "application/pdf" || mimeType.toLowerCase() === "text/html"; + /** RFC 6266 disposition with an ASCII fallback name plus a UTF-8 `filename*`. */ export function downloadContentDisposition(fileName?: string): string { if (fileName === undefined) { @@ -91,7 +97,7 @@ export function assetResponseHeaders( }, ): Record { const lowerPath = filePath.toLowerCase(); - const inlineVideoMimeType = options?.mimeType?.split(";", 1)[0]?.trim(); + const inlineMimeType = options?.mimeType?.split(";", 1)[0]?.trim(); return { "Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff", @@ -104,11 +110,24 @@ export function assetResponseHeaders( ? options.mimeType : "application/octet-stream", } - : inlineVideoMimeType !== undefined && isSafeInlineVideoMimeType(inlineVideoMimeType) - ? { "Content-Type": inlineVideoMimeType } - : lowerPath.endsWith(".html") || lowerPath.endsWith(".htm") - ? { "Content-Type": "text/html; charset=utf-8" } - : {}), + : inlineMimeType !== undefined && isSafeInlineVideoMimeType(inlineMimeType) + ? { "Content-Type": inlineMimeType } + : inlineMimeType !== undefined && isSafeInlineDocumentMimeType(inlineMimeType) + ? { + "Content-Type": + inlineMimeType.toLowerCase() === "text/html" + ? "text/html; charset=utf-8" + : "application/pdf", + ...(inlineMimeType.toLowerCase() === "text/html" + ? { "Content-Security-Policy": HTML_CONTENT_SECURITY_POLICY } + : {}), + } + : lowerPath.endsWith(".html") || lowerPath.endsWith(".htm") + ? { + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": HTML_CONTENT_SECURITY_POLICY, + } + : {}), ...(!options?.download && lowerPath.endsWith(".svg") ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY } : {}), @@ -141,17 +160,30 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( readonly download?: boolean; readonly fileName?: string; readonly mimeType?: string; + readonly file?: OpenMediaFile; }, rangeHeader?: string, ifRangeHeader?: string, + method: "GET" | "HEAD" = "GET", ) { const headers = assetResponseHeaders(asset.path, asset); - if (headers["Content-Type"]?.toLowerCase().startsWith("video/")) { + const mediaFile = asset.file; + const mediaInfo = mediaFile ? yield* statMediaFile(asset.path, mediaFile) : undefined; + const isVideo = headers["Content-Type"]?.toLowerCase().startsWith("video/") === true; + if (mediaFile && isVideo) { + // Host videos can change in place. Do not invite conditional range requests + // with validators that cannot establish byte-for-byte identity. + headers["Cache-Control"] = "private, no-store"; + } + let status = 200; + let offset = 0n; + let bytesToRead: bigint | undefined; + if (isVideo) { headers["Accept-Ranges"] = "bytes"; // If-Range requires a matching validator. A full response is safe when we cannot validate it. - if (rangeHeader && !ifRangeHeader) { + if (method === "GET" && rangeHeader && ifRangeHeader === undefined) { const fs = yield* FileSystem.FileSystem; - const info = yield* fs.stat(asset.path); + const info = mediaInfo ?? (yield* fs.stat(asset.path)); const range = assetByteRange(rangeHeader, info.size); if (range?._tag === "Unsatisfiable") { return HttpServerResponse.empty({ @@ -160,16 +192,34 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( }); } if (range?._tag === "Range") { - return yield* HttpServerResponse.file(asset.path, { - status: 206, - offset: range.offset, - bytesToRead: range.bytesToRead, - headers: { ...headers, "Content-Range": range.contentRange }, - }); + status = 206; + offset = range.offset; + bytesToRead = range.bytesToRead; + headers["Content-Range"] = range.contentRange; } } } - return yield* HttpServerResponse.file(asset.path, { status: 200, headers }); + if (mediaFile && mediaInfo) { + const size = bytesToRead ?? mediaInfo.size; + headers["Content-Type"] ??= Mime.getType(asset.path) ?? "application/octet-stream"; + headers["Content-Length"] = String(size); + if (!isVideo) { + headers["Last-Modified"] = mediaInfo.mtime.toUTCString(); + headers.ETag = `W/"${mediaInfo.size.toString(16)}-${mediaInfo.mtimeMs.toString(16)}"`; + } + if (method === "HEAD" || size === 0n) { + return HttpServerResponse.empty({ status, headers }); + } + const body = streamMediaFile(mediaFile, offset, size); + if (!body) { + return HttpServerResponse.text("File is too large to preview.", { status: 413 }); + } + return HttpServerResponse.stream(body, { + status, + headers, + }); + } + return yield* HttpServerResponse.file(asset.path, { status, offset, bytesToRead, headers }); }); export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), { @@ -412,6 +462,7 @@ export const assetRouteLayer = HttpRouter.add( asset, request.method === "GET" ? request.headers.range : undefined, request.headers["if-range"], + request.method === "HEAD" ? "HEAD" : "GET", ).pipe( Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), ); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 17c9b67e9..b99575b1c 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -1,3 +1,6 @@ +import * as PullRequestMcpService from "./PullRequestMcpService.ts"; +import { PullRequestsToolkit } from "./toolkits/pullRequests/tools.ts"; +import { PullRequestsToolkitHandlersLive } from "./toolkits/pullRequests/handlers.ts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -247,6 +250,11 @@ export const WorktreeToolkitRegistrationLive = McpServer.toolkit(WorktreeToolkit Layer.provide(WorktreeMcpService.layer), ); +export const PullRequestsToolkitRegistrationLive = McpServer.toolkit(PullRequestsToolkit).pipe( + Layer.provide(PullRequestsToolkitHandlersLive), + Layer.provide(PullRequestMcpService.layer), +); + const McpTransportLive = McpServer.layerHttp({ name: "T3 Code", version: packageJson.version, @@ -258,4 +266,5 @@ export const layer = Layer.mergeAll( PreviewToolkitRegistrationLive, OrchestratorToolkitRegistrationLive, WorktreeToolkitRegistrationLive, + PullRequestsToolkitRegistrationLive, ).pipe(Layer.provideMerge(McpTransportLive)); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 62b4b31b2..3401eca7c 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -7,7 +7,12 @@ import { import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; -export const ALL_MCP_CAPABILITIES = ["preview", "orchestration", "worktree"] as const; +export const ALL_MCP_CAPABILITIES = [ + "preview", + "orchestration", + "worktree", + "pull-requests", +] as const; export type McpCapability = (typeof ALL_MCP_CAPABILITIES)[number]; export interface McpInvocationScope { diff --git a/apps/server/src/mcp/McpProviderSession.ts b/apps/server/src/mcp/McpProviderSession.ts index 5872f5202..a859863c6 100644 --- a/apps/server/src/mcp/McpProviderSession.ts +++ b/apps/server/src/mcp/McpProviderSession.ts @@ -10,7 +10,7 @@ export interface McpProviderSessionConfig { readonly endpoint: string; readonly authorizationHeader: string; readonly audience?: string; - readonly capabilities?: ReadonlyArray<"preview" | "orchestration" | "worktree">; + readonly capabilities?: ReadonlyArray<"preview" | "orchestration" | "worktree" | "pull-requests">; readonly issuedAt?: number; } diff --git a/apps/server/src/mcp/PullRequestMcpService.test.ts b/apps/server/src/mcp/PullRequestMcpService.test.ts new file mode 100644 index 000000000..d716859df --- /dev/null +++ b/apps/server/src/mcp/PullRequestMcpService.test.ts @@ -0,0 +1,138 @@ +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationV2Command, + type OrchestrationV2ThreadShell, +} from "@t3tools/contracts"; +import { updateLinkedPullRequests } from "@t3tools/shared/threadPullRequests"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; +import { ProjectService } from "../project/ProjectService.ts"; +import type { McpInvocationScope } from "./McpInvocationContext.ts"; +import { make, resolvePullRequestTarget } from "./PullRequestMcpService.ts"; + +const threadId = ThreadId.make("owner-thread"); +const projectId = ProjectId.make("owner-project"); +const scope: McpInvocationScope = { + credentialId: "credential", + environmentId: EnvironmentId.make("env"), + threadId, + providerSessionId: "session", + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["pull-requests"]), + audience: "urn:t3-code:mcp:env", + issuedAt: 1, +}; + +it.effect("links, lists, and unlinks only the credential's thread without host writes", () => + Effect.gen(function* () { + let thread = { + id: threadId, + projectId, + deletedAt: null, + pullRequests: [], + } as unknown as OrchestrationV2ThreadShell; + const commands: OrchestrationV2Command[] = []; + const layer = Layer.mergeAll( + Layer.mock(ThreadManagementService)({ + getThreadShell: (id) => + Effect.sync(() => { + expect(id).toBe(threadId); + return thread; + }), + dispatch: (command) => + Effect.sync(() => { + if (command.type !== "thread.metadata.update") + throw new Error("Expected V2 metadata update"); + expect(command.threadId).toBe(threadId); + commands.push(command); + thread = { + ...thread, + ...updateLinkedPullRequests(thread, command, "2026-09-11T00:00:00.000Z"), + }; + return { sequence: commands.length, storedEvents: [] }; + }), + }), + Layer.mock(ProjectService)({ getById: () => Effect.succeed(Option.none()) }), + NodeServices.layer, + ); + const service = yield* make.pipe(Effect.provide(layer)); + const input = { url: "https://github.com/org/repo/pull/41" }; + expect((yield* service.link(scope, input)).alreadyLinked).toBe(false); + expect((yield* service.link(scope, input)).alreadyLinked).toBe(true); + expect(commands).toHaveLength(1); + expect((yield* service.list(scope)).pullRequests).toMatchObject([ + { number: 41, source: "agent", state: null }, + ]); + expect((yield* service.unlink(scope, input)).wasLinked).toBe(true); + expect((yield* service.unlink(scope, input)).wasLinked).toBe(false); + expect(commands).toHaveLength(2); + expect((yield* service.list(scope)).pullRequests).toEqual([]); + }).pipe(Effect.scoped), +); + +it.effect("requires the PR capability before touching thread state", () => + Effect.gen(function* () { + const service = yield* make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ThreadManagementService)({ + getThreadShell: () => Effect.die("Must not read thread"), + }), + Layer.mock(ProjectService)({}), + NodeServices.layer, + ), + ), + ); + const result = yield* service + .link( + { ...scope, capabilities: new Set(["orchestration"]) }, + { url: "https://github.com/org/repo/pull/41" }, + ) + .pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "McpPullRequestCapabilityUnavailableError" }, + }); + }).pipe(Effect.scoped), +); + +it.effect("resolves host-specific URLs and refuses incomplete or non-PR targets", () => + Effect.gen(function* () { + const gitlab = yield* resolvePullRequestTarget( + { url: "https://gitlab.example/team/sub/repo/-/merge_requests/17" }, + undefined, + ); + expect(gitlab).toMatchObject({ + host: "gitlab.example", + repository: "team/sub/repo", + number: 17, + }); + const azure = yield* resolvePullRequestTarget( + { url: "https://dev.azure.com/org/project/_git/repo/pullrequest/8" }, + undefined, + ); + expect(azure).toMatchObject({ + host: "dev.azure.com", + repository: "org/project/_git/repo", + number: 8, + }); + expect( + yield* resolvePullRequestTarget( + { url: "https://github.com/org/repo/issues/41" }, + undefined, + ).pipe(Effect.result), + ).toMatchObject({ _tag: "Failure" }); + expect( + yield* resolvePullRequestTarget({ repository: "org/repo", number: 41 }, undefined).pipe( + Effect.result, + ), + ).toMatchObject({ _tag: "Failure", failure: { _tag: "PullRequestHostRequiredError" } }); + }), +); diff --git a/apps/server/src/mcp/PullRequestMcpService.ts b/apps/server/src/mcp/PullRequestMcpService.ts new file mode 100644 index 000000000..8bfb698cd --- /dev/null +++ b/apps/server/src/mcp/PullRequestMcpService.ts @@ -0,0 +1,194 @@ +import { + CommandId, + pullRequestHostOf, + McpPullRequestCapabilityUnavailableError, + PullRequestUrlInvalidError, + PullRequestTargetIncompleteError, + PullRequestHostRequiredError, + PullRequestThreadNotFoundError, + PullRequestLinkFailedError, + PullRequestUnlinkFailedError, + PullRequestListFailedError, + type Project, + type PullRequestTargetInput, + type PullRequestToolError, + type LinkPullRequestResult, + type UnlinkPullRequestResult, + type ListThreadPullRequestsResult, + type ThreadPullRequestEntry, + type OrchestrationV2ThreadShell, + type SourceControlProviderKind, +} from "@t3tools/contracts"; +import { changeRequestUrlFor, parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { allThreadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; +import { + resolveThreadPullRequestChains, + threadPullRequestKeyOf, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequestChains"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; +import { ProjectService } from "../project/ProjectService.ts"; +import type { McpInvocationScope } from "./McpInvocationContext.ts"; + +export class PullRequestMcpService extends Context.Service< + PullRequestMcpService, + { + readonly link: ( + scope: McpInvocationScope, + input: PullRequestTargetInput, + ) => Effect.Effect; + readonly unlink: ( + scope: McpInvocationScope, + input: PullRequestTargetInput, + ) => Effect.Effect; + readonly list: ( + scope: McpInvocationScope, + ) => Effect.Effect; + } +>()("t3/mcp/PullRequestMcpService") {} + +export const resolvePullRequestTarget = Effect.fn("PullRequestMcpService.resolveTarget")(function* ( + input: PullRequestTargetInput, + project: Project | undefined, +) { + if (input.url !== undefined) { + const parsed = parseChangeRequestUrl(input.url); + if (parsed === null) return yield* new PullRequestUrlInvalidError({}); + return { ...parsed, url: input.url }; + } + if (input.repository === undefined || input.number === undefined) + return yield* new PullRequestTargetIncompleteError({}); + const identity = project?.repositoryIdentity; + const kind = identity?.provider as SourceControlProviderKind | undefined; + const projectHost = identity && kind ? pullRequestHostOf(identity, kind) : undefined; + const host = (input.host ?? projectHost)?.toLowerCase(); + if (!host) return yield* new PullRequestHostRequiredError({}); + const repository = input.repository.toLowerCase(); + const url = + changeRequestUrlFor(host === projectHost ? kind : null, host, repository, input.number) ?? + `https://${host}/${repository}/pull/${input.number}`; + return { host, repository, number: input.number, url }; +}); + +export function listV2ThreadPullRequests( + thread: OrchestrationV2ThreadShell, +): ListThreadPullRequestsResult { + const links = visibleThreadPullRequests(allThreadPullRequestsOf(thread)); + const chains = resolveThreadPullRequestChains(links); + return { + pullRequests: links.map((link): ThreadPullRequestEntry => { + const chain = chains.find( + (candidate) => + candidate.layers.length > 1 && + candidate.layers.some( + (layer) => threadPullRequestKeyOf(layer) === threadPullRequestKeyOf(link), + ), + ); + return { + host: link.host, + repository: link.repository, + number: link.number, + url: link.url, + source: link.source, + state: link.snapshot?.state ?? null, + title: link.snapshot?.title ?? null, + headBranch: link.snapshot?.headBranch ?? null, + baseBranch: link.snapshot?.baseBranch ?? null, + isDraft: link.snapshot?.isDraft ?? null, + stack: chain + ? { + kind: chain.kind, + position: + chain.layers.findIndex( + (layer) => threadPullRequestKeyOf(layer) === threadPullRequestKeyOf(link), + ) + 1, + size: chain.layers.length, + } + : null, + }; + }), + chains: chains.map((chain) => ({ + kind: chain.kind, + numbers: chain.layers.map((layer) => layer.number), + })), + }; +} + +export const make = Effect.gen(function* () { + const threads = yield* ThreadManagementService; + const projects = yield* ProjectService; + const crypto = yield* Crypto.Crypto; + const requireThread = Effect.fn("PullRequestMcpService.requireThread")(function* ( + scope: McpInvocationScope, + ) { + if (!scope.capabilities.has("pull-requests")) + return yield* new McpPullRequestCapabilityUnavailableError({}); + const thread = yield* threads + .getThreadShell(scope.threadId) + .pipe(Effect.mapError((cause) => new PullRequestListFailedError({ cause }))); + if (!thread || thread.deletedAt !== null) + return yield* new PullRequestThreadNotFoundError({ threadId: scope.threadId }); + return thread; + }); + const mutate = (scope: McpInvocationScope, input: PullRequestTargetInput, unlink: boolean) => + Effect.gen(function* () { + const thread = yield* requireThread(scope); + const project = yield* projects.getById(thread.projectId).pipe( + Effect.map(Option.getOrUndefined), + Effect.mapError((cause) => new PullRequestListFailedError({ cause })), + ); + const target = yield* resolvePullRequestTarget(input, project); + const key = threadPullRequestKeyOf(target); + const wasLinked = visibleThreadPullRequests(allThreadPullRequestsOf(thread)).some( + (link) => threadPullRequestKeyOf(link) === key, + ); + if (unlink ? wasLinked : !wasLinked) { + const reference = { + projectId: thread.projectId, + repository: target.repository, + number: target.number, + url: target.url, + }; + const uuid = yield* crypto.randomUUIDv4.pipe(Effect.orDie); + yield* threads + .dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`mcp:pr:${uuid}`), + threadId: scope.threadId, + ...(unlink + ? { unlinkPullRequest: reference } + : { linkPullRequest: reference, linkPullRequestSource: "agent" as const }), + }) + .pipe( + Effect.mapError((cause) => + unlink + ? new PullRequestUnlinkFailedError({ cause }) + : new PullRequestLinkFailedError({ cause }), + ), + ); + } + return { target, wasLinked }; + }); + return PullRequestMcpService.of({ + link: (scope, input) => + mutate(scope, input, false).pipe( + Effect.map(({ target, wasLinked }) => ({ ...target, alreadyLinked: wasLinked })), + ), + unlink: (scope, input) => + mutate(scope, input, true).pipe( + Effect.map(({ target, wasLinked }) => ({ + host: target.host, + repository: target.repository, + number: target.number, + wasLinked, + })), + ), + list: (scope) => requireThread(scope).pipe(Effect.map(listV2ThreadPullRequests)), + }); +}); +export const layer = Layer.effect(PullRequestMcpService, make); diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 1b9e1ae57..fc6fd795b 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -436,7 +436,7 @@ describe("t3_worktree_handoff", () => { projectId, projectCwd: workspaceRoot, worktreePath: "/worktrees/project/feature/handoff", - project: { workspaceRoot, scripts: [] }, + project: { id: projectId, workspaceRoot, scripts: [] }, }); }); }); diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts index 51a144256..dad10c031 100644 --- a/apps/server/src/mcp/WorktreeMcpService.ts +++ b/apps/server/src/mcp/WorktreeMcpService.ts @@ -516,6 +516,7 @@ const make = Effect.gen(function* () { projectCwd, worktreePath, project: { + id: project.id, workspaceRoot: project.workspaceRoot, scripts: project.scripts, }, diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts new file mode 100644 index 000000000..32692167b --- /dev/null +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -0,0 +1,22 @@ +import * as Effect from "effect/Effect"; +import { McpInvocationContext } from "../../McpInvocationContext.ts"; +import { PullRequestMcpService } from "../../PullRequestMcpService.ts"; +import { PullRequestsToolkit } from "./tools.ts"; + +export const PullRequestsToolkitHandlersLive = PullRequestsToolkit.toLayer({ + link_pull_request: (input) => + Effect.gen(function* () { + const service = yield* PullRequestMcpService; + return yield* service.link(yield* McpInvocationContext, input); + }), + unlink_pull_request: (input) => + Effect.gen(function* () { + const service = yield* PullRequestMcpService; + return yield* service.unlink(yield* McpInvocationContext, input); + }), + list_thread_pull_requests: () => + Effect.gen(function* () { + const service = yield* PullRequestMcpService; + return yield* service.list(yield* McpInvocationContext); + }), +}); diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts new file mode 100644 index 000000000..1017ed54b --- /dev/null +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -0,0 +1,62 @@ +import { + PullRequestTargetInput, + LinkPullRequestResult, + UnlinkPullRequestResult, + ListThreadPullRequestsResult, + PullRequestToolError, +} from "@t3tools/contracts"; +import { Tool, Toolkit } from "effect/unstable/ai"; +import { McpInvocationContext } from "../../McpInvocationContext.ts"; +import { PullRequestMcpService } from "../../PullRequestMcpService.ts"; + +const dependencies = [McpInvocationContext, PullRequestMcpService]; +const REGISTER_EVERY_PR = + "Register every pull request you open for this thread, including each layer of a stack, right after creating it."; + +const LinkPullRequestTool = Tool.make("link_pull_request", { + description: `${REGISTER_EVERY_PR} Links a pull request to this thread so T3 Code tracks it, shows its status beside the thread. Pass the URL, or repository plus number. Linking an already-linked pull request succeeds with alreadyLinked=true.`, + parameters: PullRequestTargetInput, + success: LinkPullRequestResult, + failure: PullRequestToolError, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "Link pull request to thread") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +const UnlinkPullRequestTool = Tool.make("unlink_pull_request", { + description: + "Remove a pull request link from this thread, for example after closing a pull request you opened by mistake. Pass the URL, or repository plus number. Unlinking a pull request that is not linked succeeds with wasLinked=false.", + parameters: PullRequestTargetInput, + success: UnlinkPullRequestResult, + failure: PullRequestToolError, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "Unlink pull request from thread") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { + description: `List the pull requests linked to this thread with their last known host state, and how they chain into stacks (bottom to top). ${REGISTER_EVERY_PR}`, + success: ListThreadPullRequestsResult, + failure: PullRequestToolError, + failureMode: "return", + dependencies, +}) + .annotate(Tool.Title, "List thread pull requests") + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + +export const PullRequestsToolkit = Toolkit.make( + LinkPullRequestTool, + UnlinkPullRequestTool, + ListThreadPullRequestsTool, +); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index e2b08528a..757e5e348 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1,6 +1,8 @@ +import { type ClaudeModelCatalog } from "../../provider/ClaudeModelCatalog.ts"; import type { Query as ClaudeQuery, SDKMessage, + SDKRateLimitInfo, SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -1298,6 +1300,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); const makeWakeHarnessWithOptions = (options?: { + readonly usageLimitListener?: { + readonly publish: (info: SDKRateLimitInfo) => Effect.Effect; + }; readonly close?: (sdkMessages: Queue.Queue) => Effect.Effect; readonly interrupt?: Effect.Effect; }) => @@ -1311,6 +1316,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const offeredMessages: Array = []; const continuationRequests: Array = []; const adapter = makeClaudeAdapterV2({ + ...(options?.usageLimitListener ? { usageLimitListener: options.usageLimitListener } : {}), instanceId: CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, environment: {}, @@ -1384,6 +1390,44 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); const makeWakeHarness = makeWakeHarnessWithOptions(); + it.effect("delivers live quota frames to the V2 account listener", () => + Effect.scoped( + Effect.gen(function* () { + const receipt = yield* Deferred.make(); + const harness = yield* makeWakeHarnessWithOptions({ + usageLimitListener: { + publish: (info) => Deferred.succeed(receipt, info).pipe(Effect.asVoid), + }, + }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-usage"), + text: "hello", + attachments: [], + }), + ); + const info = { + status: "allowed" as const, + rateLimitType: "five_hour" as const, + utilization: 0.42, + }; + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "rate_limit_event", + rate_limit_info: info, + uuid: "00000000-0000-4000-8000-000000000209", + session_id: WAKE_NATIVE_SESSION, + }), + ); + assert.deepStrictEqual(yield* Deferred.await(receipt), info); + }), + ).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ); + it.effect("projects API retries and resolves the same item after recovery", () => Effect.scoped( Effect.gen(function* () { @@ -3582,3 +3626,122 @@ describe("ClaudeAdapterV2 query message stream", () => { }), ); }); + +describe("ClaudeAdapterV2 model catalog", () => { + it.effect("compiles remote model profiles and keeps steering on the active turn's profile", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const offered: SDKUserMessage[] = []; + const opened: ClaudeAgentSdkQueryOptions[] = []; + let catalogReads = 0; + const catalog: ClaudeModelCatalog = { + models: [ + { + model: { + slug: "remote-model", + aliases: ["remote"], + name: "Remote", + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Effort", + type: "select", + options: [ + { id: "high", label: "High", isDefault: true }, + { id: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }, + ], + }, + }, + runtime: { effortMap: { high: "remote-high" } }, + compatibility: {}, + }, + ], + }; + const adapter = makeClaudeAdapterV2({ + instanceId: CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir: "/unused", + fileSystem, + idAllocator, + modelCatalog: Effect.sync(() => { + catalogReads += 1; + return catalogReads === 1 ? catalog : { models: [] }; + }), + queryRunner: { + allocateSessionId: Effect.succeed("native-remote-model"), + open: ({ options }) => + Effect.sync(() => { + opened.push(options); + return { + messages: Stream.never, + offer: (message: SDKUserMessage) => + Effect.sync(() => { + offered.push(message); + }), + setModel: () => Effect.void, + interrupt: Effect.void, + close: Effect.void, + }; + }), + forkSession: () => Effect.die("unused"), + assertComplete: Effect.void, + }, + }); + const threadId = ThreadId.make("thread-remote-model"); + const modelSelection: ModelSelection = { ...CLAUDE_TEST_MODEL_SELECTION, model: "remote" }; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("session-remote-model"), + modelSelection, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const attemptId = RunAttemptId.make("attempt-remote-model"); + yield* runtime.startTurn({ + ...makeClaudeTestTurnInput({ + threadId, + providerThread, + now: yield* DateTime.now, + attemptId, + text: "First", + attachments: [], + }), + modelSelection, + }); + yield* runtime.steerTurn({ + threadId, + runId: RunId.make("run-remote-model"), + providerThread, + providerTurnId: idAllocator.derive.providerTurn({ + driver: CLAUDE_PROVIDER, + nativeTurnId: `turn:${attemptId}`, + }), + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("steer-remote-model"), + text: "Follow up", + attachments: [], + }, + }); + assert.equal(opened[0]?.model, "remote-model"); + assert.equal(opened[0]?.effort, "remote-high"); + assert.deepEqual( + offered.map((message) => message.message.content), + ["Ultrathink:\nFirst", "Ultrathink:\nFollow up"], + ); + assert.equal(catalogReads, 1); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index c7a0a3194..43f1cfe13 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1,3 +1,11 @@ +import { ClaudeUsageLimitListener } from "../../provider/providerUsageLimits.ts"; +import { ModelManifest } from "../../provider/ModelManifest.ts"; +import { + BUNDLED_CLAUDE_MODEL_CATALOG, + type ClaudeModelCatalog, + resolveClaudeModelCatalog, + scopeClaudeModelCatalog, +} from "../../provider/ClaudeModelCatalog.ts"; import { type CanUseTool, forkSession as forkClaudeSession, @@ -678,6 +686,7 @@ export function claudeAutoCompactWindow( } export function makeClaudeQueryOptions(input: { + readonly compiledSelection?: CompiledClaudeModelSelection; readonly modelSelection: ModelSelection; readonly nativeThreadId: string; readonly resume: boolean; @@ -694,7 +703,8 @@ export function makeClaudeQueryOptions(input: { readonly canUseTool?: CanUseTool; readonly allowDangerouslySkipPermissions?: boolean; }): ClaudeAgentSdkQueryOptions { - const compiledSelection = compileClaudeModelSelection(input.modelSelection); + const compiledSelection = + input.compiledSelection ?? compileClaudeModelSelection(input.modelSelection); const extraArgs = input.settings === undefined ? {} : parseCliArgs(input.settings.launchArgs).flags; const threadIdentity: ClaudeAgentSdkThreadIdentity = input.resume @@ -719,7 +729,7 @@ export function makeClaudeQueryOptions(input: { const resolvedSelectionSettings = autoCompactWindow === undefined ? selectionSettings - : ({ ...(selectionSettings ?? {}), autoCompactWindow } as ClaudeSdkSettings); + : ({ ...selectionSettings, autoCompactWindow } as ClaudeSdkSettings); const querySettings = resolvedSelectionSettings === undefined ? input.sdkSettings @@ -2159,6 +2169,7 @@ function buildAssistantArtifacts(input: { } interface ActiveClaudeTurnContext { + readonly compiledSelection: CompiledClaudeModelSelection; readonly input: ProviderAdapterV2TurnInput; readonly nativeTurnId: string; nativeMessageCursor: string | null; @@ -2272,6 +2283,12 @@ interface PendingClaudeRuntimeRequest { } export interface ClaudeAdapterV2Options { + readonly usageLimitListener?: { + readonly publish: ( + info: import("@anthropic-ai/claude-agent-sdk").SDKRateLimitInfo, + ) => Effect.Effect; + }; + readonly modelCatalog?: Effect.Effect; readonly instanceId: ProviderInstanceId; readonly settings: ClaudeSettings; readonly environment: NodeJS.ProcessEnv; @@ -2289,6 +2306,11 @@ export function makeClaudeAdapterV2( adapterOptions: ClaudeAdapterV2Options, ): ProviderAdapterV2Shape { const { attachmentsDir, fileSystem, idAllocator, queryRunner } = adapterOptions; + const modelCatalog = ( + adapterOptions.modelCatalog ?? Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG) + ).pipe( + Effect.map((catalog) => scopeClaudeModelCatalog(catalog, adapterOptions.settings.customModels)), + ); const continuationRequests = adapterOptions.continuationRequests ?? { offer: () => Effect.void, }; @@ -4112,6 +4134,11 @@ export function makeClaudeAdapterV2( } const message = input.message; + if (message.type === "rate_limit_event") { + yield* ( + adapterOptions.usageLimitListener?.publish(message.rate_limit_info) ?? Effect.void + ); + } // Background lifecycle first, and deliberately ahead of the wake // buffer. A background command settles precisely when no turn is // active, and buffering its outcome until the model happens to wake up @@ -4612,6 +4639,7 @@ export function makeClaudeAdapterV2( const openQuery = Effect.fnUntraced(function* ( turnInput: ProviderAdapterV2TurnInput, nativeThreadId: string, + compiledSelection: CompiledClaudeModelSelection, ) { const queryPolicy = claudeRuntimeQueryPolicyForRuntimePolicy(turnInput.runtimePolicy); const mcpOverrides = claudeMcpQueryOverrides({ @@ -4623,7 +4651,6 @@ export function makeClaudeAdapterV2( : { allowedTools: queryPolicy.allowedTools }), }); const queryPolicyKey = claudeEffectiveQueryPolicyKey(queryPolicy, mcpOverrides); - const compiledSelection = compileClaudeModelSelection(turnInput.modelSelection); const resumeSessionAt = yield* getNativeConversationHeadId(turnInput.providerThread); const existing = yield* Ref.get(queryContext); if ( @@ -4652,6 +4679,7 @@ export function makeClaudeAdapterV2( threadId: turnInput.threadId, providerSessionId: input.providerSessionId, options: makeClaudeQueryOptions({ + compiledSelection, modelSelection: turnInput.modelSelection, nativeThreadId, resume: shouldResume, @@ -4771,7 +4799,12 @@ export function makeClaudeAdapterV2( // messages into this turn and let any still-streaming messages // follow live. The continuation prompt text never reaches the CLI. const isContinuationTurn = isClaudeProviderContinuationTurn(turnInput); + const compiledSelection = compileClaudeModelSelection( + turnInput.modelSelection, + yield* modelCatalog, + ); const context: ActiveClaudeTurnContext = { + compiledSelection, input: turnInput, nativeTurnId, nativeMessageCursor: null, @@ -4795,13 +4828,13 @@ export function makeClaudeAdapterV2( : yield* makeClaudeUserMessageWithAttachments({ text: applyClaudePromptEffortPrefix( turnInput.message.text, - compileClaudeModelSelection(turnInput.modelSelection).promptEffort, + compiledSelection.promptEffort, ), attachments: turnInput.message.attachments, attachmentsDir, fileSystem, }); - const querySession = yield* openQuery(turnInput, nativeThreadId); + const querySession = yield* openQuery(turnInput, nativeThreadId, compiledSelection); yield* Ref.set(activeTurn, context); yield* emitProviderEvent({ type: "provider_turn.updated", @@ -4950,7 +4983,7 @@ export function makeClaudeAdapterV2( const userMessage = yield* makeClaudeUserMessageWithAttachments({ text: applyClaudePromptEffortPrefix( turnInput.message.text, - compileClaudeModelSelection(currentTurn.input.modelSelection).promptEffort, + currentTurn.compiledSelection.promptEffort, ), attachments: turnInput.message.attachments, priority: "now", @@ -5314,11 +5347,19 @@ export const ClaudeAdapterV2Driver: ProviderAdapterDriver< const idAllocator = yield* IdAllocatorV2; const queryRunner = yield* ClaudeAgentSdkQueryRunner; const serverConfig = yield* ServerConfig; + const manifest = yield* Effect.serviceOption(ModelManifest); + const usageLimitListener = yield* Effect.serviceOption(ClaudeUsageLimitListener); const continuationRequests = yield* ProviderContinuationRequests; const baseEnvironment = mergeProviderInstanceEnvironment(environment, hostEnvironment); const claudeEnvironment = yield* makeClaudeEnvironment(config, baseEnvironment); return makeClaudeAdapterV2({ instanceId, + ...(Option.isSome(usageLimitListener) + ? { usageLimitListener: usageLimitListener.value } + : {}), + modelCatalog: Option.isSome(manifest) + ? manifest.value.current.pipe(Effect.map(resolveClaudeModelCatalog)) + : Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG), settings: { ...config, enabled }, environment: claudeEnvironment, attachmentsDir: serverConfig.attachmentsDir, @@ -5349,10 +5390,14 @@ const makeDefaultClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2.layer")(function* const idAllocator = yield* IdAllocatorV2; const queryRunner = yield* ClaudeAgentSdkQueryRunner; const serverConfig = yield* ServerConfig; + const manifest = yield* Effect.serviceOption(ModelManifest); const continuationRequests = yield* ProviderContinuationRequests; return makeClaudeAdapterV2({ instanceId: CLAUDE_DEFAULT_INSTANCE_ID, + modelCatalog: Option.isSome(manifest) + ? manifest.value.current.pipe(Effect.map(resolveClaudeModelCatalog)) + : Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG), settings: DEFAULT_CLAUDE_SETTINGS, environment: hostEnvironment, attachmentsDir: serverConfig.attachmentsDir, diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 08ef7ff21..f2a65d5d7 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1,3 +1,4 @@ +import type { CodexRateLimitSnapshot } from "../../provider/providerUsageLimits.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { CheckpointId, @@ -615,6 +616,32 @@ describe("CodexAdapterV2 dynamic tool projection", () => { }); }); + it("carries MCP presentation beside the output into V2", () => { + const projection = projectCodexDynamicToolItem({ + type: "mcpToolCall", + id: "browser", + server: "browser", + tool: "js", + status: "completed", + arguments: { title: "Review page" }, + result: { + content: [], + _meta: { + "codex/toolSurface": { + kind: "browserUse", + backend: "safari", + openTabs: [{ url: "https://example.com" }], + }, + }, + }, + }); + assert.equal(projection.toolSurface, "browser"); + assert.equal(projection.title, "Review page"); + assert.equal(projection.toolSource?.name, "Safari"); + assert.deepEqual(projection.toolIcon, { _tag: "website", pageUrl: "https://example.com/" }); + assert.deepEqual(projection.output, []); + }); + it("preserves namespaced dynamic tool output", () => { const projection = projectCodexDynamicToolItem({ type: "dynamicToolCall", @@ -928,6 +955,7 @@ function codexReplayPreamble(input: { readonly nativeThreadId: string; readonly nativeTurnId: string; readonly prompt: string; + readonly promptless?: boolean; }): Array { return [ { @@ -1008,7 +1036,7 @@ function codexReplayPreamble(input: { method: "turn/start", params: { threadId: input.nativeThreadId, - input: [{ type: "text", text: input.prompt }], + input: input.promptless ? [] : [{ type: "text", text: input.prompt }], cwd: "/workspace", model: "gpt-5.4", approvalPolicy: "never", @@ -1064,7 +1092,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { return yield* Effect.die(`Timed out waiting for ${label}.`); }); - const makeCodexReplayHarness = (transcript: CodexReplay.CodexAppServerReplayTranscript) => + const makeCodexReplayHarness = ( + transcript: CodexReplay.CodexAppServerReplayTranscript, + usageLimitListener?: { + readonly publish: (snapshot: CodexRateLimitSnapshot) => Effect.Effect; + }, + ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocatorV2; @@ -1087,6 +1120,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), }; const adapter = makeCodexAdapterV2({ + ...(usageLimitListener ? { usageLimitListener } : {}), instanceId: CODEX_DEFAULT_INSTANCE_ID, settings: DEFAULT_CODEX_SETTINGS, environment: {}, @@ -1115,10 +1149,14 @@ describe("CodexAdapterV2 post-settle continuation", () => { }); const events: Array = []; const terminalReceipt = yield* Deferred.make(); + const approvalReceipt = + yield* Deferred.make>(); yield* runtime.events.pipe( Stream.runForEach((event) => Effect.gen(function* () { events.push(event); + if (event.type === "turn_item.updated" && event.turnItem.type === "approval_request") + yield* Deferred.succeed(approvalReceipt, event); if (event.type === "turn.terminal") yield* Deferred.succeed(terminalReceipt, undefined); }), ), @@ -1146,17 +1184,282 @@ describe("CodexAdapterV2 post-settle continuation", () => { continuationRequests, terminalEvents, awaitTerminal: Deferred.await(terminalReceipt), + awaitApproval: Deferred.await(approvalReceipt), subagentUpdates, hasPendingBackgroundWork, }; }); + for (const malformed of [false, true]) { + it.effect( + `resumes metadata independently of historical error enums (malformed=${malformed})`, + () => + Effect.scoped( + Effect.gen(function* () { + const nativeThreadId = "resume-saved-thread"; + const transcript = makeCodexReplayTranscript({ + scenario: `resume-metadata-${malformed}`, + entries: [ + ...codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "old-turn", + prompt: "unused", + }).slice(0, 5), + { + type: "expect_outbound", + label: "resume metadata", + frame: { + id: 3, + method: "thread/resume", + params: { threadId: nativeThreadId, excludeTurns: true }, + }, + }, + { + type: "emit_inbound", + label: "historical unknown error", + frame: { + id: 3, + result: { + thread: { + id: malformed ? null : nativeThreadId, + updatedAt: 1782622450, + turns: [ + { + id: "old-turn", + status: "failed", + error: { + message: "Historical failure", + codexErrorInfo: "misalignment_policy_violation", + }, + }, + ], + }, + }, + }, + }, + ], + }); + const h = yield* makeCodexReplayHarness(transcript); + const resume = h.runtime.resumeThread({ providerThread: h.providerThread }); + if (malformed) { + const error = yield* Effect.flip(resume); + assert.equal(error._tag, "ProviderAdapterResumeThreadError"); + } else { + const resumed = yield* resume; + assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); + assert.equal(resumed.id, h.providerThread.id); + assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000); + } + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + } + + it.effect("sends promptless input for a restart continuation", () => + Effect.scoped( + Effect.gen(function* () { + const nativeThreadId = "promptless-restart"; + const nativeTurnId = "promptless-turn"; + const entries = codexReplayPreamble({ + nativeThreadId, + nativeTurnId, + prompt: "Continue after restart", + promptless: true, + }); + const h = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ scenario: "promptless-restart", entries }), + ); + const input = makeCodexTestTurnInput({ + threadId: h.threadId, + providerThread: h.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("promptless-attempt"), + text: "Continue after restart", + }); + yield* h.runtime.startTurn({ + ...input, + message: { + ...input.message, + createdBy: "agent", + creationSource: "server", + restartContinuation: true, + }, + }); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + const assistantMessages = (events: ReadonlyArray) => events.filter( (event): event is Extract => event.type === "message.updated" && event.message.role === "assistant", ); + it.effect("forwards account usage notifications through the V2 adapter", () => + Effect.scoped( + Effect.gen(function* () { + const receipt = yield* Deferred.make(); + const nativeThreadId = "usage-thread"; + const nativeTurnId = "usage-turn"; + const rateLimits = { + limitId: "codex", + primary: { usedPercent: 42, resetsAt: 1788652800, windowDurationMins: 300 }, + secondary: null, + credits: null, + planType: "plus" as const, + }; + const transcript = makeCodexReplayTranscript({ + scenario: "usage-limits", + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Hello." }), + { + type: "emit_inbound", + label: "account usage", + frame: { method: "account/rateLimits/updated", params: { rateLimits } }, + }, + { + type: "emit_inbound", + label: "turn completed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript, { + publish: (snapshot) => Deferred.succeed(receipt, snapshot).pipe(Effect.asVoid), + }); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("usage-attempt"), + text: "Hello.", + }), + ); + assert.equal((yield* Deferred.await(receipt)).primary?.usedPercent, 42); + yield* harness.awaitTerminal; + }), + ).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ); + + it.effect( + "routes Codex app-access approval choices through V2 and rejects unoffered persistence", + () => + Effect.scoped( + Effect.gen(function* () { + const nativeThreadId = "mcp-thread"; + const nativeTurnId = "mcp-turn"; + const transcript = makeCodexReplayTranscript({ + scenario: "mcp-approval", + entries: [ + ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "Use Safari." }), + { + type: "emit_inbound", + label: "mcp approval", + frame: { + id: 99, + method: "mcpServer/elicitation/request", + params: { + mode: "form", + threadId: nativeThreadId, + turnId: nativeTurnId, + serverName: "computer-use", + message: "Allow ChatGPT to use Safari?", + requestedSchema: { + type: "object", + properties: { + approval: { + type: "string", + oneOf: [ + { const: "once", title: "Once" }, + { const: "session", title: "Allow Safari this session" }, + ], + }, + }, + required: ["approval"], + }, + }, + }, + }, + { + type: "expect_outbound", + label: "approval response", + frame: { + id: 99, + result: { + action: "accept", + _meta: { persist: "session" }, + content: { approval: "session" }, + }, + }, + }, + { + type: "emit_inbound", + label: "turn completed", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + yield* harness.runtime.startTurn( + makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("mcp-attempt"), + text: "Use Safari.", + }), + ); + const event = yield* harness.awaitApproval; + if (event.turnItem.type !== "approval_request") + return yield* Effect.die("Missing approval"); + const item = event.turnItem; + assert.equal(item.requestKind, "mcp-elicitation"); + assert.equal(item.prompt, "Allow ChatGPT to use Safari?"); + assert.equal(item.title, "Safari"); + const requestNode = harness.events.find( + (event) => event.type === "node.updated" && event.node.id === item.nodeId, + ); + assert.ok(requestNode?.type === "node.updated"); + if (requestNode?.type === "node.updated") + assert.equal(requestNode.node.parentNodeId, "node-mcp-attempt"); + assert.equal(item.nativeItemRef, null); + assert.deepEqual( + item.options?.map((option) => option.decision), + ["cancel", "decline", "acceptForSession", "accept"], + ); + assert.equal( + item.options?.find((option) => option.decision === "acceptForSession")?.label, + "Allow Safari this session", + ); + const rejected = yield* Effect.exit( + harness.runtime.respondToRuntimeRequest({ + requestId: item.requestId, + decision: "acceptAlways", + }), + ); + assert.equal(rejected._tag, "Failure"); + yield* harness.runtime.respondToRuntimeRequest({ + requestId: item.requestId, + decision: "acceptForSession", + }); + yield* harness.awaitTerminal; + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + it.effect("projects async questions without holding the provider turn open", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 575995494..9d527b979 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1,3 +1,16 @@ +import { + codexMcpToolPresentation, + codexMcpIntentTitle, + type McpToolPresentation, +} from "./codexToolPresentation.ts"; +import { + CodexUsageLimitListener, + type CodexRateLimitSnapshot, +} from "../../provider/providerUsageLimits.ts"; +import { + describeMcpElicitation, + toMcpElicitationResponse, +} from "../../provider/codexMcpElicitation.ts"; import { classifyV2AgentKind, CodexSettings, @@ -26,6 +39,7 @@ import type { OrchestrationV2TurnItem, ProviderUserInputAnswers, ProviderApprovalDecision, + ProviderApprovalOption, ProviderRequestKind, ProviderTurnId, ProviderInstanceId, @@ -123,6 +137,14 @@ export const CODEX_DRIVER_KIND = CODEX_PROVIDER; export const CODEX_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(CODEX_DRIVER_KIND); const DEFAULT_CODEX_SETTINGS = Schema.decodeSync(CodexSettings)({}); const CODEX_ASSISTANT_DELTA_FLUSH_INTERVAL_MS = 50; +const decodeCodexResumeMetadata = Schema.decodeUnknownEffect( + Schema.Struct({ + thread: Schema.Struct({ + id: Schema.String.check(Schema.isMinLength(1)), + updatedAt: Schema.optional(Schema.Number), + }), + }), +); const CodexBackgroundTerminalTerminateResponse = Schema.Struct({ terminated: Schema.Boolean, }); @@ -361,7 +383,8 @@ export function codexBackgroundCommandDetail(item: { return outputTail.length === 0 ? header : `${header}\n\nOutput tail:\n${outputTail}`; } -export interface CodexDynamicToolProjection { +export interface CodexDynamicToolProjection extends McpToolPresentation { + readonly title?: string; readonly toolName: string; readonly input: unknown; readonly output?: unknown; @@ -404,10 +427,14 @@ export function projectCodexDynamicToolItem( item.type === "mcpToolCall" ? `${item.server}.${item.tool}` : [trimText(item.namespace), item.tool].filter(Boolean).join("."); + const presentation = item.type === "mcpToolCall" ? codexMcpToolPresentation(item) : {}; + const title = item.type === "mcpToolCall" ? codexMcpIntentTitle(item, presentation) : undefined; const projection: CodexDynamicToolProjection = { toolName, input: item.arguments, status: codexItemStatus(item.status).turnItem, + ...presentation, + ...(title ? { title } : {}), }; return output === undefined ? projection : { ...projection, output }; } @@ -977,6 +1004,7 @@ type PendingCodexRuntimeRequest = readonly requestId: RuntimeRequestId; readonly requestKind: ProviderRequestKind; readonly decision: Deferred.Deferred; + readonly allowedDecisions?: ReadonlyArray; } | { readonly type: "user_input"; @@ -1422,6 +1450,7 @@ export const CodexAdapterV2Driver: ProviderAdapterDriver Effect.Effect; + }; readonly instanceId: ProviderInstanceId; readonly settings: CodexSettings; readonly environment: NodeJS.ProcessEnv; @@ -1541,6 +1576,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi now, }); const events = yield* Queue.unbounded(); + const mcpRequestSequence = yield* Ref.make(0); const activeTurns = yield* Ref.make(new Map()); const pendingRootTurns = yield* Ref.make(new Map()); const turnWaiters = yield* Ref.make(new Map>()); @@ -2593,6 +2629,8 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi ) => Effect.gen(function* () { const inputItems: Array = []; + // Codex can continue its resumed native turn without adding a synthetic user prompt. + if (turnInput.message.restartContinuation === true) return inputItems; if (turnInput.message.text.length > 0) { inputItems.push({ type: "text", @@ -3039,11 +3077,14 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi parentItemId: null, ordinal, status: projection.status, - title: null, + title: projection.title ?? null, startedAt: context.startedAt, completedAt, updatedAt, type: "dynamic_tool", + ...(projection.toolSurface ? { toolSurface: projection.toolSurface } : {}), + ...(projection.toolIcon ? { toolIcon: projection.toolIcon } : {}), + ...(projection.toolSource ? { toolSource: projection.toolSource } : {}), toolName: projection.toolName, input: projection.input, ...(projection.output === undefined ? {} : { output: projection.output }), @@ -3197,13 +3238,18 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi readonly nativeRequestId: string; readonly requestKind: ProviderRequestKind; readonly prompt?: string | null; + readonly title?: string; + readonly options?: ReadonlyArray; }) => Effect.gen(function* () { const createdAt = yield* DateTime.now; - const parentNodeId = idAllocator.derive.nodeFromProviderItem({ - driver: CODEX_PROVIDER, - nativeItemId: input.nativeItemId, - }); + const parentNodeId = + input.requestKind === "mcp-elicitation" + ? input.context.rootNodeId + : idAllocator.derive.nodeFromProviderItem({ + driver: CODEX_PROVIDER, + nativeItemId: input.nativeItemId, + }); const ordinal = yield* resolveItemOrdinal( input.context, `${input.nativeItemId}:approval:${input.nativeRequestId}`, @@ -3231,7 +3277,10 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi countsForRun: false, providerThreadId: input.context.providerThread.id, providerTurnId: input.context.providerTurnId, - nativeItemRef: codexNativeItemRef(input.nativeItemId), + nativeItemRef: + input.requestKind === "mcp-elicitation" + ? null + : codexNativeItemRef(input.nativeItemId), runtimeRequestId: requestId, checkpointScopeId: null, startedAt: createdAt, @@ -3262,17 +3311,21 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi nodeId, providerThreadId: input.context.providerThread.id, providerTurnId: input.context.providerTurnId, - nativeItemRef: codexNativeItemRef(input.nativeItemId), + nativeItemRef: + input.requestKind === "mcp-elicitation" + ? null + : codexNativeItemRef(input.nativeItemId), parentItemId: null, ordinal, status: "waiting", - title: null, + title: input.title ?? null, startedAt: createdAt, completedAt: null, updatedAt: createdAt, type: "approval_request", requestId, requestKind: input.requestKind, + ...(input.options === undefined ? {} : { options: input.options }), ...(input.prompt === null || input.prompt === undefined ? {} : { prompt: input.prompt }), @@ -3377,6 +3430,12 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi return { node, request, turnItem }; }); + yield* client.handleServerNotification( + "account/rateLimits/updated", + (payload) => + adapterOptions.usageLimitListener?.publish(payload.rateLimits) ?? Effect.void, + ); + yield* client.handleServerNotification("item/agentMessage/delta", (payload) => Effect.gen(function* () { const context = (yield* Ref.get(activeTurns)).get(payload.turnId); @@ -4126,6 +4185,68 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi }).pipe(Effect.orDie), ); + yield* client.handleServerRequest("mcpServer/elicitation/request", (payload) => + Effect.gen(function* () { + if (toMcpElicitationResponse(payload, "accept").action !== "accept") + return { action: "decline" } as const; + const context = payload.turnId + ? yield* awaitActiveTurn(payload.turnId) + : yield* findActiveTurnByNativeThreadId(payload.threadId); + if ( + context === undefined || + context.providerThread.nativeThreadRef?.nativeId !== payload.threadId + ) + return { action: "decline" } as const; + const nativeRequestId = `mcp-elicitation:${yield* Ref.modify(mcpRequestSequence, (current) => [current, current + 1])}`; + const description = describeMcpElicitation(payload); + const artifacts = yield* buildApprovalRequestArtifacts({ + context, + nativeItemId: nativeRequestId, + nativeRequestId, + requestKind: "mcp-elicitation", + prompt: payload.message, + title: description.appName, + options: description.options, + }); + const decision = yield* Deferred.make(); + yield* Ref.update(pendingRuntimeRequests, (current) => + new Map(current).set(String(artifacts.request.id), { + type: "approval", + requestId: artifacts.request.id, + requestKind: "mcp-elicitation", + decision, + allowedDecisions: description.options.map((option) => option.decision), + }), + ); + yield* emitProviderEvent({ + type: "node.updated", + driver: CODEX_PROVIDER, + node: artifacts.node, + }); + yield* emitProviderEvent({ + type: "runtime_request.updated", + driver: CODEX_PROVIDER, + threadId: artifacts.node.threadId, + runtimeRequest: artifacts.request, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CODEX_PROVIDER, + turnItem: artifacts.turnItem, + }); + const resolved = yield* Deferred.await(decision).pipe( + Effect.ensuring( + Ref.update(pendingRuntimeRequests, (current) => { + const updated = new Map(current); + updated.delete(String(artifacts.request.id)); + return updated; + }), + ), + ); + return toMcpElicitationResponse(payload, resolved); + }).pipe(Effect.orDie), + ); + yield* client.handleServerRequest("item/fileChange/requestApproval", (payload) => Effect.gen(function* () { const context = yield* awaitActiveTurn(payload.turnId); @@ -4819,7 +4940,8 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi const response = yield* ensureInitialized.pipe( Effect.andThen( - client.request("thread/resume", { + client.raw.request("thread/resume", { + excludeTurns: true, threadId: nativeThreadId, ...codexThreadRuntimeParams({ threadId: threadInput.threadId ?? threadInput.providerThread.appThreadId, @@ -4832,6 +4954,7 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi }), }), ), + Effect.flatMap(decodeCodexResumeMetadata), ); return { ...threadInput.providerThread, @@ -5328,6 +5451,16 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi ), }); } + if ( + pending.allowedDecisions && + !pending.allowedDecisions.includes(requestInput.decision) + ) { + return yield* new ProviderAdapterRuntimeRequestResponseError({ + driver: CODEX_PROVIDER, + requestId: requestInput.requestId, + cause: toProtocolError("This approval decision was not offered by the provider."), + }); + } yield* Deferred.succeed(pending.decision, requestInput.decision); }).pipe( Effect.mapError((cause) => diff --git a/apps/server/src/orchestration-v2/Adapters/codexToolPresentation.test.ts b/apps/server/src/orchestration-v2/Adapters/codexToolPresentation.test.ts new file mode 100644 index 000000000..3b54f1efc --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/codexToolPresentation.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; +import { ToolActivitySource, ToolActivityIcon } from "@t3tools/contracts"; +import { codexMcpToolPresentation, codexMcpIntentTitle } from "./codexToolPresentation.ts"; + +const decodeSource = Schema.decodeUnknownSync(ToolActivitySource); +const decodeIcon = Schema.decodeUnknownSync(ToolActivityIcon); +describe("Codex tool source metadata", () => { + it("prefers the selected screenshot over a browser URL and the latest open tab", () => { + const result = codexMcpToolPresentation({ + appContext: { appName: "Google Chrome" }, + result: { + _meta: { + "codex/toolSurface": { + kind: "browserUse", + screenshot: { + pageUrl: "https://github.com/o/r", + faviconUrlDark: "https://icons.test/dark.png", + }, + openTabs: [{ url: "https://other.test" }], + }, + browser_use: { url: "https://second.test" }, + }, + }, + }); + expect(result).toEqual({ + toolSurface: "browser", + toolIcon: { + _tag: "website", + pageUrl: "https://github.com/o/r", + faviconUrlDark: "https://icons.test/dark.png", + }, + toolSource: { + key: "browser-use:chrome", + name: "Chrome", + kind: "integration", + icon: { _tag: "native-app", app: { _tag: "display-name", displayName: "Google Chrome" } }, + }, + }); + expect(decodeSource(result.toolSource)).toEqual(result.toolSource); + expect(decodeIcon(result.toolIcon)).toEqual(result.toolIcon); + }); + it("falls through malformed candidates and excludes non-image data URLs", () => { + const result = codexMcpToolPresentation({ + result: { + _meta: { + "codex/toolSurface": { + kind: "browserUse", + screenshot: { pageUrl: "javascript:alert(1)" }, + openTabs: [{ url: "https://valid.test" }, { url: "file:///private" }], + logoUrl: "data:text/html,no", + backend: "iab", + }, + }, + }, + }); + expect(result.toolIcon).toEqual({ _tag: "website", pageUrl: "https://valid.test/" }); + expect(result.toolSource).toEqual({ + key: "browser-use:browser", + name: "Browser", + kind: "browser", + }); + }); + it("preserves app identity and themed logos without exposing host paths", () => { + const result = codexMcpToolPresentation({ + arguments: { application: "Figma" }, + result: { + _meta: { + "codex/toolSurface": { + kind: "computerUse", + app: { kind: "appId", appId: "com.figma.Desktop" }, + }, + source: { + logoUrl: "https://icons.test/light.png", + logoDarkUrl: "https://icons.test/dark.png", + }, + }, + }, + }); + expect(result.toolSource).toEqual({ + key: "native-app:com.figma.desktop", + name: "Figma", + kind: "computer", + icon: { + _tag: "themed-logo", + logoUrl: "https://icons.test/light.png", + logoUrlDark: "https://icons.test/dark.png", + }, + }); + expect(decodeSource(result.toolSource)).toEqual(result.toolSource); + }); + it("bounds long app keys and rejects malformed metadata", () => { + const result = codexMcpToolPresentation({ + result: { + _meta: { + "codex/toolSurface": { + kind: "computerUse", + app: { kind: "appId", appId: "a".repeat(512) }, + }, + }, + }, + }); + expect(result.toolSource?.key.length).toBe(512); + expect(decodeSource(result.toolSource)).toEqual(result.toolSource); + expect(codexMcpToolPresentation({ result: [] })).toEqual({}); + expect( + codexMcpToolPresentation({ result: { _meta: { "codex/toolSurface": { kind: "unknown" } } } }), + ).toEqual({}); + }); +}); + +describe("Codex intent titles", () => { + it("uses bounded JS intent instead of code or protocol names", () => { + expect( + codexMcpIntentTitle( + { + server: "browser", + tool: "js", + status: "inProgress", + arguments: { title: " Inspect\n the page " }, + }, + {}, + ), + ).toBe("Inspect the page"); + const title = codexMcpIntentTitle( + { server: "browser", tool: "js", status: "completed", arguments: { title: "😀".repeat(90) } }, + {}, + ); + expect(Array.from(title ?? "")).toHaveLength(80); + expect(title?.endsWith("…")).toBe(true); + }); + it("names computer actions without calling failures successes", () => { + const item = { + server: "computer_use", + tool: "scroll", + status: "inProgress", + arguments: { application: "Figma", direction: "DOWN" }, + }; + expect(codexMcpIntentTitle(item, {})).toBe("Scrolling down in Figma"); + expect(codexMcpIntentTitle({ ...item, status: "completed" }, {})).toBe( + "Scrolled down in Figma", + ); + expect(codexMcpIntentTitle({ ...item, status: "failed" }, {})).toBeUndefined(); + expect(codexMcpIntentTitle({ ...item, server: "unrelated" }, {})).toBeUndefined(); + }); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/codexToolPresentation.ts b/apps/server/src/orchestration-v2/Adapters/codexToolPresentation.ts new file mode 100644 index 000000000..8fccb7a03 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/codexToolPresentation.ts @@ -0,0 +1,336 @@ +import * as NodeCrypto from "node:crypto"; +import type { + ToolActivityIcon, + ToolActivityNativeAppReference, + ToolActivitySource, +} from "@t3tools/contracts"; + +function asUnknownRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function normalizedHttpUrl(value: unknown): string | undefined { + if (typeof value !== "string" || value.length > 4096) return undefined; + try { + const url = new URL(value); + const href = url.href; + return (url.protocol === "http:" || url.protocol === "https:") && href.length <= 4096 + ? href + : undefined; + } catch { + return undefined; + } +} + +function normalizedImageUrl(value: unknown): string | undefined { + if (typeof value !== "string" || value.length > 4096) return undefined; + try { + const url = new URL(value); + return url.href.length <= 4096 && + (url.protocol === "http:" || + url.protocol === "https:" || + (url.protocol === "data:" && /^data:image\//iu.test(url.href))) + ? url.href + : undefined; + } catch { + return undefined; + } +} + +function normalizedAppId(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const appId = value.trim(); + return appId.length > 0 && appId.length <= 512 && /^[A-Za-z0-9._-]+$/u.test(appId) + ? appId + : undefined; +} + +function normalizedDisplayName(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const displayName = value.trim().replace(/\s+/gu, " "); + return displayName && displayName.length <= 160 ? displayName : undefined; +} + +function normalizedSourceKeyPart(value: string): string { + return value.trim().toLowerCase(); +} + +function nativeAppSourceKey(appId: string): string { + const key = `native-app:${appId.toLowerCase()}`; + if (key.length <= 512) return key; + const digest = NodeCrypto.createHash("sha256").update(key).digest("hex"); + return `${key.slice(0, 512 - digest.length - 1)}:${digest}`; +} + +function browserDisplayName(value: unknown): string | undefined { + const normalized = normalizedDisplayName(value)?.toLowerCase(); + if (!normalized) return undefined; + if (normalized.includes("chrome") || normalized === "chromium") return "Chrome"; + if (normalized.includes("edge")) return "Microsoft Edge"; + if (normalized.includes("firefox")) return "Firefox"; + if (normalized.includes("safari")) return "Safari"; + if (normalized.includes("arc")) return "Arc"; + if (normalized === "iab" || normalized.includes("in-app")) return "Browser"; + return normalizedDisplayName(value); +} + +function browserNativeAppReference(name: string): ToolActivityNativeAppReference | undefined { + switch (name) { + case "Chrome": + return { _tag: "display-name", displayName: "Google Chrome" }; + case "Microsoft Edge": + case "Firefox": + case "Safari": + case "Arc": + return { _tag: "display-name", displayName: name }; + default: + return undefined; + } +} + +function appDisplayNameFromId(appId: string): string | undefined { + const knownNames: Readonly> = { + "com.apple.finder": "Finder", + "com.apple.safari": "Safari", + "com.google.chrome": "Chrome", + "com.microsoft.edgemac": "Microsoft Edge", + "org.mozilla.firefox": "Firefox", + "company.thebrowser.browser": "Arc", + }; + return knownNames[appId.toLowerCase()]; +} + +function nativeAppReference(value: unknown): ToolActivityNativeAppReference | undefined { + const app = asUnknownRecord(value); + if (app?.kind === "appId") { + const appId = normalizedAppId(app.appId); + return appId ? { _tag: "app-id", appId } : undefined; + } + if (app?.kind === "displayName") { + const displayName = normalizedDisplayName(app.displayName); + return displayName ? { _tag: "display-name", displayName } : undefined; + } + return undefined; +} + +function themedLogoIcon( + ...records: ReadonlyArray | undefined> +): ToolActivityIcon | undefined { + for (const record of records) { + const logoUrl = normalizedImageUrl(record?.logoUrl); + if (!logoUrl) continue; + const logoUrlDark = normalizedImageUrl(record?.logoUrlDark ?? record?.logoDarkUrl); + return { + _tag: "themed-logo", + logoUrl, + ...(logoUrlDark ? { logoUrlDark } : {}), + }; + } + return undefined; +} + +export interface McpToolPresentation { + readonly toolSurface?: "browser" | "computer"; + readonly toolIcon?: ToolActivityIcon; + readonly toolSource?: ToolActivitySource; +} + +export function codexMcpToolPresentation(item: { + readonly result?: unknown; + readonly appContext?: unknown; + readonly arguments?: unknown; +}): McpToolPresentation { + const result = asUnknownRecord(item.result); + const metadata = asUnknownRecord(result?._meta); + const surface = asUnknownRecord(metadata?.["codex/toolSurface"]); + const sourceMetadata = asUnknownRecord(metadata?.source); + const appContext = asUnknownRecord(item.appContext); + const sourceLogo = themedLogoIcon(surface, sourceMetadata, appContext); + if (surface?.kind === "browserUse") { + const screenshot = asUnknownRecord(surface.screenshot); + const browserUse = asUnknownRecord(metadata?.browser_use); + const openTabs = Array.isArray(surface.openTabs) ? surface.openTabs : []; + const latestOpenTab = openTabs + .toReversed() + .map(asUnknownRecord) + .find((tab) => normalizedHttpUrl(tab?.url) !== undefined); + const selectedPage = [ + { record: screenshot, url: screenshot?.pageUrl }, + { record: browserUse, url: browserUse?.url }, + { record: latestOpenTab, url: latestOpenTab?.url }, + ] + .map((candidate) => ({ ...candidate, pageUrl: normalizedHttpUrl(candidate.url) })) + .find((candidate) => candidate.pageUrl !== undefined); + const pageUrl = selectedPage?.pageUrl; + const faviconUrl = normalizedImageUrl( + selectedPage?.record?.faviconUrl ?? selectedPage?.record?.favIconUrl, + ); + const faviconUrlDark = normalizedImageUrl( + selectedPage?.record?.faviconUrlDark ?? selectedPage?.record?.favIconUrlDark, + ); + const name = + browserDisplayName(appContext?.appName) ?? + browserDisplayName(surface.browserFamily) ?? + browserDisplayName(surface.backend) ?? + "Browser"; + const nativeBrowserIcon = browserNativeAppReference(name); + const sourceIcon = + sourceLogo ?? + (nativeBrowserIcon ? ({ _tag: "native-app", app: nativeBrowserIcon } as const) : undefined); + const sourceKeyPart = normalizedSourceKeyPart(name) || "browser"; + return { + toolSurface: "browser", + ...(pageUrl + ? { + toolIcon: { + _tag: "website", + pageUrl, + ...(faviconUrl ? { faviconUrl } : {}), + ...(faviconUrlDark ? { faviconUrlDark } : {}), + } as const, + } + : {}), + toolSource: { + key: `browser-use:${sourceKeyPart}`, + name, + kind: name === "Browser" ? "browser" : "integration", + ...(sourceIcon ? { icon: sourceIcon } : {}), + }, + }; + } + if (surface?.kind === "computerUse") { + const app = nativeAppReference(surface.app); + const args = asUnknownRecord(item.arguments); + const argumentAppName = + normalizedDisplayName(args?.appName) ?? + normalizedDisplayName(args?.application) ?? + normalizedDisplayName(typeof args?.app === "string" ? args.app : undefined); + const name = + normalizedDisplayName(appContext?.appName) ?? + argumentAppName ?? + (app?._tag === "display-name" ? app.displayName : undefined) ?? + (app?._tag === "app-id" ? appDisplayNameFromId(app.appId) : undefined) ?? + "Computer Use"; + const sourceIcon = sourceLogo ?? (app ? ({ _tag: "native-app", app } as const) : undefined); + const sourceKey = app + ? app._tag === "app-id" + ? nativeAppSourceKey(app.appId) + : `native-app-name:${normalizedSourceKeyPart(app.displayName)}` + : "computer-use"; + return { + toolSurface: "computer", + ...(app ? { toolIcon: { _tag: "native-app", app } as const } : {}), + toolSource: { + key: sourceKey, + name, + kind: "computer", + ...(sourceIcon ? { icon: sourceIcon } : {}), + }, + }; + } + + return {}; +} + +interface McpTitleItem { + readonly server: string; + readonly tool: string; + readonly status: string; + readonly arguments?: unknown; +} + +function normalizeItemType(raw: string | undefined | null): string { + const type = raw?.trim(); + if (!type) return "item"; + return type + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[._/-]/g, " ") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); +} + +function normalizeMcpIntentTitle(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const normalized = value.trim().replace(/\s+/gu, " "); + if (!normalized) return undefined; + const characters = Array.from(normalized); + return characters.length <= 80 ? normalized : `${characters.slice(0, 79).join("")}…`; +} + +function boundedToolArgument(value: unknown): string | undefined { + const normalized = typeof value === "string" ? value.trim().replace(/\s+/gu, " ") : ""; + if (!normalized) return undefined; + return normalized.length <= 48 ? normalized : `${normalized.slice(0, 47)}…`; +} + +function normalizedMcpToolName(value: string): string { + return ( + value + .split(/__|[./:]/u) + .at(-1) + ?.trim() ?? value.trim() + ); +} + +function computerUseToolTitle( + item: McpTitleItem, + presentation: McpToolPresentation, +): string | undefined { + if (normalizeItemType(item.server) !== "computer use") return undefined; + if (item.status === "failed") return undefined; + const tool = normalizeItemType(normalizedMcpToolName(item.tool)).replace(/ /gu, "_"); + const inProgress = item.status === "inProgress"; + const args = asUnknownRecord(item.arguments); + const appName = + (presentation.toolSource?.kind === "computer" && presentation.toolSource.name !== "Computer Use" + ? presentation.toolSource.name + : undefined) ?? + normalizedDisplayName(args?.appName) ?? + normalizedDisplayName(args?.application) ?? + normalizedDisplayName(typeof args?.app === "string" ? args.app : undefined); + const withApp = (label: string) => (appName ? `${label} in ${appName}` : label); + switch (tool) { + case "list_apps": + return inProgress ? "Listing apps" : "Listed apps"; + case "click": + return withApp(inProgress ? "Clicking" : "Clicked"); + case "drag": + return withApp(inProgress ? "Dragging" : "Dragged"); + case "get_app_state": + case "get_state": + return appName + ? `${inProgress ? "Looking at" : "Looked at"} ${appName}` + : inProgress + ? "Looking at the screen" + : "Looked at the screen"; + case "perform_accessibility_action": + case "perform_secondary_action": + return inProgress ? "Performing accessibility action" : "Performed accessibility action"; + case "press_key": + return withApp(inProgress ? "Pressing key" : "Pressed key"); + case "scroll": { + const direction = boundedToolArgument(args?.direction)?.toLowerCase(); + return withApp(`${inProgress ? "Scrolling" : "Scrolled"}${direction ? ` ${direction}` : ""}`); + } + case "set_value": + return withApp(inProgress ? "Setting value" : "Set value"); + case "type_text": + return withApp(inProgress ? "Typing text" : "Typed text"); + default: + return undefined; + } +} + +export function codexMcpIntentTitle( + item: McpTitleItem, + presentation: McpToolPresentation, +): string | undefined { + if (normalizedMcpToolName(item.tool) === "js") { + const title = normalizeMcpIntentTitle(asUnknownRecord(item.arguments)?.title); + if (title) return title; + } + return computerUseToolTitle(item, presentation); +} diff --git a/apps/server/src/orchestration-v2/AgentBrowserAccessPolicy.ts b/apps/server/src/orchestration-v2/AgentBrowserAccessPolicy.ts new file mode 100644 index 000000000..aad36939a --- /dev/null +++ b/apps/server/src/orchestration-v2/AgentBrowserAccessPolicy.ts @@ -0,0 +1,24 @@ +import type { ThreadId } from "@t3tools/contracts"; +import { resolveProjectAgentBrowserAccess } from "@t3tools/shared/serverSettings"; +import * as Effect from "effect/Effect"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { ProjectionStoreV2 } from "./ProjectionStore.ts"; + +/** Missing thread identity or unreadable settings must not bypass an explicit project choice. */ +export const agentBrowserAccessEnabled = Effect.fn("V2.agentBrowserAccessEnabled")( + function* (threadId: ThreadId) { + const settingsService = yield* ServerSettingsService; + const settings = yield* settingsService.getSettings; + if (Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0) { + return settings.enableAgentBrowserAccess; + } + const projections = yield* ProjectionStoreV2; + const thread = yield* projections.getThreadShell(threadId); + return thread === null ? false : resolveProjectAgentBrowserAccess(settings, thread.projectId); + }, + Effect.catch((cause) => + Effect.logWarning("Could not resolve project browser access; withholding preview tools.", { + cause, + }).pipe(Effect.as(false)), + ), +); diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index 6bcd4e4db..d624e0046 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -119,6 +119,10 @@ export interface EventSinkV2Shape { readonly threadId: ThreadId; readonly commandType: string; readonly acceptedAt: DateTime.Utc; + /** Persist command-specific state atomically before events, after reserving a new receipt. + * Must use the sink's SQL client and perform no external effects. Publication happens only + * after this transaction commits. Retries of an existing receipt do not run it again. */ + readonly prepareTransaction?: Effect.Effect; readonly events: ReadonlyArray; readonly effects: ReadonlyArray; readonly cancelUnsettledEffects?: { @@ -480,6 +484,7 @@ const baseLayer: Layer.Layer< return { ...existing, committed: false as const, cancelledEffectIds: [] }; } + if (input.prepareTransaction !== undefined) yield* input.prepareTransaction; const normalized = yield* normalizeEvents(input.events); const storedEvents = yield* eventStore.append({ commandId: input.commandId, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 7e5264645..bbaad5fbc 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1,3 +1,13 @@ +import { + canContinueAfterRestart, + RESTART_CONTINUATION_PROMPT, +} from "./RestartContinuationPolicy.ts"; +import { isAutoSettlementCandidate } from "./ThreadSettlementPolicy.ts"; +import { threadPullRequestKeysEqual } from "@t3tools/shared/threadPullRequestChains"; +import { + allThreadPullRequestsOf, + updateLinkedPullRequests, +} from "@t3tools/shared/threadPullRequests"; import { type ChatAttachment, CommandId, @@ -244,6 +254,8 @@ function commandThreadId(command: OrchestrationV2Command): ThreadId { case "prepared-run.release": case "prepared-run.progress": case "prepared-run.fail": + case "run.restart-continuation.prepare": + case "run.restart-continuation.clear": case "run.interrupt": case "queued-message.promote-to-steer": case "queued-run.reorder": @@ -1449,6 +1461,49 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ); const thread = projection.thread; + if (command.type === "thread.metadata.update") { + const edits = [ + command.linkedPullRequest, + command.linkPullRequest, + command.unlinkPullRequest, + command.syncPullRequest, + ].filter((value) => value !== undefined); + const nextLinks = updateLinkedPullRequests(thread, command); + if ( + edits.length > 1 || + nextLinks.linkedPullRequests.length > 50 || + nextLinks.pullRequests.length > 100 + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: + "Send one pull-request edit at a time; a thread can link at most 50 pull requests.", + }); + } + } + if ( + command.type === "thread.metadata.update" && + ((command.expectedBranch !== undefined && command.expectedBranch !== thread.branch) || + (command.expectedProjectId !== undefined && + command.expectedProjectId !== thread.projectId) || + (command.expectedPullRequestLink !== undefined && + !allThreadPullRequestsOf(thread).some( + (link) => + link.source !== "stack-dismissed" && + threadPullRequestKeysEqual(link, command.expectedPullRequestLink!) && + link.source === command.expectedPullRequestLink!.source && + link.linkedAt === command.expectedPullRequestLink!.linkedAt && + link.url === command.expectedPullRequestLink!.url, + )) || + (command.linkPullRequestSource !== undefined && command.linkPullRequest === undefined)) + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "The pull-request context changed or the link source has no corresponding link.", + }); + } if (thread.deletedAt !== null && command.type !== "thread.delete") { return yield* new OrchestratorDispatchError({ commandId: command.commandId, @@ -1557,6 +1612,27 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : null; const now = yield* DateTime.now; + if (command.type === "thread.settle" && command.automatic !== undefined) { + const sequence = yield* eventSink + .latestSequence({ threadId: command.threadId }) + .pipe(mapDispatchError(command)); + const shell = yield* projectionStore + .getThreadShell(command.threadId) + .pipe(mapDispatchError(command)); + if ( + sequence !== command.automatic.expectedSequence || + shell === null || + !isAutoSettlementCandidate(shell, now) + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: + "The thread changed before automatic settlement or has work that must stay visible.", + }); + } + } + let snoozedUntil: DateTime.Utc | null = null; if (command.type === "thread.snooze") { const parsedSnoozedUntil = DateTime.make(command.snoozedUntil); @@ -1635,7 +1711,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio settledOverride: "settled", settledAt: alreadySettled ? thread.settledAt : settledAt, unsettledAt: null, - pinnedAt: null, + pinnedAt: command.automatic !== undefined ? thread.pinnedAt : null, updatedAt: alreadySettled ? thread.updatedAt : settledAt, }; } @@ -1730,9 +1806,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? {} : { activeOrderKey: command.activeOrderKey }), // Absent leaves the link alone; null unlinks. - ...(command.linkedPullRequest === undefined + ...(command.linkedPullRequest === undefined && + command.linkPullRequest === undefined && + command.unlinkPullRequest === undefined && + command.syncPullRequest === undefined + ? {} + : updateLinkedPullRequests(thread, command, DateTime.formatIso(now))), + ...(command.branchPullRequest === undefined ? {} - : { linkedPullRequest: command.linkedPullRequest }), + : { branchPullRequest: command.branchPullRequest }), ...(command.workInboxRole === undefined ? {} : { @@ -1748,7 +1830,28 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : {}), }), ...(command.clearTimeline === true ? { timelineClearedAt: now } : {}), - updatedAt: now, + // Host refreshes do not create user activity or postpone inactivity settlement. + updatedAt: + (command.syncPullRequest !== undefined || + command.branchPullRequest !== undefined || + command.linkPullRequestSource === "stack") && + Object.keys(command).every((key) => + [ + "type", + "commandId", + "threadId", + "syncPullRequest", + "branchPullRequest", + "expectedBranch", + "expectedProjectId", + "expectedPullRequestLink", + "linkPullRequest", + "linkPullRequestSource", + "expectedWorktreePath", + ].includes(key), + ) + ? thread.updatedAt + : now, }; case "thread.runtime-mode.set": return { ...thread, runtimeMode: command.runtimeMode, updatedAt: now }; @@ -1896,6 +1999,35 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } + if ( + command.type === "thread.archive" || + command.type === "thread.delete" || + command.type === "thread.settle" || + command.type === "thread.model-selection.set" || + command.type === "provider.switch" || + (command.type === "thread.metadata.update" && + ((command.worktreePath !== undefined && command.worktreePath !== thread.worktreePath) || + (command.branch !== undefined && command.branch !== thread.branch))) + ) { + const current = yield* getProjectionWithPendingEvents(command.threadId, events); + for (const run of current.runs) { + if (run.restartContinuation?.status !== "pending") continue; + yield* emit( + events, + command, + )({ + type: "run.updated", + threadId: command.threadId, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { + ...run, + restartContinuation: { ...run.restartContinuation, status: "cancelled" }, + }, + }); + } + } // Settle means "done with this thread", so a live provider session must // not keep running background work (PR monitors, dev servers, subagent // fleets) after it lands. Commands are decided serially against the @@ -2906,6 +3038,48 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ]); }); + const dispatchRestartContinuation = Effect.fn("Orchestrator.dispatchRestartContinuation")( + function* ( + command: Extract< + OrchestrationV2Command, + { type: "run.restart-continuation.prepare" | "run.restart-continuation.clear" } + >, + events: Ref.Ref>, + ) { + const projection = yield* getProjectionWithPendingEvents(command.threadId, events); + const run = projection.runs.find((candidate) => candidate.id === command.runId); + if (run === undefined) return; + if (command.type === "run.restart-continuation.prepare") { + if ( + !canContinueAfterRestart(projection, run, "prepare") || + run.restartContinuation?.status === "pending" + ) + return; + } else if ( + run.restartContinuation?.status !== "pending" || + run.restartContinuation.messageId !== command.messageId + ) + return; + yield* emit( + events, + command, + )({ + type: "run.updated", + threadId: command.threadId, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: yield* DateTime.now, + payload: { + ...run, + restartContinuation: + command.type === "run.restart-continuation.prepare" + ? { messageId: command.messageId, reason: command.reason, status: "pending" } + : { ...run.restartContinuation!, status: "cancelled" }, + }, + }); + }, + ); + const dispatchMessage = ( command: Extract, events: Ref.Ref>, @@ -2913,6 +3087,41 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ) => Effect.gen(function* () { let projection = yield* getProjectionWithPendingEvents(command.threadId, events); + if (command.restartContinuation !== undefined) { + const source = projection.runs.find( + (run) => run.id === command.restartContinuation!.sourceRunId, + ); + if ( + source === undefined || + !canContinueAfterRestart(projection, source, "resume") || + source.restartContinuation?.messageId !== command.messageId || + command.createdBy !== "agent" || + command.creationSource !== "server" || + command.dispatchMode.type !== "start_immediately" || + command.attachments.length > 0 + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Restart continuation was superseded or has no resumable provider context.", + }); + } + yield* emit( + events, + command, + )({ + type: "run.updated", + threadId: command.threadId, + runId: source.id, + providerInstanceId: source.providerInstanceId, + occurredAt: yield* DateTime.now, + payload: { + ...source, + restartContinuation: { ...source.restartContinuation, status: "consumed" }, + }, + }); + projection = yield* getProjectionWithPendingEvents(command.threadId, events); + } if (projection.thread.settledOverride !== null) { const now = yield* DateTime.now; const thread: OrchestrationV2AppThread = { @@ -3010,9 +3219,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }; } const dispatchText = - delegatedCompletion === undefined - ? command.text - : delegatedCompletionWakeDetail(projection, delegatedCompletion.taskIds); + command.restartContinuation !== undefined + ? RESTART_CONTINUATION_PROMPT + : delegatedCompletion === undefined + ? command.text + : delegatedCompletionWakeDetail(projection, delegatedCompletion.taskIds); const sourcePlanProjection = command.sourcePlanRef === undefined ? null @@ -3222,6 +3433,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio completedAt: null, }; const message: OrchestrationV2ConversationMessage = { + ...(command.restartContinuation !== undefined ? { restartContinuation: true } : {}), createdBy: command.createdBy, creationSource: command.creationSource, id: command.messageId, @@ -3477,6 +3689,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio completedAt: null, }; const message: OrchestrationV2ConversationMessage = { + ...(command.restartContinuation !== undefined ? { restartContinuation: true } : {}), createdBy: command.createdBy, creationSource: command.creationSource, id: command.messageId, @@ -4248,6 +4461,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio completedAt: null, }; const message: OrchestrationV2ConversationMessage = { + ...(command.restartContinuation !== undefined ? { restartContinuation: true } : {}), createdBy: command.createdBy, creationSource: command.creationSource, id: command.messageId, @@ -5942,7 +6156,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ) => Effect.gen(function* () { const projection = yield* loadProjectionForCommand(command); - const run = projection.runs.find((candidate) => candidate.id === command.runId); + const originalRun = projection.runs.find((candidate) => candidate.id === command.runId); + const run = + originalRun?.restartContinuation?.status === "pending" + ? { + ...originalRun, + restartContinuation: { + ...originalRun.restartContinuation, + status: "cancelled" as const, + }, + } + : originalRun; const rootNode = run?.rootNodeId === null ? undefined @@ -5983,6 +6207,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); const emitEvent = emit(events, command); + if (originalRun?.restartContinuation?.status === "pending") + yield* emitEvent({ + type: "run.updated", + threadId: command.threadId, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: run, + }); const interruptRequestItem: OrchestrationV2TurnItem = { id: idAllocator.derive.runSignalTurnItem({ runId: run.id, @@ -7038,6 +7271,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "runtime-request.respond": yield* dispatchRuntimeRequestRespond(command, events, effects); break; + case "run.restart-continuation.prepare": + case "run.restart-continuation.clear": + yield* dispatchRestartContinuation(command, events); + break; case "run.interrupt": cancelUnsettledEffects = yield* dispatchRunInterrupt(command, events, effects); break; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 806b77dc9..430be7ccc 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -149,6 +149,32 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { payload: thread, }); + const links = [41, 42].map((number) => ({ + projectId, + repository: "owner/repo", + number, + url: `https://github.com/owner/repo/pull/${number}`, + })); + yield* projectionStore.apply({ + id: EventId.make("event:projection-read-state:links"), + type: "thread.metadata-updated", + threadId, + occurredAt: markedUnreadOccurredAt, + payload: { ...thread, linkedPullRequest: links[0]!, linkedPullRequests: links }, + }); + assert.deepEqual( + (yield* projectionStore.getThreadProjection(threadId)).thread.linkedPullRequests, + links, + ); + assert.deepEqual( + (yield* projectionStore.getThreadShell(threadId))?.linkedPullRequests, + links, + ); + assert.deepEqual( + (yield* projectionStore.getShellSnapshot()).threads.find((shell) => shell.id === threadId) + ?.linkedPullRequests, + links, + ); const markedUnread = yield* projectionStore.getThreadProjection(threadId); assert.isNull(markedUnread.thread.lastVisitedAt); assert.deepEqual(markedUnread.thread.updatedAt, createdAt); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 2d6e3bc25..efb6fb821 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -936,6 +936,7 @@ export function threadShellFromProjection( .filter((request) => request.status === "pending") .toSorted( (left, right) => + Number(left.responseMode === "message") - Number(right.responseMode === "message") || DateTime.toEpochMillis(right.createdAt) - DateTime.toEpochMillis(left.createdAt), )[0] ?? null; const latestVisibleMessage = @@ -974,7 +975,16 @@ export function threadShellFromProjection( ...(projection.thread.worktreeStatus === undefined ? {} : { worktreeStatus: projection.thread.worktreeStatus }), + ...(projection.thread.pullRequests === undefined + ? {} + : { pullRequests: projection.thread.pullRequests }), + ...(projection.thread.branchPullRequest === undefined + ? {} + : { branchPullRequest: projection.thread.branchPullRequest }), linkedPullRequest: projection.thread.linkedPullRequest ?? null, + ...(projection.thread.linkedPullRequests === undefined + ? {} + : { linkedPullRequests: projection.thread.linkedPullRequests }), lineage: projection.thread.lineage, forkedFrom: projection.thread.forkedFrom, activeProviderThreadId: projection.thread.activeProviderThreadId, @@ -994,6 +1004,9 @@ export function threadShellFromProjection( : { id: pendingRuntimeRequest.id, kind: pendingRuntimeRequest.kind, + ...(pendingRuntimeRequest.responseMode === undefined + ? {} + : { responseMode: pendingRuntimeRequest.responseMode }), createdAt: pendingRuntimeRequest.createdAt, }, latestVisibleMessage: @@ -1161,7 +1174,16 @@ function shellFromState(input: { ...(input.state.thread.worktreeStatus === undefined ? {} : { worktreeStatus: input.state.thread.worktreeStatus }), + ...(input.state.thread.pullRequests === undefined + ? {} + : { pullRequests: input.state.thread.pullRequests }), + ...(input.state.thread.branchPullRequest === undefined + ? {} + : { branchPullRequest: input.state.thread.branchPullRequest }), linkedPullRequest: input.state.thread.linkedPullRequest ?? null, + ...(input.state.thread.linkedPullRequests === undefined + ? {} + : { linkedPullRequests: input.state.thread.linkedPullRequests }), lineage: input.state.thread.lineage, forkedFrom: input.state.thread.forkedFrom, activeProviderThreadId: input.state.thread.activeProviderThreadId, @@ -1181,6 +1203,9 @@ function shellFromState(input: { : { id: input.state.pendingRuntimeRequest.id, kind: input.state.pendingRuntimeRequest.kind, + ...(input.state.pendingRuntimeRequest.responseMode === undefined + ? {} + : { responseMode: input.state.pendingRuntimeRequest.responseMode }), createdAt: input.state.pendingRuntimeRequest.createdAt, }, latestVisibleMessage: @@ -2425,7 +2450,8 @@ export const layer: Layer.Layer = FROM orchestration_v2_projection_runtime_requests request WHERE request.thread_id = t.thread_id AND request.status = 'pending' - ORDER BY request.created_at DESC, request.runtime_request_id DESC + ORDER BY CASE WHEN json_extract(request.payload_json, '$.responseMode') = 'message' THEN 1 ELSE 0 END, + request.created_at DESC, request.runtime_request_id DESC LIMIT 1 ) AS pending_request_payload_json, ( diff --git a/apps/server/src/orchestration-v2/ProviderAdapter.ts b/apps/server/src/orchestration-v2/ProviderAdapter.ts index 063a8b4a1..e59dbd0b5 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapter.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapter.ts @@ -54,6 +54,7 @@ export const ProviderAdapterV2RuntimePolicy = Schema.Struct({ export type ProviderAdapterV2RuntimePolicy = typeof ProviderAdapterV2RuntimePolicy.Type; export const ProviderAdapterV2TurnMessage = Schema.Struct({ + restartContinuation: Schema.optional(Schema.Boolean), messageId: MessageId, text: Schema.String, attachments: Schema.Array(ChatAttachment), diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index f6bef3468..9c35b4d93 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -1,6 +1,11 @@ +import { agentBrowserAccessEnabled } from "./AgentBrowserAccessPolicy.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { + DEFAULT_SERVER_SETTINGS, + ServerSettingsError, + ProjectId, EnvironmentId, type ModelSelection, type OrchestrationV2AppThread, @@ -345,7 +350,7 @@ function makeTestLayer(input: { readonly failReleaseEventWrites?: boolean; readonly hasPendingBackgroundWork?: Effect.Effect; readonly hangSessionScopeClose?: boolean; - readonly agentBrowserAccessEnabled?: Effect.Effect; + readonly agentBrowserAccessEnabled?: (threadId: ThreadId) => Effect.Effect; }) { const configuredEventSinkLayer = input.failReleaseEventWrites ? FailingReleaseEventSinkLayer @@ -777,7 +782,10 @@ it.effect( assert.isDefined(token); const resolved = yield* registry.resolve(token!, registry.audience); assert.equal(resolved?.threadId, threadId); - assert.deepEqual(resolved?.capabilities, new Set(["preview", "orchestration", "worktree"])); + assert.deepEqual( + resolved?.capabilities, + new Set(["preview", "orchestration", "pull-requests", "worktree"]), + ); yield* manager.close(providerSessionId); assert.isUndefined(McpProviderSession.readMcpProviderSession(threadId)); @@ -830,10 +838,16 @@ it.effect( // cost the thread its orchestration and worktree tools. const captured = (yield* Ref.get(mcpConfigs))[0]; assert.isDefined(captured); - assert.deepEqual([...(captured?.capabilities ?? [])], ["orchestration", "worktree"]); + assert.deepEqual( + [...(captured?.capabilities ?? [])], + ["orchestration", "pull-requests", "worktree"], + ); const token = captured?.authorizationHeader.replace(/^Bearer\s+/, ""); const resolved = yield* registry.resolve(token!, registry.audience); - assert.deepEqual(resolved?.capabilities, new Set(["orchestration", "worktree"])); + assert.deepEqual( + resolved?.capabilities, + new Set(["orchestration", "pull-requests", "worktree"]), + ); yield* manager.close(providerSessionId); }); @@ -844,7 +858,11 @@ it.effect( state, idleTimeoutMs: 1_000, mcpConfigs, - agentBrowserAccessEnabled: Effect.succeed(false), + agentBrowserAccessEnabled: (threadId) => + Effect.sync(() => { + assert.equal(threadId, "thread-provider-session-manager-no-browser"); + return false; + }), }), ), ); @@ -929,12 +947,12 @@ it.effect( }); const config = (yield* Ref.get(mcpConfigs))[0]; - assert.deepEqual(config?.capabilities, ["orchestration", "preview"]); + assert.deepEqual(config?.capabilities, ["orchestration", "preview", "pull-requests"]); const token = config?.authorizationHeader.replace(/^Bearer\s+/, ""); assert.isDefined(token); assert.deepEqual( (yield* registry.resolve(token!, registry.audience))?.capabilities, - new Set(["preview", "orchestration"]), + new Set(["preview", "orchestration", "pull-requests"]), ); yield* manager.close(providerSessionId); }); @@ -2463,3 +2481,129 @@ it.effect( ); }), ); + +it.effect( + "V2 browser policy resolves the durable project, defaults, reset and unknown threads", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + yield* Effect.gen(function* () { + const sink = yield* EventSinkV2; + const ids = yield* IdAllocatorV2; + const threadId = ThreadId.make("browser-policy-thread"); + const event = yield* makeThreadCreatedEvent({ + idAllocator: ids, + threadId, + now: yield* DateTime.now, + }); + yield* sink.write({ events: [event] }); + const projectId = event.payload.projectId; + for (const [defaultEnabled, override, expected] of [ + [true, false, false], + [false, true, true], + [true, undefined, true], + [false, undefined, false], + ] as const) { + const settings = { + ...DEFAULT_SERVER_SETTINGS, + enableAgentBrowserAccess: defaultEnabled, + projectAgentBrowserAccessOverrides: { + [ProjectId.make("other")]: false, + ...(override === undefined ? {} : { [projectId]: override }), + }, + }; + const policyLayer = Layer.mock(ServerSettingsService)({ + getSettings: Effect.succeed(settings), + }); + assert.equal( + yield* agentBrowserAccessEnabled(threadId).pipe(Effect.provide(policyLayer)), + expected, + ); + assert.isFalse( + yield* agentBrowserAccessEnabled(ThreadId.make("unknown")).pipe( + Effect.provide(policyLayer), + ), + ); + } + const noOverrides = Layer.mock(ServerSettingsService)({ + getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS), + }); + assert.isTrue( + yield* agentBrowserAccessEnabled(ThreadId.make("unknown")).pipe( + Effect.provide(noOverrides), + ), + ); + assert.isFalse( + yield* agentBrowserAccessEnabled(threadId).pipe( + Effect.provide( + Layer.mock(ServerSettingsService)({ + getSettings: Effect.fail( + new ServerSettingsError({ + settingsPath: "/fixture/settings.json", + operation: "read-file", + cause: "unreadable", + }), + ), + }), + ), + ), + ); + }).pipe(Effect.provide(makeTestLayer({ state, idleTimeoutMs: 1_000 }))); + }), +); + +it.effect("changing project browser access rotates credentials when the thread reattaches", () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const allowed = yield* Ref.make(true); + yield* Effect.gen(function* () { + const sink = yield* EventSinkV2; + const ids = yield* IdAllocatorV2; + const manager = yield* ProviderSessionManagerV2; + const registry = yield* McpSessionRegistry.McpSessionRegistry; + const threadId = ThreadId.make("browser-override-rotation"); + const providerSessionId = yield* ids.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* sink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator: ids, threadId, now: yield* DateTime.now }), + ], + }); + const open = manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + yield* open; + let previous = McpProviderSession.readMcpProviderSession(threadId)!; + for (const enabled of [false, true]) { + yield* manager.detach({ providerSessionId, threadId, detail: "Settings changed." }); + yield* Ref.set(allowed, enabled); + yield* open; + const next = McpProviderSession.readMcpProviderSession(threadId)!; + assert.notEqual(next.providerSessionId, previous.providerSessionId); + assert.isUndefined( + yield* registry.resolve( + previous.authorizationHeader.replace(/^Bearer\s+/, ""), + registry.audience, + ), + ); + const credential = yield* registry.resolve( + next.authorizationHeader.replace(/^Bearer\s+/, ""), + registry.audience, + ); + assert.equal(credential?.capabilities.has("preview"), enabled); + assert.isTrue(credential?.capabilities.has("orchestration")); + assert.isTrue(credential?.capabilities.has("worktree")); + previous = next; + } + yield* manager.close(providerSessionId); + }).pipe( + Effect.provide( + makeTestLayer({ + state, + idleTimeoutMs: 1_000, + agentBrowserAccessEnabled: () => Ref.get(allowed), + }), + ), + ); + }), +); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index b3509161b..3c6621cd8 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -220,14 +220,14 @@ export interface ProviderSessionManagerV2LayerOptions { /** Test replay harnesses can omit T3's MCP server from provider protocol fixtures. */ readonly configureMcp?: boolean; /** - * Reads the `enableAgentBrowserAccess` server setting, deciding whether a + * Resolves machine and project browser settings for the thread, deciding whether a * session's MCP credential carries the `preview` capability. Injected rather * than taken as a layer requirement so the manager keeps its narrow * dependency set; `runtimeLayer` supplies the reader backed by * `ServerSettingsService`. Omitting it grants access, which is the shipped * default. */ - readonly agentBrowserAccessEnabled?: Effect.Effect; + readonly agentBrowserAccessEnabled?: (threadId: ThreadId) => Effect.Effect; } function releaseStatusFor( @@ -333,7 +333,8 @@ export const layerWithOptions = ( * Serialized per thread so two concurrent prepares cannot interleave * their rotate steps and revoke each other's freshly minted credential. */ - const agentBrowserAccessEnabled = options.agentBrowserAccessEnabled ?? Effect.succeed(true); + const agentBrowserAccessEnabled = + options.agentBrowserAccessEnabled ?? (() => Effect.succeed(true)); const prepareMcpSession = ( threadId: ThreadId, providerInstanceId: ProviderInstanceId, @@ -354,8 +355,9 @@ export const layerWithOptions = ( // rotates the credential on the next session prepare. The // thread's orchestration and worktree tools are untouched. const capabilities = new Set([ - ...((yield* agentBrowserAccessEnabled) ? (["preview"] as const) : []), + ...((yield* agentBrowserAccessEnabled(threadId)) ? (["preview"] as const) : []), "orchestration", + "pull-requests", ...(runtimePolicy.runtimeMode === "full-access" ? (["worktree"] as const) : []), ]); // Reuse a still-valid credential for this thread instead of diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts index 2f6bd0f80..e927e831a 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts @@ -1,5 +1,6 @@ import { assert, it } from "@effect/vitest"; import { + MessageId, type ModelSelection, NodeId, type OrchestrationV2ProviderThread, @@ -304,3 +305,116 @@ it.effect( assert.equal(interrupted?.nativeThreadRef?.nativeId, "native-thread:restart-session"); }), ); + +it.effect( + "delivers readable citation context when steering a V2 provider without changing history", + () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:citation-steer"); + const sessionId = ProviderSessionId.make("session:citation-steer"); + const providerThreadId = ProviderThreadId.make("provider-thread:citation-steer"); + const providerTurnId = ProviderTurnId.make("provider-turn:citation-steer"); + const attemptId = RunAttemptId.make("attempt:citation-steer"); + const messageId = MessageId.make("message:citation-steer"); + const text = + "Explain [Assistant quote](t3-citation://v1/e/t/m?text=Selected+answer&start=0&end=15&prefix=&suffix=&comment=Why%3F)"; + const providerThread = { + id: providerThreadId, + providerSessionId: sessionId, + driver, + providerInstanceId, + } as OrchestrationV2ProviderThread; + const base = makeProjection({ now, threadId, providerThread, providerTurnId, attemptId }); + const projection = { + ...base, + runs: [ + { + id: RunId.make("run:citation-steer"), + activeAttemptId: attemptId, + modelSelection, + threadId, + ordinal: 1, + providerInstanceId, + providerThreadId, + userMessageId: messageId, + rootNodeId: null, + status: "running", + requestedAt: now, + startedAt: now, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }, + ], + messages: [ + { + id: messageId, + text, + attachments: [], + createdBy: "user", + creationSource: "web", + threadId, + runId: null, + nodeId: null, + role: "user", + streaming: false, + createdAt: now, + updatedAt: now, + }, + ], + } satisfies OrchestrationV2ThreadProjection; + let received = ""; + const runtime = { + steerTurn: (input: { message: { text: string } }) => + Effect.sync(() => { + received = input.message.text; + }), + } as unknown as ProviderAdapterV2SessionRuntime; + const layer = providerTurnControlLayer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ProjectionStoreV2)({ + getThreadProjection: () => Effect.succeed(projection), + }), + Layer.mock(ProviderSessionManagerV2)({ + get: () => Effect.succeed(Option.some(runtime)), + }), + Layer.mock(RuntimePolicyV2)({ + resolve: () => + Effect.succeed({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: "/workspace", + }), + }), + Layer.mock(AttachmentMaterialization)({ + materialize: () => + Effect.succeed({ + inlineAttachments: [], + materialized: [], + outcome: "written" as const, + promptBlock: "Attached file context", + }), + }), + ), + ), + ); + yield* Effect.gen(function* () { + const control = yield* ProviderTurnControlServiceV2; + yield* control.steer({ + threadId, + providerSessionId: sessionId, + providerThreadId, + providerTurnId, + messageId, + }); + }).pipe(Effect.provide(layer)); + assert.include(received, "Explain [assistant-quote-1]"); + assert.include(received, '"text": "Selected answer"'); + assert.include(received, '"comment": "Why?"'); + assert.include(received, "Attached file context"); + assert.notInclude(received, "t3-citation://"); + assert.equal(projection.messages[0]?.text, text); + }), +); diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts index 288aa0247..d049a4131 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnControlService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.ts @@ -1,3 +1,4 @@ +import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; import { MessageId, ProviderSessionId, @@ -291,7 +292,10 @@ export const layer: Layer.Layer< providerTurnId: loaded.providerTurn.id, message: { messageId: message.id, - text: appendUploadedFilesBlock(message.text, uploads.promptBlock), + text: appendUploadedFilesBlock( + expandAssistantCitationsForProvider(message.text), + uploads.promptBlock, + ), attachments: uploads.inlineAttachments, createdBy: message.createdBy, creationSource: message.creationSource, diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 2267028e3..da3b1c145 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -27,9 +27,10 @@ import { AttachmentMaterialization, type AttachmentMaterializationResult, } from "../attachments/AttachmentMaterialization.ts"; -import type { - ProviderAdapterV2SessionRuntime, - ProviderAdapterV2TurnMessage, +import { + ProviderAdapterResumeThreadError, + type ProviderAdapterV2SessionRuntime, + type ProviderAdapterV2TurnMessage, } from "./ProviderAdapter.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import { EventSinkV2 } from "./EventSink.ts"; @@ -431,6 +432,7 @@ function makeStartTestLayer(input: { readonly materialization: AttachmentMaterializationResult; readonly startInputs: Ref.Ref>; readonly placementWrites: Ref.Ref>; + readonly ensureCalls?: Ref.Ref; readonly now: DateTime.Utc; }) { const providerSession = { @@ -452,7 +454,19 @@ function makeStartTestLayer(input: { providerSessionId: startProviderSessionId, providerSession, events: Stream.empty, - ensureThread: () => Effect.succeed(input.projection.providerThreads[0]!), + ensureThread: () => + (input.ensureCalls ? Ref.update(input.ensureCalls, (count) => count + 1) : Effect.void).pipe( + Effect.as(input.projection.providerThreads[0]!), + ), + resumeThread: () => + Effect.fail( + new ProviderAdapterResumeThreadError({ + driver, + providerSessionId: startProviderSessionId, + providerThreadId: input.projection.providerThreads[0]!.id, + cause: "Saved session is missing", + }), + ), } as unknown as ProviderAdapterV2SessionRuntime; return providerTurnStartLayer.pipe( @@ -683,3 +697,42 @@ it.effect("stays silent about placement when there was no workspace to write to" assert.isUndefined(placement.find((event) => event.type === "message.updated")); }), ); + +it.effect("restart continuation never falls back to a fresh provider conversation", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const original = makeStartProjection({ now, text: "Continue", attachments: [] }); + const projection = { + ...original, + providerThreads: original.providerThreads.map((thread) => ({ + ...thread, + nativeThreadRef: { driver, nativeId: "saved", strength: "strong" as const }, + })), + messages: original.messages.map((message) => ({ ...message, restartContinuation: true })), + }; + const startInputs = yield* Ref.make>([]); + const placementWrites = yield* Ref.make>([]); + const ensureCalls = yield* Ref.make(0); + const layer = makeStartTestLayer({ + projection, + now, + startInputs, + placementWrites, + ensureCalls, + materialization: { + materialized: [], + promptBlock: "", + inlineAttachments: [], + outcome: "written", + }, + }); + const failure = yield* Effect.gen(function* () { + return yield* (yield* ProviderTurnStartServiceV2) + .start({ threadId: startThreadId, runId: startRunId }) + .pipe(Effect.flip); + }).pipe(Effect.provide(layer)); + assert.equal(failure._tag, "ProviderTurnStartError"); + assert.equal(yield* Ref.get(ensureCalls), 0); + assert.deepEqual(yield* Ref.get(startInputs), []); + }), +); diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index f27a2cf26..1a19432d9 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -239,6 +239,11 @@ export const layer: Layer.Layer< }); } if (providerThread.nativeThreadRef === null) { + if (message.restartContinuation === true) + return yield* new ProviderTurnStartError({ + runId, + cause: "The interrupted provider thread no longer has a saved resume reference.", + }); return yield* session.ensureThread({ threadId: projection.thread.id, modelSelection: run.modelSelection, @@ -258,6 +263,7 @@ export const layer: Layer.Layer< return resumed.success; } + if (message.restartContinuation === true) return yield* resumed.failure; const replacement = yield* session.ensureThread({ threadId: projection.thread.id, modelSelection: run.modelSelection, @@ -513,6 +519,9 @@ export const layer: Layer.Layer< captureFilesystemCheckpoint: session.providerSession.capabilities.checkpointing.appCanCheckpointFilesystem, message: { + ...(message.restartContinuation === undefined + ? {} + : { restartContinuation: message.restartContinuation }), messageId: message.id, // Appended after the handoff composition on purpose: the upload list // should be the last thing the model reads, not something buried diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts new file mode 100644 index 000000000..213d8c09b --- /dev/null +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.test.ts @@ -0,0 +1,239 @@ +import { expect, it, vi } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + ProjectId, + ThreadId, + type OrchestrationV2ThreadShell, + type OrchestrationV2ThreadShellSnapshot, + type OrchestrationV2Command, + type PullRequestSummary, + type PullRequestStack, +} from "@t3tools/contracts"; +import { updateLinkedPullRequests } from "@t3tools/shared/threadPullRequests"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import { ThreadManagementService } from "./ThreadManagementService.ts"; +import { + type PullRequestMergeEvent, + PullRequestService, +} from "../pullRequest/PullRequestService.ts"; +import { make } from "./PullRequestSyncReactor.ts"; + +const projectId = ProjectId.make("p"); +const ref = { + projectId, + repository: "org/repo", + number: 1, + url: "https://github.com/org/repo/pull/1", +}; +const key = { host: "github.com", repository: ref.repository, number: 1 }; +const at = "2026-09-11T00:00:00.000Z"; +const overview: PullRequestSummary = { + provider: "github", + projectId, + repository: ref.repository, + number: 1, + url: ref.url, + title: "A pull request", + state: "open", + headBranch: "feature/one", + baseBranch: "main", + updatedAt: at, +}; +const stack: PullRequestStack = { + id: "native-9", + number: 9, + url: "https://github.com/org/repo/stack/9", + base: "main", + layers: [ + { number: 1, headBranch: "feature/one", state: "open" }, + { number: 2, headBranch: "feature/two", state: "open" }, + ], +}; +const shell = (id: string): OrchestrationV2ThreadShell => + ({ + id: ThreadId.make(id), + projectId, + archivedAt: null, + deletedAt: null, + settledAt: null, + settledOverride: null, + ...updateLinkedPullRequests({}, { linkPullRequest: ref }, at), + }) as unknown as OrchestrationV2ThreadShell; + +function harness( + initial: OrchestrationV2ThreadShell[], + read: Effect.Effect = Effect.succeed(overview), + nativeStack: PullRequestStack | null = null, + merges: Stream.Stream = Stream.empty, +) { + let shells = initial; + const summary = vi.fn(() => read); + const stackRead = vi.fn(() => Effect.succeed(nativeStack)); + const dispatch = vi.fn((command: OrchestrationV2Command) => + Effect.sync(() => { + if (command.type !== "thread.metadata.update") + throw new Error("Expected V2 metadata command"); + shells = shells.map((thread) => + thread.id !== command.threadId + ? thread + : { + ...thread, + ...updateLinkedPullRequests(thread, command, at), + }, + ); + return { sequence: dispatch.mock.calls.length, storedEvents: [] }; + }), + ); + const layer = Layer.mergeAll( + Layer.mock(ThreadManagementService)({ + streamDomainEvents: Stream.empty, + getShellSnapshot: () => + Effect.succeed({ + threads: shells, + snapshotSequence: 1, + schemaVersion: 1, + archivedThreads: [], + } as OrchestrationV2ThreadShellSnapshot), + getThreadShell: (id) => Effect.succeed(shells.find((thread) => thread.id === id) ?? null), + dispatch, + }), + Layer.mock(PullRequestService)({ + summary, + stack: stackRead, + invalidate: () => Effect.void, + subscribeMerges: Effect.succeed(merges), + }), + NodeServices.layer, + ); + return { + layer, + summary, + stackRead, + dispatch, + shells: () => shells, + remove: (id: string) => { + shells = shells.map((thread) => + thread.id === id + ? { ...thread, ...updateLinkedPullRequests(thread, { unlinkPullRequest: ref }, at) } + : thread, + ); + }, + }; +} + +it.effect("reads a shared PR once and writes only changed snapshots", () => + Effect.gen(function* () { + const h = harness([shell("one"), shell("two")]); + const reactor = yield* make.pipe(Effect.provide(h.layer)); + yield* reactor.requestSync(key); + yield* reactor.drain; + expect(h.summary).toHaveBeenCalledTimes(1); + expect(h.dispatch).toHaveBeenCalledTimes(2); + expect(h.shells().map((thread) => thread.pullRequests?.[0]?.snapshot?.title)).toEqual([ + "A pull request", + "A pull request", + ]); + yield* reactor.requestSync(key); + yield* reactor.drain; + expect(h.summary).toHaveBeenCalledTimes(2); + expect(h.dispatch).toHaveBeenCalledTimes(2); + }).pipe(Effect.scoped), +); + +it.effect("does not add stack siblings when the anchor was removed during the host read", () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const finish = yield* Deferred.make(); + const read = Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(finish)), + Effect.as(overview), + ); + const h = harness([shell("one")], read, stack); + const reactor = yield* make.pipe(Effect.provide(h.layer)); + yield* reactor.requestSync(key); + yield* Deferred.await(entered); + h.remove("one"); + yield* Deferred.succeed(finish, undefined); + yield* reactor.drain; + expect(h.shells()[0]?.pullRequests).toEqual([]); + expect( + h.dispatch.mock.calls.some( + ([command]) => + command.type === "thread.metadata.update" && command.linkPullRequest !== undefined, + ), + ).toBe(false); + }).pipe(Effect.scoped), +); + +it.effect("preserves dismissed native stack members and guards additions by their anchor", () => + Effect.gen(function* () { + let thread = shell("one"); + thread = { + ...thread, + ...updateLinkedPullRequests( + thread, + { + linkPullRequest: { ...ref, number: 2, url: "https://github.com/org/repo/pull/2" }, + linkPullRequestSource: "stack", + }, + at, + ), + }; + thread = { + ...thread, + ...updateLinkedPullRequests( + thread, + { unlinkPullRequest: { ...ref, number: 2, url: "https://github.com/org/repo/pull/2" } }, + at, + ), + }; + const h = harness([thread, shell("two")], Effect.succeed(overview), stack); + const reactor = yield* make.pipe(Effect.provide(h.layer)); + yield* reactor.requestSync(key); + yield* reactor.drain; + expect(h.shells()[0]?.linkedPullRequests?.map((link) => link.number)).toEqual([1]); + expect(h.shells()[1]?.linkedPullRequests?.map((link) => link.number)).toEqual([1, 2]); + const additions = h.dispatch.mock.calls + .map(([command]) => command) + .filter((command) => command.type === "thread.metadata.update" && command.linkPullRequest); + expect(additions).toHaveLength(1); + expect(additions[0]).toMatchObject({ + expectedPullRequestLink: { number: 1, source: "manual", linkedAt: at }, + linkPullRequestSource: "stack", + }); + }).pipe(Effect.scoped), +); + +it.effect("a merge notification refreshes an otherwise idle merged link", () => + Effect.gen(function* () { + const notified = yield* Deferred.make(); + const read = yield* Deferred.make(); + const thread = shell("merged"); + const merged = { ...overview, state: "merged" as const, mergedAt: at }; + const h = harness( + [ + { + ...thread, + pullRequests: + thread.pullRequests?.map((link) => ({ + ...link, + snapshot: { ...merged, isDraft: false, closedAt: null, syncedAt: at }, + })) ?? [], + }, + ], + Deferred.succeed(read, undefined).pipe(Effect.as({ ...merged, title: "Confirmed merge" })), + null, + Stream.fromEffect(Deferred.await(notified)), + ); + const reactor = yield* make.pipe(Effect.provide(h.layer)); + yield* reactor.start(); + yield* Deferred.succeed(notified, { ...ref, host: key.host, mergedAt: at }); + yield* Deferred.await(read); + yield* reactor.drain; + expect(h.shells()[0]?.pullRequests?.[0]?.snapshot?.title).toBe("Confirmed merge"); + expect(h.summary).toHaveBeenCalledTimes(1); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts new file mode 100644 index 000000000..b52435cac --- /dev/null +++ b/apps/server/src/orchestration-v2/PullRequestSyncReactor.ts @@ -0,0 +1,373 @@ +import { parseChangeRequestUrl, siblingPullRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { + CommandId, + type OrchestrationV2ThreadShell, + type PullRequestSummary, + type ThreadPullRequestKey, + type ThreadPullRequestLink, + type ThreadPullRequestSnapshot, + type ThreadPullRequestStack, +} from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import { + threadPullRequestKeyOf, + threadPullRequestKeysEqual, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequestChains"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import type * as Scope from "effect/Scope"; + +import * as PullRequestService from "../pullRequest/PullRequestService.ts"; +import { forkParked } from "../serverActivation.ts"; +import { ThreadManagementService } from "./ThreadManagementService.ts"; +import { allThreadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; +import * as Stream from "effect/Stream"; + +const SLOW_SYNC_INTERVAL_MS = 15 * 60 * 1_000; + +type SnapshotFields = Omit; + +interface LinkEntry { + readonly thread: OrchestrationV2ThreadShell; + readonly link: ThreadPullRequestLink; +} + +function snapshotFieldsOf(summary: PullRequestSummary): SnapshotFields { + return { + state: summary.state, + title: summary.title, + headBranch: summary.headBranch, + baseBranch: summary.baseBranch, + isDraft: summary.isDraft ?? false, + updatedAt: summary.updatedAt, + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, + ...(summary.author === undefined ? {} : { author: summary.author }), + ...(summary.additions === undefined ? {} : { additions: summary.additions }), + ...(summary.deletions === undefined ? {} : { deletions: summary.deletions }), + ...(summary.changedFiles === undefined ? {} : { changedFiles: summary.changedFiles }), + ...(summary.reviewDecision === undefined ? {} : { reviewDecision: summary.reviewDecision }), + ...(summary.checksState === undefined ? {} : { checksState: summary.checksState }), + ...(summary.mergeability === undefined ? {} : { mergeability: summary.mergeability }), + }; +} + +function snapshotFieldsEqual(left: SnapshotFields, right: SnapshotFields): boolean { + return ( + left.state === right.state && + left.title === right.title && + left.headBranch === right.headBranch && + left.baseBranch === right.baseBranch && + left.isDraft === right.isDraft && + left.updatedAt === right.updatedAt && + (left.closedAt ?? null) === (right.closedAt ?? null) && + (left.mergedAt ?? null) === (right.mergedAt ?? null) && + (left.author?.login ?? null) === (right.author?.login ?? null) && + (left.author?.avatarUrl ?? null) === (right.author?.avatarUrl ?? null) && + left.additions === right.additions && + left.deletions === right.deletions && + left.changedFiles === right.changedFiles && + (left.reviewDecision ?? null) === (right.reviewDecision ?? null) && + (left.checksState ?? null) === (right.checksState ?? null) && + left.mergeability === right.mergeability + ); +} + +function stacksEqual( + left: ThreadPullRequestStack | null, + right: ThreadPullRequestStack | null, +): boolean { + if (left === null || right === null) return left === right; + return ( + left.kind === right.kind && + left.id === right.id && + left.number === right.number && + left.url === right.url && + left.base === right.base && + left.layers.length === right.layers.length && + left.layers.every((layer, index) => { + const other = right.layers[index]!; + return ( + layer.number === other.number && + layer.headBranch === other.headBranch && + layer.state === other.state + ); + }) + ); +} + +function isUnsettled(thread: OrchestrationV2ThreadShell): boolean { + return thread.settledOverride !== "settled" && thread.settledAt === null; +} + +/** + * Keeps every thread ↔ pull request link's host snapshot current. One sweep a minute reads + * the shell snapshot, groups visible links by pull request so the host is asked once per PR + * no matter how many threads share it, and writes back only what changed. Native stacks the + * host reports are auto-linked to the thread as `source: "stack"`. + */ +export class PullRequestSyncReactor extends Context.Service< + PullRequestSyncReactor, + { + readonly start: () => Effect.Effect; + readonly drain: Effect.Effect; + /** Force the next sweep to re-read this pull request, even when its snapshot is terminal. */ + readonly requestSync: (key: ThreadPullRequestKey) => Effect.Effect; + } +>()("t3/orchestration-v2/PullRequestSyncReactor") {} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const threads = yield* ThreadManagementService; + const pullRequests = yield* PullRequestService.PullRequestService; + const crypto = yield* Crypto.Crypto; + + const lastSyncedAt = new Map(); + const requested = new Map(); + let requestGeneration = 0; + const retryStacks = new Set(); + + const isDue = (key: string, entries: ReadonlyArray, nowMs: number): boolean => { + if (requested.has(key) || retryStacks.has(key)) return true; + if (entries.some((entry) => entry.link.snapshot === null)) return true; + if (entries.every((entry) => entry.link.snapshot?.state === "merged")) return false; + if (entries.some((entry) => entry.link.snapshot?.state === "open" && isUnsettled(entry.thread))) + return true; + // Closed requests can reopen on the host, including after the thread settles. + const last = lastSyncedAt.get(key); + return last === undefined || nowMs - last >= SLOW_SYNC_INTERVAL_MS; + }; + + const logSkipped = + (message: string, fields: Record) => + (cause: Cause.Cause): Effect.Effect => + Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning(message, fields); + + const sweep = Effect.fn("PullRequestSyncReactor.sweep")(function* () { + const snapshot = yield* threads.getShellSnapshot({ location: "active" }); + const now = yield* DateTime.now; + const nowMs = DateTime.toEpochMillis(now); + const nowIso = DateTime.formatIso(now); + + const groups = new Map>(); + for (const thread of snapshot.threads) { + if (thread.archivedAt !== null || thread.deletedAt !== null) continue; + for (const link of visibleThreadPullRequests(allThreadPullRequestsOf(thread))) { + const key = threadPullRequestKeyOf(link); + const entries = groups.get(key) ?? []; + entries.push({ thread, link }); + groups.set(key, entries); + } + } + + for (const key of lastSyncedAt.keys()) if (!groups.has(key)) lastSyncedAt.delete(key); + for (const key of retryStacks) if (!groups.has(key)) retryStacks.delete(key); + for (const key of requested.keys()) if (!groups.has(key)) requested.delete(key); + + // Layers auto-linked this sweep, so two links of one thread that share a + // stack do not both try to add the same sibling. + const linkedThisSweep = new Set(); + + const syncEntry = Effect.fn("PullRequestSyncReactor.syncEntry")(function* ( + entry: LinkEntry, + fields: SnapshotFields, + fetchedStack: { readonly stack: ThreadPullRequestStack | null } | null, + ) { + const { thread, link } = entry; + const nextStack = fetchedStack === null ? link.stack : fetchedStack.stack; + const changed = + link.snapshot === null || + !snapshotFieldsEqual(link.snapshot, fields) || + !stacksEqual(link.stack, nextStack); + if (changed) { + const uuid = yield* crypto.randomUUIDv4; + yield* threads.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`server:pr-sync:${thread.id}:${uuid}`), + threadId: thread.id, + expectedProjectId: thread.projectId, + syncPullRequest: { + reference: link, + snapshot: { ...fields, syncedAt: nowIso }, + stack: nextStack, + }, + }); + } + if (fetchedStack === null || fetchedStack.stack === null) return; + const current = yield* threads.getThreadShell(thread.id); + if (!current || current.archivedAt !== null || current.deletedAt !== null) return; + const currentLinks = allThreadPullRequestsOf(current); + if ( + !visibleThreadPullRequests(currentLinks).some( + (item) => + threadPullRequestKeysEqual(item, link) && + item.source === link.source && + item.linkedAt === link.linkedAt, + ) + ) + return; + for (const layer of fetchedStack.stack.layers) { + const layerKey = { host: link.host, repository: link.repository, number: layer.number }; + const dedupeKey = `${thread.id}:${threadPullRequestKeyOf(layerKey)}`; + if (linkedThisSweep.has(dedupeKey)) continue; + // Tombstones count as present: a dismissed layer is never re-added. + if (currentLinks.some((existing) => threadPullRequestKeysEqual(existing, layerKey))) { + continue; + } + const url = siblingPullRequestUrl(link.url, layer.number); + if (url === null) continue; + linkedThisSweep.add(dedupeKey); + const uuid = yield* crypto.randomUUIDv4; + yield* threads + .dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`server:pr-stack-link:${thread.id}:${uuid}`), + threadId: thread.id, + expectedProjectId: thread.projectId, + linkPullRequest: { + projectId: link.projectId ?? thread.projectId, + repository: link.repository, + number: layer.number, + url, + }, + linkPullRequestSource: "stack", + expectedPullRequestLink: link, + }) + .pipe( + Effect.catchCause( + logSkipped("pull request stack layer link skipped", { + threadId: thread.id, + number: layer.number, + }), + ), + ); + } + }); + + const syncGroup = Effect.fn("PullRequestSyncReactor.syncGroup")(function* ( + key: string, + entries: ReadonlyArray, + ) { + const first = entries[0]!; + const ref = { + projectId: first.link.projectId ?? first.thread.projectId, + host: first.link.host, + repository: first.link.repository, + number: first.link.number, + }; + const generation = requested.get(key); + if (generation !== undefined) yield* pullRequests.invalidate({ reference: ref }); + const summary = yield* pullRequests.summary(ref); + const fields = snapshotFieldsOf(summary); + const needsStack = + generation !== undefined || + retryStacks.has(key) || + entries.some( + (entry) => + entry.link.snapshot === null || !snapshotFieldsEqual(entry.link.snapshot, fields), + ); + const fetchedStack = needsStack + ? yield* pullRequests.stack(ref, { includeDetails: false }).pipe( + Effect.map((stack) => ({ + stack: stack === null ? null : ({ kind: "native", ...stack } as const), + })), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("pull request stack lookup failed", { + key, + }).pipe(Effect.as(null)), + ), + ) + : null; + if (needsStack) { + if (fetchedStack === null) retryStacks.add(key); + else retryStacks.delete(key); + } + // The host answered, so the cadence clock ticks even if a dispatch below is rejected. + lastSyncedAt.set(key, nowMs); + // A refresh requested while the host read was in flight belongs to the next sweep. + if (requested.get(key) === generation) requested.delete(key); + yield* Effect.forEach( + entries, + (entry) => + syncEntry(entry, fields, fetchedStack).pipe( + Effect.catchCause( + logSkipped("pull request sync skipped", { threadId: entry.thread.id, key }), + ), + ), + { discard: true }, + ); + }); + + yield* Effect.forEach( + groups, + ([key, entries]) => + isDue(key, entries, nowMs) + ? syncGroup(key, entries).pipe( + Effect.catchCause(logSkipped("pull request sync skipped", { key })), + ) + : Effect.void, + { concurrency: 8, discard: true }, + ); + }); + + let sweepQueued = false; + const worker = yield* makeDrainableWorker(() => + Effect.suspend(() => { + sweepQueued = false; + return sweep().pipe(Effect.catchCause(logSkipped("pull request sync sweep failed", {}))); + }), + ); + const enqueueSweep = Effect.suspend(() => { + if (sweepQueued) return Effect.void; + sweepQueued = true; + return worker.enqueue(undefined).pipe(Effect.asVoid); + }); + + const start: PullRequestSyncReactor["Service"]["start"] = Effect.fn( + "PullRequestSyncReactor.start", + )(function* () { + const merges = yield* pullRequests.subscribeMerges; + yield* forkParked( + Stream.runForEach(merges, (event) => requestSync(parseChangeRequestUrl(event.url) ?? event)), + ); + yield* forkParked( + Stream.runForEach(threads.streamDomainEvents, (event) => { + if ( + event.type !== "thread.metadata-updated" && + event.type !== "thread.created" && + event.type !== "thread.unarchived" + ) + return Effect.void; + return visibleThreadPullRequests(allThreadPullRequestsOf(event.payload)).some( + (link) => link.snapshot === null, + ) + ? enqueueSweep + : Effect.void; + }), + ); + yield* forkParked( + Effect.gen(function* () { + yield* enqueueSweep; + yield* worker.drain; + }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid), + ); + }); + + const requestSync: PullRequestSyncReactor["Service"]["requestSync"] = (key) => + Effect.suspend(() => { + requested.set(threadPullRequestKeyOf(key), ++requestGeneration); + return enqueueSweep; + }); + + return { start, drain: worker.drain, requestSync } satisfies PullRequestSyncReactor["Service"]; +}); + +export const layer = Layer.effect(PullRequestSyncReactor, make); diff --git a/apps/server/src/orchestration-v2/RestartContinuationPolicy.test.ts b/apps/server/src/orchestration-v2/RestartContinuationPolicy.test.ts new file mode 100644 index 000000000..516ddb6cd --- /dev/null +++ b/apps/server/src/orchestration-v2/RestartContinuationPolicy.test.ts @@ -0,0 +1,109 @@ +import * as DateTime from "effect/DateTime"; +import { describe, expect, it } from "@effect/vitest"; +import { type OrchestrationV2Run, type OrchestrationV2ThreadProjection } from "@t3tools/contracts"; +import { canContinueAfterRestart } from "./RestartContinuationPolicy.ts"; + +const run = { + id: "run", + ordinal: 1, + providerInstanceId: "codex", + providerThreadId: "native", + status: "running", +} as OrchestrationV2Run; +const fixture = () => + ({ + thread: { + id: "thread", + archivedAt: null, + deletedAt: null, + providerInstanceId: "codex", + activeProviderThreadId: "native", + settledOverride: null, + }, + runs: [run], + turnItems: [], + runtimeRequests: [], + messages: [], + providerThreads: [ + { + id: "native", + ownerNodeId: null, + status: "active", + nativeThreadRef: { nativeId: "saved", strength: "strong" }, + }, + ], + }) as unknown as OrchestrationV2ThreadProjection; + +describe("restart continuation ownership", () => { + it("prepares only running work with a saved provider reference", () => { + const projection = fixture(); + expect(canContinueAfterRestart(projection, run, "prepare")).toBe(true); + for (const status of ["starting", "queued", "completed", "cancelled", "failed"] as const) + expect(canContinueAfterRestart(projection, { ...run, status }, "prepare")).toBe(false); + expect(canContinueAfterRestart({ ...projection, providerThreads: [] }, run, "prepare")).toBe( + false, + ); + expect( + canContinueAfterRestart( + { + ...projection, + providerThreads: projection.providerThreads.map((thread) => ({ + ...thread, + nativeThreadRef: { ...thread.nativeThreadRef!, nativeId: null }, + })), + }, + run, + "prepare", + ), + ).toBe(false); + }); + it("does not resume archived, settled, superseded or blocked work", () => { + const projection = fixture(); + const pending = { + ...run, + status: "cancelled" as const, + restartContinuation: { messageId: "continuation", reason: "restart", status: "pending" }, + } as OrchestrationV2Run; + const recovered = { ...projection, runs: [pending] }; + expect(canContinueAfterRestart(recovered, pending, "resume")).toBe(true); + for (const thread of [ + { ...projection.thread, archivedAt: DateTime.makeUnsafe(0) }, + { ...projection.thread, settledOverride: "settled" }, + { ...projection.thread, activeProviderThreadId: null }, + ]) + expect( + canContinueAfterRestart( + { ...recovered, thread } as unknown as OrchestrationV2ThreadProjection, + pending, + "resume", + ), + ).toBe(false); + expect( + canContinueAfterRestart( + { ...recovered, runs: [pending, { ...run, ordinal: 2 }] }, + pending, + "resume", + ), + ).toBe(false); + expect( + canContinueAfterRestart( + { + ...recovered, + messages: [{ id: "continuation" }], + } as unknown as OrchestrationV2ThreadProjection, + pending, + "resume", + ), + ).toBe(false); + expect( + canContinueAfterRestart( + { + ...recovered, + runtimeRequests: [{ status: "pending", responseMode: "callback" }], + } as unknown as OrchestrationV2ThreadProjection, + pending, + "resume", + ), + ).toBe(false); + }); +}); diff --git a/apps/server/src/orchestration-v2/RestartContinuationPolicy.ts b/apps/server/src/orchestration-v2/RestartContinuationPolicy.ts new file mode 100644 index 000000000..378f751ce --- /dev/null +++ b/apps/server/src/orchestration-v2/RestartContinuationPolicy.ts @@ -0,0 +1,50 @@ +import type { OrchestrationV2Run, OrchestrationV2ThreadProjection } from "@t3tools/contracts"; + +export const RESTART_CONTINUATION_PROMPT = + "Continue where you left off before the server restarted. Check the current state before repeating any action."; + +/** Recovery only owns the latest uninterrupted provider context, never a newer user turn. */ +export function canContinueAfterRestart( + projection: OrchestrationV2ThreadProjection, + run: OrchestrationV2Run, + phase: "prepare" | "resume", +): boolean { + const thread = projection.thread; + if ( + thread.settledOverride === "settled" || + thread.deletedAt !== null || + thread.archivedAt !== null || + thread.providerInstanceId !== run.providerInstanceId || + thread.activeProviderThreadId !== run.providerThreadId || + run.providerThreadId === null || + projection.runs.some((other) => other.ordinal > run.ordinal) || + projection.turnItems.some( + (item) => item.runId === run.id && item.type === "run_interrupt_request", + ) || + projection.runtimeRequests.some( + (request) => request.status === "pending" && request.responseMode !== "message", + ) + ) + return false; + const providerThread = projection.providerThreads.find( + (candidate) => candidate.id === run.providerThreadId, + ); + if ( + providerThread?.nativeThreadRef?.nativeId == null || + providerThread.nativeThreadRef.strength === "none" || + providerThread.ownerNodeId !== null || + providerThread.status === "closed" || + providerThread.status === "archived" || + providerThread.status === "error" + ) + return false; + if (phase === "prepare") return run.status === "running"; + return ( + run.status === "cancelled" && + run.restartContinuation?.status === "pending" && + !projection.messages.some((message) => message.id === run.restartContinuation?.messageId) && + !projection.runs.some((other) => + ["preparing", "queued", "starting", "running", "waiting"].includes(other.status), + ) + ); +} diff --git a/apps/server/src/orchestration-v2/RestartContinuationService.test.ts b/apps/server/src/orchestration-v2/RestartContinuationService.test.ts new file mode 100644 index 000000000..ecc1fe537 --- /dev/null +++ b/apps/server/src/orchestration-v2/RestartContinuationService.test.ts @@ -0,0 +1,137 @@ +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + ThreadId, + type OrchestrationV2Command, + type OrchestrationV2ThreadProjection, + type OrchestrationV2ThreadShell, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { ThreadManagementService } from "./ThreadManagementService.ts"; +import { OrchestratorDispatchError } from "./Orchestrator.ts"; +import { make } from "./RestartContinuationService.ts"; + +function harness(enabled: boolean, failSecond = false, completed = false) { + const commands: OrchestrationV2Command[] = []; + let reads = 0; + const shells = ["one", "two"].map((id) => ({ + id: ThreadId.make(id), + status: "running", + archivedAt: null, + deletedAt: null, + })) as OrchestrationV2ThreadShell[]; + const layer = Layer.mergeAll( + NodeServices.layer, + ServerSettingsService.layerTest({ continueThreadsAfterServerUpdate: enabled }), + Layer.mock(ThreadManagementService)({ + getShellSnapshot: () => + Effect.sync(() => { + reads++; + return { threads: shells, archivedThreads: [], schemaVersion: 1, snapshotSequence: 1 }; + }), + getThreadProjection: (id) => + Effect.succeed({ + thread: { + id, + providerInstanceId: "codex", + activeProviderThreadId: "native", + archivedAt: null, + deletedAt: null, + settledOverride: null, + }, + providerThreads: [ + { + id: "native", + ownerNodeId: null, + nativeThreadRef: { nativeId: "saved", strength: "strong" }, + status: "idle", + }, + ], + turnItems: [], + runtimeRequests: [], + messages: [], + runs: [ + { + id: `run-${id}`, + ordinal: 1, + providerInstanceId: "codex", + providerThreadId: "native", + status: completed ? "completed" : "running", + ...(completed + ? { + restartContinuation: { + messageId: `message-${id}`, + status: "pending", + reason: "restart", + }, + } + : {}), + }, + ], + } as unknown as OrchestrationV2ThreadProjection), + dispatch: (command) => + Effect.suspend(() => { + commands.push(command); + if ( + failSecond && + command.type === "run.restart-continuation.prepare" && + command.threadId === "two" + ) + return Effect.fail( + new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Concurrent state change", + }), + ); + return Effect.succeed({ sequence: commands.length, storedEvents: [] }); + }), + }), + ); + return { layer, commands, reads: () => reads }; +} + +it.effect("restart recovery remains opt-in without scanning threads when disabled", () => + Effect.gen(function* () { + const h = harness(false); + const service = yield* make.pipe(Effect.provide(h.layer)); + expect(yield* service.prepare("restart")).toEqual([]); + expect(h.reads()).toBe(0); + expect(h.commands).toEqual([]); + }), +); + +it.effect("clears partially prepared markers when another thread cannot be prepared", () => + Effect.gen(function* () { + const h = harness(true, true); + const service = yield* make.pipe(Effect.provide(h.layer)); + expect((yield* service.prepare("restart").pipe(Effect.flip))._tag).toBe( + "RestartContinuationError", + ); + expect(h.commands.map((command) => command.type)).toEqual([ + "run.restart-continuation.prepare", + "run.restart-continuation.prepare", + "run.restart-continuation.clear", + ]); + expect(h.commands[2]).toMatchObject({ threadId: "one", runId: "run-one" }); + expect(h.commands[2]).toHaveProperty( + "messageId", + h.commands[0] && "messageId" in h.commands[0] ? h.commands[0].messageId : null, + ); + }), +); + +it.effect("completed work clears stale markers instead of sending a continuation", () => + Effect.gen(function* () { + const h = harness(true, false, true); + const service = yield* make.pipe(Effect.provide(h.layer)); + yield* service.resume; + yield* service.awaitInitialResume; + expect(h.commands.map((command) => command.type)).toEqual([ + "run.restart-continuation.clear", + "run.restart-continuation.clear", + ]); + }), +); diff --git a/apps/server/src/orchestration-v2/RestartContinuationService.ts b/apps/server/src/orchestration-v2/RestartContinuationService.ts new file mode 100644 index 000000000..1ee509560 --- /dev/null +++ b/apps/server/src/orchestration-v2/RestartContinuationService.ts @@ -0,0 +1,159 @@ +import { CommandId, MessageId, type RunId, type ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { ThreadManagementService } from "./ThreadManagementService.ts"; +import { + canContinueAfterRestart, + RESTART_CONTINUATION_PROMPT, +} from "./RestartContinuationPolicy.ts"; + +export interface RestartContinuationMarker { + readonly threadId: ThreadId; + readonly runId: RunId; + readonly messageId: MessageId; +} +export class RestartContinuationError extends Schema.TaggedErrorClass()( + "RestartContinuationError", + { + cause: Schema.Defect(), + }, +) {} +export class RestartContinuationService extends Context.Service< + RestartContinuationService, + { + readonly prepare: ( + reason: "restart" | "update", + ) => Effect.Effect, RestartContinuationError>; + readonly clear: ( + markers: ReadonlyArray, + ) => Effect.Effect; + readonly resume: Effect.Effect; + readonly awaitInitialResume: Effect.Effect; + } +>()("t3/orchestration-v2/RestartContinuationService") {} + +export const make = Effect.gen(function* () { + const initialResume = yield* Deferred.make(); + const crypto = yield* Crypto.Crypto; + const threads = yield* ThreadManagementService; + const settings = yield* ServerSettingsService; + const clear = (markers: ReadonlyArray) => + Effect.forEach( + markers, + (marker) => + threads.dispatch({ + type: "run.restart-continuation.clear", + ...marker, + commandId: CommandId.make(`restart-clear:${marker.messageId}`), + }), + { discard: true }, + ).pipe(Effect.mapError((cause) => new RestartContinuationError({ cause }))); + const prepare = Effect.fn("RestartContinuation.prepare")( + function* (reason: "restart" | "update") { + if (reason === "restart" && !(yield* settings.getSettings).continueThreadsAfterServerUpdate) + return []; + const markers: RestartContinuationMarker[] = []; + yield* Effect.gen(function* () { + const snapshot = yield* threads.getShellSnapshot({ location: "active" }); + for (const shell of snapshot.threads) { + if (shell.archivedAt !== null || shell.deletedAt !== null || shell.status !== "running") + continue; + const projection = yield* threads.getThreadProjection(shell.id); + const run = projection.runs.find((candidate) => + canContinueAfterRestart(projection, candidate, "prepare"), + ); + if (run === undefined || run.restartContinuation?.status === "pending") continue; + const marker = { + threadId: shell.id, + runId: run.id, + messageId: MessageId.make(`restart-continuation:${yield* crypto.randomUUIDv4}`), + }; + yield* threads.dispatch({ + type: "run.restart-continuation.prepare", + ...marker, + reason, + commandId: CommandId.make(`restart-prepare:${marker.messageId}`), + }); + markers.push(marker); + } + }).pipe( + Effect.catchCause((cause) => clear(markers).pipe(Effect.andThen(Effect.failCause(cause)))), + ); + return markers; + }, + Effect.mapError((cause) => new RestartContinuationError({ cause })), + ); + const resume = Effect.gen(function* () { + const enabled = (yield* settings.getSettings).continueThreadsAfterServerUpdate; + const snapshot = yield* threads.getShellSnapshot(); + yield* Effect.forEach( + [...snapshot.threads, ...snapshot.archivedThreads], + (shell) => + Effect.gen(function* () { + const projection = yield* threads.getThreadProjection(shell.id); + for (const run of projection.runs) { + if (run.restartContinuation?.status !== "pending") continue; + const marker = { + threadId: shell.id, + runId: run.id, + messageId: run.restartContinuation.messageId, + }; + if ( + (!enabled && run.restartContinuation.reason !== "update") || + !canContinueAfterRestart(projection, run, "resume") + ) { + yield* clear([marker]); + continue; + } + yield* threads + .dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`restart-dispatch:${marker.messageId}`), + threadId: shell.id, + messageId: marker.messageId, + text: RESTART_CONTINUATION_PROMPT, + attachments: [], + createdBy: "agent", + creationSource: "server", + restartContinuation: { sourceRunId: run.id }, + dispatchMode: { type: "start_immediately" }, + }) + .pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : clear([marker]).pipe( + Effect.andThen( + Effect.logWarning("restart continuation skipped", { + threadId: shell.id, + cause: Cause.pretty(cause), + }), + ), + ), + ), + ); + } + }), + { concurrency: 4, discard: true }, + ); + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.interrupt + : Effect.logWarning("restart continuation failed", { cause: Cause.pretty(cause) }), + ), + ); + return { + prepare, + clear, + resume: resume.pipe(Effect.ensuring(Deferred.succeed(initialResume, undefined))), + awaitInitialResume: Deferred.await(initialResume), + } satisfies RestartContinuationService["Service"]; +}); +export const layer = Layer.effect(RestartContinuationService, make); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index e47999946..6350cab44 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -1,8 +1,10 @@ +import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; import { assert, it, vi } from "@effect/vitest"; import { CheckpointScopeId, CommandId, EventId, + EnvironmentId, MessageId, NodeId, type OrchestrationV2AppThread, @@ -379,6 +381,97 @@ it.effect("refreshes MCP credential liveness before calling the provider", () => }).pipe(Effect.provide(RunExecutionTestLayer)), ); +it.effect("expands citations on the V2 start path while preserving the stored source", () => + Effect.gen(function* () { + const runExecution = yield* RunExecutionServiceV2; + const order = yield* Ref.make>([]); + const source = serializeAssistantCitation({ + version: 1, + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("source-thread"), + messageId: MessageId.make("assistant"), + text: "Selected answer", + comment: "Explain this", + start: 0, + end: 15, + prefix: "", + suffix: "", + }); + let received = ""; + const threadId = ThreadId.make("thread:run-execution-mcp-liveness"); + const touchActiveMcpThread = vi + .spyOn(McpSessionRegistry, "touchActiveMcpThread") + .mockImplementation((touchedThreadId) => + Ref.update(order, (entries) => [...entries, `touch:${touchedThreadId}`]), + ); + + yield* runExecution + .startRootRun({ + commandId: CommandId.make("command:run-execution-mcp-liveness"), + appThread: { id: threadId } as OrchestrationV2AppThread, + providerSessionId: ProviderSessionId.make("session:run-execution-mcp-liveness"), + session: { + events: Stream.never, + startTurn: (input: { message: { text: string } }) => + Effect.sync(() => { + received = input.message.text; + }), + } as unknown as ProviderAdapterV2SessionRuntime, + run: { + id: RunId.make("run:run-execution-mcp-liveness"), + threadId, + ordinal: 1, + providerInstanceId: ProviderInstanceId.make("codex"), + } as OrchestrationV2Run, + rootNode: { + id: NodeId.make("node:run-execution-mcp-liveness"), + } as OrchestrationV2ExecutionNode, + checkpointScope: { + id: CheckpointScopeId.make("checkpoint-scope:run-execution-mcp-liveness"), + } as OrchestrationV2CheckpointScope, + providerThread: { + id: ProviderThreadId.make("provider-thread:run-execution-mcp-liveness"), + driver, + } as OrchestrationV2ProviderThread, + attempt: { + id: RunAttemptId.make("attempt:run-execution-mcp-liveness"), + providerTurnId: null, + } as OrchestrationV2RunAttempt, + attemptId: RunAttemptId.make("attempt:run-execution-mcp-liveness"), + providerTurnOrdinal: 1, + message: { + messageId: MessageId.make("message:run-execution-mcp-liveness"), + text: source, + attachments: [], + createdBy: "user", + creationSource: "web", + }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimePolicy: { + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }) + .pipe(Effect.ensuring(Effect.sync(() => touchActiveMcpThread.mockRestore()))); + + assert.include(received, "[assistant-quote-1]"); + assert.include(received, '"text": "Selected answer"'); + assert.include(received, '"comment": "Explain this"'); + assert.notInclude(received, "t3-citation://"); + assert.include(source, "t3-citation://"); + }).pipe(Effect.provide(RunExecutionTestLayer)), +); + it.effect("skips Git baseline capture for projectless Hermes runs", () => Effect.gen(function* () { const captures = yield* Ref.make(0); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index e7887b579..8ac45d028 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -1,3 +1,4 @@ +import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; import { CommandId, type EventId, @@ -1119,7 +1120,10 @@ export const layer: Layer.Layer< attemptId: input.attemptId, rootNodeId: input.rootNode.id, providerThread: input.providerThread, - message: input.message, + message: { + ...input.message, + text: expandAssistantCitationsForProvider(input.message.text), + }, modelSelection: input.modelSelection, runtimePolicy: input.runtimePolicy, }) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index c558ca73f..4bd632e6b 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -444,6 +444,7 @@ export const make = Effect.gen(function* () { projectCwd: project.workspaceRoot, worktreePath: cwd, project: { + id: project.id, workspaceRoot: project.workspaceRoot, scripts: project.scripts, }, diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestReactor.ts b/apps/server/src/orchestration-v2/ThreadPullRequestReactor.ts new file mode 100644 index 000000000..f9466deaa --- /dev/null +++ b/apps/server/src/orchestration-v2/ThreadPullRequestReactor.ts @@ -0,0 +1,338 @@ +import { + canonicalRepositoryKey, + sourceControlRepositorySelector, +} from "@t3tools/shared/sourceControl"; +import { + CommandId, + type OrchestrationV2DomainEvent, + type Project, + type ThreadId, + type ThreadLinkedPullRequest, +} from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as GitManager from "../git/GitManager.ts"; +import * as PullRequestService from "../pullRequest/PullRequestService.ts"; +import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; +import { forkParked } from "../serverActivation.ts"; +import { ThreadManagementService } from "./ThreadManagementService.ts"; +import * as ProjectService from "../project/ProjectService.ts"; + +export class ThreadPullRequestReactor extends Context.Service< + ThreadPullRequestReactor, + { + readonly start: () => Effect.Effect; + readonly drain: Effect.Effect; + } +>()("t3/orchestration-v2/ThreadPullRequestReactor") {} + +function samePullRequest( + left: ThreadLinkedPullRequest | null | undefined, + right: ThreadLinkedPullRequest | null, +): boolean { + if (left == null || right === null) return left == null && right === null; + return ( + left.projectId === right.projectId && + left.repository.toLowerCase() === right.repository.toLowerCase() && + left.number === right.number && + left.url === right.url + ); +} + +/** Startup lookups per settled thread before discovery gives up on it. */ +export const BACKFILL_ATTEMPTS = 5; + +interface RefreshRequest { + readonly threadId: ThreadId | null; + readonly refresh: boolean; + readonly backfill?: boolean; +} + +export function pullRequestMatchesProject( + pullRequest: GitManager.GitBranchPullRequest, + project: Project, +): boolean { + return ( + pullRequest.repositoryKey !== null && + project.repositoryIdentity != null && + canonicalRepositoryKey(pullRequest.repositoryKey) === + canonicalRepositoryKey(project.repositoryIdentity.canonicalKey) + ); +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const engine = yield* ThreadManagementService; + const projectService = yield* ProjectService.ProjectService; + const git = yield* GitManager.GitManager; + const pullRequests = yield* PullRequestService.PullRequestService; + const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + // Settled threads get one link discovery at startup. Failed lookups retry on + // the periodic pass a few times, then stop until the thread changes or the + // server restarts, so a missing or logged-out CLI cannot loop forever. + const pendingBackfill = new Map(); + const finishBackfill = (threads: ReadonlyArray<{ readonly id: ThreadId }>) => { + for (const thread of threads) pendingBackfill.delete(thread.id); + }; + const failBackfill = (threads: ReadonlyArray<{ readonly id: ThreadId }>) => { + for (const thread of threads) { + const remaining = pendingBackfill.get(thread.id); + if (remaining === undefined) continue; + if (remaining <= 1) pendingBackfill.delete(thread.id); + else pendingBackfill.set(thread.id, remaining - 1); + } + }; + + const synchronize = Effect.fn("ThreadPullRequestReactor.synchronize")(function* ( + request: RefreshRequest, + ) { + const snapshot = yield* engine.getShellSnapshot({ location: "active" }); + const projectSnapshot = yield* projectService.snapshot; + const projects = new Map(projectSnapshot.projects.map((project) => [project.id, project])); + if (request.backfill) { + for (const thread of snapshot.threads) { + if ( + (thread.settledOverride === "settled" || thread.settledAt !== null) && + thread.branchPullRequest == null + ) { + pendingBackfill.set(thread.id, BACKFILL_ATTEMPTS); + } + } + } + const threadIds = new Set(snapshot.threads.map((thread) => thread.id)); + for (const threadId of pendingBackfill.keys()) { + if (!threadIds.has(threadId)) pendingBackfill.delete(threadId); + } + const threads = snapshot.threads.filter( + (thread) => + thread.archivedAt === null && + thread.deletedAt === null && + (request.threadId === null || thread.id === request.threadId) && + ((thread.settledOverride !== "settled" && thread.settledAt === null) || + request.threadId !== null || + pendingBackfill.has(thread.id)) && + (thread.branch !== null || thread.branchPullRequest != null), + ); + const groups = Map.groupBy(threads, (thread) => + JSON.stringify([thread.projectId, thread.worktreePath, thread.branch]), + ); + + yield* Effect.forEach( + groups.values(), + (group) => + Effect.gen(function* () { + const first = group[0]!; + const project = projects.get(first.projectId); + if (project === undefined) return finishBackfill(group); + const repository = sourceControlRepositorySelector(project.repositoryIdentity); + if (first.branch !== null && repository === null) return finishBackfill(group); + const worktreeExists = + first.worktreePath !== null && (yield* fileSystem.exists(first.worktreePath)); + const cwd = + worktreeExists && first.worktreePath !== null + ? first.worktreePath + : project.workspaceRoot; + const detected = + first.branch === null + ? null + : yield* git.branchPullRequest( + { cwd, branch: first.branch }, + { refresh: request.refresh }, + ); + // A worktree can have different remotes, and the project identity + // can lag a remote edit. Do not attach its PR to the wrong repository. + if (detected !== null && !pullRequestMatchesProject(detected, project)) { + return finishBackfill(group); + } + const detectedReference = + detected !== null && repository !== null + ? { + projectId: project.id, + repository, + number: detected.number, + url: detected.url, + } + : null; + + const plans = yield* Effect.forEach(group, (thread) => + Effect.gen(function* () { + let branchPullRequest = detectedReference; + // Shared checkouts often return to the default branch after + // a merge. Keep that thread's terminal PR across the change. + if ( + branchPullRequest === null && + thread.branch !== null && + thread.worktreePath === null && + thread.branchPullRequest != null + ) { + const previous = yield* pullRequests.summary(thread.branchPullRequest); + if (previous.state === "merged" || previous.state === "closed") { + branchPullRequest = thread.branchPullRequest; + } + } + + if (samePullRequest(thread.branchPullRequest, branchPullRequest)) { + pendingBackfill.delete(thread.id); + return null; + } + return { thread, branchPullRequest }; + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("thread pull request discovery failed", { + threadId: thread.id, + cause: Cause.pretty(cause), + }).pipe( + Effect.tap(() => Effect.sync(() => failBackfill([thread]))), + Effect.as(null), + ), + ), + ), + ); + const updates = plans.filter((plan) => plan !== null); + if (updates.length === 0) return; + + if (detected !== null && first.branch !== null) { + // Summary reads can outlast a remote edit. Recheck the branch and + // the project's primary remote before saving the group's links. + const current = yield* git.branchPullRequest({ cwd, branch: first.branch }); + const currentIdentity = yield* repositoryIdentities.resolve(project.workspaceRoot, { + refresh: true, + }); + if ( + current === null || + current.number !== detected.number || + current.url !== detected.url || + current.state !== detected.state || + current.repositoryKey !== detected.repositoryKey || + !pullRequestMatchesProject(current, { + ...project, + repositoryIdentity: currentIdentity, + }) + ) { + return failBackfill(updates.map((update) => update.thread)); + } + } + + yield* Effect.forEach( + updates, + ({ thread, branchPullRequest }) => + Effect.gen(function* () { + const uuid = yield* crypto.randomUUIDv4; + yield* engine.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`server:thread-pull-request:${thread.id}:${uuid}`), + threadId: thread.id, + expectedProjectId: project.id, + expectedBranch: thread.branch, + expectedWorktreePath: thread.worktreePath, + branchPullRequest, + }); + pendingBackfill.delete(thread.id); + }).pipe( + // The thread changed since the lookup. Its own events requeue it. + Effect.catchTags({ + OrchestratorDispatchError: () => Effect.sync(() => finishBackfill([thread])), + }), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("thread pull request update failed", { + threadId: thread.id, + cause: Cause.pretty(cause), + }).pipe(Effect.tap(() => Effect.sync(() => failBackfill([thread])))), + ), + ), + { discard: true }, + ); + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("thread branch pull request lookup failed", { + threadIds: group.map((thread) => thread.id), + cause: Cause.pretty(cause), + }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))), + ), + ), + { concurrency: 8, discard: true }, + ); + }); + + const worker = yield* makeDrainableWorker((request: RefreshRequest) => + synchronize(request).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("thread pull request refresh failed", { + cause: Cause.pretty(cause), + }), + ), + ), + ); + + // V2 metadata events carry the whole thread. Track lookup context so our own + // branch-candidate updates (and background snapshot writes) do not trigger a loop. + const contexts = new Map(); + const processEvent = (event: OrchestrationV2DomainEvent) => { + if (event.type === "thread.deleted" || event.type === "thread.archived") { + contexts.delete(event.threadId); + pendingBackfill.delete(event.threadId); + return Effect.void; + } + if ( + event.type === "thread.created" || + event.type === "thread.unarchived" || + event.type === "thread.metadata-updated" + ) { + const thread = event.payload; + const context = JSON.stringify([thread.projectId, thread.branch, thread.worktreePath]); + if (contexts.get(thread.id) === context && event.type === "thread.metadata-updated") + return Effect.void; + contexts.set(thread.id, context); + return worker.enqueue({ threadId: thread.id, refresh: false }); + } + if (event.type === "checkpoint.captured" || event.type === "thread.unsettled") { + return worker.enqueue({ threadId: event.threadId, refresh: true }); + } + if ( + event.type === "run.updated" && + ["completed", "failed", "cancelled", "interrupted"].includes(event.payload.status) + ) { + return worker.enqueue({ threadId: event.threadId, refresh: false }); + } + return Effect.void; + }; + + const start = Effect.fn("ThreadPullRequestReactor.start")(function* () { + yield* forkParked(Stream.runForEach(engine.streamDomainEvents, processEvent)); + // Run without client demand. Saved branch lookups share GitManager's + // provider cache and retry backoff with status and automatic settlement. + yield* forkParked( + Effect.gen(function* () { + yield* worker.enqueue({ threadId: null, refresh: false, backfill: true }); + yield* worker.drain; + yield* Effect.gen(function* () { + yield* worker.enqueue({ threadId: null, refresh: false }); + yield* worker.drain; + }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.delay("1 minute")); + }).pipe(Effect.asVoid), + ); + }); + + return { start, drain: worker.drain } satisfies ThreadPullRequestReactor["Service"]; +}); + +export const layer = Layer.effect(ThreadPullRequestReactor, make); diff --git a/apps/server/src/orchestration-v2/ThreadSettlementPolicy.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementPolicy.test.ts new file mode 100644 index 000000000..ac0c02b04 --- /dev/null +++ b/apps/server/src/orchestration-v2/ThreadSettlementPolicy.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "@effect/vitest"; +import { RunId, RuntimeRequestId, type ThreadPullRequestLink } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import { + isAutoSettlementCandidate, + resolveAutoSettlementAt, + type SettlementThread, + type SettlementPullRequest, +} from "./ThreadSettlementPolicy.ts"; + +const date = DateTime.makeUnsafe; +const now = date("2026-09-11T12:00:00Z"); +const before = date("2026-09-01T12:00:00Z"); +const thread = (overrides: Partial = {}): SettlementThread => ({ + createdAt: before, + latestUserMessageAt: before, + latestRunRequestedAt: before, + latestRunStartedAt: before, + latestRunCompletedAt: before, + status: "completed", + activeRunId: null, + pendingRuntimeRequest: null, + archivedAt: null, + deletedAt: null, + settledOverride: null, + pullRequests: [], + ...overrides, +}); +const decide = ( + overrides: Partial = {}, + pr: SettlementPullRequest | null = null, + days: number | null = 3, + merge = true, +) => + resolveAutoSettlementAt({ + thread: thread(overrides), + pullRequest: pr, + now, + autoSettleAfterDays: days, + autoSettleOnMerge: merge, + }); +const link = (state: "open" | "merged" | "closed", number = 1): ThreadPullRequestLink => ({ + host: "github.com", + repository: "org/repo", + number, + url: `https://github.com/org/repo/pull/${number}`, + source: "manual", + linkedAt: "2026-09-01T12:00:00Z", + stack: null, + snapshot: { + state, + title: "Change", + headBranch: `feature/${number}`, + baseBranch: "main", + isDraft: false, + updatedAt: "2026-09-11T12:00:00Z", + syncedAt: "2026-09-11T12:00:00Z", + mergedAt: state === "merged" ? "2026-09-10T12:00:00Z" : null, + closedAt: state === "closed" ? "2026-09-10T12:00:00Z" : null, + }, +}); + +describe("V2 automatic settlement policy", () => { + it("persists actual activity time and keeps never-used threads active", () => { + expect(decide()).toEqual(before); + expect( + decide({ + latestUserMessageAt: null, + latestRunRequestedAt: null, + latestRunStartedAt: null, + latestRunCompletedAt: null, + }), + ).toBeNull(); + expect(decide({}, null, null)).toBeNull(); + expect(decide({ latestRunCompletedAt: date("2026-09-08T12:00:00Z") })).toBeNull(); + }); + it("ignores changed PR metadata after resumed work and requires terminal timestamps", () => { + expect( + decide( + { latestUserMessageAt: now }, + { state: "merged", mergedAt: "2026-09-10T12:00:00Z" }, + null, + ), + ).toBeNull(); + expect(decide({}, { state: "merged" }, null)).toBeNull(); + expect(decide({}, { state: "merged", mergedAt: "invalid" }, null)).toBeNull(); + expect(decide({}, { state: "merged", mergedAt: "2026-09-10T12:00:00Z" }, null)).toEqual(before); + }); + it("honors merge opt-out while closed requests remain terminal", () => { + expect( + decide({}, { state: "merged", mergedAt: "2026-09-10T12:00:00Z" }, null, false), + ).toBeNull(); + expect(decide({}, { state: "closed", closedAt: "2026-09-10T12:00:00Z" }, null, false)).toEqual( + before, + ); + }); + it("does not hide open or unknown linked work because of inactivity", () => { + expect(decide({ pullRequests: [link("open")] })).toBeNull(); + expect(decide({ pullRequests: [{ ...link("closed"), snapshot: null }] })).toBeNull(); + expect(decide({ pullRequests: [link("merged"), link("open", 2)] })).toBeNull(); + expect( + decide( + { pullRequests: [link("merged"), { ...link("open", 2), source: "stack-dismissed" }] }, + null, + null, + ), + ).toEqual(before); + }); + it("protects archived, deleted, active, delegated and background work", () => { + for (const overrides of [ + { archivedAt: before }, + { deletedAt: before }, + { settledOverride: "active" as const }, + { workInboxRole: "main" as const }, + { activeRunId: RunId.make("run") }, + { status: "queued" as const }, + { backgroundProcessCount: 1 }, + { activeAgentCount: 1 }, + ]) + expect(decide(overrides)).toBeNull(); + }); + it("distinguishes blocking requests from asynchronous questions", () => { + const request = { + id: RuntimeRequestId.make("question"), + kind: "user_input" as const, + createdAt: before, + }; + expect(decide({ pendingRuntimeRequest: request })).toBeNull(); + expect(decide({ pendingRuntimeRequest: { ...request, responseMode: "callback" } })).toBeNull(); + expect(decide({ pendingRuntimeRequest: { ...request, responseMode: "message" } })).toEqual( + before, + ); + }); + it("bounds queued-message protection and accepts failed starts", () => { + expect(isAutoSettlementCandidate(thread({ latestUserMessageAt: now }), now)).toBe(false); + expect( + isAutoSettlementCandidate(thread({ latestUserMessageAt: now, status: "failed" }), now), + ).toBe(true); + expect( + isAutoSettlementCandidate(thread({ latestUserMessageAt: date("2026-09-11T11:57:00Z") }), now), + ).toBe(true); + }); + it("respects snooze unless a run completes or fails after it", () => { + const snooze = { + snoozedUntil: date("2026-09-12T12:00:00Z"), + snoozedAt: date("2026-09-10T12:00:00Z"), + }; + expect(decide(snooze)).toBeNull(); + expect(isAutoSettlementCandidate(thread({ ...snooze, latestRunCompletedAt: now }), now)).toBe( + true, + ); + }); +}); diff --git a/apps/server/src/orchestration-v2/ThreadSettlementPolicy.ts b/apps/server/src/orchestration-v2/ThreadSettlementPolicy.ts new file mode 100644 index 000000000..a0806c585 --- /dev/null +++ b/apps/server/src/orchestration-v2/ThreadSettlementPolicy.ts @@ -0,0 +1,133 @@ +import type { OrchestrationV2ThreadShell } from "@t3tools/contracts"; +import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequestChains"; +import * as DateTime from "effect/DateTime"; + +export interface SettlementPullRequest { + readonly state: "open" | "closed" | "merged"; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; +} + +export type SettlementThread = Pick< + OrchestrationV2ThreadShell, + | "createdAt" + | "latestUserMessageAt" + | "latestRunRequestedAt" + | "latestRunStartedAt" + | "latestRunCompletedAt" + | "status" + | "activeRunId" + | "pendingRuntimeRequest" + | "archivedAt" + | "deletedAt" + | "settledOverride" + | "snoozedAt" + | "snoozedUntil" + | "pinnedAt" + | "workInboxRole" + | "backgroundProcessCount" + | "activeAgentCount" + | "pullRequests" +>; + +const DAY_MS = 86_400_000; +const QUEUED_GRACE_MS = 120_000; +const millis = (value: DateTime.Utc | null | undefined) => + value == null ? Number.NEGATIVE_INFINITY : DateTime.toEpochMillis(value); +const latest = (values: readonly (DateTime.Utc | null | undefined)[]) => + values.reduce( + (result, value) => (millis(value) > millis(result) ? value! : result), + null, + ); + +/** V2 execution and request projections replace V1 session/background-liveness checks. */ +export function isAutoSettlementCandidate(thread: SettlementThread, now: DateTime.Utc): boolean { + if (thread.archivedAt !== null || thread.deletedAt !== null || thread.settledOverride !== null) + return false; + if (thread.workInboxRole === "main") return false; + if ( + thread.activeRunId !== null || + (thread.pendingRuntimeRequest !== null && + thread.pendingRuntimeRequest.responseMode !== "message") + ) + return false; + if (["preparing", "queued", "starting", "running", "waiting"].includes(thread.status)) + return false; + if ((thread.backgroundProcessCount ?? 0) > 0 || (thread.activeAgentCount ?? 0) > 0) return false; + const userAt = millis(thread.latestUserMessageAt); + if ( + thread.status !== "failed" && + Number.isFinite(userAt) && + Math.abs(DateTime.toEpochMillis(now) - userAt) <= QUEUED_GRACE_MS && + [thread.latestRunRequestedAt, thread.latestRunStartedAt, thread.latestRunCompletedAt].every( + (at) => millis(at) < userAt, + ) + ) + return false; + if (millis(thread.snoozedUntil) <= DateTime.toEpochMillis(now)) return true; + return ( + ["failed", "completed"].includes(thread.status) && + millis(thread.latestRunCompletedAt) > millis(thread.snoozedAt) + ); +} + +/** Terminal host timestamps prevent later PR metadata edits from settling resumed work. */ +export function resolveAutoSettlementAt(input: { + readonly thread: SettlementThread; + readonly pullRequest: SettlementPullRequest | null; + readonly now: DateTime.Utc; + readonly autoSettleAfterDays: number | null; + readonly autoSettleOnMerge: boolean; +}): DateTime.Utc | null { + const { thread } = input; + if (!isAutoSettlementCandidate(thread, input.now)) return null; + const links = visibleThreadPullRequests(thread.pullRequests ?? []); + if (links.some((link) => link.snapshot === null || link.snapshot.state === "open")) return null; + let pullRequest = input.pullRequest; + if (links.length > 0) { + const terminalMs = (link: (typeof links)[number]) => { + const snapshot = link.snapshot; + const timestamp = Date.parse( + (snapshot?.state === "merged" ? snapshot.mergedAt : snapshot?.closedAt) ?? "", + ); + return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp; + }; + const last = links.reduce((current, candidate) => + terminalMs(candidate) > terminalMs(current) ? candidate : current, + ); + pullRequest = + last.snapshot === null + ? null + : { + state: last.snapshot.state, + mergedAt: last.snapshot.mergedAt ?? null, + closedAt: last.snapshot.closedAt ?? null, + }; + } + const activity = latest([ + thread.latestUserMessageAt, + thread.latestRunRequestedAt, + thread.latestRunStartedAt, + thread.latestRunCompletedAt, + ]); + const anchor = latest([ + thread.createdAt, + thread.latestUserMessageAt, + thread.latestRunRequestedAt, + ]); + if ( + pullRequest !== null && + (pullRequest.state === "closed" || (pullRequest.state === "merged" && input.autoSettleOnMerge)) + ) { + const terminalAt = Date.parse( + (pullRequest.state === "merged" ? pullRequest.mergedAt : pullRequest.closedAt) ?? "", + ); + if (Number.isFinite(terminalAt) && anchor !== null && terminalAt >= millis(anchor)) + return activity ?? thread.createdAt; + } + return input.autoSettleAfterDays !== null && + activity !== null && + millis(activity) < DateTime.toEpochMillis(input.now) - input.autoSettleAfterDays * DAY_MS + ? activity + : null; +} diff --git a/apps/server/src/orchestration-v2/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementReactor.test.ts new file mode 100644 index 000000000..8fbd88393 --- /dev/null +++ b/apps/server/src/orchestration-v2/ThreadSettlementReactor.test.ts @@ -0,0 +1,204 @@ +import { expect, it, vi } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + ProjectId, + ThreadId, + type OrchestrationV2ThreadShell, + type OrchestrationV2Command, + type Project, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Stream from "effect/Stream"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; +import { ThreadManagementService } from "./ThreadManagementService.ts"; +import { ProjectService } from "../project/ProjectService.ts"; +import { + type PullRequestMergeEvent, + PullRequestService, +} from "../pullRequest/PullRequestService.ts"; +import { GitManager } from "../git/GitManager.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { make } from "./ThreadSettlementReactor.ts"; + +const epoch = DateTime.makeUnsafe(0); +const fixture = (overrides: Partial = {}) => + ({ + id: ThreadId.make("thread"), + projectId: ProjectId.make("project"), + branch: null, + worktreePath: null, + status: "completed", + activeRunId: null, + pendingRuntimeRequest: null, + createdAt: epoch, + updatedAt: epoch, + latestUserMessageAt: epoch, + latestRunRequestedAt: epoch, + latestRunStartedAt: epoch, + latestRunCompletedAt: epoch, + archivedAt: null, + deletedAt: null, + settledOverride: null, + pullRequests: [], + ...overrides, + }) as OrchestrationV2ThreadShell; + +function harness(thread: OrchestrationV2ThreadShell, enabled = true) { + const summary = vi.fn(() => Effect.die("Unexpected host read")); + const branch = vi.fn(() => Effect.die("Unexpected git read")); + const dispatch = vi.fn((command: OrchestrationV2Command) => + Effect.sync(() => { + expect(command.type).toBe("thread.settle"); + return { sequence: 8, storedEvents: [] }; + }), + ); + const snapshot = vi.fn(() => + Effect.succeed({ + schemaVersion: 1, + snapshotSequence: 7, + threads: [thread], + archivedThreads: [], + }), + ); + return { + summary, + branch, + dispatch, + snapshot, + layer: Layer.mergeAll( + Layer.mock(ThreadManagementService)({ + getShellSnapshot: snapshot, + getThreadEventSequence: () => Effect.succeed(7), + getThreadShell: () => Effect.succeed(thread), + dispatch, + }), + Layer.mock(ProjectService)({ + snapshot: Effect.succeed({ projects: [], updatedAt: "1970-01-01T00:00:00Z" }), + }), + Layer.mock(PullRequestService)({ summary }), + Layer.mock(GitManager)({ branchPullRequest: branch }), + ServerSettings.layerTest( + enabled ? {} : { sidebarAutoSettleOnMerge: false, sidebarAutoSettleAfterDays: null }, + ), + NodeServices.layer, + ), + }; +} + +it.effect("settles inactivity without touching git or host APIs", () => + Effect.gen(function* () { + yield* TestClock.adjust("10 days"); + const h = harness(fixture()); + const reactor = yield* make.pipe(Effect.provide(h.layer)); + yield* reactor.requestSweep; + yield* reactor.drain; + expect(h.dispatch).toHaveBeenCalledTimes(1); + expect(h.dispatch.mock.calls[0]?.[0]).toMatchObject({ + automatic: { expectedSequence: 7 }, + settledAt: "1970-01-01T00:00:00.000Z", + }); + expect(h.summary).not.toHaveBeenCalled(); + expect(h.branch).not.toHaveBeenCalled(); + }).pipe(Effect.scoped), +); + +it.effect("disabled automatic settings do not even scan threads", () => + Effect.gen(function* () { + const h = harness(fixture(), false); + const reactor = yield* make.pipe(Effect.provide(h.layer)); + yield* reactor.requestSweep; + yield* reactor.drain; + expect(h.snapshot).not.toHaveBeenCalled(); + expect(h.dispatch).not.toHaveBeenCalled(); + }).pipe(Effect.scoped), +); + +it.effect("unknown explicit link snapshots keep quiet work active without re-reading details", () => + Effect.gen(function* () { + yield* TestClock.adjust("10 days"); + const h = harness( + fixture({ + pullRequests: [ + { + host: "github.com", + repository: "org/repo", + number: 1, + url: "https://github.com/org/repo/pull/1", + source: "agent", + linkedAt: "1970-01-01T00:00:00Z", + snapshot: null, + stack: null, + }, + ], + }), + ); + const reactor = yield* make.pipe(Effect.provide(h.layer)); + yield* reactor.requestSweep; + yield* reactor.drain; + expect(h.dispatch).not.toHaveBeenCalled(); + expect(h.summary).not.toHaveBeenCalled(); + expect(h.branch).not.toHaveBeenCalled(); + }).pipe(Effect.scoped), +); + +it.effect("a confirmed merge invalidates the matching checkout before scheduling settlement", () => + Effect.gen(function* () { + const notified = yield* Deferred.make(); + const invalidated = yield* Deferred.make(); + const thread = fixture(); + const project: Project = { + id: thread.projectId, + title: "Repo", + workspaceRoot: "/merge-checkout", + defaultModelSelection: null, + scripts: [], + createdAt: "1970-01-01T00:00:00Z", + updatedAt: "1970-01-01T00:00:00Z", + deletedAt: null, + }; + const invalidate = vi.fn((cwd: string) => { + expect(cwd).toBe(project.workspaceRoot); + return Deferred.succeed(invalidated, undefined).pipe(Effect.asVoid); + }); + const layer = Layer.mergeAll( + Layer.mock(ThreadManagementService)({ + streamDomainEvents: Stream.empty, + getShellSnapshot: () => + Effect.succeed({ + schemaVersion: 1, + snapshotSequence: 1, + threads: [thread], + archivedThreads: [], + }), + }), + Layer.mock(ProjectService)({ + snapshot: Effect.succeed({ projects: [project], updatedAt: project.updatedAt }), + }), + Layer.mock(PullRequestService)({ + subscribeMerges: Effect.succeed(Stream.fromEffect(Deferred.await(notified))), + }), + Layer.mock(GitManager)({ invalidateStatus: invalidate }), + ServerSettings.layerTest({ + sidebarAutoSettleOnMerge: false, + sidebarAutoSettleAfterDays: null, + }), + NodeServices.layer, + ); + const reactor = yield* make.pipe(Effect.provide(layer)); + yield* reactor.start(); + yield* Deferred.succeed(notified, { + projectId: project.id, + host: "github.com", + repository: "org/repo", + number: 1, + url: "https://github.com/org/repo/pull/1", + mergedAt: "2026-09-11T00:00:00Z", + }); + yield* Deferred.await(invalidated); + yield* reactor.drain; + expect(invalidate).toHaveBeenCalledTimes(1); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/orchestration-v2/ThreadSettlementReactor.ts b/apps/server/src/orchestration-v2/ThreadSettlementReactor.ts new file mode 100644 index 000000000..2a89113d0 --- /dev/null +++ b/apps/server/src/orchestration-v2/ThreadSettlementReactor.ts @@ -0,0 +1,263 @@ +import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { canonicalRepositoryKey } from "@t3tools/shared/sourceControl"; +import { CommandId, type OrchestrationV2ThreadShell, type Project } from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequestChains"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schedule from "effect/Schedule"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as GitManager from "../git/GitManager.ts"; +import * as PullRequestService from "../pullRequest/PullRequestService.ts"; +import * as ProjectService from "../project/ProjectService.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { forkParked } from "../serverActivation.ts"; +import { ThreadManagementService } from "./ThreadManagementService.ts"; +import { pullRequestMatchesProject } from "./ThreadPullRequestReactor.ts"; +import { + isAutoSettlementCandidate, + resolveAutoSettlementAt, + type SettlementPullRequest, +} from "./ThreadSettlementPolicy.ts"; + +export class ThreadSettlementReactor extends Context.Service< + ThreadSettlementReactor, + { + readonly start: (options?: { + readonly beforeSweep?: Effect.Effect; + }) => Effect.Effect; + readonly drain: Effect.Effect; + readonly requestSweep: Effect.Effect; + } +>()("t3/orchestration-v2/ThreadSettlementReactor") {} + +/** @public Canonical Effect service construction. */ +export const make = Effect.gen(function* () { + const engine = yield* ThreadManagementService; + const settingsService = yield* ServerSettings.ServerSettingsService; + const projects = yield* ProjectService.ProjectService; + const git = yield* GitManager.GitManager; + const pullRequests = yield* PullRequestService.PullRequestService; + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + + const lookup = Effect.fn("ThreadSettlementReactor.lookup")(function* ( + thread: OrchestrationV2ThreadShell, + project: Project | undefined, + ) { + if (visibleThreadPullRequests(thread.pullRequests ?? []).length > 0) return null; + const reference = thread.linkedPullRequest ?? thread.branchPullRequest; + const cwd = + project === undefined + ? null + : thread.worktreePath !== null && (yield* fileSystem.exists(thread.worktreePath)) + ? thread.worktreePath + : project.workspaceRoot; + if (reference != null) { + const summary = yield* pullRequests.summary(reference); + if ( + summary.state !== "open" && + thread.branch !== null && + cwd !== null && + project !== undefined + ) { + // Branch reuse must win over an older terminal branch candidate. + const current = yield* git.branchPullRequest( + { cwd, branch: thread.branch }, + { refresh: true }, + ); + if (current?.state === "open" && pullRequestMatchesProject(current, project)) + return current; + } + return { + state: summary.state, + mergedAt: summary.mergedAt ?? null, + closedAt: summary.closedAt ?? null, + } satisfies SettlementPullRequest; + } + if (thread.branch === null || cwd === null || project === undefined) return null; + const candidate = yield* git.branchPullRequest({ cwd, branch: thread.branch }); + return candidate !== null && pullRequestMatchesProject(candidate, project) ? candidate : null; + }); + + const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* () { + const settings = yield* settingsService.getSettings; + if (!settings.sidebarAutoSettleOnMerge && settings.sidebarAutoSettleAfterDays === null) return; + const snapshot = yield* engine.getShellSnapshot({ location: "active" }); + const projectSnapshot = yield* projects.snapshot; + const projectById = new Map(projectSnapshot.projects.map((project) => [project.id, project])); + const now = yield* DateTime.now; + const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now)); + const settle = Effect.fn("ThreadSettlementReactor.settle")(function* ( + thread: OrchestrationV2ThreadShell, + expectedSequence: number, + pullRequest: SettlementPullRequest | null, + ) { + const current = yield* settingsService.getSettings; + const settledAt = resolveAutoSettlementAt({ + thread, + pullRequest, + now: yield* DateTime.now, + autoSettleAfterDays: current.sidebarAutoSettleAfterDays, + autoSettleOnMerge: current.sidebarAutoSettleOnMerge, + }); + if (settledAt === null) return false; + const uuid = yield* crypto.randomUUIDv4; + yield* engine.dispatch({ + type: "thread.settle", + commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`), + threadId: thread.id, + settledAt: DateTime.formatIso(settledAt), + automatic: { expectedSequence }, + }); + return true; + }); + // Complete every local decision before any provider read can occupy a worker slot. + const lookups = yield* Effect.forEach( + candidates, + (candidate) => + Effect.gen(function* () { + const expectedSequence = yield* engine.getThreadEventSequence(candidate.id); + const thread = yield* engine.getThreadShell(candidate.id); + if (thread === null || !isAutoSettlementCandidate(thread, yield* DateTime.now)) + return null; + if (yield* settle(thread, expectedSequence, null)) return null; + if (visibleThreadPullRequests(thread.pullRequests ?? []).length > 0) return null; + return { thread, expectedSequence }; + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logDebug("automatic thread settlement skipped", { + threadId: candidate.id, + cause: Cause.pretty(cause), + }).pipe(Effect.as(null)), + ), + ), + { concurrency: 8 }, + ); + yield* Effect.forEach( + lookups.filter((entry) => entry !== null), + ({ thread, expectedSequence }) => + lookup(thread, projectById.get(thread.projectId)).pipe( + Effect.flatMap((pullRequest) => settle(thread, expectedSequence, pullRequest)), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logDebug("automatic thread settlement lookup skipped", { + threadId: thread.id, + cause: Cause.pretty(cause), + }), + ), + ), + { concurrency: 8, discard: true }, + ); + }); + let beforeSweep: Effect.Effect = Effect.void; + let queued = false; + const worker = yield* makeDrainableWorker(() => + Effect.sync(() => { + queued = false; + }).pipe( + Effect.andThen(Effect.suspend(() => beforeSweep)), + Effect.andThen(sweep()), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("automatic thread settlement sweep failed", { + cause: Cause.pretty(cause), + }), + ), + ), + ); + const enqueue = Effect.suspend(() => { + if (queued) return Effect.void; + queued = true; + return worker.enqueue(undefined); + }); + const start = Effect.fn("ThreadSettlementReactor.start")(function* (options?: { + readonly beforeSweep?: Effect.Effect; + }) { + beforeSweep = options?.beforeSweep ?? Effect.void; + const changes = yield* settingsService.subscribeChanges; + const merges = yield* pullRequests.subscribeMerges; + yield* forkParked( + Stream.runForEach(merges, (event) => + Effect.gen(function* () { + const parsed = parseChangeRequestUrl(event.url) ?? event; + const repositoryKey = canonicalRepositoryKey( + `${parsed.host}/${parsed.repository}`.toLowerCase(), + ); + const projectSnapshot = yield* projects.snapshot; + const matchingProjects = new Map( + projectSnapshot.projects + .filter( + (project) => + project.id === event.projectId || + (project.repositoryIdentity != null && + canonicalRepositoryKey( + project.repositoryIdentity.canonicalKey.toLowerCase(), + ) === repositoryKey), + ) + .map((project) => [project.id, project]), + ); + const snapshot = yield* engine.getShellSnapshot({ location: "active" }); + const cwds = new Set(); + for (const thread of snapshot.threads) { + const project = matchingProjects.get(thread.projectId); + if (project === undefined || thread.deletedAt !== null || thread.archivedAt !== null) + continue; + const hasWorktree = + thread.worktreePath !== null && (yield* fileSystem.exists(thread.worktreePath)); + cwds.add( + hasWorktree && thread.worktreePath !== null + ? thread.worktreePath + : project.workspaceRoot, + ); + } + yield* Effect.forEach(cwds, (cwd) => git.invalidateStatus(cwd), { + concurrency: 8, + discard: true, + }); + yield* enqueue; + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("merged pull request settlement refresh failed", { + cause: Cause.pretty(cause), + }), + ), + ), + ), + ); + + yield* forkParked(Stream.runForEach(changes, () => enqueue)); + // Pushed host snapshots and completed runs make the decision promptly; no client is required. + yield* forkParked( + Stream.runForEach(engine.streamDomainEvents, (event) => + event.type === "thread.metadata-updated" || event.type === "run.updated" + ? enqueue + : Effect.void, + ), + ); + yield* forkParked( + Effect.gen(function* () { + yield* enqueue; + yield* worker.drain; + }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid), + ); + }); + return { + start, + drain: worker.drain, + requestSweep: enqueue, + } satisfies ThreadSettlementReactor["Service"]; +}); +export const layer = Layer.effect(ThreadSettlementReactor, make); diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts index 98476f7bc..f4391d504 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts @@ -1,3 +1,4 @@ +import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { type ChatAttachment, CommandId, type ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -36,7 +37,7 @@ export function formatThreadTitleContext( if (message.role === "system") { continue; } - const text = message.text.trim(); + const text = assistantCitationsToPlainText(message.text).trim(); const attachmentSummary = (message.attachments ?? []) .map((attachment) => attachment.name) .join(", "); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 5583639d7..eb2218092 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1606,6 +1606,333 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("consumes a restart marker atomically and never dispatches it twice", () => + Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const sink = yield* EventSinkV2; + const threadId = ThreadId.make("restart-continuation-thread"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("restart-create"), + threadId, + projectId: ProjectId.make("restart-project"), + title: "Restart", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: "/tmp/restart-test", + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("restart-original"), + threadId, + messageId: MessageId.make("restart-original"), + createdBy: "user", + creationSource: "web", + text: "Work", + attachments: [], + dispatchMode: { type: "start_immediately" }, + }); + const projection = yield* orchestrator.getThreadProjection(threadId); + const run = projection.runs[0]!; + const providerThread = projection.providerThreads.find( + (candidate) => candidate.id === run.providerThreadId, + )!; + const now = yield* DateTime.now; + yield* sink.write({ + commandId: CommandId.make("restart-started"), + events: [ + { + id: EventId.make("restart-run-started"), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { ...run, status: "running", startedAt: now }, + }, + { + id: EventId.make("restart-native-reference"), + type: "provider-thread.updated", + threadId, + occurredAt: now, + payload: { + ...providerThread, + nativeThreadRef: { driver, nativeId: "saved-provider-thread", strength: "strong" }, + }, + }, + ], + }); + const messageId = MessageId.make("restart-recovery-message"); + yield* orchestrator.dispatch({ + type: "run.restart-continuation.prepare", + commandId: CommandId.make("restart-prepare"), + threadId, + runId: run.id, + messageId, + reason: "restart", + }); + const marked = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + assert.equal(marked.restartContinuation?.status, "pending"); + yield* sink.write({ + commandId: CommandId.make("restart-process-loss"), + events: [ + { + id: EventId.make("restart-run-cancelled"), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { ...marked, status: "cancelled", completedAt: now }, + }, + ], + }); + const command = { + type: "message.dispatch" as const, + commandId: CommandId.make("restart-recovery"), + threadId, + messageId, + text: "Continue", + attachments: [], + createdBy: "agent" as const, + creationSource: "server" as const, + restartContinuation: { sourceRunId: run.id }, + dispatchMode: { type: "start_immediately" as const }, + }; + yield* orchestrator.dispatch(command); + yield* orchestrator.dispatch(command); + const resumed = yield* orchestrator.getThreadProjection(threadId); + assert.equal(resumed.runs.length, 2); + assert.equal(resumed.runs[0]?.restartContinuation?.status, "consumed"); + assert.equal( + resumed.messages.find((message) => message.id === messageId)?.restartContinuation, + true, + ); + assert.equal(resumed.runs[1]?.providerThreadId, providerThread.id); + const rejected = yield* orchestrator + .dispatch({ + ...command, + commandId: CommandId.make("restart-duplicate"), + messageId: MessageId.make("restart-other-message"), + }) + .pipe(Effect.flip); + assert.equal(rejected._tag, "OrchestratorDispatchError"); + const nextRun = resumed.runs[1]!; + yield* sink.write({ + commandId: CommandId.make("restart-next-running"), + events: [ + { + id: EventId.make("restart-next-running-event"), + type: "run.updated", + threadId, + runId: nextRun.id, + occurredAt: now, + payload: { ...nextRun, status: "running" }, + }, + ], + }); + yield* orchestrator.dispatch({ + type: "run.restart-continuation.prepare", + commandId: CommandId.make("restart-next-prepare"), + threadId, + runId: nextRun.id, + messageId: MessageId.make("restart-next-message"), + reason: "restart", + }); + yield* orchestrator.dispatch({ + type: "thread.archive", + commandId: CommandId.make("restart-archive"), + threadId, + }); + yield* orchestrator.dispatch({ + type: "thread.unarchive", + commandId: CommandId.make("restart-unarchive"), + threadId, + }); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).runs[1]?.restartContinuation?.status, + "cancelled", + ); + yield* orchestrator.dispatch({ + type: "run.restart-continuation.prepare", + commandId: CommandId.make("restart-branch-prepare"), + threadId, + runId: nextRun.id, + messageId: MessageId.make("restart-branch-message"), + reason: "restart", + }); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("restart-change-branch"), + threadId, + branch: "different", + }); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).runs[1]?.restartContinuation?.status, + "cancelled", + ); + yield* orchestrator.dispatch({ + type: "run.restart-continuation.prepare", + commandId: CommandId.make("restart-stop-prepare"), + threadId, + runId: nextRun.id, + messageId: MessageId.make("restart-stop-message"), + reason: "restart", + }); + yield* orchestrator.dispatch({ + type: "run.interrupt", + commandId: CommandId.make("restart-explicit-stop"), + threadId, + runId: nextRun.id, + }); + assert.equal( + (yield* orchestrator.getThreadProjection(threadId)).runs[1]?.restartContinuation?.status, + "cancelled", + ); + }), + ); + + it.effect("rejects stale automatic settlement and preserves its activity timestamp", () => + Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const threadId = ThreadId.make("runtime-layer-auto-settle-thread"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-auto-settle-create"), + threadId, + projectId: ProjectId.make("runtime-layer-auto-settle-project"), + title: "Auto settle", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const sink = yield* EventSinkV2; + const now = yield* DateTime.now; + const nodeId = NodeId.make("auto-settle-question-node"); + const blocking = { + id: RuntimeRequestId.make("auto-settle-blocking"), + nodeId, + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input" as const, + status: "pending" as const, + responseMode: "callback" as const, + responseCapability: { + type: "live" as const, + providerSessionId: ProviderSessionId.make("auto-settle-session"), + }, + createdAt: now, + resolvedAt: null, + }; + yield* sink.write({ + commandId: CommandId.make("auto-settle-request-seed"), + events: [ + { + id: EventId.make("auto-settle-blocking-event"), + type: "runtime-request.updated", + threadId, + nodeId, + occurredAt: now, + payload: blocking, + }, + { + id: EventId.make("auto-settle-message-event"), + type: "runtime-request.updated", + threadId, + nodeId, + occurredAt: now, + payload: { + ...blocking, + id: RuntimeRequestId.make("auto-settle-message"), + responseMode: "message", + createdAt: DateTime.add(now, { seconds: 1 }), + }, + }, + ], + }); + assert.equal( + (yield* orchestrator.getThreadShell(threadId))?.pendingRuntimeRequest?.id, + blocking.id, + ); + yield* sink.write({ + commandId: CommandId.make("auto-settle-request-resolve"), + events: [ + { + id: EventId.make("auto-settle-resolved-event"), + type: "runtime-request.updated", + threadId, + nodeId, + occurredAt: now, + payload: { ...blocking, status: "resolved", resolvedAt: now }, + }, + ], + }); + assert.equal( + (yield* orchestrator.getThreadShell(threadId))?.pendingRuntimeRequest?.responseMode, + "message", + ); + const sequence = yield* orchestrator.getThreadEventSequence(threadId); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("runtime-layer-auto-settle-edit"), + threadId, + title: "Changed", + }); + const stale = yield* orchestrator + .dispatch({ + type: "thread.settle", + commandId: CommandId.make("runtime-layer-auto-settle-stale"), + threadId, + automatic: { expectedSequence: sequence }, + }) + .pipe(Effect.flip); + assert.equal(stale._tag, "OrchestratorDispatchError"); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("runtime-layer-auto-settle-pin"), + threadId, + pinned: true, + }); + const settledAt = DateTime.formatIso( + (yield* orchestrator.getThreadProjection(threadId)).thread.createdAt, + ); + yield* TestClock.adjust("1 minute"); + yield* orchestrator.dispatch({ + type: "thread.settle", + commandId: CommandId.make("runtime-layer-auto-settle-fresh"), + threadId, + automatic: { expectedSequence: yield* orchestrator.getThreadEventSequence(threadId) }, + settledAt, + }); + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.equal(projection.thread.settledOverride, "settled"); + assert.isNotNull(projection.thread.pinnedAt); + assert.equal(DateTime.formatIso(projection.thread.settledAt!), settledAt); + assert.equal(DateTime.formatIso(projection.thread.updatedAt), settledAt); + yield* orchestrator.dispatch({ + type: "thread.unsettle", + commandId: CommandId.make("runtime-layer-auto-settle-resume"), + threadId, + reason: "user", + }); + const pinned = yield* orchestrator + .dispatch({ + type: "thread.settle", + commandId: CommandId.make("runtime-layer-auto-settle-active"), + threadId, + automatic: { expectedSequence: yield* orchestrator.getThreadEventSequence(threadId) }, + }) + .pipe(Effect.flip); + assert.equal(pinned._tag, "OrchestratorDispatchError"); + }), + ); + it.effect("rejects settling a thread while a run is active", () => Effect.gen(function* () { const orchestrator = yield* OrchestratorV2; @@ -2421,3 +2748,101 @@ it.layer(SharedApplicationDataPlaneTestLayer)("shared application data plane", ( }), ); }); + +it.layer(TestLayer)("V2 pull request metadata", (it) => { + it.effect( + "persists host snapshots without creating activity and rejects stale stack anchors", + () => + Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const threadId = ThreadId.make("runtime-pr-sync"); + const projectId = ProjectId.make("runtime-pr-project"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-pr-create"), + threadId, + projectId, + title: "PR metadata", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature/pr", + worktreePath: null, + }); + const reference = { + projectId, + repository: "org/repo", + number: 1, + url: "https://github.com/org/repo/pull/1", + }; + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("runtime-pr-link"), + threadId, + linkPullRequest: reference, + linkPullRequestSource: "agent", + }); + const before = yield* orchestrator.getThreadProjection(threadId); + const anchor = before.thread.pullRequests![0]!; + yield* TestClock.adjust("1 minute"); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("runtime-pr-sync"), + threadId, + syncPullRequest: { + reference: anchor, + stack: null, + snapshot: { + state: "open", + title: "Live title", + headBranch: "feature/pr", + baseBranch: "main", + isDraft: true, + updatedAt: "2026-09-11T00:00:00.000Z", + syncedAt: "2026-09-11T00:00:00.000Z", + }, + }, + }); + const after = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(after.thread.updatedAt, before.thread.updatedAt); + assert.equal(after.thread.pullRequests?.[0]?.snapshot?.title, "Live title"); + assert.equal( + (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.snapshot?.isDraft, + true, + ); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("runtime-pr-unlink"), + threadId, + unlinkPullRequest: reference, + }); + const error = yield* orchestrator + .dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("runtime-pr-stale-stack"), + threadId, + linkPullRequest: { ...reference, number: 2, url: "https://github.com/org/repo/pull/2" }, + linkPullRequestSource: "stack", + expectedPullRequestLink: anchor, + }) + .pipe(Effect.flip); + assert.instanceOf(error, OrchestratorDispatchError); + assert.deepEqual( + (yield* orchestrator.getThreadProjection(threadId)).thread.linkedPullRequests, + [], + ); + const staleBranch = yield* orchestrator + .dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("runtime-pr-stale-branch"), + threadId, + branchPullRequest: reference, + expectedBranch: "different-branch", + }) + .pipe(Effect.flip); + assert.instanceOf(staleBranch, OrchestratorDispatchError); + }), + ); +}); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index bc11fa79d..b7e6d4272 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -1,3 +1,6 @@ +import { agentBrowserAccessEnabled } from "./AgentBrowserAccessPolicy.ts"; +import * as AgentSessionScanner from "../project/AgentSessionScanner.ts"; +import * as AgentSessionImporter from "../project/AgentSessionImporter.ts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -25,7 +28,7 @@ import { layerFromOrchestrationEventStore as eventStoreLayer } from "./EventStor import { layer as idAllocatorLayer } from "./IdAllocator.ts"; import { layer as legacyV1ThreadImporterLayer } from "./LegacyV1ThreadImporter.ts"; import { layer as orchestratorLayer } from "./Orchestrator.ts"; -import { layer as projectionStoreLayer } from "./ProjectionStore.ts"; +import { ProjectionStoreV2, layer as projectionStoreLayer } from "./ProjectionStore.ts"; import { layer as projectionMaintenanceLayer } from "./ProjectionMaintenance.ts"; import { layerFromProviderInstanceRegistry as providerAdapterRegistryLayerFromProviderInstances } from "./ProviderAdapterRegistry.ts"; import { layer as providerContinuationRequestsLayer } from "./ProviderContinuationRequests.ts"; @@ -109,27 +112,11 @@ const providerSwitchServiceProvided = providerSwitchServiceLayer.pipe( Layer.provide(providerAdapterRegistryProvided), ); -/** - * Denies on an unreadable settings file rather than letting the read failure - * escape into session startup: an explicit "off" silently becoming "on" would - * violate the user's stated choice, whereas the reverse costs an agent one - * toolset and is visible immediately. - */ -const agentBrowserAccessEnabled = ServerSettingsService.pipe( - Effect.flatMap((serverSettings) => serverSettings.getSettings), - Effect.map((settings) => settings.enableAgentBrowserAccess), - Effect.catch((cause) => - Effect.logWarning( - "Could not read server settings; withholding agent browser access for this session.", - { cause }, - ).pipe(Effect.as(false)), - ), -); - const providerSessionManagerProvided = Layer.unwrap( - Effect.map(Effect.context(), (context) => + Effect.map(Effect.context(), (context) => providerSessionManagerLayerWithOptions({ - agentBrowserAccessEnabled: Effect.provideContext(agentBrowserAccessEnabled, context), + agentBrowserAccessEnabled: (threadId) => + Effect.provideContext(agentBrowserAccessEnabled(threadId), context), }), ), ).pipe( @@ -332,7 +319,16 @@ export const OrchestrationV2LayerLive = Layer.mergeAll( threadFeedbackServiceProvided, ); +const agentSessionScannerProvided = AgentSessionScanner.layer.pipe( + Layer.provide(ProjectServiceLayerLive), +); +const agentSessionImporterProvided = AgentSessionImporter.layer.pipe( + Layer.provide(Layer.mergeAll(agentSessionScannerProvided, eventSinkProvided, idAllocatorLayer)), +); + export const OrchestrationV2ProductionLayerLive = Layer.mergeAll( + agentSessionScannerProvided, + agentSessionImporterProvided, OrchestrationLayerLive, OrchestrationV2LayerLive, threadTitleRegenerationWorkerProvided, diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index 827c82068..b86540911 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -116,6 +116,8 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray [id, name] as const); @@ -230,6 +234,8 @@ export const forkMigrationMarkers: ReadonlyArray { Effect.sync(() => { assert.deepStrictEqual( migrationEntries.map(([id]) => id), - Array.from({ length: 58 }, (_, index) => index + 1), + Array.from({ length: 60 }, (_, index) => index + 1), ); }), ); diff --git a/apps/server/src/persistence/Migrations/059_ProjectionProjectIcon.test.ts b/apps/server/src/persistence/Migrations/059_ProjectionProjectIcon.test.ts new file mode 100644 index 000000000..3a465a520 --- /dev/null +++ b/apps/server/src/persistence/Migrations/059_ProjectionProjectIcon.test.ts @@ -0,0 +1,28 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("059_ProjectionProjectIcon", (it) => { + it.effect("adds the nullable project icon JSON to project projections", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 58 }); + yield* runMigrations({ toMigrationInclusive: 59 }); + + const columns = yield* sql<{ readonly name: string; readonly notnull: number }>` + PRAGMA table_info(projection_projects) + `; + const projectIcon = columns.find((column) => column.name === "project_icon_json"); + + assert.equal(projectIcon?.name, "project_icon_json"); + assert.equal(projectIcon?.notnull, 0); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/059_ProjectionProjectIcon.ts b/apps/server/src/persistence/Migrations/059_ProjectionProjectIcon.ts new file mode 100644 index 000000000..0523a47b1 --- /dev/null +++ b/apps/server/src/persistence/Migrations/059_ProjectionProjectIcon.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (!columns.some((column) => column.name === "project_icon_json")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN project_icon_json TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/060_AgentSessionImports.ts b/apps/server/src/persistence/Migrations/060_AgentSessionImports.ts new file mode 100644 index 000000000..ac0134f19 --- /dev/null +++ b/apps/server/src/persistence/Migrations/060_AgentSessionImports.ts @@ -0,0 +1,19 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** Private import receipts and fingerprints; conversation state stays in the V2 JSON projection. */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TABLE agent_session_imports ( + thread_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + provider_thread_id TEXT NOT NULL UNIQUE + )`; + yield* sql`CREATE INDEX agent_session_imports_project_idx ON agent_session_imports(project_id)`; + yield* sql`CREATE TABLE agent_session_import_sources ( + thread_id TEXT NOT NULL REFERENCES agent_session_imports(thread_id), + file_path TEXT NOT NULL, + source_json TEXT NOT NULL, + PRIMARY KEY(thread_id, file_path) + )`; +}); diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 339439fdf..49ff2b280 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -10,6 +10,7 @@ import { IsoDateTime, ModelSelection, ProjectId, + ProjectIconOverride, ProjectScript, ThreadEnvMode, } from "@t3tools/contracts"; @@ -27,6 +28,7 @@ export const ProjectionProject = Schema.Struct({ defaultModelSelection: Schema.NullOr(ModelSelection), defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), faviconPath: Schema.optional(Schema.NullOr(Schema.String)), + projectIcon: Schema.optional(Schema.NullOr(ProjectIconOverride)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/server/src/persistence/UpstreamJournalReconciliation.test.ts b/apps/server/src/persistence/UpstreamJournalReconciliation.test.ts index a14297e47..16c1b5f96 100644 --- a/apps/server/src/persistence/UpstreamJournalReconciliation.test.ts +++ b/apps/server/src/persistence/UpstreamJournalReconciliation.test.ts @@ -198,11 +198,11 @@ freshDatabase()("renumbered fork journal", (it) => { SELECT migration_id, name FROM effect_sql_migrations WHERE migration_id >= 36 ORDER BY migration_id `; - assert.strictEqual(journal.length, 23); + assert.strictEqual(journal.length, 25); assert.deepStrictEqual(journal[0], { migration_id: 36, name: "OrchestrationV2" }); - assert.deepStrictEqual(journal[22], { - migration_id: 58, - name: "AuthSessionClientConnection", + assert.deepStrictEqual(journal[24], { + migration_id: 60, + name: "AgentSessionImports", }); }), ); @@ -220,7 +220,7 @@ freshDatabase()("partially migrated fork database", (it) => { const journal = yield* sql<{ readonly n: number }>` SELECT COUNT(*) AS n FROM effect_sql_migrations WHERE migration_id >= 36 `; - assert.deepStrictEqual(journal, [{ n: 23 }]); + assert.deepStrictEqual(journal, [{ n: 25 }]); const hermes = yield* sql<{ readonly name: string }>` SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'hermes_session_bindings' diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts new file mode 100644 index 000000000..064e0d8c6 --- /dev/null +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -0,0 +1,230 @@ +import { it, assert } from "@effect/vitest"; +import { ProjectId, ProviderInstanceId, type AgentSessionImportSource } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { layer as eventStoreLayer } from "../orchestration-v2/EventStore.ts"; +import { layer as eventSinkLayer } from "../orchestration-v2/EventSink.ts"; +import { layer as idAllocatorLayer } from "../orchestration-v2/IdAllocator.ts"; +import { + layer as projectionStoreLayer, + ProjectionStoreV2, +} from "../orchestration-v2/ProjectionStore.ts"; +import { AgentSessionScanner, type AgentSessionRecentThread } from "./AgentSessionScanner.ts"; +import { importedAgentThreadId, importRecentAgentThreads } from "./AgentSessionImporter.ts"; + +const database = SqlitePersistenceMemory; +const stores = Layer.mergeAll( + database, + eventStoreLayer.pipe(Layer.provide(database)), + projectionStoreLayer.pipe(Layer.provide(database)), +); +const TestLayer = Layer.mergeAll( + stores, + eventSinkLayer.pipe(Layer.provide(stores)), + idAllocatorLayer, +); +const timestamp = "2026-09-10T12:00:00.000Z"; + +function source(key: string): AgentSessionImportSource { + return { + provider: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + providerSessionId: `native:${key}`, + filePath: `/fixtures/${key}.jsonl`, + size: 100, + mtimeMs: 100, + device: 1, + inode: 1, + birthtimeMs: 1, + }; +} +function transcript(item: AgentSessionImportSource): AgentSessionRecentThread { + return { + _tag: "Importable", + source: item, + thread: { + source: item.provider, + providerInstanceId: item.providerInstanceId, + providerSessionId: item.providerSessionId, + title: "CLI conversation", + model: "gpt-5.4", + createdAt: timestamp, + updatedAt: timestamp, + messages: [ + { role: "user", text: "Keep my CLI history", createdAt: timestamp }, + { role: "assistant", text: "Original response", createdAt: timestamp }, + ], + }, + }; +} +const setup = (key: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const projectId = ProjectId.make(`project:${key}`); + yield* sql`INSERT INTO projection_projects (project_id, title, workspace_root, default_model_selection_json, scripts_json, created_at, updated_at, deleted_at) + VALUES (${projectId}, 'Project', ${`/fixtures/${key}`}, NULL, '[]', ${timestamp}, ${timestamp}, NULL)`; + return projectId; + }); +const runImport = ( + projectId: ProjectId, + outcomes: ReadonlyArray, + expectedWorkspaceRoot?: string, +) => + importRecentAgentThreads({ + projectId, + ...(expectedWorkspaceRoot === undefined ? {} : { expectedWorkspaceRoot }), + }).pipe( + Effect.provideService( + AgentSessionScanner, + AgentSessionScanner.of({ + scan: Effect.succeed({ candidates: [], scannedAt: timestamp }), + recentThreads: () => Stream.fromIterable(outcomes), + }), + ), + ); + +it.layer(TestLayer)("AgentSessionImporter V2", (it) => { + it.effect( + "atomically creates settled history and the original provider resume reference, without a runtime", + () => + Effect.gen(function* () { + const projectId = yield* setup("history"); + const item = source("history"); + assert.deepStrictEqual(yield* runImport(projectId, [transcript(item)]), { + importedCount: 1, + skippedCount: 0, + }); + const store = yield* ProjectionStoreV2; + const projection = yield* store.getThreadProjection(importedAgentThreadId(item)); + assert.deepStrictEqual( + projection.messages.map((m) => [m.role, m.text]), + [ + ["user", "Keep my CLI history"], + ["assistant", "Original response"], + ], + ); + assert.deepStrictEqual( + projection.visibleTurnItems.map((i) => i.item.type), + ["user_message", "assistant_message"], + ); + assert.strictEqual(projection.thread.settledOverride, "settled"); + assert.strictEqual( + projection.thread.activeProviderThreadId, + projection.providerThreads[0]?.id, + ); + assert.strictEqual( + projection.providerThreads[0]?.nativeThreadRef?.nativeId, + item.providerSessionId, + ); + assert.strictEqual(projection.providerThreads[0]?.status, "not_loaded"); + assert.strictEqual(projection.providerThreads[0]?.providerSessionId, null); + assert.strictEqual(projection.providerSessions.length, 0); + assert.strictEqual(projection.runs.length, 0); + }), + ); + it.effect("retries and copied files count once and never rewrite continued history", () => + Effect.gen(function* () { + const projectId = yield* setup("retry"); + const item = source("retry"); + yield* runImport(projectId, [transcript(item)]); + const sql = yield* SqlClient.SqlClient; + const before = + yield* sql`SELECT event_id FROM orchestration_events WHERE stream_id = ${importedAgentThreadId(item)}`; + const changed = transcript({ ...item, size: 200 }); + assert.deepStrictEqual( + yield* runImport(projectId, [ + changed, + { _tag: "Duplicate", source: { ...item, filePath: "/fixtures/copy.jsonl" } }, + ]), + { importedCount: 1, skippedCount: 0 }, + ); + assert.strictEqual( + (yield* sql`SELECT event_id FROM orchestration_events WHERE stream_id = ${importedAgentThreadId(item)}`) + .length, + before.length, + ); + assert.strictEqual( + (yield* sql`SELECT file_path FROM agent_session_import_sources WHERE thread_id = ${importedAgentThreadId(item)}`) + .length, + 2, + ); + }), + ); + it.effect("cannot move an imported native session into another project or account", () => + Effect.gen(function* () { + const first = yield* setup("owner"); + const second = yield* setup("other"); + const item = source("owner"); + yield* runImport(first, [transcript(item)]); + assert.deepStrictEqual(yield* runImport(second, [transcript(item)]), { + importedCount: 0, + skippedCount: 1, + }); + const otherAccount = { ...item, providerInstanceId: ProviderInstanceId.make("codex-other") }; + assert.deepStrictEqual(yield* runImport(second, [transcript(otherAccount)]), { + importedCount: 0, + skippedCount: 1, + }); + const store = yield* ProjectionStoreV2; + assert.strictEqual( + (yield* store.getThreadProjection(importedAgentThreadId(item))).thread.projectId, + first, + ); + assert.strictEqual(yield* store.getThreadShell(importedAgentThreadId(otherAccount)), null); + }), + ); + it.effect("a failed projection write rolls back the import ledger, receipt and all history", () => + Effect.gen(function* () { + const projectId = yield* setup("rollback"); + const item = source("rollback"); + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TRIGGER reject_import_message BEFORE INSERT ON orchestration_v2_projection_messages + BEGIN SELECT RAISE(ABORT, 'fixture failure'); END`; + assert.deepStrictEqual(yield* runImport(projectId, [transcript(item)]), { + importedCount: 0, + skippedCount: 1, + }); + assert.strictEqual( + (yield* sql`SELECT thread_id FROM agent_session_imports WHERE thread_id = ${importedAgentThreadId(item)}`) + .length, + 0, + ); + assert.strictEqual( + (yield* sql`SELECT event_id FROM orchestration_events WHERE stream_id = ${importedAgentThreadId(item)}`) + .length, + 0, + ); + yield* sql`DROP TRIGGER reject_import_message`; + assert.deepStrictEqual(yield* runImport(projectId, [transcript(item)]), { + importedCount: 1, + skippedCount: 0, + }); + }), + ); + it.effect("rejects stale project roots and malformed Claude resume IDs", () => + Effect.gen(function* () { + const projectId = yield* setup("validation"); + const result = yield* runImport(projectId, [], "/fixtures/stale").pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") + assert.strictEqual(result.failure._tag, "AgentSessionImportProjectChangedError"); + const invalid = { + ...source("validation"), + provider: "claudeAgent" as const, + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + }; + assert.deepStrictEqual(yield* runImport(projectId, [transcript(invalid)]), { + importedCount: 0, + skippedCount: 1, + }); + const valid = { ...invalid, providerSessionId: "62ec7fdc-4b76-4213-a117-85f1c5903c92" }; + assert.deepStrictEqual(yield* runImport(projectId, [transcript(valid)]), { + importedCount: 1, + skippedCount: 0, + }); + }), + ); +}); diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts new file mode 100644 index 000000000..5e76db1d9 --- /dev/null +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -0,0 +1,353 @@ +import * as NodeCrypto from "node:crypto"; +import { + AgentSessionImportProjectChangedError, + AgentSessionImportProjectNotFoundError, + AgentSessionImportSource, + AgentSessionScanError, + CommandId, + DEFAULT_MODEL, + DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + EventId, + MessageId, + ProviderDriverKind, + ThreadId, + TurnItemId, + type AgentSessionImportInput, + type AgentSessionImportResult, + type OrchestrationV2AppThread, + type OrchestrationV2DomainEvent, + type OrchestrationV2ProviderThread, + type OrchestrationV2TurnItem, +} from "@t3tools/contracts"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; +import * as Context from "effect/Context"; +import * as Layer from "effect/Layer"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { EventSinkV2 } from "../orchestration-v2/EventSink.ts"; +import { IdAllocatorV2 } from "../orchestration-v2/IdAllocator.ts"; +import { AgentSessionScanner, type AgentSessionThread } from "./AgentSessionScanner.ts"; + +class AgentSessionOwnershipConflict extends Schema.TaggedErrorClass()( + "AgentSessionOwnershipConflict", + { threadId: ThreadId }, +) {} + +const claudeSessionIdPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const encodeSource = Schema.encodeEffect(Schema.fromJsonString(AgentSessionImportSource)); +const decodeSource = Schema.decodeUnknownEffect(Schema.fromJsonString(AgentSessionImportSource)); + +/** Native session identity is account-scoped; file copies must not create new conversations. */ +export function importedAgentThreadId(source: AgentSessionImportSource): ThreadId { + const digest = NodeCrypto.createHash("sha256") + .update(JSON.stringify([source.provider, source.providerInstanceId, source.providerSessionId])) + .digest("hex"); + return ThreadId.make(`import:${digest}`); +} + +function historyEvents( + thread: AgentSessionThread, + appThread: OrchestrationV2AppThread, + providerThread: OrchestrationV2ProviderThread, +): ReadonlyArray { + const threadId = appThread.id; + const events: Array = [ + { + id: EventId.make(`${threadId}:created`), + type: "thread.created", + threadId, + occurredAt: appThread.createdAt, + payload: appThread, + }, + { + id: EventId.make(`${threadId}:provider`), + type: "provider-thread.updated", + threadId, + occurredAt: providerThread.updatedAt, + payload: providerThread, + }, + ]; + for (const [ordinal, message] of thread.messages.entries()) { + const messageId = MessageId.make(`${threadId}:${String(ordinal).padStart(6, "0")}`); + const timestamp = DateTime.makeUnsafe(message.createdAt); + events.push({ + id: EventId.make(`${messageId}:message`), + type: "message.updated", + threadId, + occurredAt: timestamp, + payload: { + id: messageId, + threadId, + createdBy: message.role === "user" ? "user" : "agent", + creationSource: "server", + runId: null, + nodeId: null, + role: message.role, + text: message.text, + attachments: [], + streaming: false, + createdAt: timestamp, + updatedAt: timestamp, + }, + }); + const base = { + id: TurnItemId.make(`${messageId}:item`), + threadId, + runId: null, + nodeId: null, + providerThreadId: providerThread.id, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: ordinal + 1, + status: "completed" as const, + title: null, + startedAt: timestamp, + completedAt: timestamp, + updatedAt: timestamp, + }; + const item: OrchestrationV2TurnItem = + message.role === "user" + ? { + ...base, + type: "user_message", + messageId, + createdBy: "user", + creationSource: "server", + inputIntent: "turn_start", + text: message.text, + attachments: [], + } + : { ...base, type: "assistant_message", messageId, text: message.text, streaming: false }; + events.push({ + id: EventId.make(`${messageId}:item`), + type: "turn-item.updated", + threadId, + occurredAt: timestamp, + payload: item, + }); + } + // Parsing preserves source order, which need not be chronological. Keep the shell's + // activity timestamp at the transcript's end regardless of its last visible message. + events.push({ + id: EventId.make(`${threadId}:metadata`), + type: "thread.metadata-updated", + threadId, + occurredAt: appThread.updatedAt, + payload: appThread, + }); + return events; +} + +/** Import visible CLI history directly into V2, without starting a provider process. */ +export const importRecentAgentThreads = Effect.fn("importRecentAgentThreadsV2")(function* ( + input: AgentSessionImportInput, +) { + const scanner = yield* AgentSessionScanner; + const sql = yield* SqlClient.SqlClient; + const sink = yield* EventSinkV2; + const ids = yield* IdAllocatorV2; + const readProject = Effect.gen(function* () { + const rows = yield* sql<{ + workspace_root: string; + }>`SELECT workspace_root FROM projection_projects + WHERE project_id = ${input.projectId} AND deleted_at IS NULL`; + const row = rows[0]; + if (row === undefined) + return yield* new AgentSessionImportProjectNotFoundError({ projectId: input.projectId }); + return row.workspace_root; + }).pipe( + Effect.catchTag("SqlError", (cause) => + Effect.fail(new AgentSessionScanError({ operation: "read-projects", cause })), + ), + ); + const workspaceRoot = yield* readProject; + const sameRoot = (root: string) => + normalizeProjectPathForComparison(root) === normalizeProjectPathForComparison(workspaceRoot); + if (input.expectedWorkspaceRoot !== undefined && !sameRoot(input.expectedWorkspaceRoot)) { + return yield* new AgentSessionImportProjectChangedError({ projectId: input.projectId }); + } + const completedSources = yield* sql<{ source_json: string }>`SELECT s.source_json + FROM agent_session_import_sources s JOIN agent_session_imports i ON i.thread_id = s.thread_id + WHERE i.project_id = ${input.projectId}`.pipe( + Effect.flatMap((rows) => Effect.forEach(rows, (row) => decodeSource(row.source_json))), + Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })), + ); + const imported = new Set(); + let skippedCount = 0; + + const recordSource = (threadId: ThreadId, source: AgentSessionImportSource) => + Effect.gen(function* () { + const json = yield* encodeSource(source); + yield* sql`INSERT INTO agent_session_import_sources (thread_id, file_path, source_json) + VALUES (${threadId}, ${source.filePath}, ${json}) + ON CONFLICT(thread_id, file_path) DO UPDATE SET source_json = excluded.source_json`; + }); + const hasCompletedImport = (threadId: ThreadId) => + Effect.gen(function* () { + const rows = yield* sql`SELECT i.thread_id FROM agent_session_imports i + JOIN orchestration_v2_projection_threads t ON t.thread_id = i.thread_id + WHERE i.thread_id = ${threadId} AND i.project_id = ${input.projectId} + AND t.project_id = ${input.projectId} AND t.deleted_at IS NULL`; + return rows.length > 0; + }); + + yield* Stream.runForEach(scanner.recentThreads(workspaceRoot, completedSources), (outcome) => + Effect.gen(function* () { + if (outcome._tag === "Skipped") { + skippedCount += 1; + return; + } + const threadId = importedAgentThreadId(outcome.source); + const result = yield* Effect.gen(function* () { + if (!sameRoot(yield* readProject)) + return yield* new AgentSessionImportProjectChangedError({ projectId: input.projectId }); + if (yield* hasCompletedImport(threadId)) { + // A retry may observe newer CLI text or a copied transcript. Never overwrite + // history the user has already continued, renamed, archived, or edited in T3. + yield* recordSource(threadId, outcome.source); + return true; + } + if (outcome._tag !== "Importable") return false; + const thread = outcome.thread; + if ( + thread.source === "claudeAgent" && + !claudeSessionIdPattern.test(thread.providerSessionId) + ) + return false; + const driver = ProviderDriverKind.make(thread.source); + const providerThreadId = ids.derive.providerThread({ + driver, + nativeThreadId: thread.providerSessionId, + }); + const createdAt = DateTime.makeUnsafe(thread.createdAt); + const updatedAt = DateTime.makeUnsafe(thread.updatedAt); + const appThread: OrchestrationV2AppThread = { + createdBy: "system", + creationSource: "server", + id: threadId, + projectId: input.projectId, + title: thread.title, + providerInstanceId: thread.providerInstanceId, + modelSelection: { + instanceId: thread.providerInstanceId, + model: thread.model ?? DEFAULT_MODEL_BY_PROVIDER[driver] ?? DEFAULT_MODEL, + }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + activeProviderThreadId: providerThreadId, + historyOrigin: "native", + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt, + updatedAt, + archivedAt: null, + deletedAt: null, + lastVisitedAt: null, + settledOverride: "settled", + settledAt: updatedAt, + }; + const providerThread: OrchestrationV2ProviderThread = { + id: providerThreadId, + driver, + providerInstanceId: thread.providerInstanceId, + providerSessionId: null, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { + driver, + nativeId: thread.providerSessionId, + strength: "strong", + }, + nativeConversationHeadRef: null, + status: "not_loaded", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt, + updatedAt, + }; + yield* sink.commitCommand({ + commandId: CommandId.make(`${threadId}:history`), + threadId, + commandType: "thread.agent-session.import", + acceptedAt: yield* DateTime.now, + events: historyEvents(thread, appThread, providerThread), + effects: [], + prepareTransaction: Effect.gen(function* () { + if (!sameRoot(yield* readProject)) + return yield* new AgentSessionImportProjectChangedError({ + projectId: input.projectId, + }); + const existing = + yield* sql`SELECT thread_id FROM orchestration_v2_projection_threads WHERE thread_id = ${threadId}`; + // V2 provider IDs are global across accounts. A copied native ID must never + // reassign an existing T3 thread's provider projection to this import. + const owner = + yield* sql`SELECT thread_id FROM orchestration_v2_projection_provider_threads WHERE provider_thread_id = ${providerThreadId}`; + if (existing.length > 0 || owner.length > 0) + return yield* Effect.fail(new AgentSessionOwnershipConflict({ threadId })); + yield* sql`INSERT INTO agent_session_imports (thread_id, project_id, provider_thread_id) + VALUES (${threadId}, ${input.projectId}, ${providerThreadId})`; + yield* recordSource(threadId, outcome.source); + }), + }); + return yield* hasCompletedImport(threadId); + }).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not import an agent session into V2", { + provider: outcome.source.provider, + providerInstanceId: outcome.source.providerInstanceId, + cause, + }).pipe(Effect.as(false)), + ), + ); + if (result) imported.add(threadId); + else skippedCount += 1; + }), + ); + return { importedCount: imported.size, skippedCount } satisfies AgentSessionImportResult; +}); + +export class AgentSessionImporter extends Context.Service< + AgentSessionImporter, + { + readonly importRecent: ( + input: AgentSessionImportInput, + ) => Effect.Effect< + AgentSessionImportResult, + | AgentSessionScanError + | AgentSessionImportProjectChangedError + | AgentSessionImportProjectNotFoundError + >; + } +>()("t3/project/AgentSessionImporter") {} + +export const layer = Layer.effect( + AgentSessionImporter, + Effect.gen(function* () { + const scanner = yield* AgentSessionScanner; + const sql = yield* SqlClient.SqlClient; + const sink = yield* EventSinkV2; + const ids = yield* IdAllocatorV2; + return AgentSessionImporter.of({ + importRecent: (input) => + importRecentAgentThreads(input).pipe( + Effect.provideService(AgentSessionScanner, scanner), + Effect.provideService(SqlClient.SqlClient, sql), + Effect.provideService(EventSinkV2, sink), + Effect.provideService(IdAllocatorV2, ids), + ), + }); + }), +); diff --git a/apps/server/src/project/AgentSessionJson.ts b/apps/server/src/project/AgentSessionJson.ts new file mode 100644 index 000000000..d31c47833 --- /dev/null +++ b/apps/server/src/project/AgentSessionJson.ts @@ -0,0 +1,155 @@ +import * as SchemaAST from "effect/SchemaAST"; +import { isMany, none, type Many } from "stream-chain/defs.js"; +import { Assembler } from "stream-json/core/assembler.js"; +import { filter } from "stream-json/core/filters/filter.js"; +import * as StreamJson from "stream-json/core/parser.js"; +import type { ParserOptions, Token } from "stream-json/core/parser.js"; + +type JsonPath = ReadonlyArray; + +/** Select schema fields before assembling their values, without a second field list. */ +export function createTranscriptJsonSelector(schema: { readonly ast: SchemaAST.AST }) { + const ast = SchemaAST.toEncoded(schema.ast); + const includes = (node: SchemaAST.AST, path: JsonPath, index: number): boolean => { + if (index === path.length) return true; + switch (node._tag) { + case "Objects": + // Records have dynamic keys. Keep their values for the decoder to validate. + if (node.indexSignatures.length > 0) return true; + return node.propertySignatures.some( + (property) => + String(property.name) === path[index] && includes(property.type, path, index + 1), + ); + case "Arrays": { + const key = path[index]; + if (typeof key !== "number") return true; + const element = node.elements[key]; + if (element) return includes(element, path, index + 1); + return node.rest.length === 0 || node.rest.some((item) => includes(item, path, index + 1)); + } + case "Union": + return node.types.some((type) => includes(type, path, index)); + case "Suspend": + return includes(node.thunk(), path, index); + case "Unknown": + case "Any": + case "ObjectKeyword": + case "Declaration": + // Unstructured/custom schemas must reach the decoder intact. The + // shared budget still bounds their allocations. + return true; + default: + return false; + } + }; + return (path: JsonPath) => includes(ast, path, 0); +} + +export class TranscriptJsonLimitError extends Error {} + +/** + * Project a single JSONL record without materializing unselected string values. + * The caller supplies a shared allocation budget for the entire transcript. + * Budget exhaustion rejects the transcript, never a message within it. + */ +export function createTranscriptJsonReader( + reserve: (bytes: number) => void, + selectPath: (path: JsonPath) => boolean, +) { + // The synchronous tokenizer is exported at runtime in 3.6.0, but omitted + // from its bundled types. Unlike parser(), it does not wrap tokens in an + // async generator; the file reader already supplies backpressure and UTF-8. + const { jsonParser } = StreamJson as typeof StreamJson & { + jsonParser: ( + options: ParserOptions, + ) => (input: string | typeof none) => Many | typeof none; + }; + const tokenize = jsonParser({ packValues: false }); + const select = filter({ filter: selectPath, streamKeys: false }) as ( + input: Token | typeof none, + ) => Token | Many | typeof none; + const assembler = new Assembler(); + let key: string | null = null; + let value = ""; + let depth = 0; + let complete = false; + let malformed = false; + + const assemble = (token: Token) => { + reserve( + 64 + ("value" in token && typeof token.value === "string" ? token.value.length * 2 : 0), + ); + switch (token.name) { + case "startString": + case "startNumber": + value = ""; + break; + case "stringChunk": + case "numberChunk": + value += token.value; + break; + case "endString": + assembler.consume({ name: "stringValue", value }); + value = ""; + break; + case "endNumber": + assembler.consume({ name: "numberValue", value }); + value = ""; + break; + default: + assembler.consume(token); + } + }; + const selectToken = (token: Token | typeof none) => { + const selected = select(token); + if (selected === none) return; + if (isMany(selected)) { + for (const item of selected.values) assemble(item); + } else { + assemble(selected); + } + }; + const consume = (input: string | typeof none) => { + if (malformed) return; + try { + const tokens = tokenize(input); + if (tokens === none) return; + for (const token of tokens.values) { + if (token.name === "startObject" || token.name === "startArray") { + if (++depth > 128) + throw new TranscriptJsonLimitError("Transcript JSON nesting exceeds 128 levels"); + } else if (token.name === "endObject" || token.name === "endArray") { + if (--depth === 0) complete = true; + } + // Charge keys before assembling them, including unknown names. Reject + // the transcript on exhaustion instead of silently shortening a key. + if (token.name === "startKey") { + key = ""; + } else if (token.name === "stringChunk" && key !== null) { + reserve(token.value.length * 2); + key += token.value; + } else if (token.name === "endKey") { + selectToken({ name: "keyValue", value: key ?? "" }); + key = null; + } else { + selectToken(token); + } + } + } catch (cause) { + if (cause instanceof Error && cause.message.startsWith("Parser ")) { + malformed = true; + } else { + throw cause; + } + } + }; + return { + write: (chunk: string) => consume(chunk), + finish: (): unknown => { + consume(none); + if (malformed || !complete) return undefined; + selectToken(none); + return assembler.done ? assembler.current : undefined; + }, + }; +} diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts new file mode 100644 index 000000000..13cb9d1aa --- /dev/null +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -0,0 +1,3195 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeOS from "node:os"; +import { describe, expect, it } from "@effect/vitest"; +import { + type Project, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + type ServerSettings as ContractServerSettings, +} from "@t3tools/contracts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ServerConfig from "../config.ts"; +import * as ProjectService from "./ProjectService.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as AgentSessionScanner from "./AgentSessionScanner.ts"; + +const makeProjectShell = (workspaceRoot: string): Project => ({ + id: ProjectId.make("project-1"), + title: "Imported", + workspaceRoot, + defaultModelSelection: null, + deletedAt: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}); + +/** Discovery reads the surviving project aggregate; V1 thread projections are not involved. */ +const makeProjectLayer = (importedWorkspaceRoots: ReadonlyArray) => + Layer.mock(ProjectService.ProjectService)({ + snapshot: Effect.succeed({ + projects: importedWorkspaceRoots.map(makeProjectShell), + updatedAt: "2026-01-01T00:00:00.000Z", + }), + }); + +/** + * Run a scan against the given homes. Homes are temp dirs created inside the + * test, so the layer is built per run rather than shared. + */ +interface ScannerTestInput { + readonly claudeHomePath: string; + readonly codexHomePath: string; + readonly importedWorkspaceRoots?: ReadonlyArray; + /** Base dir for the test ServerConfig; worktreesDir derives from it. */ + readonly configBaseDir?: string; + readonly providerInstances?: ContractServerSettings["providerInstances"]; +} + +const makeScannerTestLayer = (input: ScannerTestInput) => + AgentSessionScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + ServerSettings.layerTest({ + providers: { + claudeAgent: { homePath: input.claudeHomePath }, + codex: { homePath: input.codexHomePath }, + }, + ...(input.providerInstances === undefined + ? {} + : { providerInstances: input.providerInstances }), + }), + ServerConfig.layerTest( + input.claudeHomePath, + input.configBaseDir ?? { prefix: "t3code-scanner-config-" }, + ), + makeProjectLayer(input.importedWorkspaceRoots ?? []), + ), + ), + ); + +const runScan = (input: ScannerTestInput) => + Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + return yield* scanner.scan; + }).pipe(Effect.provide(makeScannerTestLayer(input))); + +const runRecentThreadOutcomes = (input: ScannerTestInput & { readonly workspaceRoot: string }) => + Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + return yield* scanner.recentThreads(input.workspaceRoot).pipe( + Stream.runCollect, + Effect.map((outcomes) => Array.from(outcomes)), + ); + }).pipe(Effect.provide(makeScannerTestLayer(input))); + +const runRecentThreads = (input: ScannerTestInput & { readonly workspaceRoot: string }) => + runRecentThreadOutcomes(input).pipe( + Effect.map((outcomes) => + outcomes.flatMap((outcome) => (outcome._tag === "Importable" ? [outcome.thread] : [])), + ), + ); + +const makeTempDir = Effect.fn("AgentSessionScanner.test.makeTempDir")(function* (prefix: string) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); +}); + +const writeTranscript = Effect.fn("AgentSessionScanner.test.writeTranscript")(function* (input: { + readonly filePath: string; + readonly contents: string; + /** Epoch millis, so ordering assertions never depend on write timing. */ + readonly mtimeMs: number; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.makeDirectory(path.dirname(input.filePath), { recursive: true }); + yield* fileSystem.writeFileString(input.filePath, input.contents); + // Numeric utimes arguments are seconds, not milliseconds. + const seconds = input.mtimeMs / 1000; + yield* fileSystem.utimes(input.filePath, seconds, seconds); +}); + +/** Claude session line: the first record carries the real `cwd`. */ +const claudeSessionLine = (cwd: string) => + `${JSON.stringify({ type: "user", cwd, sessionId: "s1" })}\n${JSON.stringify({ type: "assistant" })}\n`; + +/** Codex rollout line: session metadata is nested under `payload`. */ +const codexRolloutLine = (cwd: string) => + `${JSON.stringify({ timestamp: "2026-01-01T00:00:00.000Z", type: "session_meta", payload: { id: "r1", cwd } })}\n`; + +const encodeTranscriptRecord = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +function makeRecordLimitTranscript(cwd: string, overflow: boolean): string { + const records = + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "record-limit-session", cwd }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "First prompt" }, + }), + ].join("\n") + + "\n" + + "{}\n".repeat(99_998); + return overflow + ? records + + "\n" + + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Overflow prompt" }, + }) + + "\n" + : records; +} + +it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { + describe("scan", () => { + it.effect("reads Claude project cwds from transcripts, newest first", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const olderWorkspace = yield* makeTempDir("t3code-workspace-older-"); + const newerWorkspace = yield* makeTempDir("t3code-workspace-newer-"); + + // Slugs are intentionally lossy; the scanner must not decode them. + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug-older", "a.jsonl"), + contents: claudeSessionLine(olderWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug-older", "b.jsonl"), + contents: claudeSessionLine(olderWorkspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug-newer", "c.jsonl"), + contents: claudeSessionLine(newerWorkspace), + mtimeMs: Date.parse("2026-03-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([ + { + path: newerWorkspace, + title: path.basename(newerWorkspace), + sources: ["claudeAgent"], + threadCount: 1, + lastActiveAt: "2026-03-01T00:00:00.000Z", + alreadyImported: false, + git: null, + }, + { + path: olderWorkspace, + title: path.basename(olderWorkspace), + sources: ["claudeAgent"], + threadCount: 2, + lastActiveAt: "2026-01-02T00:00:00.000Z", + alreadyImported: false, + git: null, + }, + ]); + }), + ); + + it.effect("groups Codex rollouts by cwd across date directories", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); + + const rollout = (year: string, month: string, day: string, name: string) => + path.join(codexHomePath, "sessions", year, month, day, name); + + yield* writeTranscript({ + filePath: rollout("2026", "01", "05", "rollout-2026-01-05T10-00-00-aaa.jsonl"), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-01-05T10:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: rollout("2026", "02", "09", "rollout-2026-02-09T10-00-00-bbb.jsonl"), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-02-09T10:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: rollout("2026", "02", "09", "rollout-2026-02-09T11-00-00-ccc.jsonl"), + contents: codexRolloutLine(otherWorkspace), + mtimeMs: Date.parse("2026-02-09T11:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([ + { + path: otherWorkspace, + title: path.basename(otherWorkspace), + sources: ["codex"], + threadCount: 1, + lastActiveAt: "2026-02-09T11:00:00.000Z", + alreadyImported: false, + git: null, + }, + { + path: workspace, + title: path.basename(workspace), + sources: ["codex"], + threadCount: 2, + lastActiveAt: "2026-02-09T10:00:00.000Z", + alreadyImported: false, + git: null, + }, + ]); + }), + ); + + it.effect.each(["claudeAgent", "codex"] as const)( + "does not open a non-file %s transcript", + (source) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const transcriptPath = + source === "claudeAgent" + ? path.join(claudeHomePath, "projects", "-slug", "session.jsonl") + : path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-session.jsonl"); + yield* fileSystem.makeDirectory(transcriptPath, { recursive: true }); + + let transcriptOpenCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (filePath === transcriptPath) transcriptOpenCount += 1; + return fileSystem.open(filePath, options); + }, + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect(result.candidates).toEqual([]); + expect(transcriptOpenCount).toBe(0); + }), + ); + + it.effect.each(["claudeAgent", "codex"] as const)( + "stops %s directory reads at the discovery operation budget", + (source) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const discoveryRoot = + source === "claudeAgent" + ? path.join(claudeHomePath, "projects") + : path.join(codexHomePath, "sessions"); + const emptyDirectories = Array.from( + { length: 20_001 }, + (_, index) => `empty-${index.toString().padStart(5, "0")}`, + ); + let directoryReadCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (directory, options) => { + if (directory === discoveryRoot) { + directoryReadCount += 1; + return Effect.succeed(emptyDirectories); + } + if (path.dirname(directory) === discoveryRoot) { + directoryReadCount += 1; + return Effect.succeed([]); + } + return fileSystem.readDirectory(directory, options); + }, + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect(result.candidates).toEqual([]); + expect(directoryReadCount).toBe(20_000); + }), + ); + + it.effect("merges the same cwd seen by both agents and flags imported projects", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "04", + "01", + "rollout-2026-04-01T09-00-00-aaa.jsonl", + ), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-04-01T09:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspace], + }); + + expect(result.candidates).toEqual([ + { + path: workspace, + title: path.basename(workspace), + projectId: ProjectId.make("project-1"), + sources: ["claudeAgent", "codex"], + threadCount: 2, + lastActiveAt: "2026-04-01T09:00:00.000Z", + alreadyImported: true, + git: null, + }, + ]); + }), + ); + + it.effect("returns the imported project ID through a realpath alias", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const linkParent = yield* makeTempDir("t3code-scanner-links-"); + const workspaceAlias = path.join(linkParent, "workspace-alias"); + yield* fileSystem.symlink(workspace, workspaceAlias); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspaceAlias), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspace], + }); + + expect(result.candidates[0]).toMatchObject({ + path: workspace, + projectId: ProjectId.make("project-1"), + alreadyImported: true, + git: null, + }); + }), + ); + + it.effect("matches a persisted project alias to a transcript realpath", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const linkParent = yield* makeTempDir("t3code-scanner-links-"); + const workspaceAlias = path.join(linkParent, "workspace-alias"); + yield* fileSystem.symlink(workspace, workspaceAlias); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspaceAlias], + }); + + expect(result.candidates[0]).toMatchObject({ + path: workspaceAlias, + projectId: ProjectId.make("project-1"), + alreadyImported: true, + git: null, + }); + }), + ); + + it.effect("merges case aliases and preserves the persisted project path", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const workspaceAlias = path.join( + path.dirname(workspace), + path.basename(workspace).toUpperCase(), + ); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspaceAlias), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(codexHomePath, "sessions", "2026", "01", "02", "rollout-b.jsonl"), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => fileSystem.stat(filePath === workspaceAlias ? workspace : filePath), + }); + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspace], + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(result.candidates).toEqual([ + { + path: workspace, + title: path.basename(workspace), + projectId: ProjectId.make("project-1"), + sources: ["claudeAgent", "codex"], + threadCount: 2, + lastActiveAt: "2026-01-02T00:00:00.000Z", + alreadyImported: true, + git: null, + }, + ]); + }), + ); + + it.effect("keeps case variants distinct when the filesystem identities differ", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const backingUpper = yield* makeTempDir("t3code-backing-upper-"); + const backingLower = yield* makeTempDir("t3code-backing-lower-"); + const aliasParent = yield* makeTempDir("t3code-case-aliases-"); + const upperWorkspace = path.join(aliasParent, "Repo"); + const lowerWorkspace = path.join(aliasParent, "repo"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-upper", "a.jsonl"), + contents: claudeSessionLine(upperWorkspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-lower", "b.jsonl"), + contents: claudeSessionLine(lowerWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => + fileSystem.stat( + filePath === upperWorkspace + ? backingUpper + : filePath === lowerWorkspace + ? backingLower + : filePath, + ), + }); + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([ + upperWorkspace, + lowerWorkspace, + ]); + }), + ); + + it.effect("uses explicit provider instance homes instead of overridden legacy homes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-"); + const codexHomePath = yield* makeTempDir("t3code-codex-legacy-"); + const claudeInstanceHome = yield* makeTempDir("t3code-claude-instance-"); + const codexInstanceHome = yield* makeTempDir("t3code-codex-instance-"); + const legacyWorkspace = yield* makeTempDir("t3code-workspace-legacy-"); + const claudeWorkspace = yield* makeTempDir("t3code-workspace-claude-"); + const codexWorkspace = yield* makeTempDir("t3code-workspace-codex-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-legacy", "session.jsonl"), + contents: claudeSessionLine(legacyWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeInstanceHome, "projects", "-actual", "session.jsonl"), + contents: claudeSessionLine(claudeWorkspace), + mtimeMs: Date.parse("2026-02-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join( + codexInstanceHome, + "sessions", + "2026", + "03", + "01", + "rollout-instance.jsonl", + ), + contents: codexRolloutLine(codexWorkspace), + mtimeMs: Date.parse("2026-03-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: claudeInstanceHome }, + }, + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: codexInstanceHome }, + }, + }, + }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([ + codexWorkspace, + claudeWorkspace, + ]); + }), + ); + + it.effect("scans each distinct home across multiple instances once", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const otherCodexHome = yield* makeTempDir("t3code-codex-other-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); + + for (const [home, cwd] of [ + [codexHomePath, workspace], + [otherCodexHome, otherWorkspace], + ] as const) { + yield* writeTranscript({ + filePath: path.join(home, "sessions", "2026", "01", "01", "rollout-session.jsonl"), + contents: codexRolloutLine(cwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + } + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("codex-personal")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: codexHomePath }, + }, + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: otherCodexHome }, + }, + }, + }); + + expect(result.candidates).toHaveLength(2); + expect(result.candidates.map((candidate) => candidate.threadCount)).toEqual([1, 1]); + expect(result.candidates.map((candidate) => candidate.path).sort()).toEqual( + [workspace, otherWorkspace].sort(), + ); + }), + ); + + it.effect("honors provider instance home directory environment variables", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-"); + const codexHomePath = yield* makeTempDir("t3code-codex-legacy-"); + const claudeEnvironmentHome = yield* makeTempDir("t3code-claude-env-"); + const codexEnvironmentHome = yield* makeTempDir("t3code-codex-env-"); + const claudeWorkspace = yield* makeTempDir("t3code-workspace-claude-"); + const codexWorkspace = yield* makeTempDir("t3code-workspace-codex-"); + + yield* writeTranscript({ + filePath: path.join(claudeEnvironmentHome, "projects", "-actual", "session.jsonl"), + contents: claudeSessionLine(claudeWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join( + codexEnvironmentHome, + "sessions", + "2026", + "01", + "01", + "rollout-session.jsonl", + ), + contents: codexRolloutLine(codexWorkspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + environment: [ + { name: "CLAUDE_CONFIG_DIR", value: claudeEnvironmentHome, sensitive: false }, + ], + config: {}, + }, + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + environment: [{ name: "CODEX_HOME", value: codexEnvironmentHome, sensitive: false }], + config: {}, + }, + }, + }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([ + codexWorkspace, + claudeWorkspace, + ]); + }), + ); + + it.effect("ignores invalid provider instances while scanning the remaining providers", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-actual", "session.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: 123 }, + }, + }, + }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect("does not scan provider instances disabled by the envelope or config", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const envelopeDisabledHome = yield* makeTempDir("t3code-codex-disabled-envelope-"); + const configDisabledHome = yield* makeTempDir("t3code-codex-disabled-config-"); + const envelopeWorkspace = yield* makeTempDir("t3code-workspace-disabled-envelope-"); + const configWorkspace = yield* makeTempDir("t3code-workspace-disabled-config-"); + + for (const [home, workspace, session] of [ + [envelopeDisabledHome, envelopeWorkspace, "envelope-disabled"], + [configDisabledHome, configWorkspace, "config-disabled"], + ] as const) { + yield* writeTranscript({ + filePath: path.join(home, "sessions", "2026", "08", "24", `rollout-${session}.jsonl`), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + } + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("codex-envelope-disabled")]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + config: { homePath: envelopeDisabledHome }, + }, + [ProviderInstanceId.make("codex-config-disabled")]: { + driver: ProviderDriverKind.make("codex"), + config: { enabled: false, homePath: configDisabledHome }, + }, + }, + }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("ignores relative working directories from malformed transcripts", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-relative", "session.jsonl"), + contents: claudeSessionLine(path.relative(path.resolve(), workspace)), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("drops candidates whose directory no longer exists", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(path.join(claudeHomePath, "does-not-exist")), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("excludes the home directory, temporary root, and T3 data directory", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + for (const [index, cwd] of [ + NodeOS.homedir(), + NodeOS.tmpdir(), + configBaseDir, + workspace, + ].entries()) { + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "session.jsonl"), + contents: claudeSessionLine(cwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z") + index, + }); + } + + const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect("excludes T3-managed worktree sandboxes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const fileSystem = yield* FileSystem.FileSystem; + + const worktreeCwd = path.join(claudeHomePath, ".t3", "worktrees", "t3code", "wt-1"); + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(worktreeCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("excludes Codex scratch directories and Downloads", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + // The exclusions key off the real home directory, so these fixtures + // must live there. Each run owns a uniquely named subtree and removes + // only that subtree, never the shared Codex or Downloads parents. + const home = NodeOS.homedir(); + // Borrow a unique suffix from a scoped temp dir instead of reaching for + // Date.now or Math.random, which the Effect lint rejects. + const runId = path.basename(yield* makeTempDir("t3code-scanner-test-")); + const scratchRoot = path.join(home, "Documents", "Codex", runId); + const scratch = path.join(scratchRoot, "2026-09-01", "some-conversation"); + const downloads = path.join(home, "Downloads", runId); + const keep = yield* makeTempDir("t3code-workspace-keep-"); + yield* fileSystem.makeDirectory(scratch, { recursive: true }); + yield* fileSystem.makeDirectory(downloads, { recursive: true }); + yield* Effect.addFinalizer(() => + Effect.all([ + fileSystem.remove(scratchRoot, { recursive: true }).pipe(Effect.ignore), + fileSystem.remove(downloads, { recursive: true }).pipe(Effect.ignore), + ]), + ); + + for (const [index, cwd] of [scratch, downloads, keep].entries()) { + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "09", + "01", + `rollout-${index}.jsonl`, + ), + contents: codexRolloutLine(cwd), + mtimeMs: Date.parse("2026-09-01T00:00:00.000Z"), + }); + } + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([keep]); + }), + ); + + it.effect("skips linked git worktrees and reports the origin of real checkouts", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const repo = yield* makeTempDir("t3code-workspace-repo-"); + const worktree = yield* makeTempDir("t3code-workspace-worktree-"); + const plain = yield* makeTempDir("t3code-workspace-plain-"); + const noRemote = yield* makeTempDir("t3code-workspace-noremote-"); + const submodule = yield* makeTempDir("t3code-workspace-submodule-"); + + yield* fileSystem.makeDirectory(path.join(repo, ".git")); + yield* fileSystem.writeFileString( + path.join(repo, ".git", "config"), + '[core]\n\tbare = false\n[remote "origin"]\n\turl = git@github.com:pingdotgg/t3code.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n', + ); + yield* fileSystem.writeFileString( + path.join(worktree, ".git"), + `gitdir: ${path.join(repo, ".git", "worktrees", "wt")}\n`, + ); + yield* fileSystem.makeDirectory(path.join(noRemote, ".git")); + yield* fileSystem.writeFileString(path.join(noRemote, ".git", "config"), "[core]\n"); + // Submodules also use a gitdir pointer, but into `modules/`, not `worktrees/`. + const submoduleGitDir = path.join(repo, ".git", "modules", "vendor"); + yield* fileSystem.makeDirectory(submoduleGitDir, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(submoduleGitDir, "config"), + '[remote "origin"]\n\turl = ssh://github.com/pingdotgg/vendor.git\n', + ); + yield* fileSystem.writeFileString( + path.join(submodule, ".git"), + `gitdir: ${submoduleGitDir}\n`, + ); + + for (const [index, cwd] of [repo, worktree, plain, noRemote, submodule].entries()) { + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "a.jsonl"), + contents: claudeSessionLine(cwd), + mtimeMs: Date.parse(`2026-01-0${index + 1}T00:00:00.000Z`), + }); + } + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect( + result.candidates.map((candidate) => ({ path: candidate.path, git: candidate.git })), + ).toEqual([ + { + path: submodule, + git: { remoteKey: "github.com/pingdotgg/vendor", repository: "pingdotgg/vendor" }, + }, + { path: noRemote, git: { remoteKey: null, repository: null } }, + { path: plain, git: null }, + { + path: repo, + git: { remoteKey: "github.com/pingdotgg/t3code", repository: "pingdotgg/t3code" }, + }, + ]); + }), + ); + + it.effect("excludes sandboxes under the configured worktrees dir without .t3 in the path", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const fileSystem = yield* FileSystem.FileSystem; + + // worktreesDir derives as `/worktrees`, and the temp base + // dir contains no `.t3` segment — only the config-based prefix match + // can exclude this one. + const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-2"); + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(worktreeCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("excludes sandboxes reached through a symlink into the worktrees dir", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const linkParent = yield* makeTempDir("t3code-scanner-links-"); + const fileSystem = yield* FileSystem.FileSystem; + + // The recorded cwd is a symlink whose own spelling looks harmless; + // only its realpath reveals the managed sandbox. + const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-3"); + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + const symlinkCwd = path.join(linkParent, "innocent-project"); + yield* fileSystem.symlink(worktreeCwd, symlinkCwd); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(symlinkCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("finds the cwd on a later line when the first records carry none", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + // Claude transcripts often open with records that have no cwd. + const contents = `{"type":"file-history-snapshot","messageId":"m1"}\n{"type":"queue-operation","operation":"enqueue"}\n${claudeSessionLine(workspace)}`; + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect("reads a complete transcript record at the exact chunk boundary", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const record = claudeSessionLine(workspace).split("\n")[0]!; + const prefix = '{"padding":"'; + const suffix = `",${record.slice(1)}`; + const contents = `${prefix}${"x".repeat(32 * 1024 - prefix.length - suffix.length)}${suffix}`; + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-exact", "session.jsonl"), + contents, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(contents).toHaveLength(32 * 1024); + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect("finds session metadata after a first record larger than one chunk", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const history = `{"type":"file-history-snapshot","data":"${"x".repeat(32 * 1024)}"}\n`; + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-large", "session.jsonl"), + contents: `${history}${claudeSessionLine(workspace)}`, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect.each([64, 65])("shares metadata bytes across homes for %s one-MiB files", (count) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-metadata-home-"); + const secondHome = yield* makeTempDir("t3code-metadata-second-"); + const codexHomePath = yield* makeTempDir("t3code-metadata-codex-"); + const firstWorkspace = yield* makeTempDir("t3code-metadata-first-project-"); + const secondWorkspace = yield* makeTempDir("t3code-metadata-second-project-"); + const directories = [ + path.join(claudeHomePath, "projects", "p"), + path.join(secondHome, "projects", "p"), + ]; + const templates = directories.map((directory) => path.join(directory, "template.jsonl")); + for (const [index, workspace] of [firstWorkspace, secondWorkspace].entries()) { + const record = encodeTranscriptRecord({ cwd: workspace }); + yield* writeTranscript({ + filePath: templates[index]!, + contents: + " ".repeat(1024 * 1024 - new TextEncoder().encode(record).byteLength) + record, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z") - index * 1_000, + }); + } + const resolveFile = (filePath: string) => { + const index = directories.indexOf(path.dirname(filePath)); + return index === -1 ? filePath : templates[index]!; + }; + let reservedBytes = 0; + let opens = 0; + const requests: number[] = []; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (directory, options) => { + const index = directories.indexOf(directory); + return index === -1 + ? fileSystem.readDirectory(directory, options) + : Effect.succeed( + Array.from( + { length: index === 0 ? 32 : count - 32 }, + (_, item) => `session-${item}.jsonl`, + ), + ); + }, + stat: (filePath) => fileSystem.stat(resolveFile(filePath)), + open: (filePath, options) => { + if (!directories.includes(path.dirname(filePath))) + return fileSystem.open(filePath, options); + opens += 1; + return fileSystem.open(resolveFile(filePath), options).pipe( + Effect.map((file) => ({ + ...file, + stat: file.stat, + readAlloc: (size: FileSystem.SizeInput) => { + reservedBytes += Number(size); + requests.push(Number(size)); + return file.readAlloc(size); + }, + })), + ); + }, + }); + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claude-work")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: secondHome }, + }, + }, + }).pipe(Effect.provideService(FileSystem.FileSystem, observedFileSystem)); + expect(result.candidates.map((candidate) => candidate.path)).toEqual([ + firstWorkspace, + secondWorkspace, + ]); + expect(result.candidates.map((candidate) => candidate.threadCount)).toEqual([32, 32]); + expect(result.truncated).toBe(count === 65 ? true : undefined); + expect(opens).toBe(64); + expect(reservedBytes).toBe(64 * 1024 * 1024); + expect(requests[0]).toBe(8 * 1024); + expect(Math.max(...requests)).toBe(8 * 1024); + }), + ); + + it.effect.each([50, 51])("bounds metadata open/read calls for %s short-read files", (count) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-short-metadata-home-"); + const codexHomePath = yield* makeTempDir("t3code-short-metadata-codex-"); + const workspace = yield* makeTempDir("t3code-short-metadata-project-"); + const directory = path.join(claudeHomePath, "projects", "p"); + const template = path.join(directory, "template.jsonl"); + const record = encodeTranscriptRecord({ cwd: workspace }); + const contents = " ".repeat(399 - new TextEncoder().encode(record).byteLength) + record; + const bytes = new TextEncoder().encode(contents); + yield* writeTranscript({ + filePath: template, + contents, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + let operations = 0; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (target, options) => + target === directory + ? Effect.succeed( + Array.from({ length: count }, (_, index) => `session-${index}.jsonl`), + ) + : fileSystem.readDirectory(target, options), + stat: (filePath) => + fileSystem.stat(path.dirname(filePath) === directory ? template : filePath), + open: (filePath, options) => { + if (path.dirname(filePath) !== directory) return fileSystem.open(filePath, options); + operations += 1; + let offset = 0; + return fileSystem.open(template, options).pipe( + Effect.map((file) => ({ + ...file, + stat: file.stat, + readAlloc: () => + Effect.sync(() => { + operations += 1; + if (offset === bytes.length) return Option.none(); + return Option.some(bytes.subarray(offset, ++offset)); + }), + })), + ); + }, + }); + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, observedFileSystem), + ); + expect(operations).toBe(20_000); + expect(result.candidates[0]?.threadCount).toBe(50); + expect(result.truncated).toBe(count === 51 ? true : undefined); + }), + ); + + it.effect("bounds malformed metadata records without excluding another account", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-record-metadata-home-"); + const secondHome = yield* makeTempDir("t3code-record-metadata-second-"); + const codexHomePath = yield* makeTempDir("t3code-record-metadata-codex-"); + const workspace = yield* makeTempDir("t3code-record-metadata-project-"); + const directory = path.join(claudeHomePath, "projects", "p"); + const template = path.join(directory, "template.jsonl"); + yield* writeTranscript({ + filePath: template, + contents: "x\n".repeat(1_001), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(secondHome, "projects", "p", "session.jsonl"), + contents: encodeTranscriptRecord({ cwd: workspace }), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + let malformedOpens = 0; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (target, options) => + target === directory + ? Effect.succeed(Array.from({ length: 102 }, (_, index) => `session-${index}.jsonl`)) + : fileSystem.readDirectory(target, options), + stat: (filePath) => + fileSystem.stat(path.dirname(filePath) === directory ? template : filePath), + open: (filePath, options) => { + if (path.dirname(filePath) !== directory) return fileSystem.open(filePath, options); + malformedOpens += 1; + return fileSystem.open(template, options); + }, + }); + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claude-work")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: secondHome }, + }, + }, + }).pipe(Effect.provideService(FileSystem.FileSystem, observedFileSystem)); + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + expect(malformedOpens).toBe(100); + expect(result.truncated).toBe(true); + }), + ); + + it.effect.each([19_999, 20_000])( + "reports unfinished directory work for %s project directories", + (count) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-directory-budget-home-"); + const codexHomePath = yield* makeTempDir("t3code-directory-budget-codex-"); + const projectsDir = path.join(claudeHomePath, "projects"); + let reads = 0; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (directory, options) => { + if (directory === projectsDir) { + reads += 1; + return Effect.succeed( + Array.from({ length: count }, (_, index) => `project-${index}`), + ); + } + if (path.dirname(directory) === projectsDir) { + reads += 1; + return Effect.succeed([]); + } + return fileSystem.readDirectory(directory, options); + }, + }); + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, observedFileSystem), + ); + expect(reads).toBe(20_000); + expect(result.candidates).toEqual([]); + expect(result.truncated).toBe(count === 20_000 ? true : undefined); + }), + ); + + it.effect("skips malformed transcripts without failing the scan", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-broken", "a.jsonl"), + contents: "not json at all\n", + mtimeMs: Date.parse("2026-05-01T00:00:00.000Z"), + }); + // Valid JSON, but no cwd anywhere in the record. + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-no-cwd", "a.jsonl"), + contents: `{"type":"summary"}\n`, + mtimeMs: Date.parse("2026-05-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-good", "a.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([ + { + path: workspace, + title: path.basename(workspace), + sources: ["claudeAgent"], + threadCount: 1, + lastActiveAt: "2026-05-03T00:00:00.000Z", + alreadyImported: false, + git: null, + }, + ]); + }), + ); + + it.effect("returns an empty result when neither home directory exists", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const root = yield* makeTempDir("t3code-missing-homes-"); + + const result = yield* runScan({ + claudeHomePath: path.join(root, "no-claude"), + codexHomePath: path.join(root, "no-codex"), + }); + + expect(result.candidates).toEqual([]); + expect(result.scannedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }), + ); + }); + + describe("recentThreads", () => { + it.effect.each([false, true])( + "counts terminal newlines correctly with record overflow=%s", + (overflow) => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-record-limit-claude-"); + const codexHomePath = yield* makeTempDir("t3code-record-limit-codex-"); + const workspace = yield* makeTempDir("t3code-record-limit-project-"); + const directory = path.join(codexHomePath, "sessions", "2026", "08", "24"); + yield* writeTranscript({ + filePath: path.join(directory, "rollout-records.jsonl"), + contents: makeRecordLimitTranscript(workspace, overflow), + mtimeMs: nowMs, + }); + yield* writeTranscript({ + filePath: path.join(directory, "rollout-older.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "older-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Older prompt" }, + }), + ].join("\n"), + mtimeMs: nowMs - 1_000, + }); + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + expect(outcomes.map((outcome) => outcome._tag)).toEqual( + overflow ? ["Skipped", "Importable"] : ["Importable", "Skipped"], + ); + expect( + outcomes.flatMap((outcome) => + outcome._tag === "Importable" + ? outcome.thread.messages.map((message) => message.text) + : [], + ), + ).toEqual([overflow ? "Older prompt" : "First prompt"]); + }), + ); + + it.effect("imports recent Claude and Codex sessions for the selected project only", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); + + const claudeTranscript = (cwd: string, sessionId: string) => + `${JSON.stringify({ + type: "user", + cwd, + sessionId, + timestamp: "2026-08-23T12:00:00.000Z", + message: { role: "user", content: "Fix the project" }, + })}\n${JSON.stringify({ + type: "assistant", + sessionId, + timestamp: "2026-08-23T12:01:00.000Z", + message: { role: "assistant", content: [{ type: "text", text: "Done" }] }, + })}\n`; + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-selected", "claude-recent.jsonl"), + contents: claudeTranscript(workspace, "claude-recent"), + mtimeMs: nowMs - 24 * 60 * 60 * 1000, + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-selected", "claude-old.jsonl"), + contents: claudeTranscript(workspace, "claude-old"), + mtimeMs: nowMs - 31 * 24 * 60 * 60 * 1000, + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-other", "claude-other.jsonl"), + contents: claudeTranscript(otherWorkspace, "claude-other"), + mtimeMs: nowMs - 24 * 60 * 60 * 1000, + }); + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-codex-recent.jsonl", + ), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "codex-recent", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + timestamp: "2026-08-24T10:00:00.000Z", + payload: { type: "user_message", message: "Review this code" }, + }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-24T10:01:00.000Z", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Looks good" }], + }, + }), + ].join("\n"), + mtimeMs: nowMs - 60 * 60 * 1000, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(threads.map((thread) => thread.providerSessionId)).toEqual([ + "codex-recent", + "claude-recent", + ]); + expect(threads.map((thread) => thread.messages.map((message) => message.text))).toEqual([ + ["Review this code", "Looks good"], + ["Fix the project", "Done"], + ]); + }), + ); + + it.effect("imports history recorded with a case alias", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const workspaceAlias = path.join( + path.dirname(workspace), + path.basename(workspace).toUpperCase(), + ); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-alias", "case-session.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "user", + cwd: workspaceAlias, + sessionId: "case-session", + timestamp: "2026-08-24T10:00:00.000Z", + message: { role: "user", content: "Import case alias history" }, + }), + encodeTranscriptRecord({ + type: "assistant", + sessionId: "case-session", + timestamp: "2026-08-24T10:01:00.000Z", + message: { role: "assistant", content: "Imported" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => fileSystem.stat(filePath === workspaceAlias ? workspace : filePath), + }); + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(threads.map((thread) => thread.providerSessionId)).toEqual(["case-session"]); + }), + ); + + it.effect("keeps the provider instance that owns a custom session home", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const customHome = yield* makeTempDir("t3code-codex-custom-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(customHome, "sessions", "2026", "08", "24", "rollout-custom.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "custom-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Use my work account" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + providerInstances: { + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: customHome }, + }, + }, + }); + + expect(threads[0]?.providerInstanceId).toBe("codex-work"); + }), + ); + + it.effect("suppresses duplicate session copies without reporting a skipped import", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const contents = [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "copied-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Import this session once" }, + }), + ].join("\n"); + + for (const [name, mtimeMs] of [ + ["rollout-copy-a.jsonl", nowMs], + ["rollout-copy-b.jsonl", nowMs - 1], + ] as const) { + yield* writeTranscript({ + filePath: path.join(codexHomePath, "sessions", "2026", "08", "24", name), + contents, + mtimeMs, + }); + } + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Importable", "Duplicate"]); + expect(outcomes[0]).toMatchObject({ + _tag: "Importable", + thread: { providerSessionId: "copied-session" }, + }); + }), + ); + + it.effect("streams large transcripts across providers without hiding projects", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-budget-claude-"); + const codexHomePath = yield* makeTempDir("t3code-budget-codex-"); + const workspace = yield* makeTempDir("t3code-budget-workspace-"); + const transcriptPaths = new Set(); + for (const [index, source] of [ + "codex", + "claudeAgent", + "codex", + "claudeAgent", + "codex", + ].entries()) { + const sessionId = `budget-session-${index}`; + const filePath = + source === "codex" + ? path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + `rollout-${sessionId}.jsonl`, + ) + : path.join(claudeHomePath, "projects", "selected", `${sessionId}.jsonl`); + const contents = + source === "codex" + ? [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Imported prompt" }, + }), + ].join("\n") + : encodeTranscriptRecord({ + type: "user", + cwd: workspace, + sessionId, + message: { content: "Imported prompt" }, + }); + transcriptPaths.add(filePath); + yield* writeTranscript({ + filePath, + contents: `${contents}\n`.padEnd(16 * 1024 * 1024, " "), + mtimeMs: nowMs - index * 1_000, + }); + } + + const opens = new Map(); + let fullReadBytes = 0; + const trackedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + const count = (opens.get(filePath) ?? 0) + 1; + opens.set(filePath, count); + return fileSystem.open(filePath, options).pipe( + Effect.map((file) => + !transcriptPaths.has(filePath) || count === 1 + ? file + : { + ...file, + stat: file.stat, + readAlloc: (size: FileSystem.SizeInput) => + file.readAlloc(size).pipe( + Effect.tap((chunk) => + Effect.sync(() => { + if (chunk._tag === "Some") fullReadBytes += chunk.value.byteLength; + }), + ), + ), + }, + ), + ); + }, + }); + const outcomes = yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const scan = yield* scanner.scan; + expect(scan.candidates[0]?.threadCount).toBe(5); + return yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); + }).pipe( + Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath })), + Effect.provideService(FileSystem.FileSystem, trackedFileSystem), + ); + + expect(outcomes.map((outcome) => outcome._tag)).toEqual([ + "Importable", + "Importable", + "Importable", + "Importable", + "Importable", + ]); + expect(fullReadBytes).toBe(80 * 1024 * 1024); + }), + ); + + it.effect("skips excessive records without blocking an older valid transcript", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-record-budget-claude-"); + const codexHomePath = yield* makeTempDir("t3code-record-budget-codex-"); + const workspace = yield* makeTempDir("t3code-record-budget-workspace-"); + for (const [sessionId, padding, mtimeMs] of [ + ["excessive", "\n".repeat(100_001), nowMs], + ["older", "", nowMs - 1_000], + ] as const) { + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + `rollout-${sessionId}.jsonl`, + ), + contents: + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Imported prompt" }, + }), + ].join("\n") + padding, + mtimeMs, + }); + } + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Skipped", "Importable"]); + expect(outcomes[1]).toMatchObject({ thread: { providerSessionId: "older" } }); + }), + ); + + for (const source of ["claudeAgent", "codex"] as const) { + for (const replacement of [ + "same root", + "other root", + "symlink alias", + "other then same", + ] as const) { + it.effect.skipIf(replacement === "symlink alias" && !symlinksSupported)( + `rechecks ${source} snapshot cwd after replacement with ${replacement}`, + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const fixture = yield* makeTempDir("t3code-replaced-cwd-"); + const workspace = path.join(fixture, "original"); + const otherWorkspace = path.join(fixture, "other"); + const alias = path.join(fixture, "alias"); + const claudeHomePath = path.join(fixture, "claude"); + const codexHomePath = path.join(fixture, "codex"); + yield* fileSystem.makeDirectory(workspace); + yield* fileSystem.makeDirectory(otherWorkspace); + if (replacement === "symlink alias") yield* fileSystem.symlink(workspace, alias); + const filePath = + source === "codex" + ? path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-replaced.jsonl", + ) + : path.join(claudeHomePath, "projects", "p", "replaced.jsonl"); + const makeContents = (cwd: string, text: string, laterCwd?: string) => + [ + ...(source === "codex" + ? [ + { type: "session_meta", payload: { id: "replacement-session", cwd } }, + { type: "event_msg", payload: { type: "user_message", message: text } }, + ] + : [ + { + type: "user", + cwd, + sessionId: "replacement-session", + message: { content: text }, + }, + ]), + ...(laterCwd === undefined ? [] : [{ cwd: laterCwd }]), + ] + .map((record) => encodeTranscriptRecord(record)) + .join("\n"); + yield* writeTranscript({ + filePath, + contents: makeContents(workspace, "Original prompt"), + mtimeMs: nowMs, + }); + + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const scan = yield* scanner.scan; + expect(scan.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + const replacementCwd = + replacement === "symlink alias" + ? alias + : replacement === "same root" + ? workspace + : otherWorkspace; + yield* fileSystem.remove(filePath); + yield* writeTranscript({ + filePath, + contents: makeContents( + replacementCwd, + "Replacement prompt", + replacement === "other then same" ? workspace : undefined, + ), + mtimeMs: nowMs, + }); + const outcomes = yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); + if (replacement === "same root" || replacement === "symlink alias") { + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ + _tag: "Importable", + thread: { messages: [{ text: "Replacement prompt" }] }, + }); + } else { + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + } + }).pipe(Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath }))); + }), + ); + } + } + + it.effect("checks file identity and provider before skipping completed history", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-completed-claude-"); + const codexHomePath = yield* makeTempDir("t3code-completed-codex-"); + const workspace = yield* makeTempDir("t3code-completed-workspace-"); + const filePath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-replaced.jsonl", + ); + const contents = (sessionId: string) => + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Imported prompt" }, + }), + ].join("\n"); + yield* writeTranscript({ + filePath, + contents: contents("original-session"), + mtimeMs: nowMs, + }); + + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const initial = yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); + const imported = initial[0]; + expect(imported?._tag).toBe("Importable"); + if (imported?._tag !== "Importable") return; + const completed = yield* scanner + .recentThreads(workspace, [imported.source]) + .pipe(Stream.runCollect); + expect(completed[0]?._tag).toBe("AlreadyImported"); + const wrongProvider = yield* scanner + .recentThreads(workspace, [{ ...imported.source, provider: "claudeAgent" }]) + .pipe(Stream.runCollect); + expect(wrongProvider[0]?._tag).toBe("Importable"); + + // Keep the old inode allocated while replacing the path with an equal-size file. + yield* fileSystem.open(filePath); + yield* fileSystem.remove(filePath); + yield* writeTranscript({ + filePath, + contents: contents("replaced-session"), + mtimeMs: nowMs, + }); + const replaced = yield* scanner + .recentThreads(workspace, [imported.source]) + .pipe(Stream.runCollect); + expect(replaced[0]).toMatchObject({ + _tag: "Importable", + thread: { providerSessionId: "replaced-session" }, + source: { size: imported.source.size, mtimeMs: imported.source.mtimeMs }, + }); + }).pipe(Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath }))); + }), + ); + + it.effect("imports visible history from a transcript with an oversized tool record", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const transcript = `${[ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "large-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Import this large session" }, + }), + ].join("\n")}\n${encodeTranscriptRecord({ type: "tool_result", data: "" }).padEnd( + 16 * 1024 * 1024 + 1, + " ", + )}`; + yield* writeTranscript({ + filePath: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-large.jsonl"), + contents: transcript, + mtimeMs: nowMs, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(outcomes).toMatchObject([ + { + _tag: "Importable", + thread: { + providerSessionId: "large-session", + messages: [{ role: "user", text: "Import this large session" }], + }, + }, + ]); + }), + ); + + it.effect("reports stat, read, and parse failures as skipped", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const missingPath = path.join(codexHomePath, "missing.jsonl"); + const transcriptPaths = { + stat: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-stat.jsonl"), + read: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-read.jsonl"), + parse: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-parse.jsonl"), + }; + const transcriptContents = (sessionId: string) => + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Import this session" }, + }), + ].join("\n"); + + yield* writeTranscript({ + filePath: transcriptPaths.stat, + contents: transcriptContents("stat-session"), + mtimeMs: nowMs, + }); + yield* writeTranscript({ + filePath: transcriptPaths.read, + contents: transcriptContents("read-session"), + mtimeMs: nowMs, + }); + yield* writeTranscript({ + filePath: transcriptPaths.parse, + contents: encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "parse-session", cwd: workspace }, + }), + mtimeMs: nowMs, + }); + + let statCount = 0; + let readOpenCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => { + if (filePath !== transcriptPaths.stat) return fileSystem.stat(filePath); + statCount += 1; + return fileSystem.stat(statCount === 1 ? filePath : missingPath); + }, + open: (filePath, options) => { + if (filePath !== transcriptPaths.read) return fileSystem.open(filePath, options); + readOpenCount += 1; + return fileSystem.open(readOpenCount === 1 ? filePath : missingPath, options); + }, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(outcomes).toEqual([{ _tag: "Skipped" }, { _tag: "Skipped" }, { _tag: "Skipped" }]); + }), + ); + + it.effect("does not reopen a transcript that becomes a non-file after discovery", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const nonFilePath = yield* makeTempDir("t3code-non-file-"); + const transcriptPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-changed.jsonl", + ); + yield* writeTranscript({ + filePath: transcriptPath, + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "changed-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import this session" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + let transcriptStatCount = 0; + let transcriptOpenCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => { + if (filePath !== transcriptPath) return fileSystem.stat(filePath); + transcriptStatCount += 1; + return fileSystem.stat(transcriptStatCount === 1 ? transcriptPath : nonFilePath); + }, + open: (filePath, options) => { + if (filePath === transcriptPath) transcriptOpenCount += 1; + return fileSystem.open(filePath, options); + }, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(transcriptStatCount).toBe(2); + expect(transcriptOpenCount).toBe(1); + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + }), + ); + + it.effect("does not import a transcript dated after the current time", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-future.jsonl", + ), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "future-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Future work" }, + }), + ].join("\n"), + mtimeMs: nowMs + 1, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(outcomes).toEqual([]); + }), + ); + + it.effect("skips growth during reading without exceeding the reserved bytes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const transcriptPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-growing.jsonl", + ); + const contents = [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "growing-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import a changing file" }, + }), + ].join("\n"); + yield* writeTranscript({ filePath: transcriptPath, contents, mtimeMs: nowMs }); + let transcriptOpenCount = 0; + let fullReadBytes = 0; + let grew = false; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (filePath !== transcriptPath) return fileSystem.open(filePath, options); + transcriptOpenCount += 1; + if (transcriptOpenCount === 1) return fileSystem.open(filePath, options); + return fileSystem.open(filePath, options).pipe( + Effect.map((file) => ({ + ...file, + stat: file.stat, + readAlloc: (size: FileSystem.SizeInput) => + file.readAlloc(size).pipe( + Effect.tap((chunk) => + Effect.gen(function* () { + if (chunk._tag === "None") return; + fullReadBytes += chunk.value.byteLength; + if (!grew) { + grew = true; + yield* fileSystem.writeFileString(filePath, `${contents}\nchanged`); + } + }), + ), + ), + })), + ); + }, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(transcriptOpenCount).toBe(2); + expect(fullReadBytes).toBe(new TextEncoder().encode(contents).byteLength); + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + }), + ); + + it.effect("skips a transcript that shrinks after its size check", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const transcriptPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-shrinking.jsonl", + ); + const shrunkPath = path.join(codexHomePath, "shrunk.jsonl"); + const contents = [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "shrinking-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import a changing file" }, + }), + ].join("\n"); + yield* writeTranscript({ + filePath: transcriptPath, + contents: `${contents}\n${"padding".repeat(100)}`, + mtimeMs: nowMs, + }); + yield* writeTranscript({ filePath: shrunkPath, contents, mtimeMs: nowMs }); + + let transcriptOpenCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (filePath !== transcriptPath) return fileSystem.open(filePath, options); + transcriptOpenCount += 1; + return fileSystem.open( + transcriptOpenCount === 1 ? transcriptPath : shrunkPath, + options, + ); + }, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(transcriptOpenCount).toBe(2); + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + }), + ); + + it.effect("does not read the second transcript when the consumer takes one thread", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const makeCodexTranscript = (sessionId: string, text: string) => + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: text }, + }), + ].join("\n"); + const olderPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "23", + "rollout-older.jsonl", + ); + const newerPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-newer.jsonl", + ); + yield* writeTranscript({ + filePath: olderPath, + contents: makeCodexTranscript("older-session", "Older prompt"), + mtimeMs: nowMs - 1_000, + }); + yield* writeTranscript({ + filePath: newerPath, + contents: makeCodexTranscript("newer-session", "Newer prompt"), + mtimeMs: nowMs, + }); + + const openCounts = new Map(); + const contentReads: Array = []; + const trackedPaths = new Set([olderPath, newerPath]); + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (trackedPaths.has(filePath)) { + const count = (openCounts.get(filePath) ?? 0) + 1; + openCounts.set(filePath, count); + if (count === 2) contentReads.push(filePath); + } + return fileSystem.open(filePath, options); + }, + }); + + const threads = yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + return yield* scanner.recentThreads(workspace).pipe( + Stream.take(1), + Stream.runCollect, + Effect.map((items) => Array.from(items)), + ); + }).pipe( + Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath })), + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect( + threads.flatMap((outcome) => + outcome._tag === "Importable" ? [outcome.thread.providerSessionId] : [], + ), + ).toEqual(["newer-session"]); + expect(contentReads).toEqual([newerPath]); + expect(openCounts.get(olderPath)).toBe(1); + }), + ); + + it.effect("does not import sessions from a T3-managed worktree", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const workspace = path.join(configBaseDir, "worktrees", "t3code", "managed-worktree"); + yield* fileSystem.makeDirectory(workspace, { recursive: true }); + + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-managed.jsonl", + ), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "managed-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import this session" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + configBaseDir, + workspaceRoot: workspace, + }); + + expect(threads).toEqual([]); + }), + ); + + it.effect("uses one deterministic provider instance for a shared session home", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(sharedHome, "sessions", "2026", "08", "24", "rollout-shared.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "shared-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Use the shared session" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + [ProviderInstanceId.make("codex-personal")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + }, + }); + + expect(threads.map((thread) => thread.providerInstanceId)).toEqual(["codex"]); + }), + ); + + it.effect("uses configured order when custom instances share a session home", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(sharedHome, "sessions", "2026", "08", "24", "rollout-shared.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "shared-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Use the first account" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + providerInstances: { + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + [ProviderInstanceId.make("codex-personal")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + }, + }); + + expect(threads.map((thread) => thread.providerInstanceId)).toEqual(["codex-work"]); + }), + ); + + it.effect("keeps a second account when the first has 5000 newer files", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const oldWorkspace = yield* makeTempDir("t3code-workspace-old-"); + const recentWorkspace = yield* makeTempDir("t3code-workspace-recent-"); + const recentHome = yield* makeTempDir("t3code-claude-recent-home-"); + const oldDirectory = path.join(claudeHomePath, "projects", "-aaa-old"); + const oldTranscript = path.join(oldDirectory, "old.jsonl"); + const recentDirectory = path.join(recentHome, "projects", "-zzz-recent"); + + yield* writeTranscript({ + filePath: oldTranscript, + contents: encodeTranscriptRecord({ + type: "user", + cwd: oldWorkspace, + sessionId: "old-session", + message: { role: "user", content: "Old work" }, + }), + mtimeMs: nowMs, + }); + yield* writeTranscript({ + filePath: path.join(recentDirectory, "recent.jsonl"), + contents: encodeTranscriptRecord({ + type: "user", + cwd: recentWorkspace, + sessionId: "recent-session", + message: { role: "user", content: "Recent work" }, + }), + mtimeMs: nowMs - 1_000, + }); + + const simulatedOldTranscripts = Array.from( + { length: 5_000 }, + (_, index) => `old-${index}.jsonl`, + ); + const resolveTranscript = (filePath: string) => + path.dirname(filePath) === oldDirectory && path.basename(filePath).startsWith("old-") + ? oldTranscript + : filePath; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (directory, options) => + directory === oldDirectory + ? Effect.succeed(simulatedOldTranscripts) + : fileSystem.readDirectory(directory, options), + stat: (filePath) => fileSystem.stat(resolveTranscript(filePath)), + open: (filePath, options) => fileSystem.open(resolveTranscript(filePath), options), + }); + + const input = { + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claude-work")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: recentHome }, + }, + }, + }; + const threads = yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const scan = yield* scanner.scan; + expect(scan.truncated).toBe(true); + return yield* scanner.recentThreads(recentWorkspace).pipe(Stream.runCollect); + }).pipe( + Effect.provide(makeScannerTestLayer(input)), + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect( + threads.flatMap((outcome) => + outcome._tag === "Importable" ? [outcome.thread.providerSessionId] : [], + ), + ).toEqual(["recent-session"]); + }), + ); + }); +}); + +describe("parseAgentSessionTranscript", () => { + it.each([false, true])( + "handles the exact record limit and an interior blank overflow=%s", + (overflow) => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: makeRecordLimitTranscript("/project", overflow), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "unused", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + if (overflow) expect(thread).toBeNull(); + else expect(thread?.messages.map((message) => message.text)).toEqual(["First prompt"]); + }, + ); + + it("keeps Claude text and titles while dropping malformed and tool records", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + "not valid json", + JSON.stringify({ type: "ai-title", aiTitle: "Fix authentication" }), + JSON.stringify({ + type: "user", + sessionId: "claude-session", + isMeta: true, + message: { role: "user", content: "Injected skill instructions" }, + }), + JSON.stringify({ + type: "user", + sessionId: "claude-session", + isCompactSummary: true, + message: { role: "user", content: "Injected compaction summary" }, + }), + JSON.stringify({ + type: "user", + sessionId: "claude-session", + timestamp: "2026-08-24T10:00:00.000Z", + message: { role: "user", content: [{ type: "text", text: "Fix authentication" }] }, + }), + JSON.stringify({ + type: "user", + sessionId: "claude-session", + message: { role: "user", content: [{ type: "tool_result", text: "hidden" }] }, + }), + JSON.stringify({ + type: "assistant", + sessionId: "claude-session", + message: { + role: "assistant", + model: "claude-sonnet-5", + content: [{ type: "text", text: "Updated the login flow" }], + }, + }), + JSON.stringify({ + type: "assistant", + sessionId: "claude-session", + message: { + role: "assistant", + model: "", + content: [{ type: "text", text: "The provider request failed" }], + }, + }), + ].join("\n"), + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread).toMatchObject({ + providerSessionId: "claude-session", + title: "Fix authentication", + model: "claude-sonnet-5", + messages: [ + { role: "user", text: "Fix authentication" }, + { role: "assistant", text: "Updated the login flow" }, + { role: "assistant", text: "The provider request failed" }, + ], + }); + }); + + it("drops injected Codex instructions while keeping the visible user event", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + JSON.stringify({ type: "session_meta", payload: { id: "codex-session" } }), + JSON.stringify({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [ + { + type: "input_text", + text: "\nInternal setup instructions\n", + }, + ], + }, + }), + JSON.stringify({ + type: "event_msg", + payload: { type: "user_message", message: "Fix the actual bug" }, + }), + JSON.stringify({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [{ type: "input_text", text: "Fix the actual bug" }], + }, + }), + JSON.stringify({ + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Fixed" }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Fix the actual bug", + "Fixed", + ]); + }); + + it("keeps the canonical first prompt after long Codex transcripts are capped", () => { + const canonicalPrompt = "\n Keep the canonical prompt \n"; + const canonicalTimestamp = "2026-08-24T10:01:00.000Z"; + const laterAssistantMessages = Array.from({ length: 200 }, (_, index) => + encodeTranscriptRecord({ + type: "response_item", + timestamp: `2026-08-24T11:${String(index % 60).padStart(2, "0")}:00.000Z`, + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: `Assistant message ${index}` }], + }, + }), + ); + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-24T10:00:00.000Z", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Keep the canonical prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + timestamp: canonicalTimestamp, + payload: { type: "user_message", message: canonicalPrompt }, + }), + ...laterAssistantMessages, + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages).toHaveLength(200); + expect(thread?.messages[0]).toMatchObject({ + role: "user", + text: canonicalPrompt, + createdAt: canonicalTimestamp, + }); + }); + + it("restores the canonical first prompt when a later user message remains", () => { + const canonicalPrompt = "\n Keep the canonical prompt \n"; + const canonicalTimestamp = "2026-08-24T10:01:00.000Z"; + const assistantMessages = Array.from({ length: 198 }, (_, index) => + encodeTranscriptRecord({ + type: "response_item", + timestamp: `2026-08-24T11:${String(index % 60).padStart(2, "0")}:00.000Z`, + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: `Assistant message ${index}` }], + }, + }), + ); + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-24T10:00:00.000Z", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [{ type: "input_text", text: "Keep the canonical prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + timestamp: canonicalTimestamp, + payload: { type: "user_message", message: canonicalPrompt }, + }), + ...assistantMessages, + encodeTranscriptRecord({ + type: "event_msg", + timestamp: "2026-08-24T11:58:30.000Z", + payload: { type: "user_message", message: "Keep this later prompt" }, + }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-24T11:59:00.000Z", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Keep this latest response" }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages).toHaveLength(200); + expect(thread?.messages[0]).toMatchObject({ + role: "user", + text: canonicalPrompt, + createdAt: canonicalTimestamp, + }); + expect( + thread?.messages.filter((message) => message.text.trim() === canonicalPrompt.trim()), + ).toHaveLength(1); + expect(thread?.messages.some((message) => message.text === "Keep this later prompt")).toBe( + true, + ); + expect(thread?.messages.at(-1)?.text).toBe("Keep this latest response"); + }); + + it("keeps mixed-format response users when turn IDs repeat after an assistant", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-older" }, + content: [{ type: "input_text", text: "Keep this older prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Keep this newer prompt" }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-newer" }, + content: [{ type: "input_text", text: "Keep this newer prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Ask again when needed" }], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-newer" }, + content: [{ type: "input_text", text: "Keep this newer prompt" }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Keep this older prompt", + "Keep this newer prompt", + "Ask again when needed", + "Keep this newer prompt", + ]); + }); + + it("preserves response user text when Codex turn metadata is ambiguous", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: ["unexpected"], + content: [{ type: "input_text", text: "Keep this legacy prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: " " }, + content: [{ type: "input_text", text: "Keep this prompt with a blank turn ID" }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Keep this legacy prompt", + "Keep this prompt with a blank turn ID", + ]); + }); + + it("uses the first valid Codex session ID when a fork copies ancestor metadata", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "fork-session", forked_from_id: "parent-session" }, + }), + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "parent-session" }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Continue in the fork" }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.providerSessionId).toBe("fork-session"); + }); + + it("skips Codex transcripts without a resumable session ID", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "This transcript has no session metadata" }, + }), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "rollout-2026-08-24T12-00-00-not-a-session-id", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread).toBeNull(); + }); + + it("uses the canonical Codex event when its turn has generated response context", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [ + { + type: "input_text", + text: "\n/tmp/project\nzsh\n", + }, + ], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [ + { + type: "input_text", + text: "# AGENTS.md instructions for /tmp/project\n\n\nPrivate project rules\n", + }, + ], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "user_message", + message: "Do something here so it looks like a real project.", + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [ + { + type: "input_text", + text: "Do something here so it looks like a real project.", + }, + ], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Created the project." }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe("Do something here so it looks like a real project."); + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Do something here so it looks like a real project.", + "Created the project.", + ]); + }); + + it("preserves context markup in response-only Codex messages", () => { + const context = "\n/tmp/project\n"; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + content: [ + { + type: "input_text", + text: context, + }, + ], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Initialize Git and add a README." }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe(""); + expect(thread?.messages.map((message) => message.text)).toEqual([ + context, + "Initialize Git and add a README.", + ]); + }); + + it("preserves a canonical Codex event that starts with context markup", () => { + const prompt = + "\n/tmp/project\n\n\nCreate a useful project."; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "user_message", + message: prompt, + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe(""); + expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); + }); + + it("preserves a Codex request heading in a canonical event", () => { + const prompt = "\n ## My request for Codex:\n\nFix the visible bug"; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "user_message", + message: prompt, + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe("## My request for Codex:"); + expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); + }); + + it("keeps context markup quoted inside visible Codex user text", () => { + const quoted = + "Do not remove this example:\n\n/tmp/example\n"; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: quoted }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([quoted]); + }); + + it("skips sessions without a visible user message", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: JSON.stringify({ + type: "assistant", + message: { role: "assistant", content: "Done" }, + }), + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "claude-session", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread).toBeNull(); + }); + + it("keeps the first prompt when later assistant output exceeds the message limit", () => { + const transcript = [ + encodeTranscriptRecord({ + type: "user", + sessionId: "claude-session", + message: { role: "user", content: "Keep this prompt" }, + }), + ...Array.from({ length: 250 }, (_, index) => + encodeTranscriptRecord({ + type: "assistant", + message: { role: "assistant", content: `Assistant update ${index}` }, + }), + ), + ].join("\n"); + + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: transcript, + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages).toHaveLength(200); + expect(thread?.messages[0]?.text).toBe("Keep this prompt"); + expect(thread?.messages.at(-1)?.text).toBe("Assistant update 249"); + }); +}); diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts new file mode 100644 index 000000000..ec03e3ba7 --- /dev/null +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -0,0 +1,1504 @@ +/** + * AgentSessionScanner - discovery of projects a user already works on. + * + * Claude Code and Codex both keep a per-session transcript on disk, and each + * transcript records the directory the session ran in. Reading those `cwd` + * values gives us the set of directories worth offering as projects during + * onboarding, without asking the user to browse the filesystem. + * + * The scan is read-only and best-effort: an unreadable home, a malformed + * transcript, or a directory that has since been deleted is skipped rather + * than failing the scan. Project creation stays with the client, which + * dispatches `project.create` for whichever candidates the user picks. + * + * @module project/AgentSessionScanner + */ +import * as NodeOS from "node:os"; + +import { + AgentSessionScanError, + ClaudeSettings, + CodexSettings, + ProviderDriverKind, + ProviderInstanceId, + resolveProviderInstanceEnabled, + type AgentSessionImportSource, + type AgentSessionProjectCandidate, + type AgentSessionProjectGit, + type AgentSessionScanResult, + type ProviderInstanceConfig, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import { + normalizeGitRemoteUrl, + parseGitHubRepositoryNameWithOwnerFromRemoteUrl, + parseOriginUrlFromGitConfig, +} from "@t3tools/shared/git"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; + +import * as ServerConfig from "../config.ts"; +import * as ProjectService from "./ProjectService.ts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { expandHomePath } from "../pathExpansion.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { + createTranscriptJsonReader, + createTranscriptJsonSelector, + TranscriptJsonLimitError, +} from "./AgentSessionJson.ts"; + +/** Chunk size for full transcript reads. */ +const TRANSCRIPT_PREFIX_BYTES = 32 * 1024; +/** Small reads avoid wasting the metadata budget on long Codex instruction headers. */ +const METADATA_READ_BYTES = 8 * 1024; +/** Prevent malformed transcripts from turning project discovery into a full file scan. */ +const MAX_TRANSCRIPT_SCAN_BYTES = 1024 * 1024; + +/** + * Upper bound on transcripts inspected (first line read) per source. + * Newest-first ordering means the cap drops only stale sessions when a home + * directory is unusually large. + */ +const MAX_TRANSCRIPTS_PER_SOURCE = 5000; + +/** + * Upper bound on discovery filesystem operations per source. Newest-first + * ordering needs mtimes before the read cap can be applied, so directory reads + * and candidate stats share a larger budget. Once it runs out the scan stops. + */ +const MAX_DISCOVERY_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4; +const MAX_METADATA_BYTES_PER_SOURCE = 64 * 1024 * 1024; +const MAX_METADATA_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4; +const MAX_METADATA_RECORDS_PER_SOURCE = 100_000; +const MAX_METADATA_RECORDS_PER_TRANSCRIPT = 1_000; +const RECENT_THREAD_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; +/** + * Large tool results (especially screenshots) can make an otherwise ordinary + * Codex transcript several GiB. Streaming field selection avoids allocating + * those payloads. Raw I/O and selected history have separate budgets. + */ +const MAX_IMPORTED_TRANSCRIPT_BYTES = 4 * 1024 * 1024 * 1024; +const MAX_IMPORTED_MESSAGES = 200; +const MAX_IMPORT_HISTORY_BYTES = 32 * 1024 * 1024; +const MAX_IMPORT_BYTES = 4 * 1024 * 1024 * 1024; +const MAX_IMPORT_TRANSCRIPTS = 100; +const MAX_IMPORT_RECORDS = 100_000; + +const TranscriptContentBlock = Schema.Struct({ + type: Schema.optional(Schema.String), + text: Schema.optional(Schema.String), +}); + +const TranscriptMessage = Schema.Struct({ + role: Schema.optional(Schema.String), + content: Schema.optional(Schema.Union([Schema.String, Schema.Array(TranscriptContentBlock)])), + model: Schema.optional(Schema.String), +}); + +const CodexTurnMetadata = Schema.Struct({ + turn_id: Schema.optional(Schema.Union([Schema.String, Schema.Null])), +}); + +const TranscriptRecord = Schema.Struct({ + type: Schema.optional(Schema.String), + timestamp: Schema.optional(Schema.String), + cwd: Schema.optional(Schema.String), + sessionId: Schema.optional(Schema.String), + aiTitle: Schema.optional(Schema.String), + isSidechain: Schema.optional(Schema.Boolean), + isMeta: Schema.optional(Schema.Boolean), + isCompactSummary: Schema.optional(Schema.Boolean), + message: Schema.optional(TranscriptMessage), + payload: Schema.optional( + Schema.Struct({ + id: Schema.optional(Schema.String), + session_id: Schema.optional(Schema.String), + type: Schema.optional(Schema.String), + role: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + model: Schema.optional(Schema.String), + cwd: Schema.optional(Schema.String), + content: Schema.optional(Schema.Array(TranscriptContentBlock)), + internal_chat_message_metadata_passthrough: Schema.optional(Schema.Unknown), + }), + ), +}); + +const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); +const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); +const decodeTranscriptRecord = Schema.decodeUnknownOption(Schema.fromJsonString(TranscriptRecord)); +const decodeTranscriptValue = Schema.decodeUnknownOption(TranscriptRecord); +const selectTranscriptPath = createTranscriptJsonSelector(TranscriptRecord); +const decodeCodexTurnMetadata = Schema.decodeUnknownOption(CodexTurnMetadata); + +type DecodedTranscriptRecord = typeof TranscriptRecord.Type; + +interface AgentSessionTranscriptMetadata { + readonly source: AgentSessionSource; + readonly providerInstanceId: ProviderInstanceId; + readonly fallbackSessionId: string; + readonly lastActiveAtMs: number; +} + +export interface AgentSessionThreadMessage { + readonly role: "user" | "assistant"; + readonly text: string; + readonly createdAt: string; +} + +export interface AgentSessionThread { + readonly source: AgentSessionSource; + readonly providerInstanceId: ProviderInstanceId; + readonly providerSessionId: string; + readonly title: string; + readonly model: string | null; + readonly createdAt: string; + readonly updatedAt: string; + readonly messages: ReadonlyArray; +} + +export type AgentSessionRecentThread = + | { + readonly _tag: "Importable"; + readonly thread: AgentSessionThread; + readonly source: AgentSessionImportSource; + } + | { readonly _tag: "AlreadyImported"; readonly source: AgentSessionImportSource } + | { readonly _tag: "Duplicate"; readonly source: AgentSessionImportSource } + | { readonly _tag: "Skipped" }; + +/** Service tag for agent session discovery. */ +export class AgentSessionScanner extends Context.Service< + AgentSessionScanner, + { + /** + * Discover every directory the configured Claude and Codex homes have run + * a session in. Candidates are returned newest-first; the client decides + * which ones to import and how far back to look. Fails with the contract + * error directly — there is no server-local context worth wrapping. + */ + readonly scan: Effect.Effect; + readonly recentThreads: ( + workspaceRoot: string, + completedSources?: ReadonlyArray, + ) => Stream.Stream; + } +>()("t3/project/AgentSessionScanner") {} + +type AgentSessionSource = AgentSessionProjectCandidate["sources"][number]; + +/** A single directory's worth of evidence from one source. */ +interface RawCandidate { + readonly cwd: string; + readonly source: AgentSessionSource; + readonly providerInstanceId: ProviderInstanceId; + readonly threadCount: number; + readonly lastActiveAtMs: number | null; + readonly transcripts: ReadonlyArray<{ + readonly filePath: string; + readonly mtimeMs: number | null; + }>; +} + +interface TranscriptCandidate { + readonly filePath: string; + readonly mtimeMs: number; + readonly providerInstanceId: ProviderInstanceId; + readonly size: number; +} + +interface MetadataReadBudget { + bytesRemaining: number; + operationsRemaining: number; + recordsRemaining: number; + truncated: boolean; +} + +function selectMetadataTranscripts(transcripts: ReadonlyArray) { + const selected: Array = []; + let pending = Array.from( + Map.groupBy(transcripts, (transcript) => transcript.providerInstanceId).values(), + (entries) => entries.values(), + ); + while (pending.length > 0 && selected.length < MAX_TRANSCRIPTS_PER_SOURCE) { + const nextRound: typeof pending = []; + for (const iterator of pending) { + if (selected.length === MAX_TRANSCRIPTS_PER_SOURCE) break; + const next = iterator.next(); + if (next.done) continue; + selected.push(next.value); + nextRound.push(iterator); + } + pending = nextRound; + } + return selected; +} + +function splitTranscriptRecords(contents: string, limit: number): string[] { + const records = contents.endsWith("\n") ? contents.slice(0, -1) : contents; + return records.split("\n", limit); +} + +function extractText( + content: string | ReadonlyArray | undefined, +): string { + if (typeof content === "string") return content.trim(); + if (content === undefined) return ""; + return content + .filter( + (block) => + block.type === "text" || block.type === "input_text" || block.type === "output_text", + ) + .map((block) => block.text?.trim() ?? "") + .filter((text) => text.length > 0) + .join("\n"); +} + +function normalizeTimestamp(value: string | undefined, fallback: string): string { + if (value === undefined) return fallback; + const parsed = DateTime.make(value); + return Option.isSome(parsed) ? DateTime.formatIso(parsed.value) : fallback; +} + +function codexTurnId(metadata: unknown): string | null { + const decoded = decodeCodexTurnMetadata(metadata); + if ( + Option.isNone(decoded) || + typeof decoded.value.turn_id !== "string" || + decoded.value.turn_id.trim().length === 0 + ) { + return null; + } + return decoded.value.turn_id; +} + +/** Keep visible user and assistant text while ignoring tools, reasoning, and malformed records. */ +export function parseAgentSessionTranscript( + input: AgentSessionTranscriptMetadata & { + readonly contents: string; + }, + lines = splitTranscriptRecords(input.contents, MAX_IMPORT_RECORDS + 1), +): AgentSessionThread | null { + if (lines.length > MAX_IMPORT_RECORDS) return null; + const records = lines.flatMap((line) => Option.toArray(decodeTranscriptRecord(line))); + return parseAgentSessionRecords(input, records); +} + +function parseAgentSessionRecords( + input: AgentSessionTranscriptMetadata, + records: ReadonlyArray, +): AgentSessionThread | null { + const fallbackTimestamp = DateTime.formatIso(DateTime.makeUnsafe(input.lastActiveAtMs)); + // Claude filenames are session IDs. Codex rollout filenames include extra + // timestamp text, so only transcript metadata can provide a resumable ID. + let providerSessionId = input.source === "codex" ? "" : input.fallbackSessionId; + let title: string | null = null; + let model: string | null = null; + let hasCodexSessionId = false; + const messages: Array = []; + let firstUserMessage: + | (AgentSessionThreadMessage & { readonly codexResponseUser: boolean }) + | undefined; + // A Codex response item can include generated setup text beside the real + // prompt. Suppress response-user records only when the shared turn ID and a + // verbatim event copy prove which prompt the user submitted. + const canonicalCodexResponseUserIndices = new Set(); + let canonicalUserTextsInTurn = new Set(); + let responseUsersInTurn: Array<{ + readonly index: number; + readonly turnId: string; + readonly text: string; + }> = []; + const finishCodexTurn = () => { + const canonicalTurnIds = new Set( + responseUsersInTurn.flatMap((responseUser) => + canonicalUserTextsInTurn.has(responseUser.text) ? [responseUser.turnId] : [], + ), + ); + for (const responseUser of responseUsersInTurn) { + if (canonicalTurnIds.has(responseUser.turnId)) { + canonicalCodexResponseUserIndices.add(responseUser.index); + } + } + canonicalUserTextsInTurn = new Set(); + responseUsersInTurn = []; + }; + if (input.source === "codex") { + let recordIndex = -1; + for (const record of records) { + recordIndex += 1; + if ( + record.type === "response_item" && + record.payload?.type === "message" && + record.payload.role === "assistant" + ) { + finishCodexTurn(); + continue; + } + if (record.type === "event_msg" && record.payload?.type === "user_message") { + const text = record.payload.message?.trim() ?? ""; + if (text.length > 0) canonicalUserTextsInTurn.add(text); + continue; + } + if ( + record.type === "response_item" && + record.payload?.type === "message" && + record.payload.role === "user" + ) { + const turnId = codexTurnId(record.payload.internal_chat_message_metadata_passthrough); + const text = extractText(record.payload.content); + if (turnId !== null && text.length > 0) { + responseUsersInTurn.push({ index: recordIndex, turnId, text }); + } + } + } + finishCodexTurn(); + } + + const retainMessage = ( + message: AgentSessionThreadMessage & { readonly codexResponseUser: boolean }, + ) => { + if (firstUserMessage === undefined && message.role === "user") { + firstUserMessage = message; + } + messages.push(message); + if (messages.length > MAX_IMPORTED_MESSAGES) messages.shift(); + }; + + const hasMatchingCodexEventInTurn = (text: string) => { + const comparisonText = text.trim(); + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message?.role === "assistant") return false; + if ( + message?.role === "user" && + !message.codexResponseUser && + message.text.trim() === comparisonText + ) { + return true; + } + } + return false; + }; + + let recordIndex = -1; + for (const record of records) { + recordIndex += 1; + if (input.source === "claudeAgent") { + if ( + record.isSidechain === true || + record.isMeta === true || + record.isCompactSummary === true + ) { + continue; + } + if (record.sessionId?.trim()) providerSessionId = record.sessionId.trim(); + if (record.aiTitle?.trim()) title = record.aiTitle.trim(); + const messageModel = record.message?.model?.trim(); + // Claude uses this sentinel for local error responses. It is not a + // model ID that can be selected when the imported session resumes. + if (messageModel && messageModel !== "") model = messageModel; + if (record.type !== "user" && record.type !== "assistant") { + continue; + } + + const text = extractText(record.message?.content); + if (text.length === 0) continue; + retainMessage({ + role: record.type, + text, + createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), + codexResponseUser: false, + }); + continue; + } + + if (record.type === "session_meta") { + const sessionId = record.payload?.id?.trim() || record.payload?.session_id?.trim(); + if (!hasCodexSessionId && sessionId) { + providerSessionId = sessionId; + hasCodexSessionId = true; + } + continue; + } + if (record.type === "turn_context" && record.payload?.model?.trim()) { + model = record.payload.model.trim(); + continue; + } + if (record.type === "event_msg" && record.payload?.type === "user_message") { + const text = record.payload.message ?? ""; + if (text.trim().length === 0) continue; + // Codex can write the same prompt as both a response item and an event. + // Remove only the matching response copy so mixed-format logs keep every + // distinct user message. + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message?.role === "assistant") break; + if (message?.codexResponseUser === true && message.text.trim() === text.trim()) { + if (firstUserMessage === message) firstUserMessage = undefined; + messages.splice(index, 1); + break; + } + } + retainMessage({ + role: "user", + text, + createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), + codexResponseUser: false, + }); + continue; + } + if ( + record.type !== "response_item" || + record.payload?.type !== "message" || + (record.payload.role !== "user" && record.payload.role !== "assistant") + ) { + continue; + } + + const extractedText = extractText(record.payload.content); + if (extractedText.length === 0) continue; + if (record.payload.role === "user" && canonicalCodexResponseUserIndices.has(recordIndex)) { + continue; + } + if (record.payload.role === "user" && hasMatchingCodexEventInTurn(extractedText)) { + continue; + } + retainMessage({ + role: record.payload.role, + text: extractedText, + createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), + codexResponseUser: record.payload.role === "user", + }); + } + + const visibleMessages = messages.map( + ({ codexResponseUser: _codexResponseUser, ...message }) => message, + ); + if (providerSessionId.trim().length === 0 || firstUserMessage === undefined) return null; + const firstUserMessageRetained = messages.includes(firstUserMessage); + const { codexResponseUser: _codexResponseUser, ...visibleFirstUserMessage } = firstUserMessage; + const retainedMessages = firstUserMessageRetained + ? visibleMessages + : [visibleFirstUserMessage, ...visibleMessages.slice(-(MAX_IMPORTED_MESSAGES - 1))]; + const derivedTitle = visibleFirstUserMessage.text.trim().split("\n")[0]?.slice(0, 100).trim(); + + return { + source: input.source, + providerInstanceId: input.providerInstanceId, + providerSessionId, + title: title ?? (derivedTitle && derivedTitle.length > 0 ? derivedTitle : "Imported thread"), + model, + createdAt: retainedMessages[0]?.createdAt ?? fallbackTimestamp, + updatedAt: fallbackTimestamp, + messages: retainedMessages, + }; +} + +function extractDecodedCwd(record: DecodedTranscriptRecord): string | null { + const cwd = record.cwd?.trim() || record.payload?.cwd?.trim(); + return cwd && cwd.length > 0 ? cwd : null; +} + +function shouldRetainDecodedRecord( + source: AgentSessionSource, + record: DecodedTranscriptRecord, +): boolean { + if (extractDecodedCwd(record) !== null) return true; + if (source === "claudeAgent") { + return ( + record.type === "user" || + record.type === "assistant" || + record.sessionId !== undefined || + record.aiTitle !== undefined || + record.message?.model !== undefined + ); + } + return ( + record.type === "session_meta" || + record.type === "turn_context" || + (record.type === "event_msg" && record.payload?.type === "user_message") || + (record.type === "response_item" && + record.payload?.type === "message" && + (record.payload.role === "user" || record.payload.role === "assistant")) + ); +} + +/** + * T3 Code runs its own agent sessions inside disposable worktrees. Their + * transcripts look exactly like user sessions, but re-importing the app's own + * sandboxes as projects is never right. Matches this server's configured + * worktrees directory plus the conventional `.t3/worktrees` layout, which + * also catches sandboxes from other T3 homes on the same machine. Separators + * are normalized (and, on Windows, case folded) so the prefix match holds + * there too. Callers check both the recorded spelling and its realpath so a + * symlink into the worktrees directory cannot bypass the filter. + */ +function normalizeForWorktreeMatch(value: string, caseFold: boolean): string { + const normalized = `${value.replaceAll("\\", "/")}/`; + return caseFold ? normalized.toLowerCase() : normalized; +} + +function isT3ManagedWorktree( + candidatePath: string, + worktreesDir: string, + caseFold: boolean, +): boolean { + const normalized = normalizeForWorktreeMatch(candidatePath, caseFold); + return ( + normalized.startsWith(normalizeForWorktreeMatch(worktreesDir, caseFold)) || + normalized.includes("/.t3/worktrees/") + ); +} + +/** Extract `cwd` from a session-meta record, tolerating the shapes each CLI writes. */ +function extractCwd(line: string): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + + const record = parsed as Record; + if (typeof record.cwd === "string" && record.cwd.trim().length > 0) { + return record.cwd; + } + // Codex nests session metadata under `payload`. + const payload = record.payload; + if (typeof payload === "object" && payload !== null) { + const nested = (payload as Record).cwd; + if (typeof nested === "string" && nested.trim().length > 0) { + return nested; + } + } + return null; +} + +function transcriptIdentity(filePath: string, stats: FileSystem.File.Info) { + return { + filePath, + size: Number(stats.size), + mtimeMs: Option.match(stats.mtime, { onNone: () => null, onSome: (date) => date.getTime() }), + device: stats.dev, + inode: Option.getOrNull(stats.ino), + birthtimeMs: Option.match(stats.birthtime, { + onNone: () => null, + onSome: (date) => date.getTime(), + }), + }; +} + +function sameTranscriptIdentity( + left: ReturnType, + right: ReturnType, +): boolean { + return ( + left.filePath === right.filePath && + left.size === right.size && + left.mtimeMs === right.mtimeMs && + left.device === right.device && + left.inode === right.inode && + left.birthtimeMs === right.birthtimeMs + ); +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + // Different project imports can arrive concurrently from multiple clients. + // Only one transcript may hold its selected-history budget at a time. + const importReadLock = yield* Semaphore.make(1); + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig.ServerConfig; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const projectService = yield* ProjectService.ProjectService; + const baseDir = path.resolve(serverConfig.baseDir); + const worktreesDir = path.resolve(serverConfig.worktreesDir); + // macOS aliases /var to /private/var; compare both configured and canonical + // roots so a transcript reached through a symlink cannot import T3 state. + const realBaseDir = yield* fileSystem.realPath(baseDir).pipe(Effect.orElseSucceed(() => baseDir)); + const realWorktreesDir = yield* fileSystem + .realPath(worktreesDir) + .pipe(Effect.orElseSucceed(() => worktreesDir)); + // Windows filesystems are case-insensitive, so path prefix checks there + // must case fold. + const foldWorktreeCase = (yield* HostProcessPlatform) === "win32"; + const hostEnvironment = yield* HostProcessEnvironment; + const homeDir = NodeOS.homedir(); + // `/private/tmp` is what macOS reports for sessions started in `/tmp`. + const excludedProjectRoots = new Set( + [homeDir, NodeOS.tmpdir(), "/tmp", "/private/tmp"].map((directory) => + normalizeProjectPathForComparison(path.resolve(directory)), + ), + ); + // Codex creates one scratch directory per conversation under + // ~/Documents/Codex//. Neither those nor anything a user + // unpacked into Downloads is a project. + const excludedProjectAncestors = [ + path.join(homeDir, "Downloads"), + path.join(homeDir, "Documents", "Codex"), + ]; + + const isExcludedProjectPath = (candidatePath: string) => + excludedProjectRoots.has(normalizeProjectPathForComparison(candidatePath)) || + excludedProjectAncestors.some((ancestor) => + normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith( + normalizeForWorktreeMatch(ancestor, foldWorktreeCase), + ), + ) || + [baseDir, realBaseDir].some((root) => + normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith( + normalizeForWorktreeMatch(root, foldWorktreeCase), + ), + ) || + [worktreesDir, realWorktreesDir].some((root) => + isT3ManagedWorktree(candidatePath, root, foldWorktreeCase), + ); + + const listDirectory = (directory: string) => + fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + + const statOption = (target: string) => + fileSystem.stat(target).pipe(Effect.map(Option.some), Effect.orElseSucceed(Option.none)); + + /** Match directory aliases without assuming the host volume is case-insensitive. */ + const directoryIdentity = Effect.fn("AgentSessionScanner.directoryIdentity")(function* ( + target: string, + knownStats?: FileSystem.File.Info, + ) { + const resolved = path.resolve(target); + const stats = knownStats === undefined ? yield* statOption(resolved) : Option.some(knownStats); + if ( + Option.isSome(stats) && + Option.isSome(stats.value.ino) && + Number.isSafeInteger(stats.value.ino.value) && + stats.value.ino.value > 0 + ) { + return `inode:${stats.value.dev}:${stats.value.ino.value}`; + } + const realPath = yield* fileSystem + .realPath(resolved) + .pipe(Effect.orElseSucceed(() => resolved)); + return `path:${normalizeProjectPathForComparison(realPath)}`; + }); + + /** + * Git identity of a directory, or the reason it has none. Reads `.git` + * directly instead of spawning git so a scan over hundreds of candidates + * stays cheap. A `.git` file is a `gitdir:` pointer. When it points into a + * `worktrees/` directory the checkout is a linked worktree, which + * onboarding skips because its history belongs to the main checkout. + * Submodules use the same pointer shape but live under `modules/`, and + * are offered like any other repository. + */ + const readGitIdentity = Effect.fn("AgentSessionScanner.readGitIdentity")(function* ( + directory: string, + ): Effect.fn.Return< + | { readonly _tag: "Repository"; readonly git: AgentSessionProjectGit | null } + | { readonly _tag: "Worktree" } + | { readonly _tag: "NotGit" } + > { + const gitPath = path.join(directory, ".git"); + const gitStats = yield* statOption(gitPath); + if (Option.isNone(gitStats)) return { _tag: "NotGit" } as const; + let gitDir = gitPath; + if (gitStats.value.type !== "Directory") { + const pointer = yield* fileSystem + .readFileString(gitPath) + .pipe(Effect.orElseSucceed(() => "")); + const target = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim(); + if (target === undefined || target.length === 0) return { _tag: "NotGit" } as const; + gitDir = path.resolve(directory, target); + if (/[\\/]worktrees[\\/][^\\/]+[\\/]?$/.test(gitDir)) return { _tag: "Worktree" } as const; + } + const configText = yield* fileSystem + .readFileString(path.join(gitDir, "config")) + .pipe(Effect.orElseSucceed(() => "")); + const originUrl = parseOriginUrlFromGitConfig(configText); + return { + _tag: "Repository", + git: { + remoteKey: originUrl === null ? null : normalizeGitRemoteUrl(originUrl), + repository: parseGitHubRepositoryNameWithOwnerFromRemoteUrl(originUrl), + }, + } as const; + }); + + // A large history snapshot can precede session metadata. Read bounded + // chunks until a complete record names its cwd or the safety budget ends. + const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* ( + transcript: TranscriptCandidate, + budget: MetadataReadBudget, + ) { + if (transcript.size === 0) return null; + if ( + budget.bytesRemaining === 0 || + budget.operationsRemaining < 2 || + budget.recordsRemaining === 0 + ) { + budget.truncated = true; + return null; + } + budget.operationsRemaining -= 1; + return yield* Effect.scoped( + fileSystem.open(transcript.filePath, { flag: "r" }).pipe( + Effect.flatMap((file) => + Effect.gen(function* () { + const decoder = new TextDecoder(); + let remaining = ""; + let bytesRead = 0; + let recordsRead = 0; + const maxBytes = Math.min(MAX_TRANSCRIPT_SCAN_BYTES, transcript.size); + const reserveRecord = () => { + if ( + recordsRead === MAX_METADATA_RECORDS_PER_TRANSCRIPT || + budget.recordsRemaining === 0 + ) { + budget.truncated = true; + return false; + } + recordsRead += 1; + budget.recordsRemaining -= 1; + return true; + }; + const readLastRecord = () => { + const record = remaining + decoder.decode(); + return record.length === 0 || !reserveRecord() ? null : extractCwd(record.trim()); + }; + + while (bytesRead < maxBytes) { + if (budget.bytesRemaining === 0 || budget.operationsRemaining === 0) { + budget.truncated = true; + return null; + } + const readSize = Math.min( + METADATA_READ_BYTES, + maxBytes - bytesRead, + budget.bytesRemaining, + ); + budget.operationsRemaining -= 1; + budget.bytesRemaining -= readSize; + const next = yield* file.readAlloc(readSize); + if (Option.isNone(next)) { + return readLastRecord(); + } + + bytesRead += next.value.byteLength; + remaining += decoder.decode(next.value, { stream: true }); + const lines = remaining.split("\n"); + remaining = lines.pop() ?? ""; + + for (const line of lines) { + if (!reserveRecord()) return null; + const cwd = extractCwd(line.trim()); + if (cwd !== null) return cwd; + } + } + + if (bytesRead < transcript.size) { + budget.truncated = true; + return null; + } + return readLastRecord(); + }), + ), + ), + ).pipe(Effect.orElseSucceed(() => null)); + }); + + /** + * Project history fields while reading, before allocating whole JSON records. + * Check the file identity on both sides of the read. A selected-history budget + * failure rejects the entire transcript before any imported messages persist. + */ + const readTranscript = Effect.fn("AgentSessionScanner.readTranscript")(function* ( + filePath: string, + expected: ReturnType, + recordLimit: number, + source: AgentSessionSource, + ) { + if (expected.size > MAX_IMPORTED_TRANSCRIPT_BYTES) return null; + + return yield* Effect.scoped( + fileSystem.open(filePath, { flag: "r" }).pipe( + Effect.flatMap((file) => + Effect.gen(function* () { + if (!sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat))) { + return null; + } + const records: Array = []; + let historyBytes = 0; + let recordBytes = 0; + let recordCount = 0; + let bytesRead = 0; + const reserve = (bytes: number) => { + recordBytes += bytes; + if (historyBytes + recordBytes > MAX_IMPORT_HISTORY_BYTES) { + throw new TranscriptJsonLimitError( + "Transcript selected history exceeds the 32 MiB memory budget", + ); + } + }; + let reader = createTranscriptJsonReader(reserve, selectTranscriptPath); + let decoder = new TextDecoder(); + let recordStarted = false; + + const finishRecord = () => { + reader.write(decoder.decode()); + recordCount += 1; + if (recordCount > recordLimit) return false; + const decoded = decodeTranscriptValue(reader.finish()); + if (Option.isSome(decoded) && shouldRetainDecodedRecord(source, decoded.value)) { + records.push(decoded.value); + historyBytes += recordBytes; + } + recordBytes = 0; + reader = createTranscriptJsonReader(reserve, selectTranscriptPath); + decoder = new TextDecoder(); + recordStarted = false; + return true; + }; + + while (bytesRead < expected.size) { + const next = yield* file.readAlloc( + Math.min(TRANSCRIPT_PREFIX_BYTES, expected.size - bytesRead), + ); + if (Option.isNone(next)) { + return null; + } + + bytesRead += next.value.byteLength; + const withinBudget = yield* Effect.try(() => { + let start = 0; + while (start < next.value.byteLength) { + const newline = next.value.indexOf(10, start); + const end = newline === -1 ? next.value.byteLength : newline; + recordStarted = true; + reader.write(decoder.decode(next.value.subarray(start, end), { stream: true })); + if (newline === -1) break; + if (!finishRecord()) return false; + start = newline + 1; + } + return true; + }); + if (!withinBudget) return null; + } + + if (recordStarted && !(yield* Effect.try(finishRecord))) return null; + return sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat)) + ? { records, recordCount } + : null; + }), + ), + ), + ).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not read imported transcript", { filePath, cause }).pipe( + Effect.as(null), + ), + ), + ); + }); + + /** + * Resolve the Claude config directory the CLI would use, matching the + * precedence the spawned CLI sees: the instance's `homePath` (exported as + * `CLAUDE_CONFIG_DIR`), then a `CLAUDE_CONFIG_DIR` already in the + * environment, then `~/.claude`. + */ + const resolveClaudeConfigDir = (homePath: string, environmentHome?: string): string => { + const configured = homePath.trim(); + if (configured.length > 0) { + return path.resolve(expandHomePath(configured)); + } + const fromEnvironment = environmentHome?.trim() ?? ""; + if (fromEnvironment.length > 0) { + return path.resolve(expandHomePath(fromEnvironment)); + } + return path.join(NodeOS.homedir(), ".claude"); + }; + + const discoverClaudeTranscripts = Effect.fn("AgentSessionScanner.discoverClaudeTranscripts")( + function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) { + const projectsDir = path.join(homePath, "projects"); + let operationsRemaining = operationBudget; + let truncated = false; + const readDirectory = (directory: string) => { + if (operationsRemaining <= 0) { + truncated = true; + return Effect.succeed>([]); + } + operationsRemaining -= 1; + return listDirectory(directory); + }; + const projectDirectories = yield* readDirectory(projectsDir); + const transcripts: Array = []; + + for (const projectDirectory of projectDirectories) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + const directory = path.join(projectsDir, projectDirectory); + const directoryTranscripts = (yield* readDirectory(directory)) + .filter((entry) => entry.endsWith(".jsonl")) + .map((entry) => path.join(directory, entry)); + + for (const filePath of directoryTranscripts) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + operationsRemaining -= 1; + const stats = yield* statOption(filePath); + if ( + Option.isNone(stats) || + stats.value.type !== "File" || + Option.isNone(stats.value.mtime) + ) { + continue; + } + transcripts.push({ + filePath, + mtimeMs: stats.value.mtime.value.getTime(), + providerInstanceId, + size: Number(stats.value.size), + }); + } + } + return { transcripts, truncated }; + }, + ); + + const discoverCodexTranscripts = Effect.fn("AgentSessionScanner.discoverCodexTranscripts")( + function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) { + const sessionsDir = path.join(homePath, "sessions"); + + const transcripts: Array = []; + let operationsRemaining = operationBudget; + let truncated = false; + const readDirectory = (directory: string) => { + if (operationsRemaining <= 0) { + truncated = true; + return Effect.succeed>([]); + } + operationsRemaining -= 1; + return listDirectory(directory); + }; + // Date-partitioned directories sort chronologically, so walking them in + // reverse spends each home's share of the operation budget on recent sessions. + for (const year of (yield* readDirectory(sessionsDir)).toSorted().toReversed()) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + for (const month of (yield* readDirectory(path.join(sessionsDir, year))) + .toSorted() + .toReversed()) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + for (const day of (yield* readDirectory(path.join(sessionsDir, year, month))) + .toSorted() + .toReversed()) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + const directory = path.join(sessionsDir, year, month, day); + for (const entry of (yield* readDirectory(directory)).toSorted().toReversed()) { + if (!entry.startsWith("rollout-") || !entry.endsWith(".jsonl")) continue; + if (operationsRemaining <= 0) { + truncated = true; + break; + } + const filePath = path.join(directory, entry); + operationsRemaining -= 1; + const stats = yield* statOption(filePath); + if ( + Option.isSome(stats) && + stats.value.type === "File" && + Option.isSome(stats.value.mtime) + ) { + transcripts.push({ + filePath, + mtimeMs: stats.value.mtime.value.getTime(), + providerInstanceId, + size: Number(stats.value.size), + }); + } + } + } + } + } + return { transcripts, truncated }; + }, + ); + + const groupTranscriptsByCwd = Effect.fn("AgentSessionScanner.groupTranscriptsByCwd")(function* ( + source: AgentSessionSource, + transcripts: ReadonlyArray, + budget: MetadataReadBudget, + ) { + const byOwnerAndCwd = new Map< + string, + { + cwd: string; + providerInstanceId: ProviderInstanceId; + lastActiveAtMs: number; + transcripts: Array<{ filePath: string; mtimeMs: number }>; + } + >(); + + for (const transcript of transcripts) { + const cwd = yield* readCwd(transcript, budget); + if (cwd === null) continue; + const key = `${transcript.providerInstanceId}\0${cwd}`; + const existing = byOwnerAndCwd.get(key); + if (existing) { + existing.lastActiveAtMs = Math.max(existing.lastActiveAtMs, transcript.mtimeMs); + existing.transcripts.push(transcript); + } else { + byOwnerAndCwd.set(key, { + cwd, + providerInstanceId: transcript.providerInstanceId, + lastActiveAtMs: transcript.mtimeMs, + transcripts: [transcript], + }); + } + } + + return Array.from( + byOwnerAndCwd.values(), + (group): RawCandidate => ({ + cwd: group.cwd, + source, + providerInstanceId: group.providerInstanceId, + threadCount: group.transcripts.length, + lastActiveAtMs: group.lastActiveAtMs, + transcripts: group.transcripts, + }), + ); + }); + + const collectCandidates = Effect.fn("AgentSessionScanner.collectCandidates")(function* () { + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-settings", cause })), + ); + + const raw: Array = []; + let truncated = false; + + for (const source of ["claudeAgent", "codex"] as const) { + const instances: Array<{ + readonly instanceId: ProviderInstanceId; + readonly config: ProviderInstanceConfig; + }> = Object.entries(settings.providerInstances) + .filter( + ([, instance]) => instance.driver === source && resolveProviderInstanceEnabled(instance), + ) + .map(([instanceId, config]) => ({ + instanceId: ProviderInstanceId.make(instanceId), + config, + })); + if (!Object.hasOwn(settings.providerInstances, source)) { + const legacyInstance = { + instanceId: ProviderInstanceId.make(source), + config: { + driver: ProviderDriverKind.make(source), + config: settings.providers[source], + }, + }; + if (resolveProviderInstanceEnabled(legacyInstance.config)) { + instances.push(legacyInstance); + } + } + + // A shared home contains one copy of each session. Prefer the built-in + // instance as its owner, then keep configured order for custom accounts. + instances.sort((left, right) => { + const leftDefault = left.instanceId === source ? 0 : 1; + const rightDefault = right.instanceId === source ? 0 : 1; + return leftDefault - rightDefault; + }); + const homes: Array<{ homePath: string; providerInstanceId: ProviderInstanceId }> = []; + const seenHomes = new Set(); + for (const { instanceId, config: instance } of instances) { + const homeVariable = source === "claudeAgent" ? "CLAUDE_CONFIG_DIR" : "CODEX_HOME"; + const environmentHome = + instance.environment?.findLast((variable) => variable.name === homeVariable)?.value ?? + hostEnvironment[homeVariable]; + + let homePath: string; + if (source === "claudeAgent") { + const config = decodeClaudeSettings(instance.config ?? {}); + if (Option.isNone(config)) continue; + homePath = resolveClaudeConfigDir(config.value.homePath, environmentHome); + } else { + const config = decodeCodexSettings(instance.config ?? {}); + if (Option.isNone(config)) continue; + const codexSettings = + config.value.homePath.trim().length === 0 && + config.value.shadowHomePath.trim().length === 0 && + environmentHome?.trim() + ? { ...config.value, homePath: environmentHome } + : config.value; + const layout = yield* resolveCodexHomeLayout(codexSettings).pipe( + Effect.provideService(Path.Path, path), + ); + homePath = layout.sharedHomePath; + } + + const homeKey = `${source}\0${yield* directoryIdentity(homePath)}`; + if (seenHomes.has(homeKey)) continue; + seenHomes.add(homeKey); + homes.push({ homePath, providerInstanceId: instanceId }); + } + + const transcriptCandidates: Array = []; + const baseOperationBudget = Math.floor( + MAX_DISCOVERY_OPERATIONS_PER_SOURCE / Math.max(1, homes.length), + ); + const extraOperationBudgets = MAX_DISCOVERY_OPERATIONS_PER_SOURCE % Math.max(1, homes.length); + for (const [index, home] of homes.entries()) { + const operationBudget = baseOperationBudget + (index < extraOperationBudgets ? 1 : 0); + if (operationBudget === 0) { + truncated = true; + continue; + } + const discovered = yield* source === "claudeAgent" + ? discoverClaudeTranscripts(home.homePath, home.providerInstanceId, operationBudget) + : discoverCodexTranscripts(home.homePath, home.providerInstanceId, operationBudget); + truncated ||= discovered.truncated; + transcriptCandidates.push(...discovered.transcripts); + } + + transcriptCandidates.sort( + (left, right) => + right.mtimeMs - left.mtimeMs || left.filePath.localeCompare(right.filePath), + ); + if (transcriptCandidates.length > MAX_TRANSCRIPTS_PER_SOURCE) { + truncated = true; + } + // Give each account a turn before taking another file from the same home. + const selectedTranscripts = selectMetadataTranscripts(transcriptCandidates); + const metadataBudget: MetadataReadBudget = { + bytesRemaining: MAX_METADATA_BYTES_PER_SOURCE, + operationsRemaining: MAX_METADATA_OPERATIONS_PER_SOURCE, + recordsRemaining: MAX_METADATA_RECORDS_PER_SOURCE, + truncated: false, + }; + raw.push(...(yield* groupTranscriptsByCwd(source, selectedTranscripts, metadataBudget))); + truncated ||= metadataBudget.truncated; + } + + return { candidates: raw, truncated }; + }); + + let cachedCandidates: ReadonlyArray | null = null; + + const scan: AgentSessionScanner["Service"]["scan"] = Effect.gen(function* () { + const { candidates: raw, truncated } = yield* collectCandidates(); + cachedCandidates = raw; + + // Filesystem identity merges symlinks and case aliases without collapsing + // distinct case-sensitive directories. + const merged = new Map< + string, + { + path: string; + sources: Array; + threadCount: number; + lastActiveAtMs: number | null; + git: AgentSessionProjectGit | null; + } + >(); + const directoryKeys = new Map(); + const gitIdentities = new Map(); + + for (const candidate of raw) { + const expanded = expandHomePath(candidate.cwd.trim()); + if (!path.isAbsolute(expanded)) continue; + const resolved = path.resolve(expanded); + if (isExcludedProjectPath(resolved)) continue; + let key = directoryKeys.get(resolved); + if (key === undefined) { + const stats = yield* statOption(resolved); + // Directories that no longer exist can't be imported. + if (Option.isNone(stats) || stats.value.type !== "Directory") { + directoryKeys.set(resolved, ""); + continue; + } + const realPath = yield* fileSystem + .realPath(resolved) + .pipe(Effect.orElseSucceed(() => resolved)); + // A symlink can point into the worktrees directory even when its own + // spelling doesn't; check again with links resolved. + if (isExcludedProjectPath(realPath)) { + key = ""; + } else { + const gitIdentity = yield* readGitIdentity(resolved); + if (gitIdentity._tag === "Worktree") { + key = ""; + } else { + key = yield* directoryIdentity(resolved, stats.value); + gitIdentities.set(key, gitIdentity._tag === "Repository" ? gitIdentity.git : null); + } + } + directoryKeys.set(resolved, key); + } + if (key === "") continue; + + const existing = merged.get(key); + if (!existing) { + merged.set(key, { + path: resolved, + sources: [candidate.source], + threadCount: candidate.threadCount, + lastActiveAtMs: candidate.lastActiveAtMs, + git: gitIdentities.get(key) ?? null, + }); + continue; + } + if (!existing.sources.includes(candidate.source)) { + existing.sources.push(candidate.source); + } + existing.threadCount += candidate.threadCount; + existing.lastActiveAtMs = + existing.lastActiveAtMs === null || candidate.lastActiveAtMs === null + ? (existing.lastActiveAtMs ?? candidate.lastActiveAtMs) + : Math.max(existing.lastActiveAtMs, candidate.lastActiveAtMs); + } + + // Resolve persisted roots too. A project and a transcript can name + // different symlinks to the same directory. + const shellSnapshot = yield* projectService.snapshot.pipe( + Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })), + ); + const importedProjectsByRoot = new Map(); + for (const project of shellSnapshot.projects) { + const projectRoot = path.resolve(expandHomePath(project.workspaceRoot)); + importedProjectsByRoot.set(normalizeProjectPathForComparison(projectRoot), project); + importedProjectsByRoot.set(yield* directoryIdentity(projectRoot), project); + } + + const candidates: Array = []; + for (const [key, entry] of merged.entries()) { + // Keep the path key for missing roots and use filesystem identity for + // aliases that resolve to the same directory. + const importedProject = + importedProjectsByRoot.get(normalizeProjectPathForComparison(entry.path)) ?? + importedProjectsByRoot.get(key); + const candidatePath = importedProject?.workspaceRoot ?? entry.path; + candidates.push({ + path: candidatePath, + title: path.basename(candidatePath) || candidatePath, + ...(importedProject === undefined ? {} : { projectId: importedProject.id }), + sources: entry.sources, + threadCount: entry.threadCount, + lastActiveAt: + entry.lastActiveAtMs === null + ? null + : DateTime.formatIso(DateTime.makeUnsafe(entry.lastActiveAtMs)), + alreadyImported: importedProject !== undefined, + git: entry.git, + }); + } + + // Newest first, undated candidates last. + candidates.sort((left, right) => { + if (left.lastActiveAt === right.lastActiveAt) return left.path.localeCompare(right.path); + if (left.lastActiveAt === null) return 1; + if (right.lastActiveAt === null) return -1; + return right.lastActiveAt.localeCompare(left.lastActiveAt); + }); + + return { + candidates, + scannedAt: DateTime.formatIso(yield* DateTime.now), + ...(truncated ? { truncated: true } : {}), + }; + }); + + const prepareRecentThreads = Effect.fn("AgentSessionScanner.prepareRecentThreads")(function* ( + workspaceRoot: string, + completedSources: ReadonlyArray, + ) { + const root = path.resolve(expandHomePath(workspaceRoot)); + const realRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)); + if (isExcludedProjectPath(root) || isExcludedProjectPath(realRoot)) return Stream.empty; + const rootIdentity = yield* directoryIdentity(root); + const nowMs = DateTime.toEpochMillis(yield* DateTime.now); + const cutoffMs = nowMs - RECENT_THREAD_WINDOW_MS; + + const candidates = cachedCandidates ?? (yield* collectCandidates()).candidates; + cachedCandidates = candidates; + + const eligibleTranscripts: Array<{ + readonly candidate: RawCandidate; + readonly transcript: RawCandidate["transcripts"][number] & { readonly mtimeMs: number }; + }> = []; + for (const candidate of candidates) { + const expanded = expandHomePath(candidate.cwd.trim()); + if (!path.isAbsolute(expanded)) continue; + const resolved = path.resolve(expanded); + if ((yield* directoryIdentity(resolved)) !== rootIdentity) continue; + + for (const transcript of candidate.transcripts) { + if ( + transcript.mtimeMs === null || + transcript.mtimeMs < cutoffMs || + transcript.mtimeMs > nowMs + ) { + continue; + } + eligibleTranscripts.push({ + candidate, + transcript: { ...transcript, mtimeMs: transcript.mtimeMs }, + }); + } + } + + eligibleTranscripts.sort((left, right) => { + if (left.transcript.mtimeMs !== right.transcript.mtimeMs) { + return right.transcript.mtimeMs - left.transcript.mtimeMs; + } + return left.transcript.filePath.localeCompare(right.transcript.filePath); + }); + + const completedByFile = Map.groupBy( + completedSources, + (source) => `${source.providerInstanceId}\0${source.filePath}`, + ); + const importedSessions = new Set(); + let bytesRemaining = MAX_IMPORT_BYTES; + let transcriptsRemaining = MAX_IMPORT_TRANSCRIPTS; + let recordsRemaining = MAX_IMPORT_RECORDS; + return Stream.fromIteratorSucceed(eligibleTranscripts.values(), 1).pipe( + Stream.mapEffect(({ candidate, transcript }) => + Effect.gen(function* () { + const completed = completedByFile.get( + `${candidate.providerInstanceId}\0${transcript.filePath}`, + ); + if ( + completed === undefined && + (transcriptsRemaining === 0 || bytesRemaining === 0 || recordsRemaining === 0) + ) { + return Option.some({ _tag: "Skipped" }); + } + const stats = yield* statOption(transcript.filePath); + if (Option.isNone(stats) || stats.value.type !== "File") { + return Option.some({ _tag: "Skipped" }); + } + const identity = transcriptIdentity(transcript.filePath, stats.value); + const completedSource = completed?.find( + (source) => + source.provider === candidate.source && sameTranscriptIdentity(source, identity), + ); + if (completedSource !== undefined) { + const sessionKey = `${completedSource.providerInstanceId}\0${completedSource.providerSessionId}`; + if (importedSessions.has(sessionKey)) return Option.none(); + importedSessions.add(sessionKey); + return Option.some({ + _tag: "AlreadyImported", + source: completedSource, + }); + } + if ( + transcriptsRemaining === 0 || + recordsRemaining === 0 || + identity.size > MAX_IMPORTED_TRANSCRIPT_BYTES || + identity.size > bytesRemaining + ) { + return Option.some({ _tag: "Skipped" }); + } + // Reserve the whole file even if its read or parse fails. + transcriptsRemaining -= 1; + bytesRemaining -= identity.size; + const snapshot = yield* readTranscript( + transcript.filePath, + identity, + recordsRemaining, + candidate.source, + ); + if (snapshot === null) { + return Option.some({ _tag: "Skipped" }); + } + recordsRemaining -= snapshot.recordCount; + + // A stable replacement file can belong to a different project than the cached candidate. + let snapshotCwd: string | null = null; + for (const record of snapshot.records) { + snapshotCwd = extractDecodedCwd(record); + if (snapshotCwd !== null) break; + } + if (snapshotCwd === null) { + return Option.some({ _tag: "Skipped" }); + } + const expandedCwd = expandHomePath(snapshotCwd.trim()); + if ( + !path.isAbsolute(expandedCwd) || + (yield* directoryIdentity(path.resolve(expandedCwd))) !== rootIdentity + ) { + return Option.some({ _tag: "Skipped" }); + } + + const parsedThread = parseAgentSessionRecords( + { + source: candidate.source, + providerInstanceId: candidate.providerInstanceId, + fallbackSessionId: path.basename(transcript.filePath, ".jsonl"), + lastActiveAtMs: transcript.mtimeMs, + }, + snapshot.records, + ); + if (parsedThread === null) { + return Option.some({ _tag: "Skipped" }); + } + + const source: AgentSessionImportSource = { + ...identity, + provider: parsedThread.source, + providerInstanceId: parsedThread.providerInstanceId, + providerSessionId: parsedThread.providerSessionId, + }; + const sessionKey = `${parsedThread.providerInstanceId}\0${parsedThread.providerSessionId}`; + if (importedSessions.has(sessionKey)) { + return Option.some({ _tag: "Duplicate", source }); + } + importedSessions.add(sessionKey); + return Option.some({ + _tag: "Importable", + thread: parsedThread, + source, + }); + }).pipe(importReadLock.withPermits(1)), + ), + Stream.map(Option.toArray), + Stream.flattenIterable, + ); + }); + + const recentThreads: AgentSessionScanner["Service"]["recentThreads"] = ( + workspaceRoot, + completedSources = [], + ) => Stream.unwrap(prepareRecentThreads(workspaceRoot, completedSources)); + + return AgentSessionScanner.of({ scan, recentThreads }); +}); + +export const layer = Layer.effect(AgentSessionScanner, make); diff --git a/apps/server/src/project/ProjectAutoPull.test.ts b/apps/server/src/project/ProjectAutoPull.test.ts new file mode 100644 index 000000000..94b690507 --- /dev/null +++ b/apps/server/src/project/ProjectAutoPull.test.ts @@ -0,0 +1,96 @@ +import { assert, it } from "@effect/vitest"; +import { + DEFAULT_SERVER_SETTINGS, + GitManagerError, + ProjectId, + type Project, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { autoPullProjects } from "../serverRuntimeStartup.ts"; +import { ProjectService } from "./ProjectService.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; + +const project = (id: string, workspaceRoot: string): Project => ({ + id: ProjectId.make(id), + title: id, + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-09-10T00:00:00.000Z", + updatedAt: "2026-09-10T00:00:00.000Z", + deletedAt: null, +}); + +it.effect( + "startup deduplicates enabled workspaces, honors explicit off, and continues after failures", + () => { + const refreshed: string[] = []; + return autoPullProjects.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ServerSettingsService)({ + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + defaultAutoPull: true, + projectAutoPullOverrides: { [ProjectId.make("off")]: false }, + }), + }), + Layer.mock(ProjectService)({ + snapshot: Effect.succeed({ + projects: [ + project("one", "/same"), + project("two", "/same"), + project("bad", "/failed"), + project("off", "/off"), + project("three", "/next"), + ], + updatedAt: "2026-09-10T00:00:00.000Z", + }), + }), + Layer.mock(VcsStatusBroadcaster)({ + refreshStatus: (cwd) => + Effect.sync(() => { + refreshed.push(cwd); + }).pipe( + Effect.andThen( + cwd === "/failed" + ? Effect.fail( + new GitManagerError({ operation: "refresh", cwd, detail: "offline" }), + ) + : Effect.succeed({ + isRepo: false, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }), + ), + ), + }), + ), + ), + Effect.tap(() => + Effect.sync(() => assert.deepStrictEqual(refreshed.sort(), ["/failed", "/next", "/same"])), + ), + ); + }, +); + +it.effect("startup performs no project or Git reads when all automatic pulls are off", () => + autoPullProjects.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ServerSettingsService)({ getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS) }), + Layer.mock(ProjectService)({ snapshot: Effect.die("must not scan projects") }), + Layer.mock(VcsStatusBroadcaster)({ refreshStatus: () => Effect.die("must not read Git") }), + ), + ), + ), +); diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index c4a877edc..0a608198f 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -1,12 +1,19 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { CommandId, type Project, ProjectId, ProviderInstanceId } from "@t3tools/contracts"; +import { + CommandId, + type Project, + ProjectIconOverride, + ProjectId, + ProviderInstanceId, +} from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import { TestClock } from "effect/testing"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -59,6 +66,7 @@ const makeTestLayer = ( ); const TestLayer = makeTestLayer(metadataLayer); +const decodeProjectIconJson = Schema.decodeEffect(Schema.fromJsonString(ProjectIconOverride)); const waitForProject = Effect.fn("ProjectServiceTest.waitForProject")(function* ( service: ProjectService.ProjectService["Service"], @@ -74,6 +82,57 @@ const waitForProject = Effect.fn("ProjectServiceTest.waitForProject")(function* }); it.layer(TestLayer)("ProjectService", (it) => { + it.effect( + "persists project icons through updates and snapshots, preserving omitted values and clearing null", + () => + Effect.gen(function* () { + const service = yield* ProjectService.ProjectService; + const sql = yield* SqlClient.SqlClient; + const projectId = ProjectId.make("project:icons"); + yield* service.create({ + commandId: CommandId.make("icons:create"), + projectId, + title: "Icons", + workspaceRoot: "/work/icons", + }); + const icon = { kind: "lucide", name: "folder-code", color: "violet" } as const; + const updated = yield* service.update({ + commandId: CommandId.make("icons:set"), + projectId, + projectIcon: icon, + }); + assert.deepEqual(updated.projectIcon, icon); + const stored = yield* sql<{ + project_icon_json: string; + }>`SELECT project_icon_json FROM projection_projects WHERE project_id = ${projectId}`; + assert.deepEqual(yield* decodeProjectIconJson(stored[0]!.project_icon_json), icon); + yield* service.update({ + commandId: CommandId.make("icons:rename"), + projectId, + title: "Renamed", + }); + assert.deepEqual(Option.getOrThrow(yield* service.getById(projectId)).projectIcon, icon); + const emoji = { kind: "emoji", emoji: "🚀" } as const; + yield* service.update({ + commandId: CommandId.make("icons:emoji"), + projectId, + projectIcon: emoji, + }); + assert.deepEqual(Option.getOrThrow(yield* service.getById(projectId)).projectIcon, emoji); + yield* service.update({ + commandId: CommandId.make("icons:clear"), + projectId, + projectIcon: null, + }); + assert.isNull(Option.getOrThrow(yield* service.getById(projectId)).projectIcon); + const cleared = yield* sql<{ + project_icon_json: string | null; + }>`SELECT project_icon_json FROM projection_projects WHERE project_id = ${projectId}`; + assert.isNull(cleared[0]!.project_icon_json); + yield* service.delete({ commandId: CommandId.make("icons:delete"), projectId }); + }), + ); + it.effect("creates, updates, resolves, snapshots, and soft-deletes projects", () => Effect.gen(function* () { const service = yield* ProjectService.ProjectService; diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 1da7ac967..f461e47e1 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -4,6 +4,7 @@ import { ProjectId, type Project, type ProjectScript, + type ProjectIconOverride, type ProjectSnapshot, type ThreadEnvMode, } from "@t3tools/contracts"; @@ -37,6 +38,7 @@ export interface ProjectUpdateInput { readonly defaultModelSelection?: ModelSelection | null; readonly defaultThreadEnvMode?: ThreadEnvMode | null; readonly faviconPath?: string | null; + readonly projectIcon?: ProjectIconOverride | null; readonly scripts?: ReadonlyArray; } @@ -132,6 +134,7 @@ export const make = Effect.gen(function* () { workspaceRoot: row.workspaceRoot, repositoryIdentity: enrichment?.repositoryIdentity ?? null, faviconPath: row.faviconPath ?? enrichment?.faviconPath ?? null, + projectIcon: row.projectIcon ?? null, defaultModelSelection: row.defaultModelSelection, scripts: row.scripts, createdAt: row.createdAt, @@ -334,6 +337,7 @@ export const make = Effect.gen(function* () { ? {} : { defaultThreadEnvMode: input.defaultThreadEnvMode }), ...(input.faviconPath === undefined ? {} : { faviconPath: input.faviconPath }), + ...(input.projectIcon === undefined ? {} : { projectIcon: input.projectIcon }), ...(input.scripts === undefined ? {} : { scripts: [...input.scripts] }), }, (workspaceRoot === existing.value.workspaceRoot diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 734627df0..39bf073ce 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -1,79 +1,107 @@ import { assert, it, vi } from "@effect/vitest"; -import { ProjectId } from "@t3tools/contracts"; +import { DEFAULT_SERVER_SETTINGS, ProjectId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import { ServerSettingsService } from "../serverSettings.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as ProjectService from "./ProjectService.ts"; import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts"; -it.effect("resolves setup scripts through the standalone project service", () => { - const open = vi.fn((input: Parameters[0]) => - Effect.succeed({ - threadId: input.threadId, - terminalId: input.terminalId, - cwd: input.cwd, - worktreePath: input.worktreePath ?? null, - status: "running" as const, - pid: 123, - history: "", - exitCode: null, - exitSignal: null, - label: "Shell", +for (const mode of ["legacy", "inherit-empty", "inherit-reset", "disabled"] as const) { + it.effect(`resolves ${mode} setup actions through the standalone project service`, () => { + const open = vi.fn((input: Parameters[0]) => + Effect.succeed({ + threadId: input.threadId, + terminalId: input.terminalId, + cwd: input.cwd, + worktreePath: input.worktreePath ?? null, + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "Shell", + updatedAt: "2026-06-20T00:00:00.000Z", + }), + ); + const write = vi.fn( + (_input: Parameters[0]) => Effect.void, + ); + const projectId = ProjectId.make("project:setup-runner-v2"); + const project = { + id: projectId, + title: "Project", + workspaceRoot: "/repo", + repositoryIdentity: null, + faviconPath: null, + defaultModelSelection: null, + scripts: [ + { + id: "setup", + name: "Setup", + command: "vp install", + icon: "configure" as const, + runOnWorktreeCreate: true, + }, + ], + createdAt: "2026-06-20T00:00:00.000Z", updatedAt: "2026-06-20T00:00:00.000Z", - }), - ); - const write = vi.fn( - (_input: Parameters[0]) => Effect.void, - ); - const projectId = ProjectId.make("project:setup-runner-v2"); - const project = { - id: projectId, - title: "Project", - workspaceRoot: "/repo", - repositoryIdentity: null, - faviconPath: null, - defaultModelSelection: null, - scripts: [ - { - id: "setup", - name: "Setup", - command: "vp install", - icon: "configure" as const, - runOnWorktreeCreate: true, - }, - ], - createdAt: "2026-06-20T00:00:00.000Z", - updatedAt: "2026-06-20T00:00:00.000Z", - deletedAt: null, - }; - const layer = ProjectSetupScriptRunner.layer.pipe( - Layer.provide( - Layer.mergeAll( - Layer.mock(ProjectService.ProjectService)({ - getById: () => Effect.succeed(Option.some(project)), - }), - Layer.mock(TerminalManager.TerminalManager)({ open, write }), + deletedAt: null, + }; + const defaultScript = { + ...project.scripts[0]!, + id: "machine", + name: "Machine setup", + command: "echo setup", + }; + const settings = { + ...DEFAULT_SERVER_SETTINGS, + defaultProjectScripts: [defaultScript], + projectScriptOverrides: + mode === "inherit-reset" + ? { [projectId]: null } + : mode === "disabled" + ? { [projectId]: [] } + : {}, + }; + const currentProject = { ...project, scripts: mode === "inherit-empty" ? [] : project.scripts }; + const layer = ProjectSetupScriptRunner.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ProjectService.ProjectService)({ + getById: () => Effect.succeed(Option.some(currentProject)), + }), + Layer.mock(TerminalManager.TerminalManager)({ open, write }), + Layer.mock(ServerSettingsService)({ getSettings: Effect.succeed(settings) }), + ), ), - ), - ); + ); - return Effect.gen(function* () { - const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; - const result = yield* runner.runForThread({ - threadId: "thread-1", - projectId, - worktreePath: "/repo-worktree", - }); - assert.deepEqual(result, { - status: "started", - scriptId: "setup", - scriptName: "Setup", - terminalId: "setup-setup", - cwd: "/repo-worktree", - }); - assert.equal(open.mock.calls[0]?.[0].cwd, "/repo-worktree"); - assert.equal(write.mock.calls[0]?.[0].data, "vp install\r"); - }).pipe(Effect.provide(layer)); -}); + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const result = yield* runner.runForThread({ + threadId: "thread-1", + projectId, + worktreePath: "/repo-worktree", + }); + if (mode === "disabled") { + assert.deepEqual(result, { status: "no-script" }); + assert.equal(open.mock.calls.length, 0); + assert.equal(write.mock.calls.length, 0); + return; + } + const expected = mode === "legacy" ? project.scripts[0]! : defaultScript; + assert.deepEqual(result, { + status: "started", + scriptId: expected.id, + scriptName: expected.name, + terminalId: `setup-${expected.id}`, + cwd: "/repo-worktree", + }); + assert.equal(open.mock.calls[0]?.[0].cwd, "/repo-worktree"); + assert.equal(write.mock.calls[0]?.[0].data, `${expected.command}\r`); + }).pipe(Effect.provide(layer)); + }); +} diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index eb997265e..713ddf7a7 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -1,5 +1,9 @@ import { ProjectId, type ProjectScript } from "@t3tools/contracts"; -import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts"; +import { + projectScriptRuntimeEnv, + resolveProjectScripts, + setupProjectScript, +} from "@t3tools/shared/projectScripts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -7,6 +11,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as TerminalManager from "../terminal/Manager.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; import * as ProjectService from "./ProjectService.ts"; export interface ProjectSetupScriptRunnerResultNoScript { @@ -32,6 +37,7 @@ export interface ProjectSetupScriptRunnerInput { readonly worktreePath: string; readonly preferredTerminalId?: string; readonly project?: { + readonly id: ProjectId; readonly workspaceRoot: string; readonly scripts: ReadonlyArray; }; @@ -44,7 +50,7 @@ export class ProjectSetupScriptOperationError extends Schema.TaggedErrorClass + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "readSettings", + cause, + }), + ), + ); + const script = setupProjectScript(resolveProjectScripts(settings, project)); if (!script) { return { status: "no-script", diff --git a/apps/server/src/project/ProjectTeardownScriptRunner.test.ts b/apps/server/src/project/ProjectTeardownScriptRunner.test.ts new file mode 100644 index 000000000..0005f1727 --- /dev/null +++ b/apps/server/src/project/ProjectTeardownScriptRunner.test.ts @@ -0,0 +1,80 @@ +import { assert, it, vi } from "@effect/vitest"; +import { + DEFAULT_SERVER_SETTINGS, + ProjectId, + type Project, + type ProjectScript, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { ProcessRunner } from "../processRunner.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { ProjectService } from "./ProjectService.ts"; +import * as Runner from "./ProjectTeardownScriptRunner.ts"; + +for (const disabled of [false, true]) { + it.effect(`resolves inherited teardown before worktree removal (disabled=${disabled})`, () => { + const id = ProjectId.make("teardown-project"); + const project: Project = { + id, + title: "Project", + workspaceRoot: "/repo", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-09-10T00:00:00.000Z", + updatedAt: "2026-09-10T00:00:00.000Z", + deletedAt: null, + }; + const action: ProjectScript = { + id: "teardown", + name: "Teardown", + command: "echo teardown", + icon: "configure", + runOnWorktreeCreate: false, + runOnWorktreeDelete: true, + }; + const run = vi.fn((_input: Parameters[0]) => + Effect.succeed({ + code: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }), + ); + const layer = Runner.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ProjectService)({ + getByWorkspaceRoot: () => Effect.succeed(Option.some(project)), + }), + Layer.mock(ServerSettingsService)({ + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + defaultProjectScripts: [action], + projectScriptOverrides: disabled ? { [id]: [] } : {}, + }), + }), + Layer.mock(ProcessRunner)({ run }), + FileSystem.layerNoop({ exists: () => Effect.succeed(true) }), + ), + ), + ); + return Effect.gen(function* () { + const runner = yield* Runner.ProjectTeardownScriptRunner; + yield* runner.runForWorktree({ projectCwd: "/repo", worktreePath: "/repo-worktree" }); + assert.equal(run.mock.calls.length, disabled ? 0 : 1); + if (!disabled) { + assert.equal(run.mock.calls[0]?.[0].cwd, "/repo-worktree"); + assert.isTrue(run.mock.calls[0]?.[0].args?.includes("echo teardown")); + assert.equal(run.mock.calls[0]?.[0].timeoutBehavior, "timedOutResult"); + } + }).pipe(Effect.provide(layer)); + }); +} diff --git a/apps/server/src/project/ProjectTeardownScriptRunner.ts b/apps/server/src/project/ProjectTeardownScriptRunner.ts index de22b7f28..9f58f0f14 100644 --- a/apps/server/src/project/ProjectTeardownScriptRunner.ts +++ b/apps/server/src/project/ProjectTeardownScriptRunner.ts @@ -10,7 +10,11 @@ * @module ProjectTeardownScriptRunner */ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { projectScriptRuntimeEnv, teardownProjectScript } from "@t3tools/shared/projectScripts"; +import { + projectScriptRuntimeEnv, + resolveProjectScripts, + teardownProjectScript, +} from "@t3tools/shared/projectScripts"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -19,6 +23,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { ProcessRunner } from "../processRunner.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; import * as ProjectService from "./ProjectService.ts"; const TEARDOWN_SCRIPT_TIMEOUT = Duration.minutes(2); @@ -45,6 +50,7 @@ export class ProjectTeardownScriptRunner extends Context.Service< export const make = Effect.gen(function* () { const projects = yield* ProjectService.ProjectService; + const serverSettings = yield* ServerSettingsService; const processRunner = yield* ProcessRunner; const fileSystem = yield* FileSystem.FileSystem; @@ -58,7 +64,8 @@ export const make = Effect.gen(function* () { if (!project) { return; } - const script = teardownProjectScript(project.scripts); + const settings = yield* serverSettings.getSettings; + const script = teardownProjectScript(resolveProjectScripts(settings, project)); if (!script) { return; } diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 50608e770..755008f6d 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -25,7 +25,10 @@ export interface RepositoryIdentityResolverOptions { export class RepositoryIdentityResolver extends Context.Service< RepositoryIdentityResolver, { - readonly resolve: (cwd: string) => Effect.Effect; + readonly resolve: ( + cwd: string, + options?: { readonly refresh?: boolean }, + ) => Effect.Effect; } >()("t3/project/RepositoryIdentityResolver") {} @@ -90,7 +93,6 @@ function buildRepositoryIdentity(input: { const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")( function* (cwd: string) { const processRunner = yield* ProcessRunner.ProcessRunner; - let cacheKey = cwd; // git is a real executable on every platform — no cmd.exe shell mode, which // would split paths containing spaces during cmd's re-tokenization. @@ -102,15 +104,11 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver. }) .pipe(Effect.option); if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) { - return cacheKey; + return null; } const candidate = topLevelResult.value.stdout.trim(); - if (candidate.length > 0) { - cacheKey = candidate; - } - - return cacheKey; + return candidate.length > 0 ? candidate : null; }, ); @@ -139,6 +137,22 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( options: RepositoryIdentityResolverOptions = {}, ) { const processRunner = yield* ProcessRunner.ProcessRunner; + const cacheCapacity = options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY; + + const repositoryRootCache = yield* Cache.makeWith( + (cwd) => + resolveRepositoryIdentityCacheKey(cwd).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + ), + { + capacity: cacheCapacity, + timeToLive: Exit.match({ + onSuccess: (value) => + value === null ? Duration.zero : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL), + onFailure: () => Duration.zero, + }), + }, + ); const repositoryIdentityCache = yield* Cache.makeWith( (cacheKey) => @@ -146,7 +160,7 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( Effect.provideService(ProcessRunner.ProcessRunner, processRunner), ), { - capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY, + capacity: cacheCapacity, timeToLive: Exit.match({ onSuccess: (value) => value === null @@ -159,10 +173,11 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn( "RepositoryIdentityResolver.resolve", - )(function* (cwd) { - const cacheKey = yield* resolveRepositoryIdentityCacheKey(cwd).pipe( - Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - ); + )(function* (cwd, options) { + if (options?.refresh) yield* Cache.invalidate(repositoryRootCache, cwd); + const cacheKey = yield* Cache.get(repositoryRootCache, cwd); + if (cacheKey === null) return null; + if (options?.refresh) yield* Cache.invalidate(repositoryIdentityCache, cacheKey); return yield* Cache.get(repositoryIdentityCache, cacheKey); }); diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts new file mode 100644 index 000000000..28cef6178 --- /dev/null +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import { compileClaudeModelSelection } from "../claudeModelOptions.ts"; +import { applyManifestDefault, BUNDLED_MODEL_MANIFEST } from "./ModelManifest.ts"; +import { + BUNDLED_CLAUDE_MODEL_CATALOG as bundled, + getClaudeCatalogModelCapabilities, + resolveClaudeModelsForVersion, + resolveClaudeModelSlug, + scopeClaudeModelCatalog, +} from "./ClaudeModelCatalog.ts"; + +const instanceId = ProviderInstanceId.make("claude-test"); + +describe("Claude manifest catalog", () => { + it("advertises Fable 5.1 only on a supported CLI and carries its badge and aliases", () => { + expect( + resolveClaudeModelsForVersion(bundled, "2.1.256").some( + (model) => model.slug === "claude-fable-5-1", + ), + ).toBe(false); + const model = resolveClaudeModelsForVersion(bundled, "2.1.257").find( + (model) => model.slug === "claude-fable-5-1", + ); + expect(model).toMatchObject({ + badge: "new", + aliases: ["fable", "fable-5.1", "claude-fable-5.1"], + }); + expect(resolveClaudeModelSlug(bundled, "FABLE")).toBe("claude-fable-5-1"); + expect( + resolveClaudeModelsForVersion(bundled, null).some( + (model) => model.slug === "claude-fable-5-1", + ), + ).toBe(false); + }); + + it("compiles the new model's effort profile and the fork's real compaction threshold", () => { + expect( + compileClaudeModelSelection( + { + instanceId, + model: "fable", + options: [ + { id: "effort", value: "ultracode" }, + { id: "autoCompactWindow", value: "500k" }, + ], + }, + bundled, + ), + ).toMatchObject({ + apiModelId: "claude-fable-5-1[1m]", + effort: "xhigh", + settings: { ultracode: true }, + autoCompactWindow: 500_000, + }); + const descriptors = getClaudeCatalogModelCapabilities(bundled, "fable").optionDescriptors; + expect(descriptors?.some((option) => option.id === "contextWindow")).toBe(false); + expect(descriptors?.some((option) => option.id === "autoCompactWindow")).toBe(true); + }); + + it("keeps a custom alias opaque and uses its own descriptors without built-in mappings", () => { + const catalog = scopeClaudeModelCatalog(bundled, [ + { + slug: "fable", + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Effort", + type: "select", + options: [{ id: "custom", label: "Custom", isDefault: true }], + }, + ], + }, + }, + ]); + expect(resolveClaudeModelSlug(catalog, "fable")).toBe("fable"); + expect(compileClaudeModelSelection({ instanceId, model: "fable" }, catalog)).toMatchObject({ + apiModelId: "fable", + effort: "custom", + autoCompactWindow: undefined, + }); + expect(resolveClaudeModelSlug(scopeClaudeModelCatalog(bundled, ["fable"]), "fable")).toBe( + "fable", + ); + }); + + it("assigns a manifest default even when discovery did not supply a previous default", () => { + const models = [ + { slug: "claude-sonnet-5", name: "Sonnet", isCustom: false, capabilities: null }, + ]; + expect( + applyManifestDefault( + models, + BUNDLED_MODEL_MANIFEST, + ProviderDriverKind.make("claudeAgent"), + )[0]?.isDefault, + ).toBe(true); + }); +}); diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts new file mode 100644 index 000000000..9d9686ca1 --- /dev/null +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -0,0 +1,335 @@ +import { + type CustomModelSetting, + type ModelCapabilities, + type ModelSelection, + ProviderDriverKind, + type ServerProviderModel, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { + getModelSelectionStringOptionValue, + getProviderOptionCurrentValue, + getProviderOptionDescriptors, + readCustomModelEntries, +} from "@t3tools/shared/model"; +import { buildSelectOptionDescriptor } from "./providerSnapshot.ts"; +import { compareSemverVersions } from "@t3tools/shared/semver"; + +import { + type ClaudeCodeCompatibility, + type ClaudeCodeProfile, + decodeClaudeModelAdapter, + decodeClaudeProfileAdapter, +} from "./ClaudeModelManifest.ts"; +import { + BUNDLED_MODEL_MANIFEST, + type ModelManifestData, + resolveProviderCatalog, +} from "./ModelManifest.ts"; + +const CLAUDE = ProviderDriverKind.make("claudeAgent"); +const EMPTY_CAPABILITIES: ModelCapabilities = { optionDescriptors: [] }; + +export interface ClaudeCatalogModel { + readonly model: ServerProviderModel; + readonly runtime: ClaudeCodeProfile; + readonly compatibility: ClaudeCodeCompatibility; +} + +export interface ClaudeModelCatalog { + readonly models: ReadonlyArray; +} + +const CLAUDE_AUTO_COMPACT_CHOICES = [ + { value: "250k", label: "250K", tokens: 250_000, isDefault: false }, + { value: "500k", label: "500K", tokens: 500_000, isDefault: false }, + { value: "750k", label: "750K", tokens: 750_000, isDefault: false }, + { value: "1m", label: "1M", tokens: 1_000_000, isDefault: true }, +] as const; + +export const CLAUDE_AUTO_COMPACT_OPTION_ID = "autoCompactWindow"; + +/** The top stop means "no cap of ours"; the adapter sends nothing for it. */ +const CLAUDE_AUTO_COMPACT_UNCAPPED_TOKENS = 1_000_000; + +function buildClaudeAutoCompactDescriptor() { + return buildSelectOptionDescriptor({ + id: CLAUDE_AUTO_COMPACT_OPTION_ID, + label: "Context", + description: + "Claude summarizes the conversation once it passes this much context. The model's own window stays at 1M.", + presentation: "slider", + options: CLAUDE_AUTO_COMPACT_CHOICES.map((choice) => ({ + value: choice.value, + label: choice.label, + ...(choice.isDefault ? { isDefault: true } : {}), + })), + }); +} + +// These models have a native 1M window; retain their bare SDK identifiers. +const NATIVE_1M_MODELS = new Set([ + "claude-fable-5", + "claude-opus-5", + "claude-opus-4-8", + "claude-opus-4-7", + "claude-sonnet-5", +]); + +function maximumContextTokens(runtime: ClaudeCodeProfile): number | undefined { + const values = Object.values(runtime.contextWindowTokens ?? {}).filter( + (value) => Number.isFinite(value) && value > 0, + ); + return runtime.fixedContextWindowTokens ?? (values.length ? Math.max(...values) : undefined); +} + +function withCompactionControl( + model: ServerProviderModel, + runtime: ClaudeCodeProfile, +): ServerProviderModel { + const descriptors = (model.capabilities?.optionDescriptors ?? []).filter( + (descriptor) => + descriptor.id !== "contextWindow" && descriptor.id !== CLAUDE_AUTO_COMPACT_OPTION_ID, + ); + return { + ...model, + capabilities: { + ...model.capabilities, + optionDescriptors: [ + ...descriptors, + ...((maximumContextTokens(runtime) ?? 0) >= 1_000_000 + ? [buildClaudeAutoCompactDescriptor()] + : []), + ], + }, + }; +} + +export function resolveClaudeCatalogAutoCompactTokens( + catalog: ClaudeModelCatalog, + selection: ModelSelection, +): number | undefined { + const raw = getModelSelectionStringOptionValue(selection, CLAUDE_AUTO_COMPACT_OPTION_ID); + const descriptors = getProviderOptionDescriptors({ + caps: getClaudeCatalogModelCapabilities(catalog, selection.model), + ...(raw ? { selections: [{ id: CLAUDE_AUTO_COMPACT_OPTION_ID, value: raw }] } : {}), + }); + const value = getProviderOptionCurrentValue( + descriptors.find((option) => option.id === CLAUDE_AUTO_COMPACT_OPTION_ID), + ); + const tokens = CLAUDE_AUTO_COMPACT_CHOICES.find((choice) => choice.value === value)?.tokens; + return tokens === undefined || tokens >= CLAUDE_AUTO_COMPACT_UNCAPPED_TOKENS ? undefined : tokens; +} + +function tryResolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeModelCatalog | null { + const resolved = resolveProviderCatalog(manifest, CLAUDE); + if (!resolved) return null; + + const models: Array = []; + for (const entry of resolved.models) { + const profile = decodeClaudeProfileAdapter(entry.profileAdapter ?? {}); + const adapter = decodeClaudeModelAdapter(entry.adapter ?? {}); + if (Option.isNone(profile) || Option.isNone(adapter)) return null; + models.push({ + model: withCompactionControl(entry.model, profile.value.claudeCode ?? {}), + runtime: profile.value.claudeCode ?? {}, + compatibility: adapter.value.claudeCode ?? {}, + }); + } + + return { + models, + }; +} + +export function resolveClaudeModelCatalog(manifest: ModelManifestData): ClaudeModelCatalog { + return ( + tryResolveClaudeModelCatalog(manifest) ?? + tryResolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST) ?? { + models: [], + } + ); +} + +export const BUNDLED_CLAUDE_MODEL_CATALOG = resolveClaudeModelCatalog(BUNDLED_MODEL_MANIFEST); + +/** + * Scope the catalog to one instance's settings: custom model slugs stay opaque + * (a built-in alias they shadow is dropped, canonical slugs and capabilities + * are preserved), and custom entries that declare their own capabilities are + * appended so the adapter resolves effort / fast mode / thinking against the + * user's descriptors instead of the empty default. Custom entries carry no + * runtime profile, so option values pass through to Claude Code verbatim. + */ +export function scopeClaudeModelCatalog( + catalog: ClaudeModelCatalog, + customModels: ReadonlyArray, +): ClaudeModelCatalog { + const customEntries = readCustomModelEntries(customModels); + if (customEntries.length === 0) return catalog; + const customAliases = new Set(customEntries.map((entry) => entry.slug.toLowerCase())); + + const builtInModels = catalog.models.map((entry) => { + if (!entry.model.aliases?.some((alias) => customAliases.has(alias.toLowerCase()))) { + return entry; + } + return { + ...entry, + model: { + ...entry.model, + aliases: entry.model.aliases.filter((alias) => !customAliases.has(alias.toLowerCase())), + }, + }; + }); + const builtInSlugs = new Set(builtInModels.map((entry) => entry.model.slug)); + const customCatalogModels: Array = []; + for (const entry of customEntries) { + if (!entry.capabilities || builtInSlugs.has(entry.slug)) continue; + customCatalogModels.push({ + model: { + slug: entry.slug, + name: entry.name, + isCustom: true, + capabilities: entry.capabilities, + }, + runtime: {}, + compatibility: {}, + }); + } + + return { models: [...builtInModels, ...customCatalogModels] }; +} + +function resolveClaudeCatalogModel( + catalog: ClaudeModelCatalog, + slugOrAlias: string | null | undefined, +): ClaudeCatalogModel | undefined { + const value = slugOrAlias?.trim(); + if (!value) return undefined; + return ( + catalog.models.find((entry) => entry.model.slug === value) ?? + catalog.models.find((entry) => + entry.model.aliases?.some((alias) => alias.toLowerCase() === value.toLowerCase()), + ) + ); +} + +export function resolveClaudeModelSlug(catalog: ClaudeModelCatalog, slugOrAlias: string): string { + return resolveClaudeCatalogModel(catalog, slugOrAlias)?.model.slug ?? slugOrAlias; +} + +export function getClaudeCatalogModelCapabilities( + catalog: ClaudeModelCatalog, + slugOrAlias: string | null | undefined, +): ModelCapabilities { + return resolveClaudeCatalogModel(catalog, slugOrAlias)?.model.capabilities ?? EMPTY_CAPABILITIES; +} + +function isVersionSupported( + compatibility: ClaudeCodeCompatibility, + version: string | null | undefined, +): boolean { + if (!compatibility.minVersion && !compatibility.maxVersionExclusive) return true; + if (!version) return false; + if (compatibility.minVersion && compareSemverVersions(version, compatibility.minVersion) < 0) { + return false; + } + return !( + compatibility.maxVersionExclusive && + compareSemverVersions(version, compatibility.maxVersionExclusive) >= 0 + ); +} + +export function resolveClaudeModelsForVersion( + catalog: ClaudeModelCatalog, + version: string | null | undefined, +): ReadonlyArray { + return catalog.models + .filter((entry) => isVersionSupported(entry.compatibility, version)) + .map((entry) => entry.model); +} + +export function formatClaudeVersionUpgradeMessage( + catalog: ClaudeModelCatalog, + version: string | null, +): string | undefined { + const unavailable = catalog.models + .filter( + (entry) => + entry.compatibility.minVersion && + (!version || compareSemverVersions(version, entry.compatibility.minVersion) < 0), + ) + .toSorted((left, right) => + compareSemverVersions(left.compatibility.minVersion!, right.compatibility.minVersion!), + )[0]; + if (!unavailable?.compatibility.minVersion) return undefined; + const versionLabel = version ? `v${version}` : "the installed version"; + return `Claude Code ${versionLabel} is too old for ${unavailable.model.name}. Upgrade to v${unavailable.compatibility.minVersion} or newer to access it.`; +} + +export function resolveClaudeCatalogEffort( + catalog: ClaudeModelCatalog, + model: string | null | undefined, + raw: string | null | undefined, +): string | undefined { + const caps = getClaudeCatalogModelCapabilities(catalog, model); + const descriptors = getProviderOptionDescriptors({ + caps, + ...(raw ? { selections: [{ id: "effort", value: raw }] } : {}), + }); + const descriptor = descriptors.find((candidate) => candidate.id === "effort"); + const value = getProviderOptionCurrentValue(descriptor); + return typeof value === "string" ? value : undefined; +} + +export function normalizeClaudeCatalogEffort( + catalog: ClaudeModelCatalog, + effort: string | null | undefined, + model: string | null | undefined, +): string | undefined { + if (!effort) return undefined; + const effortMap = resolveClaudeCatalogModel(catalog, model)?.runtime.effortMap; + if (!effortMap || !Object.prototype.hasOwnProperty.call(effortMap, effort)) return effort; + return effortMap[effort] ?? undefined; +} + +export function isClaudeCatalogUltracodeEffort(effort: string | null | undefined): boolean { + return effort === "ultracode"; +} + +export function resolveClaudeCatalogApiModelId( + catalog: ClaudeModelCatalog, + modelSelection: ModelSelection, +): string { + const entry = resolveClaudeCatalogModel(catalog, modelSelection.model); + const slug = entry?.model.slug ?? modelSelection.model; + const descriptors = getProviderOptionDescriptors({ + caps: entry?.model.capabilities ?? EMPTY_CAPABILITIES, + selections: modelSelection.options, + }); + for (const [optionId, suffixes] of Object.entries(entry?.runtime.modelSuffixes ?? {})) { + if (optionId === "contextWindow") { + if (NATIVE_1M_MODELS.has(slug)) continue; + const windows = Object.entries(entry?.runtime.contextWindowTokens ?? {}).toSorted( + (a, b) => b[1] - a[1], + ); + const largest = windows[0]?.[0]; + if (largest && suffixes[largest]) return `${slug}${suffixes[largest]}`; + continue; + } + const value = getProviderOptionCurrentValue( + descriptors.find((descriptor) => descriptor.id === optionId), + ); + if (typeof value === "string" && suffixes[value]) return `${slug}${suffixes[value]}`; + } + return slug; +} + +export function resolveClaudeCatalogContextWindowTokens( + catalog: ClaudeModelCatalog, + modelSelection: ModelSelection | undefined, +): number | undefined { + const entry = resolveClaudeCatalogModel(catalog, modelSelection?.model); + if (!entry) return undefined; + return maximumContextTokens(entry.runtime); +} diff --git a/apps/server/src/provider/ClaudeModelManifest.ts b/apps/server/src/provider/ClaudeModelManifest.ts new file mode 100644 index 000000000..1bac30b2c --- /dev/null +++ b/apps/server/src/provider/ClaudeModelManifest.ts @@ -0,0 +1,82 @@ +import { TrimmedNonEmptyString } from "@t3tools/contracts"; +import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +export const ClaudeCodeProfileSchema = Schema.Struct({ + effortMap: Schema.optional( + Schema.Record(TrimmedNonEmptyString, Schema.NullOr(TrimmedNonEmptyString)), + ), + modelSuffixes: Schema.optional( + Schema.Record( + TrimmedNonEmptyString, + Schema.Record(TrimmedNonEmptyString, TrimmedNonEmptyString), + ), + ), + contextWindowTokens: Schema.optional(Schema.Record(TrimmedNonEmptyString, Schema.Number)), + fixedContextWindowTokens: Schema.optional(Schema.Number), +}); + +export const ClaudeProfileAdapterSchema = Schema.Struct({ + claudeCode: Schema.optional(ClaudeCodeProfileSchema), +}); + +const ClaudeVersionSchema = TrimmedNonEmptyString.pipe( + Schema.check( + Schema.makeFilter((version) => parseSemver(version) !== null, { + expected: "a supported semantic version", + }), + ), +); + +const ClaudeCodeCompatibilitySchema = Schema.Struct({ + minVersion: Schema.optional(ClaudeVersionSchema), + maxVersionExclusive: Schema.optional(ClaudeVersionSchema), +}).pipe( + Schema.check( + Schema.makeFilter( + ({ minVersion, maxVersionExclusive }) => + minVersion === undefined || + maxVersionExclusive === undefined || + compareSemverVersions(minVersion, maxVersionExclusive) < 0, + { expected: "a minimum version below the exclusive maximum version" }, + ), + ), +); + +export const ClaudeModelAdapterSchema = Schema.Struct({ + claudeCode: Schema.optional(ClaudeCodeCompatibilitySchema), +}); + +export type ClaudeCodeProfile = typeof ClaudeCodeProfileSchema.Type; +export type ClaudeCodeCompatibility = NonNullable; + +export const decodeClaudeProfileAdapter = Schema.decodeUnknownOption(ClaudeProfileAdapterSchema); +export const decodeClaudeModelAdapter = Schema.decodeUnknownOption(ClaudeModelAdapterSchema); + +interface ClaudeManifestAdapterInput { + readonly providers?: + | Readonly< + Record< + string, + | { + readonly profiles: Readonly>; + readonly models: ReadonlyArray<{ readonly adapter?: unknown }>; + } + | undefined + > + > + | undefined; +} + +export function hasValidClaudeManifestAdapters(manifest: ClaudeManifestAdapterInput): boolean { + const catalog = manifest.providers?.claudeAgent; + if (!catalog) return true; + + return ( + Object.values(catalog.profiles).every((profile) => + Option.isSome(decodeClaudeProfileAdapter(profile.adapter ?? {})), + ) && + catalog.models.every((model) => Option.isSome(decodeClaudeModelAdapter(model.adapter ?? {}))) + ); +} diff --git a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts index ad136beb8..bd32f8d66 100644 --- a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -1,3 +1,4 @@ +import { providerModelsFromSettings } from "../providerSnapshot.ts"; import { AcpRegistrySettings, ProviderDriverKind, @@ -51,7 +52,6 @@ const makeSnapshot = (input: { readonly continuationKey: string; readonly checkedAt: string; }): ServerProvider => { - const modelIds = Array.from(new Set(["default", ...input.settings.customModels])); return { instanceId: input.instanceId, driver: DRIVER_KIND, @@ -64,12 +64,11 @@ const makeSnapshot = (input: { status: input.enabled ? "ready" : "disabled", auth: { status: "unknown" }, checkedAt: input.checkedAt, - models: modelIds.map((model) => ({ - slug: model, - name: model, - isCustom: model !== "default", - capabilities: null, - })), + models: providerModelsFromSettings( + [{ slug: "default", name: "default", isCustom: false, capabilities: null }], + input.settings.customModels, + { optionDescriptors: [] }, + ), slashCommands: [], skills: [], }; diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index dc6cdf9f8..e5102528f 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -1,3 +1,6 @@ +import { applyClaudeRateLimitEvent, ClaudeUsageLimitListener } from "../providerUsageLimits.ts"; +import * as DateTime from "effect/DateTime"; +import { resolveClaudeModelCatalog, scopeClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; /** * ClaudeDriver — `ProviderDriver` for the Claude Agent SDK runtime. * @@ -147,25 +150,18 @@ export const ClaudeDriver: ProviderDriver = { continuationGroupKey, }); - const orchestrationAdapter = yield* ClaudeAdapterV2Driver.create({ - instanceId, - displayName, - accentColor, - environment, - enabled, - config, - }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build Claude orchestration adapter.", - cause, - }), + const textGeneration = yield* makeClaudeTextGeneration( + effectiveConfig, + processEnv, + modelManifest.current.pipe( + Effect.map((manifest) => + scopeClaudeModelCatalog( + resolveClaudeModelCatalog(manifest), + effectiveConfig.customModels, + ), + ), ), ); - const textGeneration = yield* makeClaudeTextGeneration(effectiveConfig, processEnv); // Per-instance capabilities cache: keyed on binary + resolved HOME so // account-specific probes never share auth metadata across instances. @@ -184,17 +180,18 @@ export const ClaudeDriver: ProviderDriver = { // provider check. A refresh that lands mid-probe applies on the next one. const checkProvider = modelManifest.refreshInBackground.pipe( Effect.andThen( - Effect.zipWith( + Effect.flatMap(modelManifest.current, (manifest) => checkClaudeProviderStatus( effectiveConfig, () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), processEnv, cwd, + resolveClaudeModelCatalog(manifest), + ).pipe( + Effect.map((draft) => + stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), + ), ), - modelManifest.current, - (draft, manifest) => - stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), - { concurrent: true }, ), ), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), @@ -209,11 +206,12 @@ export const ClaudeDriver: ProviderDriver = { streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - Effect.zipWith( - makePendingClaudeProvider(settings.provider), - modelManifest.current, - (draft, manifest) => - stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), + Effect.flatMap(modelManifest.current, (manifest) => + makePendingClaudeProvider(settings.provider, resolveClaudeModelCatalog(manifest)).pipe( + Effect.map((draft) => + stampIdentity(ModelManifest.applyModelManifest(draft, manifest, DRIVER_KIND)), + ), + ), ), checkProvider, enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => @@ -235,6 +233,33 @@ export const ClaudeDriver: ProviderDriver = { ), ); + const orchestrationAdapter = yield* ClaudeAdapterV2Driver.create({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.provideService(ClaudeUsageLimitListener, { + publish: (info) => + Effect.gen(function* () { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + yield* snapshot.updateUsageLimits((previous) => + applyClaudeRateLimitEvent(previous, info, checkedAt), + ); + }), + }), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Claude orchestration adapter.", + cause, + }), + ), + ); return { instanceId, driverKind: DRIVER_KIND, diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index fad60bef4..4ba4e614b 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -1,3 +1,5 @@ +import { applyCodexRateLimitEvent, CodexUsageLimitListener } from "../providerUsageLimits.ts"; +import * as DateTime from "effect/DateTime"; /** * CodexDriver — first concrete `ProviderDriver` in the new per-instance model. * @@ -39,7 +41,11 @@ import { } from "../../orchestration-v2/Adapters/CodexAdapterV2.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; -import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; +import { + checkCodexProviderStatus, + makePendingCodexProvider, + withCodexAppServerClient, +} from "../Layers/CodexProvider.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import * as ModelManifest from "../ModelManifest.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; @@ -60,6 +66,11 @@ import { materializeCodexShadowHome, resolveCodexHomeLayout, } from "./CodexHomeLayout.ts"; +import { + CodexResetCreditCoordinator, + CODEX_RESET_CREDIT_TIMEOUT, +} from "../Layers/codexResetCredit.ts"; +import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts"; const decodeCodexSettings = Schema.decodeSync(CodexSettings); const DRIVER_KIND = ProviderDriverKind.make("codex"); @@ -77,6 +88,7 @@ const UPDATE = makePackageManagedProviderMaintenanceResolver({ */ export type CodexDriverEnv = | CodexAdapterV2DriverEnv + | CodexResetCreditCoordinator | BackgroundPolicy.BackgroundPolicy | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto @@ -119,6 +131,7 @@ export const CodexDriver: ProviderDriver = { defaultConfig: (): CodexSettings => decodeCodexSettings({}), create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { + const resetCreditCoordinator = yield* CodexResetCreditCoordinator; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; @@ -153,24 +166,6 @@ export const CodexDriver: ProviderDriver = { env: processEnv, }); - const orchestrationAdapter = yield* CodexAdapterV2Driver.create({ - instanceId, - displayName, - accentColor, - environment, - enabled, - config, - }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build Codex orchestration adapter.", - cause, - }), - ), - ); const textGeneration = yield* makeCodexTextGeneration(effectiveConfig, processEnv); // Build a managed snapshot whose settings never change — mutations come @@ -225,6 +220,81 @@ export const CodexDriver: ProviderDriver = { ), ); + const orchestrationAdapter = yield* CodexAdapterV2Driver.create({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.provideService(CodexUsageLimitListener, { + publish: (info) => + Effect.gen(function* () { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + yield* snapshot.updateUsageLimits((previous) => + applyCodexRateLimitEvent(previous, info, checkedAt), + ); + }), + }), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Codex orchestration adapter.", + cause, + }), + ), + ); + const accountKey = homeLayout.effectiveHomePath ?? homeLayout.sharedHomePath; + const consumeResetCredit: NonNullable = () => + resetCreditCoordinator + .redeem(accountKey, (idempotencyKey) => + Effect.gen(function* () { + const { client } = yield* withCodexAppServerClient({ + binaryPath: effectiveConfig.binaryPath, + homePath: effectiveConfig.homePath, + launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv), + cwd: process.cwd(), + environment: processEnv, + }); + return (yield* client.request("account/rateLimitResetCredit/consume", { + idempotencyKey, + })).outcome; + }).pipe(Effect.scoped, Effect.timeout(CODEX_RESET_CREDIT_TIMEOUT)), + ) + .pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: + "Codex could not redeem the reset credit. Retrying will reuse the same attempt.", + cause, + }), + ), + Effect.flatMap((outcome) => + Effect.gen(function* () { + const before = (yield* snapshot.getSnapshot).usageLimits?.checkedAt; + const refreshed = yield* snapshot.refresh; + const limits = refreshed.usageLimits; + return { + outcome, + ...(limits?.checkedAt === undefined || + limits.checkedAt === before || + limits.unavailable?.reason === "probeFailed" + ? { + warning: + "Codex reported the redemption outcome, but new limits could not be confirmed. Refresh to check.", + } + : {}), + }; + }), + ), + ); return { instanceId, driverKind: DRIVER_KIND, @@ -234,6 +304,7 @@ export const CodexDriver: ProviderDriver = { enabled, snapshot, orchestrationAdapter, + consumeResetCredit, textGeneration, } satisfies ProviderInstance; }), diff --git a/apps/server/src/provider/Drivers/HermesDriver.ts b/apps/server/src/provider/Drivers/HermesDriver.ts index 839643fcc..d89bc9e77 100644 --- a/apps/server/src/provider/Drivers/HermesDriver.ts +++ b/apps/server/src/provider/Drivers/HermesDriver.ts @@ -1,3 +1,5 @@ +import { providerModelsFromSettings } from "../providerSnapshot.ts"; +import { readCustomModelEntries } from "@t3tools/shared/model"; import { HermesSettings, ProviderDriverKind, @@ -395,11 +397,23 @@ function snapshot(input: { readonly effectiveEndpoint: string; readonly connectionOwnership?: HermesServeOwnership; }): ServerProvider { - const models = hermesProviderModels( + const customEntries = readCustomModelEntries(input.settings.customModels); + const baseModels = hermesProviderModels( input.inventory?.models, input.inventory?.reasoning, input.inventory?.fast, - ["default", ...input.settings.customModels], + ["default", ...customEntries.map((entry) => entry.slug)], + ).map((model) => { + if (!model.isCustom) return model; + const entry = customEntries.find((entry) => entry.slug === model.slug); + return entry + ? { ...model, name: entry.name, capabilities: entry.capabilities ?? model.capabilities } + : model; + }); + const models = providerModelsFromSettings( + baseModels, + input.settings.customModels, + baseModels.find((model) => model.slug === "default")?.capabilities ?? { optionDescriptors: [] }, ); const hasProfileKey = input.settings.profileKey.trim().length > 0; const connectionSecurity = input.effectiveEndpoint diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 847dbec8c..9cca260bc 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -1,10 +1,20 @@ +import { + BUNDLED_CLAUDE_MODEL_CATALOG, + type ClaudeModelCatalog, + getClaudeCatalogModelCapabilities, + normalizeClaudeCatalogEffort, + isClaudeCatalogUltracodeEffort, + resolveClaudeCatalogApiModelId, + resolveClaudeCatalogAutoCompactTokens, + resolveClaudeModelsForVersion, + formatClaudeVersionUpgradeMessage, +} from "../ClaudeModelCatalog.ts"; import { claudeUsageLimits, unavailableUsageLimits } from "../providerUsageLimits.ts"; import type { SDKControlGetUsageResponse } from "@anthropic-ai/claude-agent-sdk"; import { type ClaudeSettings, type ModelCapabilities, type ModelSelection, - type ServerProviderModel, type ServerProviderSlashCommand, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -16,12 +26,10 @@ import * as Result from "effect/Result"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { createModelCapabilities, - getModelSelectionStringOptionValue, getProviderOptionCurrentValue, getProviderOptionDescriptors, } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import { compareSemverVersions } from "@t3tools/shared/semver"; import { query as claudeQuery, type Options as ClaudeQueryOptions, @@ -31,8 +39,6 @@ import { } from "@anthropic-ai/claude-agent-sdk"; import { - buildBooleanOptionDescriptor, - buildSelectOptionDescriptor, buildServerProvider, DEFAULT_TIMEOUT_MS, isCommandMissingCause, @@ -52,377 +58,12 @@ const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabili const CLAUDE_PRESENTATION = { displayName: "Claude", showInteractionModeToggle: true, + reportsContextWindow: true, } as const; -const MINIMUM_CLAUDE_OPUS_5_VERSION = "2.1.219"; -const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; -const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; -const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; - -/** - * Models whose context window only reaches 1M with the `[1m]` model-id suffix. - * - * The rest of the catalogue is either natively 1M — Fable 5, Opus 5/4.8/4.7 and - * Sonnet 5, where the suffix is redundant — or genuinely 200k with no 1M form - * worth offering (Opus 4.5, Haiku 4.5). Claude Code's own model registry is the - * source of truth for the split: `context.native_1m` and - * `context.supports_1m_suffix`. - */ -const CLAUDE_MODELS_NEEDING_1M_SUFFIX: ReadonlySet = new Set([ - "claude-opus-4-6", - "claude-sonnet-4-6", -]); - -/** - * Where Claude compacts the conversation, in tokens. - * - * This replaced a 200k/1M "Context Window" picker that could not work: on a - * natively-1M model, sending the bare model id instead of `model[1m]` left the - * window at 1M anyway, so picking 200k changed nothing. Every model now runs at - * its largest window and this is the real control — Claude Code resolves the - * compaction threshold as `min(model window, autoCompactWindow)`, so a stop - * below 1M genuinely caps the working set. - */ -const CLAUDE_AUTO_COMPACT_CHOICES = [ - { value: "250k", label: "250K", tokens: 250_000, isDefault: false }, - { value: "500k", label: "500K", tokens: 500_000, isDefault: false }, - { value: "750k", label: "750K", tokens: 750_000, isDefault: false }, - { value: "1m", label: "1M", tokens: 1_000_000, isDefault: true }, -] as const; - -export const CLAUDE_AUTO_COMPACT_OPTION_ID = "autoCompactWindow"; - -/** The top stop means "no cap of ours"; the adapter sends nothing for it. */ -const CLAUDE_AUTO_COMPACT_UNCAPPED_TOKENS = 1_000_000; - -function buildClaudeAutoCompactDescriptor() { - return buildSelectOptionDescriptor({ - id: CLAUDE_AUTO_COMPACT_OPTION_ID, - label: "Context", - description: - "Claude summarizes the conversation once it passes this much context. The model's own window stays at 1M.", - presentation: "slider", - options: CLAUDE_AUTO_COMPACT_CHOICES.map((choice) => ({ - value: choice.value, - label: choice.label, - ...(choice.isDefault ? { isDefault: true } : {}), - })), - }); -} - -/** - * The compaction threshold a selection asks for, or undefined when it asks for - * none — an unknown value, or the top stop, which is the model's own ceiling. - */ -export function resolveClaudeAutoCompactTokens(modelSelection: ModelSelection): number | undefined { - const raw = getModelSelectionStringOptionValue(modelSelection, CLAUDE_AUTO_COMPACT_OPTION_ID); - const descriptors = getProviderOptionDescriptors({ - caps: getClaudeModelCapabilities(modelSelection.model), - ...(raw ? { selections: [{ id: CLAUDE_AUTO_COMPACT_OPTION_ID, value: raw }] } : {}), - }); - const descriptor = descriptors.find( - (candidate) => candidate.id === CLAUDE_AUTO_COMPACT_OPTION_ID, - ); - const value = getProviderOptionCurrentValue(descriptor); - if (typeof value !== "string") return undefined; - const tokens = CLAUDE_AUTO_COMPACT_CHOICES.find((choice) => choice.value === value)?.tokens; - return tokens === undefined || tokens >= CLAUDE_AUTO_COMPACT_UNCAPPED_TOKENS ? undefined : tokens; -} - -const CLAUDE_MODEL_CATALOG: ReadonlyArray = [ - { - slug: "claude-fable-5", - name: "Claude Fable 5", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, - { - value: "ultracode", - label: "Ultracode", - description: "xhigh effort plus multi-agent workflow orchestration", - }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildClaudeAutoCompactDescriptor(), - ], - }), - }, - { - slug: "claude-opus-5", - name: "Claude Opus 5", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, - { - value: "ultracode", - label: "Ultracode", - description: "xhigh effort plus multi-agent workflow orchestration", - }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildBooleanOptionDescriptor({ - id: "fastMode", - label: "Fast Mode", - }), - buildClaudeAutoCompactDescriptor(), - ], - }), - }, - { - slug: "claude-opus-4-8", - name: "Claude Opus 4.8", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, - { - value: "ultracode", - label: "Ultracode", - description: "xhigh effort plus multi-agent workflow orchestration", - }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildBooleanOptionDescriptor({ - id: "fastMode", - label: "Fast Mode", - }), - buildClaudeAutoCompactDescriptor(), - ], - }), - }, - { - slug: "claude-opus-4-7", - name: "Claude Opus 4.7", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildBooleanOptionDescriptor({ - id: "fastMode", - label: "Fast Mode", - }), - buildClaudeAutoCompactDescriptor(), - ], - }), - }, - { - slug: "claude-opus-4-6", - name: "Claude Opus 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildBooleanOptionDescriptor({ - id: "fastMode", - label: "Fast Mode", - }), - buildClaudeAutoCompactDescriptor(), - ], - }), - }, - { - slug: "claude-opus-4-5", - name: "Claude Opus 4.5", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - ], - }), - buildBooleanOptionDescriptor({ - id: "fastMode", - label: "Fast Mode", - }), - ], - }), - }, - { - slug: "claude-sonnet-5", - name: "Claude Sonnet 5", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "xhigh", label: "Extra High" }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildClaudeAutoCompactDescriptor(), - ], - }), - }, - { - slug: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "effort", - label: "Reasoning", - options: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "max", label: "Max" }, - { value: "ultrathink", label: "Ultrathink" }, - ], - promptInjectedValues: ["ultrathink"], - }), - buildClaudeAutoCompactDescriptor(), - ], - }), - }, - { - slug: "claude-haiku-4-5", - name: "Claude Haiku 4.5", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildBooleanOptionDescriptor({ - id: "thinking", - label: "Thinking", - }), - ], - }), - }, -]; - -// Legacy classification happens at the driver boundary via `applyModelManifest`, -// so the catalog itself carries no `isLegacy` flags. -const BUILT_IN_MODELS: ReadonlyArray = CLAUDE_MODEL_CATALOG; - -function supportsClaudeOpus5(version: string | null | undefined): boolean { - return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; -} - -function supportsClaudeFable5(version: string | null | undefined): boolean { - return version ? compareSemverVersions(version, MINIMUM_CLAUDE_FABLE_5_VERSION) >= 0 : false; -} - -function supportsClaudeOpus48(version: string | null | undefined): boolean { - return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_4_8_VERSION) >= 0 : false; -} - -function supportsClaudeOpus47(version: string | null | undefined): boolean { - return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_4_7_VERSION) >= 0 : false; -} - -function getBuiltInClaudeModelsForVersion( - version: string | null | undefined, -): ReadonlyArray { - return BUILT_IN_MODELS.filter((model) => { - if (model.slug === "claude-opus-5") { - return supportsClaudeOpus5(version); - } - if (model.slug === "claude-fable-5") { - return supportsClaudeFable5(version); - } - if (model.slug === "claude-opus-4-8") { - return supportsClaudeOpus48(version); - } - if (model.slug === "claude-opus-4-7") { - return supportsClaudeOpus47(version); - } - return true; - }); -} - -function formatClaudeOpus5UpgradeMessage(version: string | null): string { - const versionLabel = version ? `v${version}` : "the installed version"; - return `Claude Code ${versionLabel} is too old for Claude Opus 5. Upgrade to v${MINIMUM_CLAUDE_OPUS_5_VERSION} or newer to access it.`; -} - -function formatClaudeFable5UpgradeMessage(version: string | null): string { - const versionLabel = version ? `v${version}` : "the installed version"; - return `Claude Code ${versionLabel} is too old for Claude Fable 5. Upgrade to v${MINIMUM_CLAUDE_FABLE_5_VERSION} or newer to access it.`; -} - -function formatClaudeOpus48UpgradeMessage(version: string | null): string { - const versionLabel = version ? `v${version}` : "the installed version"; - return `Claude Code ${versionLabel} is too old for Claude Opus 4.8. Upgrade to v${MINIMUM_CLAUDE_OPUS_4_8_VERSION} or newer to access it.`; -} - -function formatClaudeOpus47UpgradeMessage(version: string | null): string { - const versionLabel = version ? `v${version}` : "the installed version"; - return `Claude Code ${versionLabel} is too old for Claude Opus 4.7. Upgrade to v${MINIMUM_CLAUDE_OPUS_4_7_VERSION} or newer to access it.`; -} +export { CLAUDE_AUTO_COMPACT_OPTION_ID } from "../ClaudeModelCatalog.ts"; export function getClaudeModelCapabilities(model: string | null | undefined): ModelCapabilities { - const slug = model?.trim(); - return ( - BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? - DEFAULT_CLAUDE_MODEL_CAPABILITIES - ); + return getClaudeCatalogModelCapabilities(BUNDLED_CLAUDE_MODEL_CATALOG, model); } export function resolveClaudeEffort( @@ -433,64 +74,24 @@ export function resolveClaudeEffort( caps, ...(raw ? { selections: [{ id: "effort", value: raw }] } : {}), }); - const effortDescriptor = descriptors.find((descriptor) => descriptor.id === "effort"); - const value = getProviderOptionCurrentValue(effortDescriptor); + const value = getProviderOptionCurrentValue( + descriptors.find((descriptor) => descriptor.id === "effort"), + ); return typeof value === "string" ? value : undefined; } -/** - * Normalize a resolved Claude effort value into one suitable for the Claude - * CLI's `--effort` flag. - * - * Mirrors the mapping used when invoking the Claude Agent SDK - * ({@link getEffectiveClaudeAgentEffort} in ClaudeAdapter): `ultracode` is a - * Claude Code setting that pairs with `xhigh`, `ultrathink` is filtered out - * because it is a prompt-prefix mode, and older model compatibility mappings - * are preserved for current Claude Code behavior. - */ export function normalizeClaudeCliEffort( effort: string | null | undefined, model: string | null | undefined, ): string | undefined { - if (!effort || effort === "ultrathink") { - return undefined; - } - if (effort === "ultracode") { - return "xhigh"; - } - if ( - effort === "xhigh" && - model !== "claude-fable-5" && - model !== "claude-opus-5" && - model !== "claude-opus-4-8" && - model !== "claude-sonnet-5" - ) { - return "max"; - } - if (effort === "max" && model === "claude-sonnet-4-6") { - return "high"; - } - return effort; + return normalizeClaudeCatalogEffort(BUNDLED_CLAUDE_MODEL_CATALOG, effort, model); } - -export function isClaudeUltracodeEffort(effort: string | null | undefined): boolean { - return effort === "ultracode"; +export const isClaudeUltracodeEffort = isClaudeCatalogUltracodeEffort; +export function resolveClaudeApiModelId(selection: ModelSelection): string { + return resolveClaudeCatalogApiModelId(BUNDLED_CLAUDE_MODEL_CATALOG, selection); } - -/** - * The model id to send, always at the model's largest context window. - * - * The window stopped being a user choice: Claude Code's registry marks Fable 5, - * Opus 5/4.8/4.7 and Sonnet 5 as natively 1M, so the bare id already carried a - * 1M window and the old "200k" option changed nothing. Only the models that - * need the `[1m]` suffix to get there take one; everything else — including the - * genuinely-200k Opus 4.5 and Haiku 4.5, which have no 1M form — is sent bare. - * Capping the working set is `autoCompactWindow`'s job now. - */ -export function resolveClaudeApiModelId(modelSelection: ModelSelection): string { - return CLAUDE_MODELS_NEEDING_1M_SUFFIX.has(modelSelection.model) - ? `${modelSelection.model}[1m]` - : modelSelection.model; +export function resolveClaudeAutoCompactTokens(selection: ModelSelection): number | undefined { + return resolveClaudeCatalogAutoCompactTokens(BUNDLED_CLAUDE_MODEL_CATALOG, selection); } function toTitleCaseWords(value: string): string { @@ -661,6 +262,7 @@ function nonEmptyProbeString(value: string): string | undefined { } type ClaudeCapabilitiesProbe = { + readonly usageCheckedAt?: string; readonly usage?: Pick; readonly email: string | undefined; readonly subscriptionType: string | undefined; @@ -771,6 +373,7 @@ const probeClaudeCapabilities = ( claudeSettings.binaryPath, claudeEnvironment, ); + const usageCheckedAt = DateTime.formatIso(yield* DateTime.now); return yield* Effect.tryPromise(async () => { const q = claudeQuery({ // Never yield — we only need initialization data, not a conversation. @@ -811,7 +414,7 @@ const probeClaudeCapabilities = ( tokenSource: account?.tokenSource, apiProvider: account?.apiProvider, slashCommands: parseClaudeInitializationCommands(init.commands), - ...(usage ? { usage } : {}), + ...(usage ? { usage, usageCheckedAt } : {}), } satisfies ClaudeCapabilitiesProbe; }); }).pipe( @@ -852,6 +455,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ) => Effect.Effect, environment?: NodeJS.ProcessEnv, cwd?: string, + modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, ): Effect.fn.Return< ServerProviderDraft, never, @@ -860,7 +464,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const allModels = providerModelsFromSettings( - BUILT_IN_MODELS, + modelCatalog.models.map((entry) => entry.model), claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); @@ -950,19 +554,11 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( } const models = providerModelsFromSettings( - getBuiltInClaudeModelsForVersion(parsedVersion), + resolveClaudeModelsForVersion(modelCatalog, parsedVersion), claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); - const versionUpgradeMessage = supportsClaudeOpus5(parsedVersion) - ? undefined - : supportsClaudeFable5(parsedVersion) - ? formatClaudeOpus5UpgradeMessage(parsedVersion) - : supportsClaudeOpus48(parsedVersion) - ? formatClaudeFable5UpgradeMessage(parsedVersion) - : supportsClaudeOpus47(parsedVersion) - ? formatClaudeOpus48UpgradeMessage(parsedVersion) - : formatClaudeOpus47UpgradeMessage(parsedVersion); + const versionUpgradeMessage = formatClaudeVersionUpgradeMessage(modelCatalog, parsedVersion); const capabilities = resolveCapabilities ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) @@ -1018,7 +614,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }, ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), usageLimits: capabilities.usage - ? claudeUsageLimits(capabilities.usage, checkedAt) + ? claudeUsageLimits(capabilities.usage, capabilities.usageCheckedAt ?? checkedAt) : unavailableUsageLimits(checkedAt, "probeFailed"), }, }); @@ -1028,11 +624,12 @@ const nowIso = Effect.map(DateTime.now, DateTime.formatIso); export const makePendingClaudeProvider = ( claudeSettings: ClaudeSettings, + modelCatalog: ClaudeModelCatalog = BUNDLED_CLAUDE_MODEL_CATALOG, ): Effect.Effect => Effect.gen(function* () { const checkedAt = yield* nowIso; const models = providerModelsFromSettings( - BUILT_IN_MODELS, + modelCatalog.models.map((entry) => entry.model), claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index e54a4b17e..577c22834 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -1,3 +1,5 @@ +import { type CustomModelSetting } from "@t3tools/contracts"; +import { readCustomModelEntries } from "@t3tools/shared/model"; import { codexUsageLimits, unavailableUsageLimits, @@ -46,11 +48,15 @@ const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; const CODEX_PRESENTATION = { displayName: "Codex", showInteractionModeToggle: true, + reportsContextWindow: true, } as const; export interface CodexAppServerProviderSnapshot { readonly account: CodexSchema.V2GetAccountResponse; readonly rateLimits?: CodexRateLimitSnapshot | undefined; + readonly resetCredits?: + | CodexSchema.V2GetAccountRateLimitsResponse["rateLimitResetCredits"] + | undefined; readonly version: string | undefined; readonly models: ReadonlyArray; readonly skills: ReadonlyArray; @@ -231,7 +237,7 @@ export function applyPreferredCodexDefaultModel( function appendCustomCodexModels( models: ReadonlyArray, - customModels: ReadonlyArray, + customModels: ReadonlyArray, ): ReadonlyArray { if (customModels.length === 0) { return models; @@ -240,17 +246,16 @@ function appendCustomCodexModels( const seen = new Set(models.map((model) => model.slug)); const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null; const customEntries: ServerProviderModel[] = []; - for (const rawModel of customModels) { - const slug = rawModel.trim(); - if (!slug || seen.has(slug)) { + for (const entry of readCustomModelEntries(customModels)) { + if (seen.has(entry.slug)) { continue; } - seen.add(slug); + seen.add(entry.slug); customEntries.push({ - slug, - name: slug, + slug: entry.slug, + name: entry.name, isCustom: true, - capabilities: fallbackCapabilities, + capabilities: entry.capabilities ?? fallbackCapabilities, }); } return customEntries.length === 0 ? models : [...models, ...customEntries]; @@ -323,12 +328,12 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { }; } -const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { +export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(function* (input: { readonly binaryPath: string; readonly homePath?: string; readonly launchArgs?: string; readonly cwd: string; - readonly customModels?: ReadonlyArray; + readonly customModels?: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; }) { // `~` is not shell-expanded when env vars are set via `child_process.spawn`, @@ -385,6 +390,13 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun }); yield* client.notify("initialized", undefined); + return { client, initialize }; +}); + +const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* ( + input: Parameters[0], +) { + const { client, initialize } = yield* withCodexAppServerClient(input); // Extract the version string after the first '/' in userAgent, up to the next space or the end const versionMatch = initialize.userAgent.match(/\/([^\s]+)/); const version = versionMatch ? versionMatch[1] : undefined; @@ -406,7 +418,6 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun }), requestAllCodexModels(client), client.request("account/rateLimits/read", undefined).pipe( - Effect.map((response) => response.rateLimitsByLimitId?.codex ?? response.rateLimits), Effect.timeoutOption(Duration.millis(3_000)), Effect.map(Option.getOrUndefined), Effect.orElseSucceed(() => undefined), @@ -417,7 +428,8 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun return { account: accountResponse, - rateLimits, + rateLimits: rateLimits?.rateLimitsByLimitId?.codex ?? rateLimits?.rateLimits, + resetCredits: rateLimits?.rateLimitResetCredits, version, models: applyPreferredCodexDefaultModel( appendCustomCodexModels(models, input.customModels ?? []), @@ -426,21 +438,8 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun } satisfies CodexAppServerProviderSnapshot; }); -const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => { - const models = new Set(); - for (const model of codexSettings.customModels) { - const trimmed = model.trim(); - if (trimmed.length > 0) { - models.add(trimmed); - } - } - return Array.from(models, (model) => ({ - slug: model, - name: model, - isCustom: true, - capabilities: null, - })); -}; +const emptyCodexModelsFromSettings = (codexSettings: CodexSettings): ServerProvider["models"] => + appendCustomCodexModels([], codexSettings.customModels); const makePendingCodexProvider = ( codexSettings: CodexSettings, @@ -518,7 +517,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu readonly homePath?: string; readonly launchArgs?: string; readonly cwd: string; - readonly customModels: ReadonlyArray; + readonly customModels: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; }) => Effect.Effect< CodexAppServerProviderSnapshot, @@ -628,7 +627,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu snapshot.account.account?.type !== "chatgpt" ? unavailableUsageLimits(checkedAt, "unsupported") : snapshot.rateLimits - ? codexUsageLimits(snapshot.rateLimits, checkedAt) + ? codexUsageLimits(snapshot.rateLimits, checkedAt, snapshot.resetCredits) : unavailableUsageLimits(checkedAt, "probeFailed"), ...(accountStatus.message ? { message: accountStatus.message } : {}), }, diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 9763f1f98..909297488 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -1,3 +1,4 @@ +import type { CustomModelSetting } from "@t3tools/contracts"; import { type GrokSettings, type ModelCapabilities, @@ -96,7 +97,7 @@ export function buildInitialGrokProviderSnapshot( } function grokModelsFromSettings( - customModels: ReadonlyArray | undefined, + customModels: ReadonlyArray | undefined, builtInModels: ReadonlyArray = GROK_BUILT_IN_MODELS, ): ReadonlyArray { return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts index a6d6c7ff1..7bd66b398 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts @@ -1,3 +1,4 @@ +import * as CodexResetCredit from "./codexResetCredit.ts"; /** * ProviderInstanceRegistryHydration — derive a `ProviderInstanceConfigMap` * from `ServerSettings` and keep `ProviderInstanceRegistry` in sync with it. @@ -73,7 +74,9 @@ import { type ProviderInstanceRegistryHydrationEnv = | Exclude< BuiltInDriversEnv, - ProviderOrchestrationAdapterInfrastructure | HermesSessionBindingRepository + | ProviderOrchestrationAdapterInfrastructure + | HermesSessionBindingRepository + | CodexResetCredit.CodexResetCreditCoordinator > | ServerSettingsService; @@ -279,6 +282,7 @@ export const ProviderInstanceRegistryHydrationLive: Layer.Layer< Layer.provide( Layer.mergeAll( ProviderOrchestrationAdapterInfrastructureLive, + CodexResetCredit.layer, Layer.succeed( HermesSessionBindingRepository, Option.getOrElse(hermesRepository, () => UnavailableHermesSessionBindingRepository), diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index b49cdc43d..7e1737b5f 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -50,6 +50,7 @@ import { CodexDriver, type CodexDriverEnv } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; +import * as CodexResetCredit from "./codexResetCredit.ts"; import * as ModelManifest from "../ModelManifest.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; @@ -152,6 +153,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provideMerge(ModelManifest.layerTest), + Layer.provideMerge(CodexResetCredit.layerTest), ); const testLayer = ProviderOrchestrationAdapterInfrastructureLive.pipe( Layer.provideMerge(baseLayer), @@ -322,6 +324,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provideMerge(ModelManifest.layerTest), + Layer.provideMerge(CodexResetCredit.layerTest), ); const testLayer = ProviderOrchestrationAdapterInfrastructureLive.pipe( Layer.provideMerge(baseLayer), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 7a379e9e7..f7409e458 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -34,6 +34,7 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import * as CodexResetCredit from "./codexResetCredit.ts"; import * as ModelManifest from "../ModelManifest.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; @@ -1650,6 +1651,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ), Layer.provideMerge(ModelManifest.layerTest), + Layer.provideMerge(CodexResetCredit.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), // NO spawner mock — `ChildProcessSpawner` is supplied by the @@ -1744,6 +1746,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ), Layer.provideMerge(ModelManifest.layerTest), + Layer.provideMerge(CodexResetCredit.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.updateService(ChildProcessSpawner.ChildProcessSpawner, (spawner) => ChildProcessSpawner.make((command) => { @@ -1867,6 +1870,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ), Layer.provideMerge(ModelManifest.layerTest), + Layer.provideMerge(CodexResetCredit.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), @@ -1927,6 +1931,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ), Layer.provideMerge(ModelManifest.layerTest), + Layer.provideMerge(CodexResetCredit.layerTest), Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(BackgroundPolicyAlwaysRunLayer), Layer.provideMerge( diff --git a/apps/server/src/provider/Layers/codexResetCredit.test.ts b/apps/server/src/provider/Layers/codexResetCredit.test.ts new file mode 100644 index 000000000..f03b29737 --- /dev/null +++ b/apps/server/src/provider/Layers/codexResetCredit.test.ts @@ -0,0 +1,86 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Ref from "effect/Ref"; + +import { CodexResetCreditCoordinator, layerTest } from "./codexResetCredit.ts"; + +describe("CodexResetCreditCoordinator", () => { + it.effect("re-sends the same idempotency key after a failed attempt, then clears it", () => + Effect.gen(function* () { + const { redeem } = yield* CodexResetCreditCoordinator; + const keys = yield* Ref.make>([]); + const attempts = yield* Ref.make(0); + const consume = (key: string) => + Effect.gen(function* () { + yield* Ref.update(keys, (seen) => [...seen, key]); + const attempt = yield* Ref.updateAndGet(attempts, (n) => n + 1); + if (attempt === 1) return yield* Effect.fail("timed out" as const); + return "reset" as const; + }); + + const first = yield* redeem("acct", consume).pipe(Effect.result); + assert.isTrue(first._tag === "Failure"); + const second = yield* redeem("acct", consume); + assert.strictEqual(second, "reset"); + // A fresh redemption after success must be a fresh attempt. + yield* redeem("acct", consume); + + const seen = yield* Ref.get(keys); + assert.strictEqual(seen.length, 3); + assert.strictEqual(seen[0], seen[1]); + assert.notStrictEqual(seen[1], seen[2]); + }).pipe(Effect.provide(layerTest)), + ); + + it.effect("serialises concurrent redemptions on the same account, not per caller", () => + Effect.gen(function* () { + const { redeem } = yield* CodexResetCreditCoordinator; + const release = yield* Deferred.make(); + const inFlight = yield* Ref.make(0); + const peak = yield* Ref.make(0); + const consume = () => + Effect.gen(function* () { + const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1); + yield* Ref.update(peak, (p) => Math.max(p, now)); + yield* Deferred.await(release); + yield* Ref.update(inFlight, (n) => n - 1); + return "reset" as const; + }); + + // Two instances of the same account redeem at once. + const a = yield* redeem("acct", consume).pipe(Effect.forkChild); + const b = yield* redeem("acct", consume).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(a); + yield* Fiber.join(b); + + assert.strictEqual(yield* Ref.get(peak), 1); + }).pipe(Effect.provide(layerTest)), + ); + + it.effect("keeps different accounts independent", () => + Effect.gen(function* () { + const { redeem } = yield* CodexResetCreditCoordinator; + const release = yield* Deferred.make(); + const peak = yield* Ref.make(0); + const inFlight = yield* Ref.make(0); + const consume = () => + Effect.gen(function* () { + const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1); + yield* Ref.update(peak, (p) => Math.max(p, now)); + yield* Deferred.await(release); + return "reset" as const; + }); + const a = yield* redeem("acct-a", consume).pipe(Effect.forkChild); + const b = yield* redeem("acct-b", consume).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(a); + yield* Fiber.join(b); + assert.strictEqual(yield* Ref.get(peak), 2); + }).pipe(Effect.provide(layerTest)), + ); +}); diff --git a/apps/server/src/provider/Layers/codexResetCredit.ts b/apps/server/src/provider/Layers/codexResetCredit.ts new file mode 100644 index 000000000..34bd0a77f --- /dev/null +++ b/apps/server/src/provider/Layers/codexResetCredit.ts @@ -0,0 +1,114 @@ +/** + * Redeeming a Codex reset credit is an account-level action: instances that + * share the directory holding `auth.json` share the credit, so their + * redemptions must serialise on that directory, not the instance. This + * service keeps one lock and one pending idempotency key per account key so + * overlapping confirmations from any instance queue rather than spending two + * credits, and a retry after a timeout re-sends the same attempt. + * + * @module provider/Layers/codexResetCredit + */ +import type { ProviderConsumeResetCreditOutcome } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; + +/** + * Bounded so a hung app-server cannot hold the account lock forever; the + * timeout interrupts the scoped request, which kills the process, and the + * kept idempotency key makes the user's retry safe. + */ +export const CODEX_RESET_CREDIT_TIMEOUT = Duration.seconds(20); + +interface AccountRedemptionState { + readonly lock: Semaphore.Semaphore; + readonly pendingKey: Ref.Ref; +} + +export class CodexResetCreditCoordinator extends Context.Service< + CodexResetCreditCoordinator, + { + /** + * Run `consume` under the account's lock with a stable idempotency key. + * The key is cleared only when Codex reports an outcome; a failure + * (timeout included) keeps it so the next attempt is the same attempt. + */ + readonly redeem: ( + accountKey: string, + consume: (idempotencyKey: string) => Effect.Effect, + ) => Effect.Effect; + } +>()("t3/provider/Layers/codexResetCredit/CodexResetCreditCoordinator") {} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const statesRef = yield* Ref.make>(new Map()); + + // Get-or-create through one Ref.modify so two first redemptions for the + // same account cannot each install their own lock. + const stateFor = Effect.fn("CodexResetCreditCoordinator.stateFor")(function* ( + accountKey: string, + ) { + const existing = (yield* Ref.get(statesRef)).get(accountKey); + if (existing) return existing; + const candidate = { + lock: yield* Semaphore.make(1), + pendingKey: yield* Ref.make(null), + }; + return yield* Ref.modify(statesRef, (states) => { + const current = states.get(accountKey); + if (current) return [current, states] as const; + const next = new Map(states); + next.set(accountKey, candidate); + return [candidate, next] as const; + }); + }); + + const redeem: CodexResetCreditCoordinator["Service"]["redeem"] = (accountKey, consume) => + Effect.gen(function* () { + const state = yield* stateFor(accountKey); + return yield* state.lock.withPermits(1)( + Effect.gen(function* () { + const existing = yield* Ref.get(state.pendingKey); + const idempotencyKey = existing ?? (yield* crypto.randomUUIDv4); + yield* Ref.set(state.pendingKey, idempotencyKey); + const outcome = yield* consume(idempotencyKey); + yield* Ref.set(state.pendingKey, null); + return outcome; + }), + ); + }); + + return { redeem } satisfies CodexResetCreditCoordinator["Service"]; +}); + +export const layer = Layer.effect(CodexResetCreditCoordinator, make); + +/** + * Self-contained for tests: a counter-backed Crypto so keys are deterministic + * and distinct without the platform layer. + */ +export const layerTest = Layer.effect( + CodexResetCreditCoordinator, + Effect.gen(function* () { + let counter = 0; + return yield* make.pipe( + Effect.provideService( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => { + counter += 1; + return new Uint8Array(size).fill(counter); + }, + digest: (_algorithm, data) => Effect.succeed(data), + }), + ), + ); + }), +); diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index fdcfa9335..aa899279f 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -1,3 +1,6 @@ +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as TestClock from "effect/testing/TestClock"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderDriverKind, type ServerProviderModel } from "@t3tools/contracts"; @@ -9,6 +12,7 @@ import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import { BUNDLED_MODEL_MANIFEST, + encodeManifestCache, classifyModels, isLegacyModel, make, @@ -48,7 +52,7 @@ describe("isLegacyModel (bundled manifest)", () => { isLegacyModel(BUNDLED_MODEL_MANIFEST, CLAUDE, model), ]), [ - ["claude-fable-5", false], + ["claude-fable-5", true], ["claude-opus-5", false], ["claude-sonnet-5", false], ["claude-opus-4-8", true], @@ -96,12 +100,107 @@ describe("classifyModels", () => { const REMOTE_MANIFEST: ModelManifestData = { version: 1, + updatedAt: "2026-09-10T00:00:00Z", currentModels: { codex: ["gpt-5.4"], claudeAgent: ["claude-fable-5"], }, }; +const REMOTE_CLAUDE_MANIFEST: ModelManifestData = { + version: 1, + updatedAt: "2099-01-01T00:00:00Z", + currentModels: {}, + providers: { + claudeAgent: { + profiles: { + synthetic: { + adapter: { claudeCode: { effortMap: { extreme: "high" } } }, + }, + }, + models: [ + { + slug: "remote-only-model", + name: "Remote Only Model", + status: "current", + profile: "synthetic", + }, + ], + }, + }, +}; + +const remoteClaudeManifestWithCompatibility = (compatibility: unknown): ModelManifestData => ({ + ...REMOTE_CLAUDE_MANIFEST, + providers: { + claudeAgent: { + profiles: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.profiles, + models: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models.map((model) => ({ + ...model, + adapter: { claudeCode: compatibility }, + })), + }, + }, +}); + +const INVALID_REMOTE_MANIFESTS: ReadonlyArray = [ + { + ...REMOTE_CLAUDE_MANIFEST, + providers: { + claudeAgent: { + profiles: { + synthetic: { + adapter: { claudeCode: { effortMap: { extreme: 123 } } }, + }, + }, + models: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models, + }, + }, + }, + { + ...REMOTE_CLAUDE_MANIFEST, + providers: { + claudeAgent: { + profiles: {}, + models: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models, + }, + }, + }, + { + ...REMOTE_CLAUDE_MANIFEST, + providers: { + claudeAgent: { + profiles: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.profiles, + models: [ + ...REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models, + { + slug: "remote-only-model", + name: "Duplicate Remote Model", + status: "current", + profile: "synthetic", + }, + ], + }, + }, + }, + { + ...REMOTE_CLAUDE_MANIFEST, + providers: { + claudeAgent: { + defaults: { chat: "absent-model" }, + profiles: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.profiles, + models: REMOTE_CLAUDE_MANIFEST.providers!.claudeAgent!.models, + }, + }, + }, + remoteClaudeManifestWithCompatibility({ minVersion: "2.x" }), + remoteClaudeManifestWithCompatibility({ maxVersionExclusive: "2.x" }), + remoteClaudeManifestWithCompatibility({ + minVersion: "2.2", + maxVersionExclusive: "2.1", + }), +]; + const httpClientLayer = (handler: () => Response) => Layer.succeed( HttpClient.HttpClient, @@ -120,6 +219,73 @@ const serviceLayers = (input: { ); describe("ModelManifest service", () => { + it.effect("preserves the last-good remote cache when later payloads are invalid", () => { + let responseIndex = 0; + const responses = [REMOTE_CLAUDE_MANIFEST, ...INVALID_REMOTE_MANIFESTS]; + + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, REMOTE_CLAUDE_MANIFEST); + + for (const _invalid of INVALID_REMOTE_MANIFESTS) { + yield* TestClock.adjust("1 hour"); + responseIndex += 1; + assert.deepStrictEqual(yield* service.refresh, REMOTE_CLAUDE_MANIFEST); + } + + const rebooted = yield* make; + assert.deepStrictEqual(yield* rebooted.current, REMOTE_CLAUDE_MANIFEST); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-last-good-test", + response: () => Response.json(responses[responseIndex]), + }), + ), + ); + }); + + it.live("drops a disk cache of a manifest older than the bundled one", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const cachePath = path.join(config.stateDir, "model-manifest.json"); + // A cache of the manifest as it was before the release edited it. The + // fetch time is irrelevant: the remote may be unreachable now, so + // `current` must already prefer the bundle. + const { updatedAt: _undated, ...undatedManifest } = REMOTE_MANIFEST; + for (const stale of [ + undatedManifest, + { ...REMOTE_MANIFEST, updatedAt: "2000-01-01T00:00:00Z" }, + ]) { + yield* fs.writeFileString( + cachePath, + yield* encodeManifestCache({ fetchedAtMs: 0, manifest: stale }), + ); + const service = yield* make; + assert.deepStrictEqual(yield* service.current, BUNDLED_MODEL_MANIFEST); + } + + // A cache of a newer edit still outranks the bundle. + yield* fs.writeFileString( + cachePath, + yield* encodeManifestCache({ fetchedAtMs: 0, manifest: REMOTE_MANIFEST }), + ); + const later = yield* make; + assert.deepStrictEqual(yield* later.current, REMOTE_MANIFEST); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-newer-bundle-test", + response: () => Response.json(REMOTE_MANIFEST), + }), + ), + ), + ); + it.live("prefers a fetched manifest over the bundle and caches it to disk", () => Effect.gen(function* () { const service = yield* make; diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index cb9494992..ee67b6ddd 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -1,20 +1,24 @@ /** - * ModelManifest — decides which provider models are current and which belong - * in the model picker's legacy section. + * ModelManifest — remote provider-model metadata with a bundled offline + * fallback. * - * The classification data (current slugs per driver kind) lives in - * `model-manifest.json` next to this file. The bundled copy ships with every - * release. At runtime the service refreshes it from the same file on `main` - * via raw.githubusercontent.com, so a new model can leave the legacy section - * with a commit to `main` instead of a release. Preference order is remote, - * then the on-disk copy of the last successful fetch, then the bundle. A - * failed fetch never fails a provider check. + * Provider catalogs and legacy classification live in `model-manifest.json`. + * The bundled copy ships with every release; at runtime the service refreshes + * it from the same file on `main`. Preference order is remote, then the last + * successful on-disk copy, then the bundle. A failed fetch never fails a + * provider check. * - * Drivers apply the manifest to snapshot drafts with `applyModelManifest` - * before publishing, so every path that produces models (pending, probe, - * error fallbacks) is classified the same way. + * Providers with authoritative discovery can use only the classification + * overlay. Providers with static catalogs can resolve presentation and + * capabilities from `providers`, then decode their own allowlisted adapter + * payload separately. */ -import type { ProviderDriverKind, ServerProviderModel } from "@t3tools/contracts"; +import { + ModelCapabilities, + TrimmedNonEmptyString, + type ProviderDriverKind, + type ServerProviderModel, +} from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -27,6 +31,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; +import { hasValidClaudeManifestAdapters } from "./ClaudeModelManifest.ts"; import bundledManifestJson from "./model-manifest.json" with { type: "json" }; import type { ServerProviderDraft } from "./providerSnapshot.ts"; @@ -42,23 +47,148 @@ const MANIFEST_RETRY_MS = 5 * 60 * 1000; const FETCH_TIMEOUT_MS = 10_000; +const ManifestModelStatus = Schema.Literals(["current", "legacy"]); + +const ManifestModelProfile = Schema.Struct({ + capabilities: Schema.optional(ModelCapabilities), + adapter: Schema.optional(Schema.Unknown), +}); + +const ManifestProviderModel = Schema.Struct({ + slug: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + shortName: Schema.optional(TrimmedNonEmptyString), + subProvider: Schema.optional(TrimmedNonEmptyString), + aliases: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + status: ManifestModelStatus, + badge: Schema.optional(Schema.Literal("new")), + profile: Schema.optional(TrimmedNonEmptyString), + adapter: Schema.optional(Schema.Unknown), +}); + +const ManifestProviderCatalog = Schema.Struct({ + defaults: Schema.optional( + Schema.Struct({ + chat: Schema.optional(TrimmedNonEmptyString), + }), + ), + profiles: Schema.Record(Schema.String, ManifestModelProfile), + models: Schema.Array(ManifestProviderModel), +}); + /** - * `version` gates breaking schema changes: a build only accepts remote - * manifests whose version it understands, and keeps its bundled copy - * otherwise. `currentModels` is keyed by driver kind; kinds absent from the - * map have no legacy concept and their models are left unflagged. + * `version` gates breaking schema changes. Provider catalogs are additive so + * clients that only understand `currentModels` keep accepting this v1 file. */ -const ModelManifestSchema = Schema.Struct({ +const ModelManifestEnvelopeSchema = Schema.Struct({ version: Schema.Literal(1), + /** + * ISO date of the last edit. A release bundles its manifest, and a disk + * cache of an older edit must not outrank it. Optional so older remote + * files still decode; they count as older than any dated bundle. + */ + updatedAt: Schema.optional(Schema.String), currentModels: Schema.Record(Schema.String, Schema.Array(Schema.String)), + providers: Schema.optional(Schema.Record(Schema.String, ManifestProviderCatalog)), }); + +const hasValidProviderCatalogReferences = ( + manifest: typeof ModelManifestEnvelopeSchema.Type, +): boolean => + Object.values(manifest.providers ?? {}).every((catalog) => { + const slugs = new Set(); + const modelsAreValid = catalog.models.every((model) => { + if (slugs.has(model.slug)) return false; + slugs.add(model.slug); + return model.profile === undefined || catalog.profiles[model.profile] !== undefined; + }); + return ( + modelsAreValid && (catalog.defaults?.chat === undefined || slugs.has(catalog.defaults.chat)) + ); + }); + +const ModelManifestSchema = ModelManifestEnvelopeSchema.pipe( + Schema.check( + Schema.makeFilter(hasValidProviderCatalogReferences, { + expected: "unique model slugs and existing model and profile references", + }), + Schema.makeFilter(hasValidClaudeManifestAdapters, { + expected: "valid Claude adapter metadata", + }), + ), +); export type ModelManifestData = typeof ModelManifestSchema.Type; +export interface ResolvedManifestModel { + readonly model: ServerProviderModel; + readonly adapter: unknown; + readonly profileAdapter: unknown; +} + +export interface ResolvedProviderCatalog { + readonly models: ReadonlyArray; + readonly defaults: { + readonly chat: string | undefined; + }; +} + const decodeManifest = Schema.decodeUnknownEffect(ModelManifestSchema); export const BUNDLED_MODEL_MANIFEST: ModelManifestData = Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson); +/** Epoch millis of the manifest's `updatedAt`, or 0 when absent or unparsable. */ +function manifestUpdatedAtMs(manifest: ModelManifestData): number { + if (manifest.updatedAt === undefined) return 0; + const parsed = Date.parse(manifest.updatedAt); + return Number.isNaN(parsed) ? 0 : parsed; +} + +/** Resolve provider-neutral model presentation and capability data. */ +export function resolveProviderCatalog( + manifest: ModelManifestData, + driverKind: ProviderDriverKind, +): ResolvedProviderCatalog | null { + const catalog = manifest.providers?.[driverKind]; + if (!catalog) return null; + + const seen = new Set(); + const models: Array = []; + for (const entry of catalog.models) { + if (seen.has(entry.slug)) return null; + seen.add(entry.slug); + + const profile = entry.profile ? catalog.profiles[entry.profile] : undefined; + if (entry.profile && !profile) return null; + + models.push({ + model: { + slug: entry.slug, + name: entry.name, + ...(entry.shortName ? { shortName: entry.shortName } : {}), + ...(entry.subProvider ? { subProvider: entry.subProvider } : {}), + ...(entry.aliases ? { aliases: entry.aliases } : {}), + ...(entry.badge ? { badge: entry.badge } : {}), + isCustom: false, + ...(catalog.defaults?.chat === entry.slug ? { isDefault: true } : {}), + ...(entry.status === "legacy" ? { isLegacy: true } : {}), + capabilities: profile?.capabilities ?? null, + }, + adapter: entry.adapter, + profileAdapter: profile?.adapter, + }); + } + + if (catalog.defaults?.chat !== undefined && !seen.has(catalog.defaults.chat)) return null; + + return { + models, + defaults: { + chat: catalog.defaults?.chat, + }, + }; +} + /** On-disk shape of the last successfully fetched manifest. */ const ManifestCacheFile = Schema.Struct({ fetchedAtMs: Schema.Number, @@ -69,7 +199,8 @@ const decodeManifestCache = Schema.decodeUnknownEffect( ManifestCacheFile as unknown as Schema.Codec, ), ); -const encodeManifestCache = Schema.encodeEffect( +/** Exported for tests that seed the disk cache. */ +export const encodeManifestCache = Schema.encodeEffect( Schema.fromJsonString( ManifestCacheFile as unknown as Schema.Codec, ), @@ -81,6 +212,10 @@ export function isLegacyModel( driverKind: ProviderDriverKind, slug: string, ): boolean { + const catalogModel = manifest.providers?.[driverKind]?.models.find( + (model) => model.slug === slug, + ); + if (catalogModel) return catalogModel.status === "legacy"; const currentModels = manifest.currentModels[driverKind]; if (!currentModels) return false; return !currentModels.includes(slug); @@ -95,7 +230,51 @@ export function applyModelManifest( manifest: ModelManifestData, driverKind: ProviderDriverKind, ): ServerProviderDraft { - return { ...draft, models: classifyModels(draft.models, manifest, driverKind) }; + return { + ...draft, + models: applyManifestDefault( + classifyModels(draft.models, manifest, driverKind), + manifest, + driverKind, + ), + }; +} + +/** The manifest's chat default for `driverKind`, when it names one. */ +export function manifestDefaultModel( + manifest: ModelManifestData, + driverKind: ProviderDriverKind, +): string | undefined { + return manifest.providers?.[driverKind]?.defaults?.chat; +} + +/** + * Moves `isDefault` to the manifest's chat default when the catalog carries + * it. Providers that learn their default from the runtime (Antigravity takes + * Google's current model) can be overridden here without a release. Aliases + * that pointed at the old default move with the flag so the shared + * "provider default" alias keeps resolving. + */ +export function applyManifestDefault( + models: ReadonlyArray, + manifest: ModelManifestData, + driverKind: ProviderDriverKind, +): ReadonlyArray { + const slug = manifestDefaultModel(manifest, driverKind); + if (slug === undefined || !models.some((model) => model.slug === slug)) return models; + const previous = models.find((model) => model.isDefault && model.slug !== slug); + const movedAliases = previous?.aliases ?? []; + return models.map((model) => { + if (model.slug === previous?.slug) { + const { isDefault: _isDefault, aliases: _aliases, ...rest } = model; + return rest; + } + if (model.slug === slug) { + const aliases = [...new Set([...(model.aliases ?? []), ...movedAliases])]; + return { ...model, isDefault: true, ...(aliases.length > 0 ? { aliases } : {}) }; + } + return model; + }); } /** Model-level half of `applyModelManifest`, exported for focused tests. */ @@ -130,7 +309,7 @@ export class ModelManifest extends Context.Service< } >()("t3/provider/ModelManifest") {} -/** Constant service for tests and callers that only need the bundled data. */ +/** Constant service backing the bundled-data test layer. */ export const BundledOnlyModelManifest: ModelManifest["Service"] = { current: Effect.succeed(BUNDLED_MODEL_MANIFEST), refresh: Effect.succeed(BUNDLED_MODEL_MANIFEST), @@ -164,7 +343,14 @@ export const make = Effect.gen(function* () { ); if (fromDisk === null) return; // The disk copy is the last-seen remote manifest, so it outranks the - // bundle even when stale: it is refreshed on the next successful fetch. + // bundle even when stale, unless the bundle's own edit date is newer + // than the cached manifest's. Then the release carries data the cache + // has not seen and the cache is dropped so the next refresh replaces + // it. Comparing edit dates, not fetch time, keeps this independent of + // when the cache was written relative to the release. + if (manifestUpdatedAtMs(BUNDLED_MODEL_MANIFEST) > manifestUpdatedAtMs(fromDisk.manifest)) { + return; + } manifest = fromDisk.manifest; fetchedAtMs = fromDisk.fetchedAtMs; }), diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index 6b3339abf..d8be9c770 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -23,6 +23,7 @@ */ import type { ProviderDriverKind, + ProviderConsumeResetCreditResult, ProviderInstanceEnvironment, ProviderInstanceId, } from "@t3tools/contracts"; @@ -70,6 +71,11 @@ export interface ProviderInstance { readonly accentColor?: string | undefined; readonly enabled: boolean; readonly snapshot: ServerProviderShape; + /** Confirmed account action; a failed follow-up is a warning, not a second redemption. */ + readonly consumeResetCredit?: () => Effect.Effect< + ProviderConsumeResetCreditResult, + ProviderDriverError + >; readonly orchestrationAdapter: ProviderAdapterV2Shape; readonly textGeneration: TextGenerationShape; /** diff --git a/apps/server/src/provider/codexMcpElicitation.test.ts b/apps/server/src/provider/codexMcpElicitation.test.ts new file mode 100644 index 000000000..7dcb6a434 --- /dev/null +++ b/apps/server/src/provider/codexMcpElicitation.test.ts @@ -0,0 +1,206 @@ +import * as NodeAssert from "node:assert/strict"; +import { describe, it } from "@effect/vitest"; +import type * as EffectCodexSchema from "effect-codex-app-server/schema"; +import { describeMcpElicitation, toMcpElicitationResponse } from "./codexMcpElicitation.ts"; + +describe("Codex MCP elicitation approvals", () => { + const request = { + mode: "form", + message: "Allow ChatGPT to use Safari?", + serverName: "computer-use", + threadId: "provider-thread-1", + turnId: "turn-1", + _meta: { + app_name: "Safari", + persist: ["session", "always"], + }, + requestedSchema: { + type: "object", + properties: { + approval: { + type: "string", + oneOf: [ + { const: "once", title: "Allow once" }, + { const: "session", title: "Allow for this session" }, + { const: "always", title: "Always allow Safari" }, + ], + }, + }, + required: ["approval"], + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + it("preserves the app name and advertised persistence choices", () => { + NodeAssert.deepStrictEqual(describeMcpElicitation(request), { + appName: "Safari", + options: [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + { decision: "acceptForSession", label: "Allow for this session" }, + { decision: "acceptAlways", label: "Always allow Safari" }, + { decision: "accept", label: "Approve" }, + ], + }); + }); + + it("extracts the app name from a Computer Use request without metadata", () => { + const { _meta, ...requestWithoutMetadata } = request; + + NodeAssert.equal(describeMcpElicitation(requestWithoutMetadata).appName, "Safari"); + }); + + it("returns the accepted form option to Codex", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "accept"), { + action: "accept", + content: { approval: "once" }, + }); + }); + + it("returns session-scoped approval in the MCP response", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "acceptForSession"), { + action: "accept", + _meta: { persist: "session" }, + content: { approval: "session" }, + }); + }); + + it("returns persistent approval in the MCP response", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "acceptAlways"), { + action: "accept", + _meta: { persist: "always" }, + content: { approval: "always" }, + }); + }); + + it("returns rejection without form content", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "decline"), { + action: "decline", + }); + }); + + it("returns cancellation without form content", () => { + NodeAssert.deepStrictEqual(toMcpElicitationResponse(request, "cancel"), { + action: "cancel", + }); + }); + + it("supports boolean permanent-approval fields", () => { + const booleanRequest = { + ...request, + _meta: { app_name: "Safari" }, + requestedSchema: { + type: "object", + properties: { + always: { type: "boolean", title: "Always allow Safari" }, + }, + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.ok( + describeMcpElicitation(booleanRequest).options.some( + (option) => option.decision === "acceptAlways", + ), + ); + NodeAssert.deepStrictEqual(toMcpElicitationResponse(booleanRequest, "acceptAlways"), { + action: "accept", + _meta: { persist: "always" }, + content: { always: true }, + }); + }); + + it("preserves valid nullable MCP form fields and persistence choices", () => { + const nullableRequest = { + ...request, + _meta: { + app_name: null, + appName: "Safari", + connector_name: null, + persist: null, + target: null, + tool_params: null, + }, + requestedSchema: { + type: "object", + properties: { + approval: { + type: "string", + title: null, + description: null, + default: null, + enum: ["once", "always"], + enumNames: null, + }, + }, + required: ["approval"], + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.equal(describeMcpElicitation(nullableRequest).appName, "Safari"); + NodeAssert.ok( + describeMcpElicitation(nullableRequest).options.some( + (option) => option.decision === "acceptAlways", + ), + ); + NodeAssert.deepStrictEqual(toMcpElicitationResponse(nullableRequest, "acceptAlways"), { + action: "accept", + _meta: { persist: "always" }, + content: { approval: "always" }, + }); + }); + + it("declines required form fields that an approval prompt cannot collect", () => { + const inputRequest = { + ...request, + requestedSchema: { + type: "object", + properties: { + email: { type: "string", format: "email" }, + }, + required: ["email"], + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.deepStrictEqual(toMcpElicitationResponse(inputRequest, "accept"), { + action: "decline", + }); + }); + + it("does not approve URL elicitations without opening their requested URL", () => { + const urlRequest = { + mode: "url", + message: "Finish signing in to continue.", + serverName: "computer-use", + threadId: "provider-thread-1", + turnId: "turn-1", + elicitationId: "sign-in-1", + url: "https://example.com/authorize", + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.deepStrictEqual(toMcpElicitationResponse(urlRequest, "accept"), { + action: "decline", + }); + }); + + it("omits persistence choices that cannot satisfy required form fields", () => { + const onceOnlyRequest = { + ...request, + _meta: { app_name: "Safari", persist: ["session", "always"] }, + requestedSchema: { + type: "object", + properties: { + approval: { + type: "string", + enum: ["once"], + }, + }, + required: ["approval"], + }, + } satisfies EffectCodexSchema.McpServerElicitationRequestParams; + + NodeAssert.deepStrictEqual(describeMcpElicitation(onceOnlyRequest).options, [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + { decision: "accept", label: "Approve" }, + ]); + }); +}); diff --git a/apps/server/src/provider/codexMcpElicitation.ts b/apps/server/src/provider/codexMcpElicitation.ts new file mode 100644 index 000000000..3ac2fdca0 --- /dev/null +++ b/apps/server/src/provider/codexMcpElicitation.ts @@ -0,0 +1,222 @@ +import type { ProviderApprovalDecision, ProviderApprovalOption } from "@t3tools/contracts"; +import type * as EffectCodexSchema from "effect-codex-app-server/schema"; +import * as Schema from "effect/Schema"; + +const NullableMcpElicitationString = Schema.NullOr(Schema.String); +const McpElicitationMetadata = Schema.Struct({ + app: Schema.optionalKey(NullableMcpElicitationString), + app_name: Schema.optionalKey(NullableMcpElicitationString), + appName: Schema.optionalKey(NullableMcpElicitationString), + connector_name: Schema.optionalKey(NullableMcpElicitationString), + connectorName: Schema.optionalKey(NullableMcpElicitationString), + allowPersistentApproval: Schema.optionalKey(Schema.NullOr(Schema.Boolean)), + persist: Schema.optionalKey( + Schema.NullOr(Schema.Union([Schema.String, Schema.Array(Schema.String)])), + ), + target: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + app: Schema.optionalKey(NullableMcpElicitationString), + name: Schema.optionalKey(NullableMcpElicitationString), + }), + ), + ), + tool_params: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + app: Schema.optionalKey(NullableMcpElicitationString), + app_name: Schema.optionalKey(NullableMcpElicitationString), + }), + ), + ), +}); +const McpElicitationFormField = Schema.Struct({ + type: Schema.optionalKey(NullableMcpElicitationString), + title: Schema.optionalKey(NullableMcpElicitationString), + description: Schema.optionalKey(NullableMcpElicitationString), + default: Schema.optionalKey(Schema.Unknown), + enum: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), + enumNames: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), + oneOf: Schema.optionalKey( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + const: Schema.String, + title: Schema.optionalKey(NullableMcpElicitationString), + }), + ), + ), + ), +}); +const McpElicitationForm = Schema.Struct({ + properties: Schema.optionalKey(Schema.Record(Schema.String, McpElicitationFormField)), + required: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), +}); +const isMcpElicitationMetadata = Schema.is(McpElicitationMetadata); +const isMcpElicitationForm = Schema.is(McpElicitationForm); + +type McpElicitationPersistenceDecision = Extract< + ProviderApprovalDecision, + "acceptForSession" | "acceptAlways" +>; + +function mcpElicitationPersistenceDecision( + value: string, +): McpElicitationPersistenceDecision | null { + const normalized = value.toLowerCase(); + if (normalized.includes("session")) return "acceptForSession"; + if ( + normalized.includes("always") || + normalized.includes("permanent") || + normalized.includes("forever") || + normalized.includes("persistent") + ) { + return "acceptAlways"; + } + return null; +} + +function mcpElicitationFormFields(payload: EffectCodexSchema.McpServerElicitationRequestParams) { + if (payload.mode === "url" || !isMcpElicitationForm(payload.requestedSchema)) { + return undefined; + } + return payload.requestedSchema; +} + +function mcpElicitationFieldOptions(field: typeof McpElicitationFormField.Type) { + if (field.oneOf) { + return field.oneOf.map((option) => ({ value: option.const, label: option.title })); + } + return (field.enum ?? []).map((value, index) => ({ + value, + label: field.enumNames?.[index], + })); +} + +function isMcpElicitationPersistenceField( + key: string, + field: typeof McpElicitationFormField.Type, +): boolean { + return ( + mcpElicitationPersistenceDecision(key) !== null || + key.toLowerCase() === "persist" || + mcpElicitationPersistenceDecision(field.title ?? "") !== null || + mcpElicitationPersistenceDecision(field.description ?? "") !== null + ); +} + +/** Returns the app and approval choices advertised by an MCP elicitation. */ +export function describeMcpElicitation( + payload: EffectCodexSchema.McpServerElicitationRequestParams, +): { readonly appName: string; readonly options: ReadonlyArray } { + const metadata = isMcpElicitationMetadata(payload._meta) ? payload._meta : undefined; + const appName = + metadata?.app_name ?? + metadata?.appName ?? + metadata?.app ?? + metadata?.target?.app ?? + metadata?.target?.name ?? + metadata?.tool_params?.app_name ?? + metadata?.tool_params?.app ?? + payload.message.match(/^Allow ChatGPT to use (.+?)\?$/i)?.[1] ?? + metadata?.connector_name ?? + metadata?.connectorName ?? + payload.serverName; + const persistenceOptions = new Map(); + const persist = metadata?.persist; + for (const value of typeof persist === "string" ? [persist] : (persist ?? [])) { + const decision = mcpElicitationPersistenceDecision(value); + if (decision) persistenceOptions.set(decision, ""); + } + if (metadata?.allowPersistentApproval) { + persistenceOptions.set("acceptAlways", ""); + } + + const form = mcpElicitationFormFields(payload); + for (const [key, field] of Object.entries(form?.properties ?? {})) { + for (const option of mcpElicitationFieldOptions(field)) { + const decision = mcpElicitationPersistenceDecision(option.value); + if (decision) persistenceOptions.set(decision, option.label ?? ""); + } + if (field.type === "boolean" && isMcpElicitationPersistenceField(key, field)) { + persistenceOptions.set("acceptAlways", field.title ?? ""); + } + } + + return { + appName, + options: [ + { decision: "cancel", label: "Cancel" }, + { decision: "decline", label: "Decline" }, + ...(persistenceOptions.has("acceptForSession") && + toMcpElicitationResponse(payload, "acceptForSession").action === "accept" + ? [ + { + decision: "acceptForSession" as const, + label: persistenceOptions.get("acceptForSession") || "Always allow this session", + }, + ] + : []), + ...(persistenceOptions.has("acceptAlways") && + toMcpElicitationResponse(payload, "acceptAlways").action === "accept" + ? [ + { + decision: "acceptAlways" as const, + label: persistenceOptions.get("acceptAlways") || "Always allow", + }, + ] + : []), + { decision: "accept", label: "Approve" }, + ], + }; +} + +/** Converts a T3 approval decision into the MCP elicitation wire response. */ +export function toMcpElicitationResponse( + payload: EffectCodexSchema.McpServerElicitationRequestParams, + decision: ProviderApprovalDecision, +): EffectCodexSchema.McpServerElicitationRequestResponse { + if (decision === "decline" || decision === "cancel") { + return { action: decision }; + } + + if (payload.mode === "url") { + return { action: "decline" }; + } + + const persist = + decision === "acceptForSession" + ? "session" + : decision === "acceptAlways" + ? "always" + : undefined; + const form = mcpElicitationFormFields(payload); + const content: Record = {}; + + for (const [key, field] of Object.entries(form?.properties ?? {})) { + const options = mcpElicitationFieldOptions(field); + const chosenOption = options.find((option) => + persist + ? mcpElicitationPersistenceDecision(option.value) === decision + : /once|accept|approve|allow/i.test(option.value) && + mcpElicitationPersistenceDecision(option.value) === null, + ); + if (chosenOption) { + content[key] = chosenOption.value; + } else if (field.type === "boolean" && isMcpElicitationPersistenceField(key, field)) { + content[key] = decision === "acceptAlways"; + } else if (field.default !== undefined && field.default !== null) { + content[key] = field.default; + } + } + + if (form?.required?.some((key) => !Object.hasOwn(content, key))) { + return { action: "decline" }; + } + + return { + action: "accept", + ...(persist ? { _meta: { persist } } : {}), + ...(form ? { content } : {}), + }; +} diff --git a/apps/server/src/provider/consumeResetCredit.test.ts b/apps/server/src/provider/consumeResetCredit.test.ts new file mode 100644 index 000000000..2181b9ed0 --- /dev/null +++ b/apps/server/src/provider/consumeResetCredit.test.ts @@ -0,0 +1,63 @@ +import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { describe, expect, it } from "@effect/vitest"; +import { ProviderDriverError } from "./Errors.ts"; +import { consumeInstanceResetCredit } from "./consumeResetCredit.ts"; +const input = { instanceId: ProviderInstanceId.make("work") }; +describe("reset credit dispatch", () => { + it.effect("rejects missing, disabled and unsupported accounts without redeeming", () => + Effect.gen(function* () { + let calls = 0; + const consumeResetCredit = () => + Effect.sync(() => { + calls++; + return { outcome: "reset" as const }; + }); + for (const instance of [ + undefined, + { enabled: false, consumeResetCredit }, + { enabled: true }, + ]) { + const result = yield* consumeInstanceResetCredit(instance, input).pipe(Effect.result); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") expect(result.failure.instanceId).toBe("work"); + } + expect(calls).toBe(0); + }), + ); + it.effect("keeps an applied outcome and refresh warning successful", () => + Effect.gen(function* () { + const expected = { outcome: "reset" as const, warning: "Refresh to check new limits." }; + const actual = yield* consumeInstanceResetCredit( + { enabled: true, consumeResetCredit: () => Effect.succeed(expected) }, + input, + ); + expect(actual).toEqual(expected); + }), + ); + it.effect("preserves the account and actionable error when redemption fails", () => + Effect.gen(function* () { + const result = yield* consumeInstanceResetCredit( + { + enabled: true, + consumeResetCredit: () => + Effect.fail( + new ProviderDriverError({ + driver: ProviderDriverKind.make("codex"), + instanceId: input.instanceId, + detail: "Retry this attempt.", + }), + ), + }, + input, + ).pipe(Effect.result); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") + expect(result.failure).toMatchObject({ + instanceId: "work", + operation: "consume-reset-credit", + detail: "Retry this attempt.", + }); + }), + ); +}); diff --git a/apps/server/src/provider/consumeResetCredit.ts b/apps/server/src/provider/consumeResetCredit.ts new file mode 100644 index 000000000..e244d6529 --- /dev/null +++ b/apps/server/src/provider/consumeResetCredit.ts @@ -0,0 +1,31 @@ +import { ProviderSetupError, type ProviderConsumeResetCreditInput } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import type { ProviderInstance } from "./ProviderDriver.ts"; + +/** Validate the current instance immediately before an account-level operation. */ +export const consumeInstanceResetCredit = Effect.fn("consumeInstanceResetCredit")(function* ( + instance: Pick | undefined, + input: ProviderConsumeResetCreditInput, +) { + if (!instance?.enabled || !instance.consumeResetCredit) + return yield* new ProviderSetupError({ + instanceId: input.instanceId, + operation: "consume-reset-credit", + detail: !instance + ? "Provider instance not found." + : !instance.enabled + ? "This provider is disabled." + : "This provider does not bank reset credits.", + }); + return yield* instance.consumeResetCredit().pipe( + Effect.mapError( + (error) => + new ProviderSetupError({ + instanceId: input.instanceId, + operation: "consume-reset-credit", + detail: error.detail, + cause: error, + }), + ), + ); +}); diff --git a/apps/server/src/provider/makeManagedServerProvider.test.ts b/apps/server/src/provider/makeManagedServerProvider.test.ts index fd50fa13e..6a1cf8773 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/apps/server/src/provider/makeManagedServerProvider.test.ts @@ -150,6 +150,55 @@ const enrichedSnapshotSecond: ServerProvider = { }; describe("makeManagedServerProvider", () => { + it.effect( + "publishes account-local usage and retains it when an older in-flight probe completes", + () => + Effect.scoped( + Effect.gen(function* () { + const releaseCheck = yield* Deferred.make(); + const limits = { + checkedAt: "2026-09-10T01:00:00.000Z", + windows: [ + { id: "five_hour", kind: "session" as const, label: "Session", usedPercent: 50 }, + ], + }; + const makeProvider = () => + makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed({ enabled: true }), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: () => Effect.succeed(initialSnapshot), + checkProvider: Deferred.await(releaseCheck).pipe( + Effect.as({ + ...refreshedSnapshot, + usageLimits: { ...limits, checkedAt: "2026-09-10T00:00:00.000Z" }, + }), + ), + refreshOnInterval: false, + }); + const first = yield* makeProvider(); + const second = yield* makeProvider(); + const updates = yield* first.streamChanges.pipe( + Stream.take(2), + Stream.runCollect, + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* first.updateUsageLimits(() => limits); + assert.deepStrictEqual((yield* first.getSnapshot).usageLimits, limits); + assert.strictEqual((yield* second.getSnapshot).usageLimits, undefined); + yield* first.updateUsageLimits((current) => current); + yield* Deferred.succeed(releaseCheck, undefined); + const emitted = yield* Fiber.join(updates); + assert.strictEqual(emitted.length, 2); + assert.deepStrictEqual( + emitted.map((snapshot) => snapshot.usageLimits), + [limits, limits], + ); + }), + ).pipe(Effect.provide(AlwaysRunTestLayer)), + ); it.effect( "runs the initial provider check in the background and streams the refreshed snapshot", () => diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index a00915714..1203a9df1 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -1,6 +1,7 @@ import { DEFAULT_PROVIDER_HEALTH_REFRESH_INTERVAL, type ServerProvider, + type ServerProviderUsageLimits, ServerSettingsError, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; @@ -19,6 +20,8 @@ import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import { ServerSettingsService } from "../serverSettings.ts"; import type { ServerProviderShape } from "./Services/ServerProvider.ts"; +import { usageLimitsAfterProbe } from "./providerUsageLimits.ts"; + interface ProviderSnapshotState { readonly snapshot: ServerProvider; readonly enrichmentGeneration: number; @@ -43,7 +46,13 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( readonly refreshOnInterval?: boolean; readonly checkProviderOnSettingsChange?: (previous: Settings, next: Settings) => boolean; }): Effect.fn.Return< - ServerProviderShape, + ServerProviderShape & { + readonly updateUsageLimits: ( + update: ( + current: ServerProviderUsageLimits | undefined, + ) => ServerProviderUsageLimits | undefined, + ) => Effect.Effect; + }, ServerSettingsError, Scope.Scope | BackgroundPolicy.BackgroundPolicy | ServerSettingsService > { @@ -69,6 +78,11 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( nextSnapshot: ServerProvider, ) { const snapshotToPublish = yield* Ref.modify(snapshotStateRef, (state) => { + const usageLimits = usageLimitsAfterProbe( + state.snapshot.usageLimits, + nextSnapshot.usageLimits, + ); + nextSnapshot = { ...nextSnapshot, ...(usageLimits === undefined ? {} : { usageLimits }) }; if (state.enrichmentGeneration !== generation || Equal.equals(state.snapshot, nextSnapshot)) { return [null, state] as const; } @@ -138,17 +152,19 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return state.snapshot; } - const nextSnapshot = yield* input.checkProvider; - const nextGeneration = yield* Ref.modify(snapshotStateRef, (state) => { + const probedSnapshot = yield* input.checkProvider; + const [nextSnapshot, nextGeneration] = yield* Ref.modify(snapshotStateRef, (state) => { + const usageLimits = usageLimitsAfterProbe( + state.snapshot.usageLimits, + probedSnapshot.usageLimits, + ); + const snapshot = { ...probedSnapshot, ...(usageLimits === undefined ? {} : { usageLimits }) }; const generation = input.enrichSnapshot ? state.enrichmentGeneration + 1 : state.enrichmentGeneration; return [ - generation, - { - snapshot: nextSnapshot, - enrichmentGeneration: generation, - }, + [snapshot, generation] as const, + { snapshot, enrichmentGeneration: generation }, ] as const; }); yield* Ref.set(settingsRef, nextSettings); @@ -239,10 +255,20 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( return { maintenanceCapabilities: input.maintenanceCapabilities, + updateUsageLimits: (update) => + Effect.gen(function* () { + const next = yield* Ref.modify(snapshotStateRef, (state) => { + const usageLimits = update(state.snapshot.usageLimits); + if (Equal.equals(usageLimits, state.snapshot.usageLimits)) return [null, state] as const; + const snapshot = { ...state.snapshot, usageLimits }; + return [snapshot, { ...state, snapshot }] as const; + }); + if (next) yield* PubSub.publish(changesPubSub, next); + }), getSnapshot: Ref.get(snapshotStateRef).pipe(Effect.map((state) => state.snapshot)), refresh: refreshSnapshot().pipe(Effect.tapError(Effect.logError), Effect.orDie), get streamChanges() { return Stream.fromPubSub(changesPubSub); }, - } satisfies ServerProviderShape; + }; }); diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index d7d543295..bca9f09ee 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,5 +1,6 @@ { "version": 1, + "updatedAt": "2026-09-04T19:10:48Z", "currentModels": { "codex": [ "gpt-6-astra", @@ -9,6 +10,668 @@ "gpt-daybreak-blue-latest", "gpt-daybreak-red-latest" ], - "claudeAgent": ["claude-fable-5", "claude-opus-5", "claude-sonnet-5"] + "claudeAgent": ["claude-fable-5-1", "claude-opus-5", "claude-sonnet-5"], + "antigravity": ["gemini-3.8-flash-high", "gemini-3.8-flash-medium", "gemini-3.8-flash-low"] + }, + "providers": { + "claudeAgent": { + "defaults": { + "chat": "claude-sonnet-5" + }, + "profiles": { + "fable-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { + "id": "low", + "label": "Low" + }, + { + "id": "medium", + "label": "Medium" + }, + { + "id": "high", + "label": "High", + "isDefault": true + }, + { + "id": "xhigh", + "label": "Extra High" + }, + { + "id": "max", + "label": "Max" + }, + { + "id": "ultracode", + "label": "Ultracode", + "description": "xhigh effort plus multi-agent workflow orchestration" + }, + { + "id": "ultrathink", + "label": "Ultrathink" + } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { + "id": "200k", + "label": "200k" + }, + { + "id": "1m", + "label": "1M", + "isDefault": true + } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { + "ultracode": "xhigh", + "ultrathink": null + }, + "modelSuffixes": { + "contextWindow": { + "1m": "[1m]" + } + }, + "contextWindowTokens": { + "200k": 200000, + "1m": 1000000 + } + } + } + }, + "opus-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { + "id": "low", + "label": "Low" + }, + { + "id": "medium", + "label": "Medium" + }, + { + "id": "high", + "label": "High", + "isDefault": true + }, + { + "id": "xhigh", + "label": "Extra High" + }, + { + "id": "max", + "label": "Max" + }, + { + "id": "ultracode", + "label": "Ultracode", + "description": "xhigh effort plus multi-agent workflow orchestration" + }, + { + "id": "ultrathink", + "label": "Ultrathink" + } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "fastMode", + "label": "Fast Mode", + "type": "boolean" + }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { + "id": "200k", + "label": "200k" + }, + { + "id": "1m", + "label": "1M", + "isDefault": true + } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { + "ultracode": "xhigh", + "ultrathink": null + }, + "modelSuffixes": { + "contextWindow": { + "1m": "[1m]" + } + }, + "contextWindowTokens": { + "200k": 200000, + "1m": 1000000 + } + } + } + }, + "opus-4-8": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { + "id": "low", + "label": "Low" + }, + { + "id": "medium", + "label": "Medium" + }, + { + "id": "high", + "label": "High", + "isDefault": true + }, + { + "id": "xhigh", + "label": "Extra High" + }, + { + "id": "max", + "label": "Max" + }, + { + "id": "ultracode", + "label": "Ultracode", + "description": "xhigh effort plus multi-agent workflow orchestration" + }, + { + "id": "ultrathink", + "label": "Ultrathink" + } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "fastMode", + "label": "Fast Mode", + "type": "boolean" + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { + "ultracode": "xhigh", + "ultrathink": null + }, + "fixedContextWindowTokens": 1000000 + } + } + }, + "opus-4-7": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { + "id": "low", + "label": "Low" + }, + { + "id": "medium", + "label": "Medium" + }, + { + "id": "high", + "label": "High" + }, + { + "id": "xhigh", + "label": "Extra High", + "isDefault": true + }, + { + "id": "max", + "label": "Max" + }, + { + "id": "ultrathink", + "label": "Ultrathink" + } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "fastMode", + "label": "Fast Mode", + "type": "boolean" + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { + "xhigh": "max", + "ultrathink": null + }, + "fixedContextWindowTokens": 1000000 + } + } + }, + "opus-4-6": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { + "id": "low", + "label": "Low" + }, + { + "id": "medium", + "label": "Medium" + }, + { + "id": "high", + "label": "High", + "isDefault": true + }, + { + "id": "max", + "label": "Max" + }, + { + "id": "ultrathink", + "label": "Ultrathink" + } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "fastMode", + "label": "Fast Mode", + "type": "boolean" + }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { + "id": "200k", + "label": "200k" + }, + { + "id": "1m", + "label": "1M", + "isDefault": true + } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { + "ultrathink": null + }, + "modelSuffixes": { + "contextWindow": { + "1m": "[1m]" + } + }, + "contextWindowTokens": { + "200k": 200000, + "1m": 1000000 + } + } + } + }, + "opus-4-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { + "id": "low", + "label": "Low" + }, + { + "id": "medium", + "label": "Medium" + }, + { + "id": "high", + "label": "High", + "isDefault": true + }, + { + "id": "max", + "label": "Max" + } + ] + }, + { + "id": "fastMode", + "label": "Fast Mode", + "type": "boolean" + } + ] + }, + "adapter": { + "claudeCode": {} + } + }, + "sonnet-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { + "id": "low", + "label": "Low" + }, + { + "id": "medium", + "label": "Medium" + }, + { + "id": "high", + "label": "High", + "isDefault": true + }, + { + "id": "xhigh", + "label": "Extra High" + }, + { + "id": "max", + "label": "Max" + }, + { + "id": "ultrathink", + "label": "Ultrathink" + } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { + "id": "200k", + "label": "200k", + "isDefault": true + }, + { + "id": "1m", + "label": "1M" + } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { + "ultrathink": null + }, + "modelSuffixes": { + "contextWindow": { + "1m": "[1m]" + } + }, + "contextWindowTokens": { + "200k": 200000, + "1m": 1000000 + } + } + } + }, + "sonnet-4-6": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { + "id": "low", + "label": "Low" + }, + { + "id": "medium", + "label": "Medium" + }, + { + "id": "high", + "label": "High", + "isDefault": true + }, + { + "id": "max", + "label": "Max" + }, + { + "id": "ultrathink", + "label": "Ultrathink" + } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { + "id": "200k", + "label": "200k", + "isDefault": true + }, + { + "id": "1m", + "label": "1M" + } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { + "max": "high", + "ultrathink": null + }, + "modelSuffixes": { + "contextWindow": { + "1m": "[1m]" + } + }, + "contextWindowTokens": { + "200k": 200000, + "1m": 1000000 + } + } + } + }, + "haiku-4-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "thinking", + "label": "Thinking", + "type": "boolean" + } + ] + }, + "adapter": { + "claudeCode": {} + } + } + }, + "models": [ + { + "slug": "claude-fable-5-1", + "name": "Claude Fable 5.1", + "aliases": ["fable", "fable-5.1", "claude-fable-5.1"], + "status": "current", + "badge": "new", + "profile": "fable-5", + "adapter": { + "claudeCode": { + "minVersion": "2.1.257" + } + } + }, + { + "slug": "claude-fable-5", + "name": "Claude Fable 5", + "status": "legacy", + "profile": "fable-5", + "adapter": { + "claudeCode": { + "minVersion": "2.1.169" + } + } + }, + { + "slug": "claude-opus-5", + "name": "Claude Opus 5", + "aliases": ["opus", "opus-5", "claude-opus-5.0", "claude-opus-5-0"], + "status": "current", + "profile": "opus-5", + "adapter": { + "claudeCode": { + "minVersion": "2.1.219" + } + } + }, + { + "slug": "claude-opus-4-8", + "name": "Claude Opus 4.8", + "aliases": ["opus-4.8", "claude-opus-4.8"], + "status": "legacy", + "profile": "opus-4-8", + "adapter": { + "claudeCode": { + "minVersion": "2.1.154" + } + } + }, + { + "slug": "claude-opus-4-7", + "name": "Claude Opus 4.7", + "aliases": ["opus-4.7", "claude-opus-4.7"], + "status": "legacy", + "profile": "opus-4-7", + "adapter": { + "claudeCode": { + "minVersion": "2.1.111" + } + } + }, + { + "slug": "claude-opus-4-6", + "name": "Claude Opus 4.6", + "aliases": ["opus-4.6", "claude-opus-4.6", "claude-opus-4-6-20251117"], + "status": "legacy", + "profile": "opus-4-6" + }, + { + "slug": "claude-opus-4-5", + "name": "Claude Opus 4.5", + "status": "legacy", + "profile": "opus-4-5" + }, + { + "slug": "claude-sonnet-5", + "name": "Claude Sonnet 5", + "aliases": ["sonnet", "sonnet-5", "claude-sonnet-5.0", "claude-sonnet-5-0"], + "status": "current", + "profile": "sonnet-5" + }, + { + "slug": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "aliases": ["sonnet-4.6", "claude-sonnet-4.6", "claude-sonnet-4-6-20251117"], + "status": "legacy", + "profile": "sonnet-4-6" + }, + { + "slug": "claude-haiku-4-5", + "name": "Claude Haiku 4.5", + "aliases": ["haiku", "haiku-4.5", "claude-haiku-4.5", "claude-haiku-4-5-20251001"], + "status": "legacy", + "profile": "haiku-4-5" + } + ] + }, + "antigravity": { + "defaults": { + "chat": "gemini-3.8-flash-high" + }, + "profiles": {}, + "models": [ + { + "slug": "gemini-3.8-flash-high", + "name": "Gemini 3.8 Flash (High)", + "status": "current" + }, + { + "slug": "gemini-3.8-flash-medium", + "name": "Gemini 3.8 Flash (Medium)", + "status": "current" + }, + { + "slug": "gemini-3.8-flash-low", + "name": "Gemini 3.8 Flash (Low)", + "status": "current" + } + ] + } } } diff --git a/apps/server/src/provider/providerSnapshot.test.ts b/apps/server/src/provider/providerSnapshot.test.ts index 011572780..6d1dcb5de 100644 --- a/apps/server/src/provider/providerSnapshot.test.ts +++ b/apps/server/src/provider/providerSnapshot.test.ts @@ -10,6 +10,7 @@ import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { + buildServerProvider, isCommandMissingCause, providerModelsFromSettings, spawnAndCollect, @@ -35,6 +36,30 @@ const OPENCODE_CUSTOM_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabil }); describe("providerModelsFromSettings", () => { + it("publishes custom names and replaces options without overriding built-in models", () => { + const defaults = createModelCapabilities({ optionDescriptors: [] }); + const builtIn = { slug: "built-in", name: "Official", isCustom: false, capabilities: defaults }; + const models = providerModelsFromSettings( + [builtIn], + [ + { slug: "built-in", name: "Override", capabilities: OPENCODE_CUSTOM_MODEL_CAPABILITIES }, + { slug: "private", name: "My model", capabilities: OPENCODE_CUSTOM_MODEL_CAPABILITIES }, + "legacy", + ], + defaults, + ); + expect(models).toEqual([ + builtIn, + { + slug: "private", + name: "My model", + isCustom: true, + capabilities: OPENCODE_CUSTOM_MODEL_CAPABILITIES, + }, + { slug: "legacy", name: "legacy", isCustom: true, capabilities: defaults }, + ]); + }); + it("applies the provided capabilities to custom models", () => { const models = providerModelsFromSettings( [], @@ -134,3 +159,27 @@ describe("ProviderCommandNotFoundError", () => { }); }); }); + +describe("provider context reporting metadata", () => { + it("preserves true, false and unknown without inventing a runtime capability", () => { + for (const value of [true, false, undefined]) { + const snapshot = buildServerProvider({ + presentation: { + displayName: "Test", + ...(value === undefined ? {} : { reportsContextWindow: value }), + }, + enabled: true, + checkedAt: "2026-09-10T00:00:00Z", + models: [], + probe: { + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + }, + }); + expect(snapshot.reportsContextWindow).toBe(value); + expect("reportsContextWindow" in snapshot).toBe(value !== undefined); + } + }); +}); diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index fbc2762ce..27e0455e7 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -1,4 +1,5 @@ import type { + CustomModelSetting, ProviderDriverKind, ModelCapabilities, ServerProvider, @@ -13,7 +14,7 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { normalizeCustomModelSlug } from "@t3tools/shared/model"; +import { readCustomModelEntries } from "@t3tools/shared/model"; import { isWindowsCommandNotFound } from "../processRunner.ts"; import { createProviderVersionAdvisory } from "./providerMaintenance.ts"; import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; @@ -57,6 +58,7 @@ export interface ServerProviderPresentation { readonly displayName: string; readonly badgeLabel?: string; readonly showInteractionModeToggle?: boolean; + readonly reportsContextWindow?: boolean; readonly requiresNewThreadForModelChange?: boolean; } @@ -141,24 +143,23 @@ export function parseGenericCliVersion(output: string): string | null { export function providerModelsFromSettings( builtInModels: ReadonlyArray, - customModels: ReadonlyArray, + customModels: ReadonlyArray, customModelCapabilities: ModelCapabilities, ): ReadonlyArray { const resolvedBuiltInModels = [...builtInModels]; const seen = new Set(resolvedBuiltInModels.map((model) => model.slug)); const customEntries: ServerProviderModel[] = []; - for (const candidate of customModels) { - const normalized = normalizeCustomModelSlug(candidate); - if (!normalized || seen.has(normalized)) { + for (const entry of readCustomModelEntries(customModels)) { + if (seen.has(entry.slug)) { continue; } - seen.add(normalized); + seen.add(entry.slug); customEntries.push({ - slug: normalized, - name: normalized, + slug: entry.slug, + name: entry.name, isCustom: true, - capabilities: customModelCapabilities, + capabilities: entry.capabilities ?? customModelCapabilities, }); } @@ -238,6 +239,9 @@ export function buildServerProvider(input: { return { displayName: input.presentation.displayName, ...(input.presentation.badgeLabel ? { badgeLabel: input.presentation.badgeLabel } : {}), + ...(typeof input.presentation.reportsContextWindow === "boolean" + ? { reportsContextWindow: input.presentation.reportsContextWindow } + : {}), ...(typeof input.presentation.showInteractionModeToggle === "boolean" ? { showInteractionModeToggle: input.presentation.showInteractionModeToggle } : {}), diff --git a/apps/server/src/provider/providerUsageLimits.test.ts b/apps/server/src/provider/providerUsageLimits.test.ts index 3b0164dc8..56732b03b 100644 --- a/apps/server/src/provider/providerUsageLimits.test.ts +++ b/apps/server/src/provider/providerUsageLimits.test.ts @@ -1,11 +1,39 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; import { ServerProviderUsageLimits } from "@t3tools/contracts"; -import { codexUsageLimits, claudeUsageLimits } from "./providerUsageLimits.ts"; +import { + codexUsageLimits, + claudeUsageLimits, + applyClaudeRateLimitEvent, + applyCodexRateLimitEvent, + usageLimitsAfterProbe, +} from "./providerUsageLimits.ts"; const decodeLimits = Schema.decodeUnknownSync(ServerProviderUsageLimits); const checkedAt = "2026-09-06T00:00:00.000Z"; describe("provider subscription limits", () => { + it("maps available reset credits and preserves them across live window updates", () => { + const snapshot = codexUsageLimits({ primary: { usedPercent: 90 } }, checkedAt, { + availableCount: 2, + credits: [ + { status: "used", expiresAt: 1 }, + { status: "available", expiresAt: 1788652800 }, + { status: "available", expiresAt: 1788652900 }, + ], + }); + expect(snapshot.resetCredits).toEqual({ + availableCount: 2, + nextExpiresAt: "2026-09-06T00:00:00.000Z", + }); + expect(decodeLimits(snapshot)).toEqual(snapshot); + expect( + applyCodexRateLimitEvent(snapshot, { primary: { usedPercent: 95 } }, checkedAt)?.resetCredits, + ).toEqual(snapshot.resetCredits); + expect(codexUsageLimits({}, checkedAt).resetCredits).toBeUndefined(); + expect( + codexUsageLimits({}, checkedAt, { availableCount: 0 }).resetCredits?.availableCount, + ).toBe(0); + }); it("normalizes Codex windows, clamping percentages and rejecting invalid durations", () => { const limits = codexUsageLimits( { @@ -64,3 +92,143 @@ it("does not present model-specific Codex quotas as the main allowance", () => { ?.usedPercent, ).toBe(20); }); + +it("updates Claude live fractions without losing reset times, scoped identities or other windows", () => { + const previous = { + checkedAt, + windows: [ + { + id: "five_hour", + kind: "session" as const, + label: "Session", + usedPercent: 20, + resetsAt: "2026-09-06T03:00:00.000Z", + windowDurationMins: 300, + }, + { + id: "seven_day_fable", + kind: "weekly" as const, + label: "Weekly · Fable", + usedPercent: 30, + windowDurationMins: 10080, + }, + ], + }; + const update = applyClaudeRateLimitEvent( + previous, + { status: "allowed", rateLimitType: "seven_day_overage_included", utilization: 0.5 }, + "2026-09-06T01:00:00.000Z", + ); + expect(update?.windows.map((w) => [w.id, w.usedPercent])).toEqual([ + ["five_hour", 20], + ["seven_day_fable", 50], + ]); + expect(update?.windows[1]?.label).toBe("Weekly · Fable"); + expect( + applyClaudeRateLimitEvent( + update, + { status: "allowed", rateLimitType: "seven_day_overage_included", utilization: 0.5 }, + checkedAt, + ), + ).toBe(update); + expect( + applyClaudeRateLimitEvent( + previous, + { status: "allowed", rateLimitType: "five_hour", utilization: 0.4 }, + checkedAt, + )?.windows[0]?.resetsAt, + ).toBe(previous.windows[0]?.resetsAt); + expect(decodeLimits(update)).toEqual(update); +}); + +it("does not invent unknown model buckets or turn unsupported accounts into quota bars", () => { + const unavailable = { checkedAt, windows: [], unavailable: { reason: "unsupported" as const } }; + const scoped = { + status: "allowed" as const, + rateLimitType: "seven_day_overage_included" as const, + utilization: 0.5, + }; + expect(applyClaudeRateLimitEvent(undefined, scoped, checkedAt)).toBeUndefined(); + expect( + applyClaudeRateLimitEvent( + unavailable, + { status: "allowed", rateLimitType: "five_hour", utilization: 0.5 }, + checkedAt, + ), + ).toBe(unavailable); + expect( + applyClaudeRateLimitEvent( + undefined, + { status: "allowed", rateLimitType: "five_hour", utilization: Number.NaN }, + checkedAt, + ), + ).toBeUndefined(); +}); + +it("keeps live usage across stale or failed probes, while unsupported remains authoritative", () => { + const current = { checkedAt: "2026-09-06T01:00:00.000Z", windows: [] }; + expect(usageLimitsAfterProbe(current, { checkedAt, windows: [] })).toBe(current); + expect( + usageLimitsAfterProbe(current, { + checkedAt, + windows: [], + unavailable: { reason: "probeFailed" }, + }), + ).toBe(current); + const unsupported = { checkedAt, windows: [], unavailable: { reason: "unsupported" as const } }; + expect(usageLimitsAfterProbe(current, unsupported)).toBe(unsupported); +}); + +it("keeps the probe's first model-scoped bucket stable across successive live updates", () => { + const previous = { + checkedAt, + windows: [ + { id: "seven_day_zeta", kind: "weekly" as const, label: "Weekly · Zeta", usedPercent: 10 }, + { id: "seven_day_alpha", kind: "weekly" as const, label: "Weekly · Alpha", usedPercent: 20 }, + ], + }; + const first = applyClaudeRateLimitEvent( + previous, + { status: "allowed", rateLimitType: "seven_day_overage_included", utilization: 0.5 }, + checkedAt, + ); + const second = applyClaudeRateLimitEvent( + first, + { status: "allowed", rateLimitType: "seven_day_overage_included", utilization: 0.6 }, + checkedAt, + ); + expect(second?.windows.map((w) => [w.id, w.usedPercent])).toEqual([ + ["seven_day_zeta", 60], + ["seven_day_alpha", 20], + ]); +}); + +it("merges live Codex windows without replacing monthly semantics, reset times or other quotas", () => { + const previous = codexUsageLimits( + { + planType: "free", + primary: { usedPercent: 20, resetsAt: 1788652800 }, + secondary: { usedPercent: 30 }, + }, + checkedAt, + ); + const updated = applyCodexRateLimitEvent(previous, { primary: { usedPercent: 40 } }, checkedAt); + expect(updated?.windows[0]).toMatchObject({ + kind: "monthly", + usedPercent: 40, + resetsAt: previous.windows[0]?.resetsAt, + windowDurationMins: 43200, + }); + expect(updated?.windows[1]).toEqual(previous.windows[1]); + expect(applyCodexRateLimitEvent(updated, { primary: { usedPercent: 40 } }, checkedAt)).toBe( + updated, + ); + expect( + applyCodexRateLimitEvent( + updated, + { limitId: "codex_spark", primary: { usedPercent: 99 } }, + checkedAt, + ), + ).toBe(updated); + expect(decodeLimits(updated)).toEqual(updated); +}); diff --git a/apps/server/src/provider/providerUsageLimits.ts b/apps/server/src/provider/providerUsageLimits.ts index f7455ad38..a634813f0 100644 --- a/apps/server/src/provider/providerUsageLimits.ts +++ b/apps/server/src/provider/providerUsageLimits.ts @@ -1,3 +1,6 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import type { SDKRateLimitInfo } from "@anthropic-ai/claude-agent-sdk"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; import type { ServerProviderUsageLimits, ServerProviderUsageWindow } from "@t3tools/contracts"; @@ -35,6 +38,12 @@ export interface CodexRateLimitSnapshot { export function codexUsageLimits( snapshot: CodexRateLimitSnapshot, checkedAt: string, + resetCredits?: { + readonly availableCount: number; + readonly credits?: + | readonly { readonly status: string; readonly expiresAt?: number | null }[] + | null; + } | null, ): ServerProviderUsageLimits { if (snapshot.limitId && snapshot.limitId !== "codex") return { checkedAt, windows: [] }; const monthly = snapshot.planType === "free" || snapshot.planType === "go"; @@ -68,7 +77,27 @@ export function codexUsageLimits( ...(resetsAt ? { resetsAt } : {}), }); } - return { checkedAt, windows }; + const expiries = + resetCredits?.credits?.flatMap((credit) => + credit.status === "available" && + typeof credit.expiresAt === "number" && + Number.isFinite(credit.expiresAt) + ? [credit.expiresAt] + : [], + ) ?? []; + const nextExpiresAt = expiries.length ? iso(Math.min(...expiries) * 1000) : undefined; + return { + checkedAt, + windows, + ...(resetCredits && Number.isFinite(resetCredits.availableCount) + ? { + resetCredits: { + availableCount: Math.max(0, Math.floor(resetCredits.availableCount)), + ...(nextExpiresAt ? { nextExpiresAt } : {}), + }, + } + : {}), + }; } export function claudeUsageLimits( @@ -128,3 +157,135 @@ export function claudeUsageLimits( } return { checkedAt, windows }; } + +/** A driver supplies this listener to its own V2 adapter; accounts never share it. */ +export class ClaudeUsageLimitListener extends Context.Service< + ClaudeUsageLimitListener, + { + readonly publish: (info: SDKRateLimitInfo) => Effect.Effect; + } +>()("t3/provider/providerUsageLimits/ClaudeUsageLimitListener") {} + +/** Sparse live windows retain the probe's reset time and unrelated account windows. */ +export function applyClaudeRateLimitEvent( + previous: ServerProviderUsageLimits | undefined, + info: SDKRateLimitInfo, + checkedAt: string, +): ServerProviderUsageLimits | undefined { + if ( + previous?.unavailable?.reason === "unsupported" || + typeof info.utilization !== "number" || + !Number.isFinite(info.utilization) + ) + return previous; + const type: string | undefined = info.rateLimitType; + const scoped = + type === "seven_day_overage_included" + ? previous?.windows.find( + (window) => window.id.startsWith("seven_day_") && window.kind === "weekly", + ) + : undefined; + const id = type === "five_hour" || type === "seven_day" ? type : scoped?.id; + if (!id) return previous; + const existing = previous?.windows.find((window) => window.id === id); + const reset = + typeof info.resetsAt === "number" && Number.isFinite(info.resetsAt) && info.resetsAt > 0 + ? iso(info.resetsAt * 1000) + : undefined; + const kind = id === "five_hour" ? "session" : "weekly"; + const next: ServerProviderUsageWindow = { + id, + kind, + label: scoped?.label ?? (kind === "session" ? "Session" : "Weekly"), + windowDurationMins: + existing?.windowDurationMins ?? (kind === "session" ? SESSION_MINS : WEEK_MINS), + usedPercent: clamp(info.utilization * 100), + ...((reset ?? existing?.resetsAt) ? { resetsAt: reset ?? existing?.resetsAt } : {}), + }; + if ( + existing && + existing.usedPercent === next.usedPercent && + existing.resetsAt === next.resetsAt && + existing.label === next.label && + !previous?.unavailable + ) + return previous; + // Preserve the probe's scoped-model order: its first model names the + // overage-included event bucket, even when another model sorts before it. + const windows = previous?.windows.map((window) => (window.id === id ? next : window)) ?? []; + if (!existing) { + if (kind === "session") windows.unshift(next); + else windows.push(next); + } + return { checkedAt, windows }; +} + +/** Failed or cached probes cannot erase fresher usage received during a turn. */ +export function usageLimitsAfterProbe( + published: ServerProviderUsageLimits | undefined, + probed: ServerProviderUsageLimits | undefined, +): ServerProviderUsageLimits | undefined { + if (probed?.unavailable?.reason === "unsupported") return probed; + if ( + published && + !published.unavailable && + (probed?.unavailable?.reason === "probeFailed" || + (probed && Date.parse(published.checkedAt) > Date.parse(probed.checkedAt))) + ) + return published; + return probed; +} + +export class CodexUsageLimitListener extends Context.Service< + CodexUsageLimitListener, + { + readonly publish: (snapshot: CodexRateLimitSnapshot) => Effect.Effect; + } +>()("t3/provider/providerUsageLimits/CodexUsageLimitListener") {} + +export function applyCodexRateLimitEvent( + previous: ServerProviderUsageLimits | undefined, + snapshot: CodexRateLimitSnapshot, + checkedAt: string, +): ServerProviderUsageLimits | undefined { + if (previous?.unavailable?.reason === "unsupported") return previous; + const incoming = codexUsageLimits(snapshot, checkedAt); + if (incoming.windows.length === 0) return previous; + const windows = [...(previous?.windows ?? [])]; + let changed = false; + for (const window of incoming.windows) { + const index = windows.findIndex((current) => current.id === window.id); + const existing = windows[index]; + const raw = window.id === "primary" ? snapshot.primary : snapshot.secondary; + const next = { + ...window, + ...(window.resetsAt === undefined && existing?.resetsAt + ? { resetsAt: existing.resetsAt } + : {}), + ...(raw?.windowDurationMins == null && snapshot.planType == null && existing + ? { + kind: existing.kind, + label: existing.label, + windowDurationMins: existing.windowDurationMins, + } + : {}), + }; + if ( + existing && + existing.kind === next.kind && + existing.usedPercent === next.usedPercent && + existing.resetsAt === next.resetsAt && + existing.windowDurationMins === next.windowDurationMins + ) + continue; + changed = true; + if (index < 0) windows.push(next); + else windows[index] = next; + } + if (!changed && previous && !previous.unavailable) return previous; + return { + checkedAt, + windows, + ...(previous?.resetCredits ? { resetCredits: previous.resetCredits } : {}), + }; +} diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 33d0d120c..fd8cf7590 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -2300,7 +2300,12 @@ layer("GitHubPullRequestCli.layer", (it) => { // One request, because both answers hang off the same repository object. assert.strictEqual(mockedExecute.mock.calls.length, 1); expect(callAt(0).args).toContain("number=7"); - expect(access).toEqual({ canWrite: false, canUpdate: true, didAuthor: true }); + expect(access).toEqual({ + canWrite: false, + canTriage: false, + canUpdate: true, + didAuthor: true, + }); }), ); @@ -2463,7 +2468,12 @@ layer("GitHubPullRequestCli.layer", (it) => { }); assert.strictEqual(mockedExecute.mock.calls.length, 2); - expect(access).toEqual({ canWrite: false, canUpdate: true, didAuthor: true }); + expect(access).toEqual({ + canWrite: false, + canTriage: false, + canUpdate: true, + didAuthor: true, + }); yield* TestClock.setTime(Date.parse("2100-01-01T00:00:00Z")); }), ); @@ -2603,4 +2613,55 @@ layer("GitHubPullRequestCli.layer", (it) => { ]); }), ); + it.effect("puts labels on by posting to the issue's own collection, all at once", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.setLabels({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + labels: ["bug", "size:XL"], + applied: true, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 1); + const call = callAt(0); + expect(call.args).toEqual([ + "api", + "--method", + "POST", + "--hostname", + "github.com", + "repos/acme/web/issues/7/labels", + "--input", + "-", + ]); + // @effect-diagnostics-next-line preferSchemaOverJson:off - asserting the raw gh request body. + expect(JSON.parse(call.stdin ?? "")).toEqual({ labels: ["bug", "size:XL"] }); + }), + ); + + it.effect("takes labels off one at a time, naming each in the path encoded", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.setLabels({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + labels: ["good first issue", "area/web"], + applied: false, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 2); + expect(callAt(0).args).toContain("repos/acme/web/issues/7/labels/good%20first%20issue"); + expect(callAt(0).args).toContain("DELETE"); + expect(callAt(1).args).toContain("repos/acme/web/issues/7/labels/area%2Fweb"); + }), + ); }); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 5f8ee8c4e..74c890c8d 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -1,3 +1,15 @@ +import type { PullRequestLabelCandidateList } from "@t3tools/contracts"; +import { + LABEL_CANDIDATES_GRAPHQL_QUERY, + decodeLabelCandidatesJson, + buildLabelRequestJson, +} from "./gitHubPullRequestJson.ts"; +import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; +import { + decodePullRequestStacksJson, + type GitHubPullRequestStack, +} from "./gitHubPullRequestJson.ts"; +import type { PullRequestStackHead } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -241,6 +253,7 @@ export class GitHubSubjectScopeError extends Schema.TaggedErrorClass Effect.Effect; + readonly getPullRequestStack: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly includeDetails?: boolean; + }) => Effect.Effect; + /** The repository's labels, and which of them this pull request already wears. */ + readonly listLabelCandidates: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }) => Effect.Effect; + + readonly setLabels: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly labels: ReadonlyArray; + /** False takes each label off; true adds each to whatever is already there. */ + readonly applied: boolean; + }) => Effect.Effect; + readonly runPullRequestAction: (input: { readonly cwd: string; readonly repository: string; readonly host: string; readonly number: number; readonly action: PullRequestAction; + readonly stackNumber?: number; + readonly expectedStackHeads?: ReadonlyArray; readonly mergeMethod?: PullRequestMergeMethod; readonly updateMethod?: PullRequestUpdateMethod; }) => Effect.Effect; @@ -1655,6 +1695,56 @@ export const make = Effect.gen(function* () { }); }, + listLabelCandidates: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listLabelCandidates", + allowReserve: true, + variables: [ + ["-f", `owner=${owner}`], + ["-f", `name=${name}`], + ["-F", `number=${input.number}`], + ], + query: LABEL_CANDIDATES_GRAPHQL_QUERY, + decode: decodeLabelCandidatesJson, + }); + }, + + setLabels: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + // A pull request is an issue to the labels API. Adding posts a list and leaves what was + // already there; taking off is one delete per label, since the endpoint names one in its + // path. The name goes into the path encoded, because a label may carry a space or a slash. + const issue = `repos/${owner}/${name}/issues/${input.number}/labels`; + if (input.applied) { + return github + .execute({ + cwd: input.cwd, + args: ["api", "--method", "POST", "--hostname", input.host, issue, "--input", "-"], + stdin: buildLabelRequestJson(input.labels), + }) + .pipe(Effect.asVoid); + } + return Effect.forEach( + input.labels, + (label) => + github.execute({ + cwd: input.cwd, + args: [ + "api", + "--method", + "DELETE", + "--hostname", + input.host, + `${issue}/${encodeURIComponent(label)}`, + ], + }), + { concurrency: 1, discard: true }, + ); + }, + listReviewerCandidates: (input) => { const { owner, name } = parseRepositorySelector(input.repository); return graphqlRead({ @@ -1696,7 +1786,73 @@ export const make = Effect.gen(function* () { .pipe(Effect.asVoid); }, + getPullRequestStack: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return github + .execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + `repos/${owner}/${name}/stacks?pull_request=${input.number}`, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodePullRequestStacksJson(result.stdout.trim()); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestStack", + cause: decoded.failure, + }), + ); + }), + Effect.flatMap((stack) => { + if (!input.includeDetails || stack === null) return Effect.succeed(stack); + return github + .execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + `repos/${owner}/${name}/stacks/${stack.number}`, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodePullRequestStacksJson(`[${result.stdout.trim()}]`); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestStack", + cause: decoded.failure, + }), + ); + }), + ); + }), + // Hosts without the stacks preview return 404. Other failures must preserve the + // previously synced stack and let the caller retry. + Effect.catchTags({ + GitHubPullRequestNotFoundError: () => Effect.succeed(null), + }), + ); + }, + runPullRequestAction: (input) => { + if (input.stackNumber !== undefined) + return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe( + Effect.provideService(GitHubCli.GitHubCli, github), + ); const [subcommand, ...flags] = actionArgs( input.action, input.mergeMethod, diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index 2555c05dc..d3044c3dd 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -24,6 +24,8 @@ describe("gitHubViewerPermissions", () => { resolve: true, verdicts: ["comment", "approve", "request-changes"], requestReviewers: true, + labels: true, + stackRebase: true, }); }); @@ -39,6 +41,7 @@ describe("gitHubViewerPermissions", () => { verdicts: ["comment", "approve", "request-changes"], // Asking somebody else to review is the one thing read access never stretches to. requestReviewers: false, + labels: false, }); }); @@ -51,6 +54,7 @@ describe("gitHubViewerPermissions", () => { // GitHub refuses an author's approval of their own change, so the page does not offer one. verdicts: ["comment"], requestReviewers: false, + labels: false, }); }); @@ -70,6 +74,7 @@ describe("gitHubViewerPermissions", () => { resolve: false, verdicts: ["comment", "approve", "request-changes"], requestReviewers: false, + labels: false, }); }).pipe( Effect.provide( diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index cc097c30c..0000fd564 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -41,6 +41,7 @@ const CAPABILITIES: PullRequestCapabilities = { }, reviewers: { request: true, listCandidates: true }, edit: { changeRequest: true, comment: true }, + labels: true, }; /** @@ -78,6 +79,8 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest // leaves them commenting, which is what an author has to say about their own change anyway. verdicts: access.didAuthor ? (["comment"] as const) : CAPABILITIES.review.verdicts, requestReviewers: access.canWrite, + labels: access.canTriage ?? access.canWrite, + ...(access.canWrite ? { stackRebase: true } : {}), ...(access.canUpdateBranch === true ? { updateMethods: CAPABILITIES.updateMethods } : {}), }; } @@ -223,6 +226,16 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError(fail("listChangeRequestStats"))), + // A single CLI read supplies live overview fields without detail permissions or comparisons. + getChangeRequestSummary: (input) => + cli.getPullRequestDetail(input).pipe( + Effect.map((summary) => ({ + ...summary, + author: withAvatar(summary.author, new Map(), input.host), + })), + Effect.mapError(fail("getChangeRequestSummary")), + ), + getChangeRequest: (input) => Effect.all( [ @@ -414,6 +427,25 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError(fail("setReviewerRequest"))), + getStack: (input) => + cli + .getPullRequestStack({ ...input, includeDetails: input.includeDetails !== false }) + .pipe(Effect.mapError(fail("getStack"))), + listLabelCandidates: (input) => + cli.listLabelCandidates(input).pipe(Effect.mapError(fail("listLabelCandidates"))), + + setLabels: (input) => + cli + .setLabels({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + labels: input.labels, + applied: input.applied, + }) + .pipe(Effect.mapError(fail("setLabels"))), + runAction: (input) => cli .runPullRequestAction({ @@ -422,6 +454,10 @@ export const make = Effect.gen(function* () { host: input.host, number: input.number, action: input.action, + ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), + ...(input.expectedStackHeads === undefined + ? {} + : { expectedStackHeads: input.expectedStackHeads }), ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), }) diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index eadc5e931..7f9b82395 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -1,3 +1,5 @@ +import type { PullRequestLabelCandidateList } from "@t3tools/contracts"; +import type { PullRequestStack, PullRequestStackHead } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import type { @@ -87,6 +89,29 @@ export interface ProviderChangeRequest { readonly checksState?: PullRequestChecksState | null | undefined; } +/** The fields needed to keep a linked thread's pull request status live. */ +export interface ProviderChangeRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + /** Present when the host says an open pull request is still a draft. */ + readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; + readonly updatedAt: string; + /** Overview fields, present where the host's single read returns them at no extra cost. */ + readonly author?: PullRequestActor | null | undefined; + readonly additions?: number | undefined; + readonly deletions?: number | undefined; + readonly changedFiles?: number | undefined; + readonly reviewDecision?: PullRequestReviewDecision | null | undefined; + readonly checksState?: PullRequestChecksState | null | undefined; + readonly mergeability?: PullRequestMergeability | undefined; +} + export interface ProviderChangeRequestPage { readonly items: ReadonlyArray; /** True when the host has more rows than the page size asked for. */ @@ -297,6 +322,10 @@ export interface PullRequestProviderApi { }>; }) => Effect.Effect, PullRequestProviderError>; + readonly getChangeRequestSummary?: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + readonly getChangeRequest: ( input: ProviderRepositoryRef & { readonly number: number }, ) => Effect.Effect; @@ -356,10 +385,16 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; + readonly getStack?: ( + input: ProviderRepositoryRef & { readonly number: number; readonly includeDetails?: boolean }, + ) => Effect.Effect; + readonly runAction: ( input: ProviderRepositoryRef & { readonly number: number; readonly action: PullRequestAction; + readonly stackNumber?: number; + readonly expectedStackHeads?: ReadonlyArray; /** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */ readonly mergeMethod?: PullRequestMergeMethod; /** Only meaningful for `update-branch`; absent takes the host's own default. */ @@ -446,6 +481,19 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; + readonly listLabelCandidates?: ( + input: ProviderRepositoryRef & { readonly number: number }, + ) => Effect.Effect; + + /** Puts labels on the change request, or takes them off. One call for both directions. */ + readonly setLabels?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly labels: ReadonlyArray; + readonly applied: boolean; + }, + ) => Effect.Effect; + /** Only called when `capabilities.review.reply` is true. */ readonly replyToThread: ( input: ProviderRepositoryRef & { diff --git a/apps/server/src/pullRequest/PullRequestReadCache.test.ts b/apps/server/src/pullRequest/PullRequestReadCache.test.ts new file mode 100644 index 000000000..b7872c46d --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestReadCache.test.ts @@ -0,0 +1,106 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { PullRequestOperationError } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; +import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"; +import * as Persistence from "effect/unstable/persistence/Persistence"; +import * as PullRequestReadCache from "./PullRequestReadCache.ts"; + +const cacheLayer = (directory: string) => + PullRequestReadCache.make.pipe( + Effect.provide( + Persistence.layerKvs.pipe(Layer.provideMerge(KeyValueStore.layerFileSystem(directory))), + ), + ); + +it.layer(NodeServices.layer)("PR filesystem cache", (it) => { + it.effect("reuses files after restart and respects the original expiry", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" }); + let reads = 0; + const lookup = Effect.sync(() => String(++reads)); + const first = yield* cacheLayer(directory); + const key = "long/repository/key".repeat(100); + assert.strictEqual(yield* first.get(key, lookup), "1"); + yield* TestClock.adjust("59 seconds"); + const restarted = yield* cacheLayer(directory); + assert.strictEqual(yield* restarted.get(key, lookup), "1"); + yield* TestClock.adjust("1 second"); + assert.strictEqual(yield* restarted.get(key, lookup), "2"); + assert.strictEqual(reads, 2); + assert.strictEqual((yield* fs.readDirectory(directory)).length, 1); + }), + ); + + it.effect("clears in-flight reads before a new service can reuse them", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" }); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const cache = yield* cacheLayer(directory); + const read = yield* cache + .get( + "summary", + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as("old"), + ), + ) + .pipe(Effect.forkChild); + yield* Deferred.await(started); + const invalidate = yield* cache.invalidate.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(read); + yield* Fiber.join(invalidate); + const restarted = yield* cacheLayer(directory); + assert.strictEqual(yield* restarted.get("summary", Effect.succeed("new")), "new"); + }), + ); + + it.effect("does not persist failed GitHub reads", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-cache-" }); + const cache = yield* cacheLayer(directory); + const error = new PullRequestOperationError({ operation: "summary", detail: "unavailable" }); + yield* cache.get("summary", Effect.fail(error)).pipe(Effect.flip); + const restarted = yield* cacheLayer(directory); + assert.strictEqual(yield* restarted.get("summary", Effect.succeed("recovered")), "recovered"); + }), + ); + it.effect("keeps the shorter detail expiry after a restart", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-detail-cache-" }); + let reads = 0; + const lookup = Effect.sync(() => String(++reads)); + const first = yield* cacheLayer(directory); + assert.strictEqual(yield* first.get("detail", lookup, 15_000), "1"); + yield* TestClock.adjust("14 seconds"); + const restarted = yield* cacheLayer(directory); + assert.strictEqual(yield* restarted.get("detail", lookup, 15_000), "1"); + yield* TestClock.adjust("1 second"); + assert.strictEqual(yield* restarted.get("detail", lookup, 15_000), "2"); + }), + ); + it.effect("falls back to a host read when the persisted file is corrupt", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pr-corrupt-cache-" }); + const cache = yield* cacheLayer(directory); + yield* cache.get("detail", Effect.succeed("old")); + for (const file of yield* fs.readDirectory(directory)) { + yield* fs.writeFileString(`${directory}/${file}`, "not valid cache data"); + } + const restarted = yield* cacheLayer(directory); + assert.strictEqual(yield* restarted.get("detail", Effect.succeed("fresh")), "fresh"); + }), + ); +}); diff --git a/apps/server/src/pullRequest/PullRequestReadCache.ts b/apps/server/src/pullRequest/PullRequestReadCache.ts new file mode 100644 index 000000000..335f5b787 --- /dev/null +++ b/apps/server/src/pullRequest/PullRequestReadCache.ts @@ -0,0 +1,132 @@ +import * as Cache from "effect/Cache"; +import * as Clock from "effect/Clock"; +import * as Equal from "effect/Equal"; +import * as Hash from "effect/Hash"; +import { PullRequestOperationError, PullRequestUnavailableError } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Encoding from "effect/Encoding"; +import * as Option from "effect/Option"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"; +import * as Persistable from "effect/unstable/persistence/Persistable"; +import * as PersistedCache from "effect/unstable/persistence/PersistedCache"; +import * as Persistence from "effect/unstable/persistence/Persistence"; +import { ServerConfig } from "../config.ts"; + +const CONCURRENT_READS = 512; +type ReadError = PullRequestOperationError | PullRequestUnavailableError; + +class Read extends Persistable.Class<{ + payload: { key: string; lookup: Effect.Effect; ttlMs: number }; +}>()("PullRequestRead", { + primaryKey: ({ key }) => key, + success: Schema.Struct({ payload: Schema.String, expiresAt: Schema.Finite }), + error: Schema.Union([PullRequestOperationError, PullRequestUnavailableError]), +}) { + [Equal.symbol](that: unknown): boolean { + return that instanceof Read && that.key === this.key; + } + [Hash.symbol](): number { + return Hash.string(this.key); + } +} + +export class PullRequestReadCache extends Context.Service< + PullRequestReadCache, + { + readonly get: ( + key: string, + lookup: Effect.Effect, + ttlMs?: number, + ) => Effect.Effect; + readonly invalidate: Effect.Effect; + } +>()("t3/pullRequest/PullRequestReadCache") {} + +export const make = Effect.gen(function* () { + const backing = yield* KeyValueStore.KeyValueStore; + const crypto = yield* Crypto.Crypto; + const clock = yield* Clock.Clock; + let enabled = true; + const lock = yield* Semaphore.make(CONCURRENT_READS); + const timeToLive: Persistable.TimeToLiveFn = (exit) => + Exit.isSuccess(exit) + ? Duration.millis(Math.max(0, exit.value.expiresAt - clock.currentTimeMillisUnsafe())) + : Duration.zero; + const cache = yield* PersistedCache.make( + (request: Read) => + request.lookup.pipe( + Effect.map((payload) => ({ + payload, + expiresAt: clock.currentTimeMillisUnsafe() + request.ttlMs, + })), + ), + { + storeId: "pr-v2", + timeToLive, + inMemoryTTL: timeToLive, + inMemoryCapacity: CONCURRENT_READS, + }, + ); + return PullRequestReadCache.of({ + get: Effect.fn("PullRequestReadCache.get")(function* (key, lookup, ttlMs = 60_000) { + if (!enabled) return yield* lookup; + const digest = yield* crypto + .digest("SHA-256", new TextEncoder().encode(key)) + .pipe(Effect.option); + if (Option.isNone(digest)) return yield* lookup; + const read = yield* Effect.cached(lookup); + return yield* Effect.suspend(() => + enabled + ? cache + .get(new Read({ key: Encoding.encodeHex(digest.value), lookup: read, ttlMs })) + .pipe(Effect.map((result) => result.payload)) + : read, + ).pipe( + Effect.catchTags({ + PersistenceError: () => read, + SchemaError: () => read, + }), + Effect.uninterruptible, + lock.withPermits(1), + ); + }), + // Let existing reads finish before clearing, so they cannot repopulate stale entries. + invalidate: Cache.invalidateAll(cache.inMemory).pipe( + Effect.andThen(backing.clear), + Effect.catch(() => { + enabled = false; + return Effect.logWarning("PR cache disabled after clearing failed"); + }), + lock.withPermits(CONCURRENT_READS), + ), + }); +}); + +export const layer = Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig; + const path = yield* Path.Path; + return Layer.effect(PullRequestReadCache, make).pipe( + Layer.provide(Persistence.layerKvs), + Layer.provide( + KeyValueStore.layerFileSystem( + path.join(config.providerStatusCacheDir, "pull-requests"), + ).pipe( + Layer.catch(() => + Layer.effectDiscard( + Effect.logWarning("PR cache directory unavailable; using memory cache"), + ).pipe(Layer.provideMerge(KeyValueStore.layerMemory)), + ), + ), + ), + ); + }), +); diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 8f96bbca4..eebb3c70d 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1,6 +1,12 @@ +import * as Stream from "effect/Stream"; +import * as Option from "effect/Option"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"; +import * as Persistence from "effect/unstable/persistence/Persistence"; +import * as PullRequestReadCache from "./PullRequestReadCache.ts"; import * as TestClock from "effect/testing/TestClock"; import type { Project, @@ -170,6 +176,11 @@ function makeService(input: { }), }), SourceControlRateLimit.layer, + Layer.effect(PullRequestReadCache.PullRequestReadCache, PullRequestReadCache.make).pipe( + Layer.provide(Persistence.layerKvs), + Layer.provide(KeyValueStore.layerMemory), + Layer.provide(NodeServices.layer), + ), ), ), ); @@ -3420,3 +3431,484 @@ it.effect("names the signed-in account in the detail, and says nothing where the assert.strictEqual(unnamed.viewer, undefined); }), ); + +it.effect("routes stack reads and preserves reviewed heads through action authorization", () => + Effect.gen(function* () { + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; + const heads = [ + { number: 1, headSha: "abc" }, + { number: 2, headSha: "def" }, + ]; + const stack = { + id: "stack-1", + number: 1, + url: "https://github.com/acme/web/stack/1", + base: "main", + layers: [], + }; + let calls = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getStack: (input) => { + assert.equal(input.number, 2); + return Effect.succeed(stack); + }, + runAction: (input) => { + calls++; + assert.equal(input.stackNumber, 1); + assert.deepEqual(input.expectedStackHeads, heads); + return Effect.void; + }, + }), + ], + }); + assert.deepEqual(yield* service.stack(reference), stack); + yield* service.runAction({ + ...reference, + stackNumber: 1, + expectedStackHeads: heads, + action: "merge", + mergeMethod: "merge", + }); + assert.equal(calls, 1); + }), +); + +it.effect("returns no stack and refuses stack mutations on unsupported hosts", () => + Effect.gen(function* () { + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; + let calls = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + runAction: () => { + calls++; + return Effect.void; + }, + }), + ], + }); + assert.isNull(yield* service.stack(reference)); + const error = yield* Effect.flip( + service.runAction({ ...reference, stackNumber: 1, action: "merge" }), + ); + assert.equal(error._tag, "PullRequestOperationError"); + assert.equal(calls, 0); + }), +); + +it.effect("authorizes stack rebases separately from single-branch behind status", () => + Effect.gen(function* () { + let stackRebase = true; + let taken = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { + ...fakeProvider("github").capabilities, + actions: ["update-branch"], + updateMethods: ["rebase"], + }, + getStack: () => Effect.succeed(null), + getViewerPermissions: () => + Effect.succeed({ + actions: [], + stackRebase, + comment: true, + resolve: false, + verdicts: [], + requestReviewers: false, + }), + runAction: () => + Effect.sync(() => { + taken++; + }), + }), + ], + }); + const input = { + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 3, + action: "update-branch" as const, + updateMethod: "rebase" as const, + stackNumber: 50, + expectedStackHeads: [{ number: 3, headSha: "ccc" }], + }; + yield* service.runAction(input); + assert.equal(taken, 1); + stackRebase = false; + assert.equal((yield* Effect.flip(service.runAction(input)))._tag, "PullRequestOperationError"); + assert.equal(taken, 1); + }), +); + +it.effect("refuses a label change on a host that has not said it takes one", () => + Effect.gen(function* () { + let changed = false; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + // The method is there; the capability that would let it be called is not. + setLabels: () => { + changed = true; + return Effect.void; + }, + }), + ], + }); + + const error = yield* Effect.flip( + service.setLabels({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + labels: ["bug"], + applied: true, + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.include(error.message, "cannot change the labels"); + assert.isFalse(changed); + }), +); + +it.effect("refuses a label change this viewer may not make, and says what access it takes", () => + Effect.gen(function* () { + let changed = false; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { ...fakeProvider("github").capabilities, labels: true }, + getViewerPermissions: () => + Effect.succeed({ + actions: [], + comment: true, + resolve: false, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: false, + labels: false, + }), + listLabelCandidates: () => Effect.die("must not be called"), + setLabels: () => { + changed = true; + return Effect.void; + }, + }), + ], + }); + + const listError = yield* Effect.flip( + service.labelCandidates({ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }), + ); + assert.include(listError.message, "Changing labels needs triage access on this repository"); + + const error = yield* Effect.flip( + service.setLabels({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + labels: ["bug"], + applied: true, + }), + ); + assert.include(error.message, "Changing labels needs triage access on this repository"); + assert.isFalse(changed); + }), +); + +it.effect("hands a label change to the host, and reads the labels back for the menu", () => + Effect.gen(function* () { + let received: { labels: ReadonlyArray; applied: boolean } | null = null; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { ...fakeProvider("github").capabilities, labels: true }, + getViewerPermissions: () => + Effect.succeed({ + actions: [], + comment: true, + resolve: false, + verdicts: [], + requestReviewers: false, + labels: true, + }), + listLabelCandidates: () => + Effect.succeed({ + candidates: [{ name: "bug", color: null, description: null, isApplied: false }], + truncated: false, + }), + setLabels: (input) => { + received = { labels: input.labels, applied: input.applied }; + return Effect.void; + }, + }), + ], + }); + + const list = yield* service.labelCandidates({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 4, + }); + assert.deepStrictEqual( + list.candidates.map((label) => label.name), + ["bug"], + ); + + yield* service.setLabels({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 4, + labels: ["bug"], + applied: false, + }); + assert.deepStrictEqual(received, { labels: ["bug"], applied: false }); + }), +); + +it.effect("does not reuse persisted detail when a project moves to another workspace", () => + Effect.gen(function* () { + let reads = 0; + const projects = [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ]; + const service = yield* makeService({ + projects, + providers: [ + fakeProvider("github", { + getChangeRequest: ({ cwd }) => + Effect.sync(() => { + reads += 1; + return { + ...changeRequest(1, "2026-07-02T00:00:00Z"), + title: cwd, + body: "", + changedFiles: 0, + mergedAt: null, + closedAt: null, + reviewers: [], + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + viewerPermissions: { + actions: [], + comment: true, + resolve: true, + verdicts: [], + requestReviewers: true, + }, + }; + }), + }), + ], + }); + const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + assert.strictEqual((yield* service.detail(ref)).title, "/a"); + assert.strictEqual((yield* service.detail(ref)).title, "/a"); + assert.strictEqual(reads, 1); + projects[0] = project({ id: "p1", title: "web", workspaceRoot: "/b", repository: "acme/web" }); + assert.strictEqual((yield* service.detail(ref)).title, "/b"); + assert.strictEqual(reads, 2); + }), +); + +it.effect("forgets detail after a host partially writes and then reports a failure", () => + Effect.gen(function* () { + let title = "Before"; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => + Effect.sync(() => ({ + ...changeRequest(1, "2026-07-02T00:00:00Z"), + title, + body: "", + changedFiles: 0, + mergedAt: null, + closedAt: null, + reviewers: [], + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + viewerPermissions: { + actions: [], + comment: true, + resolve: true, + verdicts: [], + requestReviewers: true, + }, + })), + updateChangeRequest: () => + Effect.sync(() => { + title = "After"; + }).pipe( + Effect.andThen( + Effect.fail( + new PullRequestProviderError({ + operation: "updateChangeRequest", + provider: "github", + reason: "failed", + detail: "Second write failed", + }), + ), + ), + ), + }), + ], + }); + const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + assert.strictEqual((yield* service.detail(ref)).title, "Before"); + yield* service.update({ ...ref, title: "After" }).pipe(Effect.flip); + assert.strictEqual((yield* service.detail(ref)).title, "After"); + }), +); + +it.effect("refreshes linked summaries with the narrow provider read and shares its cache", () => + Effect.gen(function* () { + let reads = 0; + const service = yield* makeService({ + projects: [ + project({ id: "p", title: "Repo", workspaceRoot: "/repo", repository: "org/repo" }), + ], + providers: [ + fakeProvider("github", { + getChangeRequestSummary: () => + Effect.sync(() => { + reads += 1; + return { ...changeRequest(42, "2026-09-11T00:00:00Z"), isDraft: true }; + }), + }), + ], + }); + const ref = { projectId: "p" as ProjectId, repository: "org/repo", number: 42 }; + const first = yield* service.summary(ref); + const second = yield* service.summary(ref); + assert.equal(reads, 1); + assert.isTrue(first.isDraft); + assert.deepEqual(second, first); + yield* service.invalidate({ reference: ref }); + yield* service.summary(ref); + assert.equal(reads, 2); + }), +); + +it.effect( + "routes a hosted linked summary explicitly without weakening hostless repository checks", + () => + Effect.gen(function* () { + const requested: string[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p", title: "Repo", workspaceRoot: "/repo", repository: "org/repo" }), + ], + providers: [ + fakeProvider("github", { + getChangeRequestSummary: (input) => + Effect.sync(() => { + requested.push(`${input.host}/${input.repository}`); + return changeRequest(42, "2026-09-11T00:00:00Z"); + }), + }), + ], + }); + const ref = { projectId: "p" as ProjectId, repository: "other/repo", number: 42 }; + assert.isTrue((yield* service.summary(ref).pipe(Effect.result))._tag === "Failure"); + yield* service.summary({ ...ref, host: "github.com" }); + assert.deepEqual(requested, ["github.com/other/repo"]); + assert.isTrue( + (yield* service.summary({ ...ref, host: "unknown.example" }).pipe(Effect.result))._tag === + "Failure", + ); + assert.equal(requested.length, 1); + }), +); + +it.effect("keeps lightweight stack reads separate from hydrated panel stacks", () => + Effect.gen(function* () { + const reads: boolean[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p", title: "Repo", workspaceRoot: "/repo", repository: "org/repo" }), + ], + providers: [ + fakeProvider("github", { + getStack: (input) => + Effect.sync(() => { + reads.push(input.includeDetails !== false); + return null; + }), + }), + ], + }); + const ref = { projectId: "p" as ProjectId, repository: "org/repo", number: 42 }; + yield* service.stack(ref, { includeDetails: false }); + yield* service.stack(ref); + yield* service.stack(ref, { includeDetails: false }); + assert.deepEqual(reads, [false, true]); + }), +); + +it.effect("publishes only confirmed merges with the host terminal timestamp", () => + Effect.gen(function* () { + const mergedAt = "2026-09-10T10:00:00Z"; + const service = yield* makeService({ + projects: [ + project({ + id: "merge-project", + title: "Repo", + workspaceRoot: "/repo", + repository: "acme/repo", + }), + ], + providers: [ + fakeProvider("github", { + getChangeRequest: ({ number }) => + Effect.succeed({ + ...changeRequest(number, "2026-09-11T12:00:00Z"), + body: "", + changedFiles: 0, + closedAt: null, + reviewers: [], + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + viewerPermissions: { + actions: [], + comment: true, + resolve: true, + verdicts: [], + requestReviewers: true, + }, + state: number === 1 ? "open" : "merged", + mergedAt: number === 1 ? null : mergedAt, + url: `https://github.com/acme/repo/pull/${number}`, + }), + }), + ], + }); + const merges = yield* service.subscribeMerges; + const ref = { + projectId: "merge-project" as ProjectId, + repository: "acme/repo", + action: "merge" as const, + }; + yield* service.runAction({ ...ref, number: 1 }); + yield* service.runAction({ ...ref, number: 2 }); + const event = yield* Stream.runHead(merges); + assert.deepEqual(Option.getOrThrow(event), { + projectId: ref.projectId, + repository: ref.repository, + host: "github.com", + number: 2, + url: "https://github.com/acme/repo/pull/2", + mergedAt, + }); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index dbb4d7a66..ebc05b8fd 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1,3 +1,12 @@ +import * as PubSub from "effect/PubSub"; +import * as Stream from "effect/Stream"; +import type * as Scope from "effect/Scope"; +import * as Cause from "effect/Cause"; +import type { + PullRequestLabelCandidateList, + PullRequestLabelChangeInput, +} from "@t3tools/contracts"; +import { PullRequestSummary, PullRequestStack } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -5,6 +14,9 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as PullRequestReadCache from "./PullRequestReadCache.ts"; import { PullRequestOperationError, PullRequestUnavailableError, @@ -17,7 +29,7 @@ import { type PullRequestActivity, type PullRequestCommentInput, type PullRequestCommentUpdateInput, - type PullRequestDetail, + PullRequestDetail, type PullRequestDiffFileContentsInput, type PullRequestDiffFileContentsResult, type PullRequestDiffStat, @@ -46,7 +58,10 @@ import { type SourceControlProviderInfo, type SourceControlProviderKind, } from "@t3tools/contracts"; -import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; +import { + canonicalRepositoryKey, + detectSourceControlProviderFromRemoteUrl, +} from "@t3tools/shared/sourceControl"; import * as ProjectService from "../project/ProjectService.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; @@ -111,6 +126,15 @@ const LIST_STATS_CACHE_CAPACITY = 32; const DETAIL_CACHE_CAPACITY = 128; const DIFF_CACHE_CAPACITY = 128; +/** Internal linked-PR reads can explicitly target another repository on a configured host. */ +export type PullRequestLinkRef = PullRequestRef & { readonly host?: string }; + +export interface PullRequestMergeEvent extends PullRequestRef { + readonly host: string; + readonly url: string; + readonly mergedAt: string | null; +} + export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError; export class PullRequestService extends Context.Service< @@ -122,6 +146,9 @@ export class PullRequestService extends Context.Service< readonly listStats: ( input: PullRequestListStatsInput, ) => Effect.Effect; + readonly summary: ( + input: PullRequestLinkRef, + ) => Effect.Effect; readonly detail: (input: PullRequestRef) => Effect.Effect; readonly activity: ( input: PullRequestRef, @@ -135,6 +162,15 @@ export class PullRequestService extends Context.Service< readonly diffFileContents: ( input: PullRequestDiffFileContentsInput, ) => Effect.Effect; + readonly stack: ( + input: PullRequestLinkRef, + options?: { readonly includeDetails?: boolean }, + ) => Effect.Effect; + readonly subscribeMerges: Effect.Effect< + Stream.Stream, + never, + Scope.Scope + >; readonly runAction: (input: PullRequestActionInput) => Effect.Effect; readonly update: (input: PullRequestUpdateInput) => Effect.Effect; readonly comment: (input: PullRequestCommentInput) => Effect.Effect; @@ -159,6 +195,12 @@ export class PullRequestService extends Context.Service< readonly requestReviewers: ( input: PullRequestReviewerRequestInput, ) => Effect.Effect; + readonly labelCandidates: ( + input: PullRequestRef, + ) => Effect.Effect; + readonly setLabels: ( + input: PullRequestLabelChangeInput, + ) => Effect.Effect; readonly invalidate: (input: PullRequestInvalidateInput) => Effect.Effect; } >()("t3/pullRequest/PullRequestService") {} @@ -430,6 +472,11 @@ function withRateLimitBackoff( listChangeRequestStats: wrap("listChangeRequestStats", api.listChangeRequestStats), }), getChangeRequest: wrap("getChangeRequest", api.getChangeRequest), + ...(api.getChangeRequestSummary === undefined + ? {} + : { + getChangeRequestSummary: wrap("getChangeRequestSummary", api.getChangeRequestSummary), + }), getChangeRequestActivity: wrap("getChangeRequestActivity", api.getChangeRequestActivity), ...(api.getReviewThreadComments === undefined ? {} @@ -441,6 +488,7 @@ function withRateLimitBackoff( ...(api.getDiffFileContents === undefined ? {} : { getDiffFileContents: wrap("getDiffFileContents", api.getDiffFileContents) }), + ...(api.getStack === undefined ? {} : { getStack: wrap("getStack", api.getStack) }), runAction: interactive("runAction", api.runAction), ...(api.updateChangeRequest === undefined ? {} @@ -454,6 +502,10 @@ function withRateLimitBackoff( submitReview: interactive("submitReview", api.submitReview), listReviewerCandidates: interactive("listReviewerCandidates", api.listReviewerCandidates), setReviewerRequest: interactive("setReviewerRequest", api.setReviewerRequest), + ...(api.listLabelCandidates === undefined + ? {} + : { listLabelCandidates: interactive("listLabelCandidates", api.listLabelCandidates) }), + ...(api.setLabels === undefined ? {} : { setLabels: interactive("setLabels", api.setLabels) }), replyToThread: interactive("replyToThread", api.replyToThread), setReaction: interactive("setReaction", api.setReaction), setThreadResolution: interactive("setThreadResolution", api.setThreadResolution), @@ -485,7 +537,9 @@ export function repositoryIdentityOf(project: OrchestrationProjectShell): string } export const make = Effect.gen(function* () { + const mergedPullRequests = yield* PubSub.sliding(64); const registry = yield* PullRequestProviderRegistry; + const readCache = yield* PullRequestReadCache.PullRequestReadCache; const projectService = yield* ProjectService.ProjectService; const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; const rateLimits = yield* SourceControlRateLimit.SourceControlRateLimit; @@ -622,16 +676,25 @@ export const make = Effect.gen(function* () { }), ); - const requireProject = (ref: PullRequestRef): Effect.Effect => + const requireProject = ( + ref: PullRequestLinkRef, + ): Effect.Effect => listWorkspaceProjects({ projectId: ref.projectId }).pipe( Effect.flatMap(({ supported }): Effect.Effect => { - const match = supported[0]; - if (!match) { - return Effect.fail(new PullRequestUnavailableError({ reason: "provider-unsupported" })); + const own = supported[0]; + const repository = ref.repository.trim(); + const host = ref.host?.trim().toLowerCase(); + if (own !== undefined && own.repository.toLowerCase() === repository.toLowerCase()) { + // Hostless references only ever meant the project's own repository, and a hosted one + // naming it still is; either way the project serves itself. + if (host === undefined || host === own.host) return Effect.succeed(own); } - // The repository travels through the client, so it is checked against the project's - // own remote rather than being handed to a provider verbatim. - if (match.repository.toLowerCase() !== ref.repository.trim().toLowerCase()) { + if (host === undefined) { + if (own === undefined) { + return Effect.fail(new PullRequestUnavailableError({ reason: "provider-unsupported" })); + } + // The repository travels through the client, so it is checked against the project's + // own remote rather than being handed to a provider verbatim. return Effect.fail( new PullRequestOperationError({ operation: "resolveRepository", @@ -639,7 +702,39 @@ export const make = Effect.gen(function* () { }), ); } - return Effect.succeed(match); + const repositoryKey = canonicalRepositoryKey(`${host}/${repository}`.toLowerCase()); + // Azure SSH and legacy clone hosts differ from the browser URL's host. Compare + // the complete repository identity before narrowing those checkouts by host. + return listWorkspaceProjects( + repositoryKey.startsWith("dev.azure.com/") ? {} : { host }, + ).pipe( + Effect.flatMap(({ supported }) => { + const onHost = supported.filter((candidate) => candidate.host === host); + const route = + supported.find( + (candidate) => + candidate.api.kind === "azure-devops" && + candidate.project.repositoryIdentity != null && + canonicalRepositoryKey( + candidate.project.repositoryIdentity.canonicalKey.toLowerCase(), + ) === repositoryKey, + ) ?? + onHost.find( + (candidate) => + candidate.api.kind !== "azure-devops" && + candidate.repository.toLowerCase() === repository.toLowerCase(), + ) ?? + onHost.find((candidate) => candidate.api.kind !== "azure-devops"); + if (route === undefined) { + return Effect.fail( + new PullRequestUnavailableError({ reason: "provider-unsupported" }), + ); + } + return Effect.succeed( + route.api.kind === "azure-devops" ? route : { ...route, repository }, + ); + }), + ); }), ); @@ -1296,84 +1391,194 @@ export const make = Effect.gen(function* () { }), ); - const runAction: PullRequestService["Service"]["runAction"] = (input) => + const summaryUncached: PullRequestService["Service"]["summary"] = (input) => requireProject(input).pipe( - Effect.flatMap((project): Effect.Effect => { - // The surface hides what a host cannot do, and this refuses it as well: a request that - // reached here anyway must not be handed to a provider that never claimed the action. - if (!project.api.capabilities.actions.includes(input.action)) { - return Effect.fail( - new PullRequestOperationError({ - operation: "runAction", - detail: `This host cannot ${input.action} a change request.`, - }), - ); - } - // A strategy the host does not offer must be refused rather than passed on: every - // provider maps an unrecognised method to its own default, so asking Azure DevOps to - // rebase would quietly merge instead of failing. - if ( - input.mergeMethod !== undefined && - !project.api.capabilities.mergeMethods.includes(input.mergeMethod) - ) { - return Effect.fail( - new PullRequestOperationError({ - operation: "runAction", - detail: `This host cannot merge with the ${input.mergeMethod} strategy.`, - }), - ); - } - // The same for the way a stale branch is brought up to date: a host that only merges - // must not be asked to rebase and left to pick something else. - if ( - input.updateMethod !== undefined && - !(project.api.capabilities.updateMethods ?? []).includes(input.updateMethod) - ) { - return Effect.fail( - new PullRequestOperationError({ - operation: "runAction", - detail: `This host cannot update a branch by ${input.updateMethod}.`, + Effect.flatMap((project) => { + const providerInput = { + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + }; + const read = + project.api.getChangeRequestSummary === undefined + ? project.api.getChangeRequest(providerInput) + : project.api.getChangeRequestSummary(providerInput); + return read.pipe( + Effect.mapError(toPullRequestError("summary")), + Effect.map( + (changeRequest): PullRequestSummary => ({ + provider: project.api.kind, + projectId: project.project.id, + repository: project.repository, + number: changeRequest.number, + title: changeRequest.title, + url: changeRequest.url, + state: changeRequest.state, + headBranch: changeRequest.headBranch, + baseBranch: changeRequest.baseBranch, + closedAt: changeRequest.closedAt ?? null, + mergedAt: changeRequest.mergedAt ?? null, + updatedAt: changeRequest.updatedAt, + ...(changeRequest.isDraft === undefined ? {} : { isDraft: changeRequest.isDraft }), + ...(changeRequest.author === undefined ? {} : { author: changeRequest.author }), + ...(changeRequest.additions === undefined + ? {} + : { additions: changeRequest.additions }), + ...(changeRequest.deletions === undefined + ? {} + : { deletions: changeRequest.deletions }), + ...(changeRequest.changedFiles === undefined + ? {} + : { changedFiles: changeRequest.changedFiles }), + ...(changeRequest.reviewDecision === undefined + ? {} + : { reviewDecision: changeRequest.reviewDecision }), + ...(changeRequest.checksState === undefined + ? {} + : { checksState: changeRequest.checksState }), + ...(changeRequest.mergeability === undefined + ? {} + : { mergeability: changeRequest.mergeability }), }), - ); - } - // What the host can do and what this account may ask of it are two questions, and both - // have to say yes. The second is asked last, because it costs a request and the checks - // above do not. - return viewerPermissionsOf(project, input, "runAction").pipe( - Effect.flatMap((viewer): Effect.Effect => { - if (!viewer.actions.includes(input.action)) { - return Effect.fail( - new PullRequestOperationError({ - operation: "runAction", - detail: ACTION_ACCESS_REFUSALS[input.action], - }), - ); - } - if ( - input.updateMethod !== undefined && - !(viewer.updateMethods ?? []).includes(input.updateMethod) - ) { - return Effect.fail( - new PullRequestOperationError({ - operation: "runAction", - detail: ACTION_ACCESS_REFUSALS["update-branch"], - }), - ); - } - return project.api - .runAction({ + ), + ); + }), + ); + + const stackUncached: PullRequestService["Service"]["stack"] = (input, options) => + requireProject(input).pipe( + Effect.flatMap((project) => + project.api.getStack + ? project.api + .getStack({ cwd: project.project.workspaceRoot, repository: project.repository, host: project.host, number: input.number, - action: input.action, - ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), - ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), + includeDetails: options?.includeDetails !== false, }) - .pipe(Effect.mapError(toPullRequestError("runAction"))); - }), - ); - }), + .pipe(Effect.mapError(toPullRequestError("stack"))) + : Effect.succeed(null), + ), + ); + + const runAction = ( + input: PullRequestActionInput, + ): Effect.Effect<{ readonly repository: string; readonly host: string }, PullRequestError> => + requireProject(input).pipe( + Effect.flatMap( + ( + project, + ): Effect.Effect< + { readonly repository: string; readonly host: string }, + PullRequestError + > => { + if (input.stackNumber !== undefined && !project.api.getStack) { + return Effect.fail( + new PullRequestOperationError({ + operation: "runAction", + detail: "This host does not support stack actions.", + }), + ); + } + // The surface hides what a host cannot do, and this refuses it as well: a request that + // reached here anyway must not be handed to a provider that never claimed the action. + if (!project.api.capabilities.actions.includes(input.action)) { + return Effect.fail( + new PullRequestOperationError({ + operation: "runAction", + detail: `This host cannot ${input.action} a change request.`, + }), + ); + } + // A strategy the host does not offer must be refused rather than passed on: every + // provider maps an unrecognised method to its own default, so asking Azure DevOps to + // rebase would quietly merge instead of failing. + if ( + input.mergeMethod !== undefined && + !project.api.capabilities.mergeMethods.includes(input.mergeMethod) + ) { + return Effect.fail( + new PullRequestOperationError({ + operation: "runAction", + detail: `This host cannot merge with the ${input.mergeMethod} strategy.`, + }), + ); + } + // The same for the way a stale branch is brought up to date: a host that only merges + // must not be asked to rebase and left to pick something else. + if ( + input.updateMethod !== undefined && + !(project.api.capabilities.updateMethods ?? []).includes(input.updateMethod) + ) { + return Effect.fail( + new PullRequestOperationError({ + operation: "runAction", + detail: `This host cannot update a branch by ${input.updateMethod}.`, + }), + ); + } + // What the host can do and what this account may ask of it are two questions, and both + // have to say yes. The second is asked last, because it costs a request and the checks + // above do not. + return viewerPermissionsOf(project, input, "runAction").pipe( + Effect.flatMap( + ( + viewer, + ): Effect.Effect< + { readonly repository: string; readonly host: string }, + PullRequestError + > => { + const stackRebase = + input.stackNumber !== undefined && input.action === "update-branch"; + if ( + stackRebase ? viewer.stackRebase !== true : !viewer.actions.includes(input.action) + ) { + return Effect.fail( + new PullRequestOperationError({ + operation: "runAction", + detail: ACTION_ACCESS_REFUSALS[input.action], + }), + ); + } + if ( + !stackRebase && + input.updateMethod !== undefined && + !(viewer.updateMethods ?? []).includes(input.updateMethod) + ) { + return Effect.fail( + new PullRequestOperationError({ + operation: "runAction", + detail: ACTION_ACCESS_REFUSALS["update-branch"], + }), + ); + } + return project.api + .runAction({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + action: input.action, + ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), + ...(input.expectedStackHeads === undefined + ? {} + : { expectedStackHeads: input.expectedStackHeads }), + ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + ...(input.updateMethod === undefined + ? {} + : { updateMethod: input.updateMethod }), + }) + .pipe( + Effect.mapError(toPullRequestError("runAction")), + Effect.as({ repository: project.repository, host: project.host }), + ); + }, + ), + ); + }, + ), ); const comment: PullRequestService["Service"]["comment"] = (input) => @@ -1662,6 +1867,75 @@ export const make = Effect.gen(function* () { * the one the request is made from. So the same permission guards both: a page that could open * the menu without it would offer a list whose every press was going to be turned down. */ + const LABEL_CHANGE_REFUSAL = "Changing labels needs triage access on this repository."; + const labelCandidates: PullRequestService["Service"]["labelCandidates"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project): Effect.Effect => { + const list = project.api.listLabelCandidates; + if (project.api.capabilities.labels !== true || list === undefined) { + return Effect.fail( + new PullRequestOperationError({ + operation: "labelCandidates", + detail: "This host cannot change the labels on a change request.", + }), + ); + } + return viewerPermissionsOf(project, input, "labelCandidates").pipe( + Effect.flatMap( + (viewer): Effect.Effect => + viewer.labels !== true + ? Effect.fail( + new PullRequestOperationError({ + operation: "labelCandidates", + detail: LABEL_CHANGE_REFUSAL, + }), + ) + : list({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + }).pipe(Effect.mapError(toPullRequestError("labelCandidates"))), + ), + ); + }), + ); + + const setLabels: PullRequestService["Service"]["setLabels"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project): Effect.Effect => { + const change = project.api.setLabels; + if (project.api.capabilities.labels !== true || change === undefined) { + return Effect.fail( + new PullRequestOperationError({ + operation: "setLabels", + detail: "This host cannot change the labels on a change request.", + }), + ); + } + return viewerPermissionsOf(project, input, "setLabels").pipe( + Effect.flatMap( + (viewer): Effect.Effect => + viewer.labels !== true + ? Effect.fail( + new PullRequestOperationError({ + operation: "setLabels", + detail: LABEL_CHANGE_REFUSAL, + }), + ) + : change({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + labels: input.labels, + applied: input.applied, + }).pipe(Effect.mapError(toPullRequestError("setLabels"))), + ), + ); + }), + ); + const reviewerCandidates: PullRequestService["Service"]["reviewerCandidates"] = (input) => requireProject(input).pipe( Effect.flatMap( @@ -1966,20 +2240,69 @@ export const make = Effect.gen(function* () { return Cache.get(listCache, key); }; - const detailCache = yield* Cache.makeWith( - (key: string) => { - const [, projectId, repository, number] = JSON.parse(key) as [number, string, string, number]; - return detailUncached({ projectId, repository, number } as PullRequestRef); - }, - { - capacity: DETAIL_CACHE_CAPACITY, - timeToLive: (exit) => (Exit.isSuccess(exit) ? DETAIL_CACHE_TTL : Duration.zero), - }, - ); - const detail: PullRequestService["Service"]["detail"] = (input) => { - const key = JSON.stringify([refEpoch(input), input.projectId, input.repository, input.number]); - return Cache.get(detailCache, key); - }; + // Persist overview, detail and stack reads. Resolve the project before every cache read so + // removed projects and changed hosts/workspaces cannot reuse another identity's data. + const persistedRead = Effect.fn("PullRequestService.persistedRead")(function* ( + input: PullRequestRef, + operation: string, + codec: Schema.Codec, + read: Effect.Effect, + ttlMs: number, + ) { + const project = yield* requireProject(input); + const key = [ + "fork-v2", + operation, + project.api.kind, + project.host.toLowerCase(), + project.repository, + project.project.id, + project.project.workspaceRoot, + String(input.number), + ] + .map(encodeURIComponent) + .join(":"); + const lookup = yield* Effect.cached(read); + const encoded = lookup.pipe( + Effect.flatMap((value) => + Schema.encodeEffect(codec)(value).pipe( + Effect.mapError( + (cause) => + new PullRequestOperationError({ + operation: "cache", + detail: "Could not encode PR cache data.", + cause, + }), + ), + ), + ), + ); + const payload = yield* readCache.get(key, encoded, ttlMs); + const decoded = yield* Schema.decodeUnknownEffect(codec)(payload).pipe(Effect.option); + return Option.isSome(decoded) ? decoded.value : yield* lookup; + }); + const summaryCodec = Schema.fromJsonString(PullRequestSummary); + const summary: PullRequestService["Service"]["summary"] = (input) => + persistedRead(input, "summary", summaryCodec, summaryUncached(input), 60_000); + const detailCodec = Schema.fromJsonString(PullRequestDetail); + const stackCodec = Schema.fromJsonString(Schema.NullOr(PullRequestStack)); + const stack: PullRequestService["Service"]["stack"] = (input, options) => + persistedRead( + input, + `stack:${options?.includeDetails !== false}`, + stackCodec, + stackUncached(input, options), + 60_000, + ); + + const detail: PullRequestService["Service"]["detail"] = (input) => + persistedRead( + input, + "detail", + detailCodec, + detailUncached(input), + Duration.toMillis(DETAIL_CACHE_TTL), + ); const activityCache = yield* Cache.makeWith( (key: string) => { @@ -2065,27 +2388,33 @@ export const make = Effect.gen(function* () { }; const invalidate: PullRequestService["Service"]["invalidate"] = (input) => - Effect.sync(() => { - if (input.reference === undefined) { - listingsEpoch = ++epochCounter; - // A whole-workspace refresh is the reader asking to be re-answered from the hosts, - // and that includes who the hosts say they are. - viewersByHost.clear(); - return; - } - bumpRefEpoch(input.reference); - }); + readCache.invalidate.pipe( + Effect.andThen( + Effect.sync(() => { + if (input.reference === undefined) { + listingsEpoch = ++epochCounter; + // A whole-workspace refresh is the reader asking to be re-answered from the hosts, + // and that includes who the hosts say they are. + viewersByHost.clear(); + return; + } + bumpRefEpoch(input.reference); + }), + ), + ); // A mutation's own client re-reads right after it, and every other client's next read must // see the action too — so a write forgets the change request it touched and the listings its // state change reorders, for everyone, without any client asking. const invalidatedByMutation = - ( - method: (input: I) => Effect.Effect, - ): ((input: I) => Effect.Effect) => + ( + method: (input: I) => Effect.Effect, + ): ((input: I) => Effect.Effect) => (input) => - method(input).pipe( - Effect.tap(() => + readCache.invalidate.pipe( + Effect.andThen(method(input)), + Effect.ensuring(readCache.invalidate), + Effect.ensuring( Effect.sync(() => { bumpRefEpoch(input); listingsEpoch = ++epochCounter; @@ -2094,6 +2423,8 @@ export const make = Effect.gen(function* () { ); return PullRequestService.of({ + subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe(Effect.map(Stream.fromSubscription)), + summary, list, listStats, detail, @@ -2101,7 +2432,45 @@ export const make = Effect.gen(function* () { threadComments, diff, diffFileContents, - runAction: invalidatedByMutation(runAction), + stack, + runAction: (input) => + invalidatedByMutation(runAction)(input).pipe( + Effect.tap((identity) => + input.action !== "merge" + ? Effect.void + : Effect.gen(function* () { + // A successful action may only queue a merge; never publish it as completed. + const confirmed = yield* summaryUncached({ ...input, ...identity }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("failed to confirm pull request merge", { + cause: Cause.pretty(cause), + }).pipe(Effect.as(null)), + ), + ); + if (confirmed?.state !== "merged") return; + yield* PubSub.publish(mergedPullRequests, { + projectId: input.projectId, + ...identity, + number: input.number, + url: confirmed.url, + mergedAt: confirmed.mergedAt ?? null, + }); + }), + ), + Effect.asVoid, + Effect.ensuring( + input.stackNumber === undefined + ? Effect.void + : Effect.sync(() => { + // A rebase can fail after updating earlier layers. Invalidate every reviewed layer. + for (const head of input.expectedStackHeads ?? []) + bumpRefEpoch({ ...input, number: head.number }); + listingsEpoch = ++epochCounter; + }), + ), + ), update: invalidatedByMutation(update), comment: invalidatedByMutation(comment), updateComment: invalidatedByMutation(updateComment), @@ -2111,6 +2480,16 @@ export const make = Effect.gen(function* () { setReaction: invalidatedByMutation(setReaction), // The candidate list is deliberately read fresh per menu-open, so it stays uncached. reviewerCandidates, + labelCandidates, + setLabels: (input) => + invalidatedByMutation(setLabels)(input).pipe( + Effect.ensuring( + Effect.sync(() => { + bumpRefEpoch(input); + listingsEpoch = ++epochCounter; + }), + ), + ), requestReviewers: invalidatedByMutation(requestReviewers), invalidate, }); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index f372ac300..b94e9beba 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -2,6 +2,7 @@ import * as Result from "effect/Result"; import { describe, expect, it } from "vite-plus/test"; import { + decodeLabelCandidatesJson, buildReviewSubmissionJson, buildReviewerRequestJson, decodeBaseComparisonJson, @@ -763,7 +764,7 @@ describe("viewer permission decoding", () => { }), ), ), - ).toEqual({ canWrite: false, canUpdate: true, didAuthor: true }); + ).toEqual({ canWrite: false, canTriage: false, canUpdate: true, didAuthor: true }); }); it("says no to a passer-by on a repository they can only read", () => { @@ -776,7 +777,7 @@ describe("viewer permission decoding", () => { }), ), ), - ).toEqual({ canWrite: false, canUpdate: false, didAuthor: false }); + ).toEqual({ canWrite: false, canTriage: false, canUpdate: false, didAuthor: false }); }); it("reads silence as permission, but not as authorship", () => { @@ -785,6 +786,7 @@ describe("viewer permission decoding", () => { // and claiming it for someone who did not is how an author's own rules get handed out. expect(expectSuccess(decodeViewerPermissionsJson(viewerJson({ pullRequest: null })))).toEqual({ canWrite: false, + canTriage: false, canUpdate: true, didAuthor: false, }); @@ -1361,3 +1363,75 @@ describe("how far a branch trails its base", () => { expect(Result.isSuccess(decodeBaseComparisonJson("{"))).toBe(false); }); }); + +it("allows triage accounts to label without granting write", () => { + const access = expectSuccess( + decodeViewerPermissionsJson( + JSON.stringify({ + data: { + repository: { + viewerPermission: "TRIAGE", + pullRequest: { viewerCanUpdate: false, viewerDidAuthor: false }, + }, + }, + }), + ), + ); + expect(access.canTriage).toBe(true); + expect(access.canWrite).toBe(false); +}); + +describe("label candidate decoding", () => { + const labelsJson = (input: { + readonly defined: ReadonlyArray>; + readonly applied?: ReadonlyArray; + readonly hasNextPage?: boolean; + }) => + JSON.stringify({ + data: { + repository: { + labels: { + pageInfo: { hasNextPage: input.hasNextPage ?? false }, + nodes: input.defined, + }, + pullRequest: { labels: { nodes: (input.applied ?? []).map((name) => ({ name })) } }, + }, + }, + }); + + it("marks the labels the pull request already wears", () => { + const list = expectSuccess( + decodeLabelCandidatesJson( + labelsJson({ + defined: [ + { name: "bug", color: "d73a4a", description: "Something is broken" }, + { name: "size:XL", color: "e4572e", description: null }, + ], + applied: ["size:XL"], + }), + ), + ); + expect(list.candidates).toEqual([ + { name: "bug", color: "d73a4a", description: "Something is broken", isApplied: false }, + { name: "size:XL", color: "e4572e", description: null, isApplied: true }, + ]); + expect(list.truncated).toBe(false); + }); + + it("keeps a worn label the repository no longer defines, so it can be taken off", () => { + const list = expectSuccess( + decodeLabelCandidatesJson(labelsJson({ defined: [{ name: "bug" }], applied: ["legacy"] })), + ); + expect(list.candidates.map((label) => [label.name, label.isApplied])).toEqual([ + ["legacy", true], + ["bug", false], + ]); + }); + + it("says so when the repository defines more labels than the read asked for", () => { + expect( + expectSuccess(decodeLabelCandidatesJson(labelsJson({ defined: [], hasNextPage: true }))) + .truncated, + ).toBe(true); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 6ec17ea11..ac2835a4c 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -1,3 +1,4 @@ +import type { PullRequestLabelCandidate, PullRequestLabelCandidateList } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Exit from "effect/Exit"; import * as Result from "effect/Result"; @@ -2124,6 +2125,7 @@ export function buildReviewerRequestJson( * only read access can still be told apart from a passer-by. */ export interface GitHubViewerAccess { + readonly canTriage?: boolean; readonly canWrite: boolean; /** GitHub's own `viewerCanUpdate`, true for the author as well as for anyone with write. */ readonly canUpdate: boolean; @@ -2171,6 +2173,9 @@ export function decodeViewerPermissionsJson( const repository = decoded.success.data.repository; return Result.succeed({ canWrite: toCanWrite(repository.viewerPermission), + canTriage: + repository.viewerPermission?.trim().toUpperCase() === "TRIAGE" || + toCanWrite(repository.viewerPermission), ...toPullRequestViewerFields(repository.pullRequest), }); } @@ -2239,3 +2244,170 @@ export function decodePullRequestFilesJson( omittedFileStats, }); } + +/** One pull request as the stacks API lists it: a number, a head, and whether it is done. */ +const RawStackPullRequestSchema = Schema.Struct({ + title: Schema.optional(Schema.String), + draft: Schema.optional(Schema.Boolean), + number: Schema.Int, + head: Schema.Struct({ ref: Schema.String, sha: Schema.optional(Schema.String) }), + state: Schema.optional(Schema.NullOr(Schema.String)), + merged_at: Schema.optional(Schema.NullOr(Schema.String)), +}); + +/** + * A stack as `GET /repos/{owner}/{repo}/stacks` answers it, in a public preview whose shape may + * still move. Only what a stack is made of is required — where it lives, what it stands on, and + * its pull requests — and `base` is accepted both as the ref object the preview sends today and + * as the bare branch name it started out as. + */ +const RawStackSchema = Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.Union([Schema.Int, Schema.String]))), + number: Schema.Int, + node_id: Schema.optional(Schema.NullOr(Schema.String)), + url: Schema.String, + html_url: Schema.optional(Schema.NullOr(Schema.String)), + base: Schema.Union([Schema.String, Schema.Struct({ ref: Schema.String })]), + pull_requests: Schema.Array(RawStackPullRequestSchema), +}); + +const decodeStacks = decodeJsonResult(Schema.Array(RawStackSchema)); + +export interface GitHubPullRequestStackLayer { + readonly title?: string; + readonly isDraft?: boolean; + readonly headSha?: string; + readonly number: number; + readonly headBranch: string; + readonly state: PullRequestState; +} + +export interface GitHubPullRequestStack { + readonly id: string; + readonly number: number; + readonly url: string; + readonly base: string; + /** Bottom to top, which is the order GitHub lists them in. */ + readonly layers: ReadonlyArray; +} + +/** + * The first stack of a `?pull_request=` listing, or null for an empty one: a pull request is in + * at most one stack, so the array is GitHub's way of saying "none" rather than a page. + */ +export function decodePullRequestStacksJson( + raw: string, +): Result.Result { + const decoded = decodeStacks(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const stack = decoded.success[0]; + if (stack === undefined) return Result.succeed(null); + return Result.succeed({ + id: stack.id == null ? (trimmed(stack.node_id) ?? String(stack.number)) : String(stack.id), + number: stack.number, + // The page a person opens where the preview reports one; the API URL is what it always has. + url: trimmed(stack.html_url) ?? stack.url, + base: typeof stack.base === "string" ? stack.base : stack.base.ref, + layers: stack.pull_requests.map((pullRequest) => ({ + ...(pullRequest.title === undefined ? {} : { title: pullRequest.title }), + ...(pullRequest.draft === undefined ? {} : { isDraft: pullRequest.draft }), + ...(pullRequest.head.sha === undefined ? {} : { headSha: pullRequest.head.sha }), + number: pullRequest.number, + headBranch: pullRequest.head.ref, + state: toState({ state: pullRequest.state, mergedAt: pullRequest.merged_at }), + })), + }); +} + +export const LABEL_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + labels(first: ${GRAPHQL_PAGE_SIZE}, orderBy: { field: NAME, direction: ASC }) { + pageInfo { hasNextPage } + nodes { name color description } + } + pullRequest(number: $number) { + labels(first: ${GRAPHQL_PAGE_SIZE}) { nodes { name } } + } + } +}`; + +const RawLabelCandidatesSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + labels: Schema.optional( + Schema.NullOr( + Schema.Struct({ + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.Array( + Schema.NullOr( + Schema.Struct({ + ...RawLabelSchema.fields, + description: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + ), + }), + ), + ), + /** Null for a number that names no pull request the viewer can see. */ + pullRequest: Schema.NullOr( + Schema.Struct({ + labels: Schema.optional( + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.NullOr(RawLabelSchema)) })), + ), + }), + ), + }), + }), +}); + +const decodeLabelCandidates = decodeJsonResult(RawLabelCandidatesSchema); + +/** + * The repository's labels, with the ones already on this pull request marked. A label the pull + * request wears that the repository no longer defines — deleted since, or past the page — leads + * the list anyway, because a label that cannot be seen cannot be taken off. + */ +export function decodeLabelCandidatesJson( + raw: string, +): Result.Result { + const decoded = decodeLabelCandidates(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const repository = decoded.success.data.repository; + const applied = new Set( + (repository.pullRequest?.labels?.nodes ?? []).flatMap((label) => { + const name = trimmed(label?.name); + return name === null ? [] : [name]; + }), + ); + const candidates = new Map(); + for (const node of repository.labels?.nodes ?? []) { + const name = trimmed(node?.name); + if (name === null) continue; + candidates.set(name, { + name, + color: trimmed(node?.color), + description: trimmed(node?.description), + isApplied: applied.has(name), + }); + } + const missing = [...applied].filter((name) => !candidates.has(name)); + return Result.succeed({ + candidates: [ + ...missing.map((name) => ({ name, color: null, description: null, isApplied: true })), + ...candidates.values(), + ], + truncated: repository.labels?.pageInfo?.hasNextPage === true, + }); +} + +/** The body of `POST /repos/{owner}/{repo}/issues/{number}/labels`, which adds to what is there. */ +const LabelRequestSchema = Schema.Struct({ labels: Schema.Array(Schema.String) }); + +const encodeLabelRequest = Schema.encodeSync(Schema.fromJsonString(LabelRequestSchema)); + +export function buildLabelRequestJson(labels: ReadonlyArray): string { + return encodeLabelRequest({ labels }); +} diff --git a/apps/server/src/pullRequest/githubStackActions.test.ts b/apps/server/src/pullRequest/githubStackActions.test.ts new file mode 100644 index 000000000..4ed254514 --- /dev/null +++ b/apps/server/src/pullRequest/githubStackActions.test.ts @@ -0,0 +1,413 @@ +import { expect, it } from "@effect/vitest"; +import * as Layer from "effect/Layer"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts"; + +const runGitHubStackAction = ( + execute: GitHubCli.GitHubCli["Service"]["execute"], + input: Parameters[0], +) => runStackAction(input).pipe(Effect.provide(Layer.mock(GitHubCli.GitHubCli)({ execute }))); + +const stack = [ + { + number: 50, + url: "https://api.github.com/repos/acme/web/stacks/50", + base: { ref: "main" }, + pull_requests: [ + { + number: 1, + title: "Base", + head: { ref: "base", sha: "aaa" }, + state: "closed", + merged_at: "2026-01-01T00:00:00Z", + }, + { + number: 2, + title: "Middle", + head: { ref: "middle", sha: "bbb" }, + state: "open", + draft: false, + }, + { number: 3, title: "Top", head: { ref: "top", sha: "ccc" }, state: "open", draft: false }, + ], + }, +]; +const input = { + cwd: "/repo", + repository: "acme/web", + host: "github.com", + number: 3, + stackNumber: 50, + expectedStackHeads: [ + { number: 2, headSha: "bbb" }, + { number: 3, headSha: "ccc" }, + ], + action: "merge" as const, +}; +const access = { + data: { + repository: { + pr2: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, + pr3: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, + }, + }, +}; + +const branch = (number: number, headRefOid: string, behindBy = 1, processed: string[] = []) => ({ + data: { + processed: processed.map((headRefOid) => ({ headRefOid })), + repository: { + pullRequest: { id: `PR_${number}`, headRefOid, baseRef: { compare: { behindBy } } }, + }, + }, +}); +const rebased = { + data: { updatePullRequestBranch: { pullRequest: { headRefOid: "rebased-sha" } } }, +}; +const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased]; + +function fake(responses: readonly unknown[]) { + const calls: ReadonlyArray[] = []; + const execute: GitHubCli.GitHubCli["Service"]["execute"] = (request) => + Effect.sync(() => { + calls.push(request.args); + const value = responses[calls.length - 1]; + if (value === undefined) throw new Error("Unexpected GitHub request"); + return { + exitCode: ChildProcessSpawner.ExitCode(0), + // @effect-diagnostics-next-line preferSchemaOverJson:off + stdout: JSON.stringify(value), + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + }; + }); + return { execute, calls }; +} + +it.effect("submits one atomic merge with the reviewed head and respects the merge queue", () => + Effect.gen(function* () { + const api = fake([stack, { status: "enqueued", details: {} }]); + yield* runGitHubStackAction(api.execute, { ...input, mergeMethod: "squash" }); + expect(api.calls).toHaveLength(2); + expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); + expect(api.calls[1]).toContain("sha=ccc"); + expect(api.calls[1]).toContain("merge_action=default"); + expect(api.calls[1]).toContain("merge_method=squash"); + }), +); + +it.effect("merges through the selected layer without including later draft layers", () => + Effect.gen(function* () { + const fiveLayers = [ + { + ...stack[0], + pull_requests: Array.from({ length: 5 }, (_, index) => ({ + number: index + 1, + head: { ref: `layer-${index + 1}`, sha: `sha-${index + 1}` }, + state: "open", + draft: index >= 3, + })), + }, + ]; + const api = fake([fiveLayers, { status: "merged", details: {} }]); + yield* runGitHubStackAction(api.execute, { + ...input, + number: 3, + expectedStackHeads: [1, 2, 3].map((number) => ({ number, headSha: `sha-${number}` })), + }); + expect(api.calls).toHaveLength(2); + expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); + expect(api.calls[1]).toContain("sha=sha-3"); + expect(api.calls[1]).toContain("merge_action=default"); + }), +); + +it.effect("rejects stale reviewed heads below a selected middle layer", () => + Effect.gen(function* () { + const api = fake([stack]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + number: 2, + expectedStackHeads: [{ number: 2, headSha: "old-head" }], + }).pipe(Effect.result); + expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } }); + expect(api.calls).toHaveLength(1); + }), +); + +it.effect("does not merge from an already merged layer", () => + Effect.gen(function* () { + const api = fake([stack]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + number: 1, + expectedStackHeads: [], + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackUnsupportedError" }, + }); + expect(api.calls).toHaveLength(1); + }), +); + +it.effect("polls an accepted merge and reports a later rule rejection", () => + Effect.gen(function* () { + const api = fake([ + stack, + { status: "pending", details: { uuid: "operation" } }, + { status: "failed", details: { message: "Required checks have not passed" } }, + ]); + const fiber = yield* runGitHubStackAction(api.execute, input).pipe( + Effect.result, + Effect.forkChild, + ); + yield* TestClock.adjust("1 second"); + const result = yield* Fiber.join(fiber); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackMergeRejectedError" }, + }); + expect(api.calls[2]).toContain("repos/acme/web/pulls/3/merge-async/operation"); + }), +); + +it.effect("retains stack identity and a rejection response without a message", () => + Effect.gen(function* () { + const rejection = { status: "failed", details: {} }; + const api = fake([stack, rejection]); + const result = yield* runGitHubStackAction(api.execute, input).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { + _tag: "GitHubStackMergeRejectedError", + repository: input.repository, + number: input.number, + stackNumber: input.stackNumber, + cause: rejection, + }, + }); + }), +); + +it.effect("refuses a changed stack before performing any mutation", () => + Effect.gen(function* () { + const api = fake([stack]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + expectedStackHeads: [ + { number: 2, headSha: "old" }, + { number: 3, headSha: "ccc" }, + ], + }).pipe(Effect.result); + expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } }); + expect(api.calls).toHaveLength(1); + }), +); + +it.effect("rebases unmerged layers bottom to top without local git commands", () => + Effect.gen(function* () { + const api = fake([stack, access, ...rebaseResponses]); + yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); + const mutations = api.calls.filter((args) => + args.some((arg) => arg.startsWith("query=mutation")), + ); + expect(mutations).toHaveLength(2); + expect(mutations[0]).toContain("id=PR_2"); + expect(mutations[0]).toContain("sha=bbb"); + expect(mutations[1]).toContain("id=PR_3"); + expect(mutations[1]).toContain("sha=ccc"); + expect(api.calls.every((args) => args[0] === "api")).toBe(true); + }), +); + +it.effect("does not update later layers after a rebase failure", () => + Effect.gen(function* () { + const api = fake([stack, access, branch(2, "bbb")]); + const execute: typeof api.execute = (request) => + !request.args.some((arg) => arg.startsWith("query=mutation")) + ? api.execute(request) + : Effect.fail( + new GitHubCli.GitHubCliAuthenticationError({ + command: "gh", + cwd: "/repo", + cause: new Error("denied"), + }), + ); + const result = yield* runGitHubStackAction(execute, { ...input, action: "update-branch" }).pipe( + Effect.result, + ); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackRebaseFailedError", number: 2, completed: 0 }, + }); + }), +); + +it.effect("refuses the entire rebase before mutation when a later fork denies write access", () => + Effect.gen(function* () { + const api = fake([ + stack, + { + data: { + repository: { + ...access.data.repository, + pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: false }, + }, + }, + }, + ]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackPermissionError" }, + }); + expect(api.calls).toHaveLength(2); + expect(api.calls.every((args) => args[0] === "api")).toBe(true); + }), +); + +it.effect("allows a fork that explicitly permits maintainer updates", () => + Effect.gen(function* () { + const api = fake([ + stack, + { + data: { + repository: { + ...access.data.repository, + pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: true }, + }, + }, + }, + ...rebaseResponses, + ]); + yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); + expect(api.calls.at(-1)).toContain("id=PR_3"); + }), +); + +it.effect("bounds polling and reports a still-running merge without claiming success", () => + Effect.gen(function* () { + const api = fake([ + stack, + ...Array.from({ length: 40 }, () => ({ status: "pending", details: { uuid: "operation" } })), + ]); + const fiber = yield* runGitHubStackAction(api.execute, input).pipe( + Effect.result, + Effect.forkChild, + ); + yield* TestClock.adjust("6 minutes"); + expect(yield* Fiber.join(fiber)).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackMergePendingError" }, + }); + expect(api.calls.length).toBeLessThan(40); + }), +); + +it.effect("rejects a push after preflight without rebasing the new revision", () => + Effect.gen(function* () { + const api = fake([stack, access, branch(2, "new-head")]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackChangedError", number: 2, completed: 0 }, + }); + expect(api.calls).toHaveLength(3); + }), +); + +it.effect("skips current layers without submitting a rebase mutation", () => + Effect.gen(function* () { + const api = fake([stack, access, branch(2, "bbb", 0), branch(3, "ccc", 0, ["bbb"])]); + yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); + expect(api.calls.some((args) => args.some((arg) => arg.startsWith("query=mutation")))).toBe( + false, + ); + }), +); + +it.effect("keeps earlier progress and stops after a later layer fails", () => + Effect.gen(function* () { + const api = fake([ + stack, + access, + branch(2, "bbb"), + rebased, + branch(3, "ccc", 1, ["rebased-sha"]), + { data: { updatePullRequestBranch: null } }, + ]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackRebaseFailedError", number: 3, completed: 1 }, + }); + }), +); + +it.effect("reports partial progress when a later head changes during the rebase", () => + Effect.gen(function* () { + const api = fake([ + stack, + access, + branch(2, "bbb"), + rebased, + branch(3, "concurrent-head", 1, ["rebased-sha"]), + ]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackChangedError", number: 3, completed: 1 }, + }); + if (result._tag === "Failure") { + expect(result.failure.message).toContain("Earlier updates remain on GitHub"); + } + expect( + api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))), + ).toHaveLength(1); + }), +); + +it.effect.each([false, true])("rejects a push to a processed layer, rebased=%s", (rebasedParent) => + Effect.gen(function* () { + const api = fake([ + stack, + access, + branch(2, "bbb", rebasedParent ? 1 : 0), + ...(rebasedParent ? [rebased] : []), + branch(3, "ccc", 1, ["concurrent-parent-head"]), + ]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackChangedError", number: 2, completed: 1 }, + }); + expect(api.calls.at(-1)?.some((arg) => arg.includes('processed:nodes(ids:["PR_2"])'))).toBe( + true, + ); + expect( + api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))), + ).toHaveLength(rebasedParent ? 1 : 0); + }), +); diff --git a/apps/server/src/pullRequest/githubStackActions.ts b/apps/server/src/pullRequest/githubStackActions.ts new file mode 100644 index 000000000..d13de0187 --- /dev/null +++ b/apps/server/src/pullRequest/githubStackActions.ts @@ -0,0 +1,418 @@ +import type { + PullRequestAction, + PullRequestMergeMethod, + PullRequestStackHead, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Clock from "effect/Clock"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts"; + +const stackErrorIdentity = { + repository: Schema.String, + number: Schema.Int, + stackNumber: Schema.Int, +}; + +export class GitHubStackChangedError extends Schema.TaggedErrorClass()( + "GitHubStackChangedError", + { ...stackErrorIdentity, completed: Schema.Int }, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return this.completed > 0 + ? `The stack changed at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub. Refresh it before trying again.` + : "The stack changed. Refresh it before trying again."; + } +} + +export class GitHubStackUnsupportedError extends Schema.TaggedErrorClass()( + "GitHubStackUnsupportedError", + stackErrorIdentity, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "This operation is not supported for this stack."; + } +} + +export class GitHubStackResponseInvalidError extends Schema.TaggedErrorClass()( + "GitHubStackResponseInvalidError", + { ...stackErrorIdentity, cause: Schema.optional(Schema.Defect()) }, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "GitHub returned an unreadable stack operation response."; + } +} + +export class GitHubStackMergeRejectedError extends Schema.TaggedErrorClass()( + "GitHubStackMergeRejectedError", + { ...stackErrorIdentity, cause: Schema.Defect() }, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "GitHub refused the stack merge. Check the stack's branch rules and merge requirements."; + } +} + +export class GitHubStackMergePendingError extends Schema.TaggedErrorClass()( + "GitHubStackMergePendingError", + stackErrorIdentity, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "The merge is still running on GitHub. Check its status there before submitting another request."; + } +} + +export class GitHubStackPermissionError extends Schema.TaggedErrorClass()( + "GitHubStackPermissionError", + stackErrorIdentity, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "You cannot update every branch in this stack. Check write access and fork maintainer permissions before retrying."; + } +} + +export class GitHubStackRebaseFailedError extends Schema.TaggedErrorClass()( + "GitHubStackRebaseFailedError", + { ...stackErrorIdentity, completed: Schema.Int, cause: Schema.Defect() }, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return `Stack rebase stopped at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub; resolve the failing layer before retrying.`; + } +} + +export type GitHubStackActionError = + | GitHubStackChangedError + | GitHubStackUnsupportedError + | GitHubStackResponseInvalidError + | GitHubStackMergeRejectedError + | GitHubStackMergePendingError + | GitHubStackPermissionError + | GitHubStackRebaseFailedError; + +const MergeResponse = Schema.Struct({ + status: Schema.Literals(["pending", "merged", "enqueued", "failed"]), + details: Schema.Struct({ + uuid: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + }), +}); + +const decodeBranchAccess = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Record( + Schema.String, + Schema.NullOr( + Schema.Struct({ + headRepository: Schema.NullOr( + Schema.Struct({ viewerPermission: Schema.NullOr(Schema.String) }), + ), + maintainerCanModify: Schema.Boolean, + }), + ), + ), + ), + }), + }), + ), +); + +const decodeRebaseBranch = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + processed: Schema.optional( + Schema.Array(Schema.NullOr(Schema.Struct({ headRefOid: Schema.String }))), + ), + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + id: Schema.String, + headRefOid: Schema.String, + baseRef: Schema.Struct({ compare: Schema.Struct({ behindBy: Schema.Int }) }), + }), + }), + }), + }), + ), +); +const decodeRebaseResponse = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + updatePullRequestBranch: Schema.Struct({ + pullRequest: Schema.Struct({ headRefOid: Schema.String }), + }), + }), + }), + ), +); + +const encodeNodeIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); + +const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse)); + +/** Remote-only updates: a stack rebase never switches or rewrites the environment's checkout. */ +export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* (input: { + cwd: string; + repository: string; + host: string; + number: number; + stackNumber: number; + expectedStackHeads?: ReadonlyArray; + action: PullRequestAction; + mergeMethod?: PullRequestMergeMethod; +}) { + const github = yield* GitHubCli.GitHubCli; + const identity = { + repository: input.repository, + number: input.number, + stackNumber: input.stackNumber, + }; + if (input.action !== "merge" && input.action !== "update-branch") + return yield* new GitHubStackUnsupportedError({ ...identity }); + const endpoint = `repos/${input.repository}`; + const read = yield* github.execute({ + cwd: input.cwd, + args: ["api", "--hostname", input.host, `${endpoint}/stacks?pull_request=${input.number}`], + }); + const decoded = decodePullRequestStacksJson(read.stdout); + if (Result.isFailure(decoded)) + return yield* new GitHubStackResponseInvalidError({ ...identity, cause: decoded.failure }); + const stack = decoded.success; + const targetIndex = stack?.layers.findIndex((layer) => layer.number === input.number) ?? -1; + const target = stack?.layers[targetIndex]; + if ( + stack?.number !== input.stackNumber || + target === undefined || + (input.action === "update-branch" && targetIndex !== stack.layers.length - 1) + ) { + return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 }); + } + const affectedLayers = + input.action === "merge" ? stack.layers.slice(0, targetIndex + 1) : stack.layers; + const open = affectedLayers.filter((layer) => layer.state !== "merged"); + if (input.action === "merge" && target.state !== "open") + return yield* new GitHubStackUnsupportedError({ ...identity }); + if ( + !input.expectedStackHeads || + input.expectedStackHeads.length !== open.length || + new Set(input.expectedStackHeads.map((layer) => layer.number)).size !== open.length || + open.some( + (layer) => + !layer.headSha || + !input.expectedStackHeads?.some( + (expected) => expected.number === layer.number && expected.headSha === layer.headSha, + ), + ) + ) { + return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 }); + } + if (open.length === 0 || open.some((layer) => layer.state !== "open")) + return yield* new GitHubStackUnsupportedError({ ...identity }); + if (input.action === "update-branch") { + const [owner, name] = input.repository.split("/"); + const permissions = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + "graphql", + "-f", + `owner=${owner}`, + "-f", + `name=${name}`, + "-f", + `query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${open + .map( + (layer) => + `pr${layer.number}:pullRequest(number:${layer.number}){headRepository{viewerPermission} maintainerCanModify}`, + ) + .join(" ")}}}`, + ], + }); + const access = yield* decodeBranchAccess(permissions.stdout).pipe( + Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), + ); + // viewerCanUpdateBranch is false for an already-current layer, even if rebasing its parent + // will make it stale. Check branch write access separately before touching any layer. + if ( + open.some((layer) => { + const pr = access.data.repository?.[`pr${layer.number}`]; + return ( + !pr?.headRepository || + (!pr.maintainerCanModify && + !["ADMIN", "MAINTAIN", "WRITE"].includes(pr.headRepository.viewerPermission ?? "")) + ); + }) + ) + return yield* new GitHubStackPermissionError({ ...identity }); + const processed: Array<{ id: string; number: number; headSha: string }> = []; + for (const [index, layer] of open.entries()) { + yield* Effect.gen(function* () { + const read = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + "graphql", + "-f", + `owner=${owner}`, + "-f", + `name=${name}`, + "-F", + `number=${layer.number}`, + "-f", + `sha=${layer.headSha}`, + "-f", + `query=query($owner:String!,$name:String!,$number:Int!,$sha:String!){${ + processed.length === 0 + ? "" + : `processed:nodes(ids:${encodeNodeIds(processed.map((head) => head.id))}){... on PullRequest{headRefOid}}` + } repository(owner:$owner,name:$name){pullRequest(number:$number){id headRefOid baseRef{compare(headRef:$sha){behindBy}}}}}`, + ], + }); + const { + data: { + processed: observed, + repository: { pullRequest: pr }, + }, + } = yield* decodeRebaseBranch(read.stdout); + // A push to an earlier layer must not silently become the next layer's new base. + const changed = processed.find( + (head, index) => observed?.[index]?.headRefOid !== head.headSha, + ); + if (changed !== undefined) + return yield* new GitHubStackChangedError({ + ...identity, + number: changed.number, + completed: index, + }); + if (pr.headRefOid !== layer.headSha) + return yield* new GitHubStackChangedError({ + ...identity, + number: layer.number, + completed: index, + }); + if (pr.baseRef.compare.behindBy === 0) { + processed.push({ id: pr.id, number: layer.number, headSha: pr.headRefOid }); + return; + } + // Pass the reviewed revision to GitHub, including when a push races this read. + const updated = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + "graphql", + "-f", + `id=${pr.id}`, + "-f", + `sha=${layer.headSha}`, + "-f", + "query=mutation($id:ID!,$sha:GitObjectID!){updatePullRequestBranch(input:{pullRequestId:$id,expectedHeadOid:$sha,updateMethod:REBASE}){pullRequest{headRefOid}}}", + ], + }); + const response = yield* decodeRebaseResponse(updated.stdout); + processed.push({ + id: pr.id, + number: layer.number, + headSha: response.data.updatePullRequestBranch.pullRequest.headRefOid, + }); + }).pipe( + Effect.mapError((cause) => + cause._tag === "GitHubStackChangedError" + ? cause + : new GitHubStackRebaseFailedError({ + ...identity, + number: layer.number, + completed: index, + cause, + }), + ), + ); + } + return; + } + if (open.some((layer) => layer.isDraft)) + return yield* new GitHubStackUnsupportedError({ ...identity }); + const decode = (raw: string) => + decodeMergeResponse(raw).pipe( + Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), + ); + const request = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + "--method", + "PUT", + `${endpoint}/pulls/${input.number}/merge-async`, + "-f", + `merge_method=${input.mergeMethod ?? "merge"}`, + "-f", + "merge_action=default", + "-f", + `sha=${target.headSha}`, + ], + }); + let result = yield* decode(request.stdout); + const deadline = (yield* Clock.currentTimeMillis) + 5 * 60_000; + for ( + let attempt = 0; + result.status === "pending" && (yield* Clock.currentTimeMillis) < deadline; + attempt++ + ) { + const uuid = result.details.uuid; + if (!uuid) return yield* new GitHubStackResponseInvalidError({ ...identity }); + yield* Effect.sleep(Math.min(1_000 * 2 ** attempt, 10_000)); + const poll = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`, + ], + }); + result = yield* decode(poll.stdout); + } + if (result.status === "pending") return yield* new GitHubStackMergePendingError({ ...identity }); + if (result.status === "failed") + return yield* new GitHubStackMergeRejectedError({ ...identity, cause: result }); +}); diff --git a/apps/server/src/resourceTelemetry/HostResources.test.ts b/apps/server/src/resourceTelemetry/HostResources.test.ts new file mode 100644 index 000000000..dbf7166d1 --- /dev/null +++ b/apps/server/src/resourceTelemetry/HostResources.test.ts @@ -0,0 +1,70 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as TestClock from "effect/testing/TestClock"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as HostResources from "./HostResources.ts"; + +it.layer(NodeServices.layer)("whole-host resources", (it) => { + it.effect("counts reclaimable macOS pages once and shares simultaneous reads", () => + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const service = yield* HostResources.make().pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provide( + Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({ + string: () => + Ref.update(calls, (count) => count + 1).pipe( + Effect.as( + "Mach Virtual Memory Statistics: (page size of 16384 bytes)\nPages free: 10.\nPages inactive: 20.\nPages speculative: 5.\nPages purgeable: 999.\n", + ), + ), + }), + ), + ); + const [first, second] = yield* Effect.all([service.read, service.read], { + concurrency: "unbounded", + }); + assert.equal(first.availableMemoryBytes, 35 * 16384); + assert.deepEqual(first, second); + assert.deepEqual(yield* service.read, first); + assert.equal(yield* Ref.get(calls), 1); + assert.isAbove(first.cpuCount, 0); + if (first.cpuUtilization !== null) { + assert.isAtLeast(first.cpuUtilization, 0); + assert.isAtMost(first.cpuUtilization, 1); + } + }).pipe(TestClock.withLive), + ); + it.effect("retries immediately after the previous caller is interrupted", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const calls = yield* Ref.make(0); + const service = yield* HostResources.make().pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provide( + Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({ + string: () => + Effect.gen(function* () { + if ((yield* Ref.updateAndGet(calls, (count) => count + 1)) === 1) { + yield* Deferred.succeed(started, undefined); + return yield* Effect.never; + } + return "Mach Virtual Memory Statistics: (page size of 4096 bytes)\nPages free: 10.\nPages inactive: 20.\nPages speculative: 5.\n"; + }), + }), + ), + ); + const first = yield* service.read.pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* Fiber.interrupt(first); + assert.equal((yield* service.read).availableMemoryBytes, 35 * 4096); + assert.equal(yield* Ref.get(calls), 2); + }).pipe(TestClock.withLive), + ); +}); diff --git a/apps/server/src/resourceTelemetry/HostResources.ts b/apps/server/src/resourceTelemetry/HostResources.ts new file mode 100644 index 000000000..032832dd4 --- /dev/null +++ b/apps/server/src/resourceTelemetry/HostResources.ts @@ -0,0 +1,93 @@ +import * as NodeOS from "node:os"; +import type { HostResourcesSnapshot } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +export class HostResources extends Context.Service< + HostResources, + { readonly read: Effect.Effect } +>()("t3/resourceTelemetry/HostResources") {} + +function readCpu() { + const cpus = NodeOS.cpus(); + const cpu = cpus.reduce( + (sum, { times }) => ({ + idle: sum.idle + times.idle, + total: sum.total + times.user + times.nice + times.sys + times.idle + times.irq, + }), + { idle: 0, total: 0 }, + ); + return { ...cpu, count: cpus.length }; +} + +function darwinAvailableMemory(output: string): number | null { + const pageSize = /page size of (\d+) bytes/.exec(output)?.[1]; + const free = /^Pages free:\s+(\d+)\./m.exec(output)?.[1]; + const inactive = /^Pages inactive:\s+(\d+)\./m.exec(output)?.[1]; + const speculative = /^Pages speculative:\s+(\d+)\./m.exec(output)?.[1]; + if (!pageSize || !free || !inactive || !speculative) return null; + // vm_stat subtracts speculative pages from its printed "Pages free" count. + // Adding them here counts each reclaimable page once; purgeable pages overlap. + const available = (Number(free) + Number(inactive) + Number(speculative)) * Number(pageSize); + return Number.isSafeInteger(available) && Number(pageSize) > 0 ? available : null; +} + +export const make = Effect.fn("makeHostResources")(function* () { + const fs = yield* FileSystem.FileSystem; + const platform = yield* HostProcessPlatform; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const sample = Effect.fn("HostResources.sample")(function* () { + const previousCpu = readCpu(); + // CPU counters need two readings; idle servers do no polling or process scans. + yield* Effect.sleep("200 millis"); + const cpu = readCpu(); + const totalDelta = cpu.total - previousCpu.total; + const idleDelta = cpu.idle - previousCpu.idle; + const cpuUtilization = + previousCpu.count === cpu.count && totalDelta > 0 && idleDelta >= 0 + ? Math.min(1, Math.max(0, 1 - idleDelta / totalDelta)) + : null; + const totalMemoryBytes = NodeOS.totalmem(); + // On Windows libuv returns GlobalMemoryStatusEx.ullAvailPhys, including standby memory. + let availableMemoryBytes = NodeOS.freemem(); + if (platform === "linux") { + const meminfo = yield* fs + .readFileString("/proc/meminfo") + .pipe(Effect.catch(() => Effect.succeed(""))); + const available = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(meminfo)?.[1]; + if (available) availableMemoryBytes = Number(available) * 1024; + } else if (platform === "darwin") { + const output = yield* spawner + .string(ChildProcess.make("/usr/bin/vm_stat", [], { stdin: "ignore", stderr: "ignore" })) + .pipe( + Effect.timeout("1 second"), + Effect.catch(() => Effect.succeed("")), + ); + availableMemoryBytes = darwinAvailableMemory(output) ?? availableMemoryBytes; + } + return { + sampledAt: DateTime.toEpochMillis(yield* DateTime.now), + cpuUtilization, + cpuCount: cpu.count, + availableMemoryBytes: Math.min(totalMemoryBytes, Math.max(0, availableMemoryBytes)), + totalMemoryBytes, + }; + }); + + // One server-lifetime cache deduplicates simultaneous requests from all sockets. + const cache = yield* Cache.make({ + capacity: 1, + lookup: (_key: "host") => sample(), + timeToLive: "5 seconds", + }); + return HostResources.of({ read: Cache.get(cache, "host") }); +}); + +export const layer = Layer.effect(HostResources, make()); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index a8eea9dd5..3e0a5fded 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,3 +1,9 @@ +import * as HostResources from "./resourceTelemetry/HostResources.ts"; +import * as RestartContinuationService from "./orchestration-v2/RestartContinuationService.ts"; +import * as ThreadSettlementReactor from "./orchestration-v2/ThreadSettlementReactor.ts"; +import * as ThreadPullRequestReactor from "./orchestration-v2/ThreadPullRequestReactor.ts"; +import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; +import * as NativeAppIconResolver from "./assets/NativeAppIconResolver.ts"; import { EnvironmentHttpApi } from "@t3tools/contracts"; import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; @@ -28,6 +34,7 @@ import { websocketRpcRouteLayer } from "./ws.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { pullRequestHttpApiLayer } from "./pullRequest/http.ts"; import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; +import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -197,6 +204,7 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe( const UsageLayerLive = UsageService.layer.pipe(Layer.provide(ServerSettingsLayerLive)); const ResourceDiagnosticsLayerLive = Layer.mergeAll( + HostResources.layer, ResourceTelemetryLayerLive, ProcessDiagnostics.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), ProcessResourceMonitor.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), @@ -323,6 +331,7 @@ const GitLayerLive = Layer.empty.pipe( ); const ProjectTeardownScriptRunnerLayerLive = ProjectTeardownScriptRunner.layer.pipe( + Layer.provide(ServerSettingsLayerLive), Layer.provide(ProjectServiceLayerLive), Layer.provide(ProcessRunner.layer), ); @@ -350,7 +359,17 @@ const VcsLayerLive = Layer.empty.pipe( Layer.provideMerge(GitWorkflowLayerLive), Layer.provideMerge(ReviewLayerLive), Layer.provideMerge(SourceControlRepositoryServiceLayerLive), - Layer.provideMerge(VcsStatusBroadcaster.layer.pipe(Layer.provide(GitWorkflowLayerLive))), + Layer.provideMerge( + VcsStatusBroadcaster.layer.pipe( + Layer.provide(GitWorkflowLayerLive), + Layer.provide( + VcsStatusBroadcaster.autoPullPolicyLayer.pipe( + Layer.provide(ProjectServiceLayerLive), + Layer.provide(ServerSettingsLayerLive), + ), + ), + ), + ), ); const CheckpointStoreLayerLive = CheckpointStore.layer.pipe( @@ -360,6 +379,7 @@ const CheckpointStoreLayerLive = CheckpointStore.layer.pipe( const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.layer)); const TerminalLayerLive = TerminalManager.layer.pipe( + Layer.provide(ServerSettingsLayerLive), Layer.provide(PtyAdapterLive), Layer.provide(PortScannerLayerLive), ); @@ -476,6 +496,7 @@ const RuntimeCoreDependenciesLive = RuntimeCoreDependenciesBaseLive.pipe( Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectEnrichmentService.layer), Layer.provideMerge(ProjectFaviconResolverLayerLive), + Layer.provideMerge(NativeAppIconResolver.layer), Layer.provideMerge(RepositoryIdentityResolver.layer), Layer.provideMerge(ServerEnvironment.layer), Layer.provideMerge(AuthLayerLive), @@ -513,6 +534,7 @@ const commandReadinessLayer = HttpRouter.middleware( ); const PullRequestServiceLive = PullRequestService.layer.pipe( + Layer.provide(PullRequestReadCache.layer), // One registry entry per supported host; the service only knows the registry. Layer.provide(PullRequestProviderRegistry.layer), Layer.provide(SourceControlProviderRegistryLayerLive), @@ -737,7 +759,18 @@ export const makeServerLayer = Layer.unwrap( ], { concurrency: "unbounded" }, ).pipe(Effect.asVoid), - }).pipe(Layer.provideMerge(RuntimeDependenciesLive), Layer.provide(launcherLayer)); + }).pipe( + Layer.provide( + Layer.mergeAll( + PullRequestSyncReactor.layer, + ThreadPullRequestReactor.layer, + ThreadSettlementReactor.layer, + RestartContinuationService.layer, + ).pipe(Layer.provide(PullRequestServiceLive)), + ), + Layer.provideMerge(RuntimeDependenciesLive), + Layer.provide(launcherLayer), + ); const routesLayer = HttpRouter.serve(makeRoutesLayer.pipe(Layer.provide(launcherLayer)), { disableLogger: !config.logWebSocketEvents, diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index e52bb66e7..a71eddc74 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -1,3 +1,9 @@ +import * as RestartContinuationService from "./orchestration-v2/RestartContinuationService.ts"; +import * as ThreadSettlementReactor from "./orchestration-v2/ThreadSettlementReactor.ts"; +import * as ThreadPullRequestReactor from "./orchestration-v2/ThreadPullRequestReactor.ts"; +import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; +import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; +import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import { CommandId, DEFAULT_MODEL, @@ -197,6 +203,8 @@ export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({ interface AutoBootstrapWelcomeTargets { readonly bootstrapProjectId?: ProjectId; readonly bootstrapThreadId?: ThreadId; + readonly bootstrapProjectCreated?: boolean; + readonly bootstrapThreadCreated?: boolean; } export const resolveWelcomeBase = Effect.gen(function* () { @@ -221,16 +229,21 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { let bootstrapProjectId: ProjectId | undefined; let bootstrapThreadId: ThreadId | undefined; + let bootstrapProjectCreated = false; + let bootstrapThreadCreated = false; if (serverConfig.autoBootstrapProjectFromCwd) { - const defaultModelSelection = getAutoBootstrapDefaultModelSelection(); - const { project } = yield* projects.bootstrap({ + const settings = yield* (yield* ServerSettings.ServerSettingsService).getSettings; + const defaultModelSelection = + settings.defaultModelSelection ?? getAutoBootstrapDefaultModelSelection(); + const { project, created } = yield* projects.bootstrap({ commandId: CommandId.make(yield* randomUUID), projectId: ProjectId.make(yield* randomUUID), title: path.basename(serverConfig.cwd) || "project", workspaceRoot: serverConfig.cwd, defaultModelSelection, }); + bootstrapProjectCreated = created; const shell = yield* threads.getShellSnapshot(); const existingThread = shell.threads.find( (thread) => @@ -250,6 +263,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { }); bootstrapProjectId = project.id; bootstrapThreadId = launched.threadId; + bootstrapThreadCreated = true; } else { bootstrapProjectId = project.id; bootstrapThreadId = existingThread.id; @@ -257,8 +271,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { } return { - ...(bootstrapProjectId ? { bootstrapProjectId } : {}), - ...(bootstrapThreadId ? { bootstrapThreadId } : {}), + ...(bootstrapProjectId ? { bootstrapProjectId, bootstrapProjectCreated } : {}), + ...(bootstrapThreadId ? { bootstrapThreadId, bootstrapThreadCreated } : {}), } satisfies AutoBootstrapWelcomeTargets; }); @@ -387,6 +401,38 @@ const awaitServerActivation = ServerActivation.pipe( Effect.flatMap((activation) => activation ?? Effect.void), ); +/** A one-time refresh after activation, before recovered provider effects can run. */ +export const autoPullProjects = Effect.gen(function* () { + const projects = yield* ProjectService.ProjectService; + const settingsService = yield* ServerSettings.ServerSettingsService; + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const settings = yield* settingsService.getSettings; + if (!settings.defaultAutoPull && !Object.values(settings.projectAutoPullOverrides).some(Boolean)) + return; + const snapshot = yield* projects.snapshot; + const roots = [ + ...new Set( + snapshot.projects + .filter((project) => resolveProjectAutoPull(settings, project.id)) + .map((project) => project.workspaceRoot), + ), + ]; + yield* Effect.forEach( + roots, + (cwd) => + broadcaster + .refreshStatus(cwd) + .pipe( + Effect.catch((cause) => Effect.logWarning("Startup project pull failed", { cwd, cause })), + ), + { concurrency: 4, discard: true }, + ); +}).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to load projects for automatic pull", { cause }), + ), +); + export const make = (options?: StartupOptions) => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; @@ -396,6 +442,10 @@ export const make = (options?: StartupOptions) => const providerRuntimeRecovery = yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService; const providerSessions = yield* ProviderSessionManager.ProviderSessionManagerV2; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; + const threadPullRequests = yield* ThreadPullRequestReactor.ThreadPullRequestReactor; + const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor; + const threadSettlement = yield* ThreadSettlementReactor.ThreadSettlementReactor; + const restartContinuation = yield* RestartContinuationService.RestartContinuationService; const hermesProactive = yield* HermesProactiveService.HermesProactiveService; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const serverSettings = yield* ServerSettings.ServerSettingsService; @@ -418,6 +468,7 @@ export const make = (options?: StartupOptions) => cause: "Server runtime is shutting down.", }), ); + yield* restartContinuation.prepare("restart").pipe(Effect.ignore({ log: true })); const workerFiber = yield* Ref.getAndSet(effectWorkerFiber, null); if (workerFiber !== null) { yield* Fiber.interrupt(workerFiber).pipe(Effect.ignore); @@ -567,14 +618,22 @@ export const make = (options?: StartupOptions) => "orchestration-v2.projections.rebuild", projectionMaintenance.rebuild, ), - recover: runStartupPhase("orchestration-v2.recovery", providerRuntimeRecovery.recover), + recover: runStartupPhase( + "orchestration-v2.recovery", + restartContinuation + .prepare("restart") + .pipe(Effect.ignore({ log: true }), Effect.andThen(providerRuntimeRecovery.recover)), + ), startEffectWorker: runStartupPhase( "orchestration-v2.effect-worker.start", startEffectWorkerWithRelay({ // The worker drains the durable effect outbox, which is exactly what // an uncommitted trial must not do, so park it until activation. The // fiber still starts here so shutdown owns a handle to interrupt. - runWorker: awaitServerActivation.pipe(Effect.andThen(EffectWorker.runDaemon)), + runWorker: awaitServerActivation.pipe( + Effect.andThen(runStartupPhase("projects.auto-pull", autoPullProjects)), + Effect.andThen(EffectWorker.runDaemon), + ), startRelay: agentAwarenessRelay.start(), workerFiberRef: effectWorkerFiber, }), @@ -674,6 +733,11 @@ export const make = (options?: StartupOptions) => }), ); + yield* threadPullRequests.start(); + yield* pullRequestSync.start(); + yield* threadSettlement.start({ beforeSweep: restartContinuation.awaitInitialResume }); + yield* forkParked(restartContinuation.resume); + yield* Effect.logDebug("startup phase: waiting for http listener"); yield* runStartupPhase("http.wait", Deferred.await(httpListening)); yield* runStartupPhase( @@ -700,6 +764,7 @@ export const make = (options?: StartupOptions) => environment, ...welcomeBase, ...bootstrapTargets, + bootstrapStatus: "complete", }, }), ); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index 21db25e79..4bdbb3528 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -40,6 +40,8 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" baseRefName: "main", headRefName: "feature/source-control", state: "open", + closedAt: null, + mergedAt: null, updatedAt: Option.none(), isCrossRepository: false, }); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 2f147452f..20a74cc8a 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -61,6 +61,9 @@ function toChangeRequest(summary: { readonly baseRefName: string; readonly headRefName: string; readonly state: "open" | "closed" | "merged"; + readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: ChangeRequest["updatedAt"]; }): ChangeRequest { return { @@ -71,6 +74,9 @@ function toChangeRequest(summary: { baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state, + ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt, isCrossRepository: false, }; diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index 59fab76e5..ffa3fb130 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -17,6 +17,7 @@ function toChangeRequest(summary: NormalizedBitbucketPullRequestRecord): ChangeR baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state, + ...(summary.isDraft === true ? { isDraft: true } : {}), updatedAt: summary.updatedAt ?? Option.none(), ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 964ed3d02..0ef98a53d 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -91,6 +91,8 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-threads", state: "open", + closedAt: null, + mergedAt: null, isCrossRepository: true, headRepositoryNameWithOwner: "octocat/codething-mvp", headRepositoryOwnerLogin: "octocat", @@ -103,7 +105,7 @@ describe("GitHubCli.layer", () => { "view", "#42", "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], cwd: "/repo", timeoutMs: 30_000, @@ -150,6 +152,8 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-threads", state: "open", + closedAt: null, + mergedAt: null, isCrossRepository: true, headRepositoryNameWithOwner: "octocat/codething-mvp", headRepositoryOwnerLogin: "octocat", @@ -203,6 +207,8 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-list", state: "open", + closedAt: null, + mergedAt: null, }, ]); }).pipe(Effect.provide(layer)), @@ -255,6 +261,8 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "t3code/codex-turn-mapping", state: "open", + closedAt: null, + mergedAt: null, isCrossRepository: false, headRepositoryNameWithOwner: "pingdotgg/codething-mvp", headRepositoryOwnerLogin: "pingdotgg", diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 974574cbd..85736a95c 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -1,6 +1,8 @@ import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -15,6 +17,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import { decodeGitHubPullRequestJson, decodeGitHubPullRequestListJson, + type NormalizedGitHubPullRequestRecord, } from "./gitHubPullRequests.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -202,11 +205,23 @@ export interface GitHubPullRequestSummary { readonly baseRefName: string; readonly headRefName: string; readonly state?: "open" | "closed" | "merged"; + readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; + readonly updatedAt?: string; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; readonly headRepositoryOwnerLogin?: string | null; } +function pullRequestSummary(input: NormalizedGitHubPullRequestRecord): GitHubPullRequestSummary { + const { updatedAt, ...summary } = input; + return { + ...summary, + ...(Option.isSome(updatedAt) ? { updatedAt: DateTime.formatIso(updatedAt.value) } : {}), + }; +} + export interface GitHubRepositoryCloneUrls { readonly nameWithOwner: string; readonly url: string; @@ -354,7 +369,7 @@ export const make = Effect.gen(function* () { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => result.stdout.trim()), @@ -373,9 +388,7 @@ export const make = Effect.gen(function* () { ); } - return Effect.succeed( - decoded.success.map(({ updatedAt: _updatedAt, ...summary }) => summary), - ); + return Effect.succeed(decoded.success.map(pullRequestSummary)); }), ), ), @@ -388,7 +401,7 @@ export const make = Effect.gen(function* () { "view", input.reference, "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => result.stdout.trim()), @@ -405,9 +418,7 @@ export const make = Effect.gen(function* () { ); } - return Effect.succeed( - (({ updatedAt: _updatedAt, ...summary }) => summary)(decoded.success), - ); + return Effect.succeed(pullRequestSummary(decoded.success)); }), ), ), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 1381271e6..3bf67efe7 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -60,6 +60,8 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = baseRefName: "main", headRefName: "feature/source-control", state: "open", + closedAt: null, + mergedAt: null, updatedAt: Option.none(), isCrossRepository: true, headRepositoryNameWithOwner: "fork/t3code", @@ -150,7 +152,7 @@ it.effect("uses gh json listing for non-open change request state queries", () = "--limit", "10", "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ]); assert.strictEqual(changeRequests[0]?.provider, "github"); assert.strictEqual(changeRequests[0]?.state, "merged"); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 3dcc8ab82..74f08a9a9 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -1,3 +1,4 @@ +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -29,7 +30,13 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state ?? "open", - updatedAt: Option.none(), + ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, + updatedAt: + summary.updatedAt === undefined + ? Option.none() + : Option.some(DateTime.makeUnsafe(summary.updatedAt)), ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } : {}), @@ -149,7 +156,7 @@ export const make = Effect.gen(function* () { "--limit", String(input.limit ?? 20), "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }) .pipe( @@ -162,10 +169,18 @@ export const make = Effect.gen(function* () { Effect.flatMap((decoded) => Result.isSuccess(decoded) ? Effect.succeed( - decoded.success.map((item) => ({ - ...toChangeRequest(item), - updatedAt: item.updatedAt, - })), + decoded.success.map((item) => { + const { updatedAt, ...summary } = item; + return { + ...toChangeRequest({ + ...summary, + ...(Option.isSome(updatedAt) + ? { updatedAt: DateTime.formatIso(updatedAt.value) } + : {}), + }), + updatedAt, + }; + }), ) : Effect.fail( new GitHubCli.GitHubChangeRequestListDecodeError({ diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index eb56b434b..679178fd6 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -72,6 +72,8 @@ layer("GitLabCli.layer", (it) => { baseRefName: "main", headRefName: "feature/mr-threads", state: "open", + closedAt: null, + mergedAt: null, isCrossRepository: true, headRepositoryNameWithOwner: "octocat/t3code", headRepositoryOwnerLogin: "octocat", @@ -130,6 +132,8 @@ layer("GitLabCli.layer", (it) => { baseRefName: "main", headRefName: "feature/mr-list", state: "merged", + closedAt: null, + mergedAt: null, }, ]); expect(mockedRun).toHaveBeenCalledWith( diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index 9a9fc3360..9f76a6182 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -246,6 +246,9 @@ export interface GitLabMergeRequestSummary { readonly baseRefName: string; readonly headRefName: string; readonly state?: "open" | "closed" | "merged"; + readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt?: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index 0d06e0665..8335efb6d 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -44,6 +44,8 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = baseRefName: "main", headRefName: "feature/source-control", state: "open", + closedAt: null, + mergedAt: null, updatedAt: Option.none(), isCrossRepository: true, headRepositoryNameWithOwner: "fork/t3code", diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 2cba12f1b..28211c6b8 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -26,6 +26,9 @@ function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRe baseRefName: summary.baseRefName, headRefName: summary.headRefName, state: summary.state ?? "open", + ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt ?? Option.none(), ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } diff --git a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts index 8c3c5c4de..24c0e49fd 100644 --- a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts +++ b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts @@ -14,6 +14,9 @@ export interface NormalizedAzureDevOpsPullRequestRecord { readonly baseRefName: string; readonly headRefName: string; readonly state: "open" | "closed" | "merged"; + readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: Option.Option; } @@ -35,6 +38,7 @@ const AzureDevOpsPullRequestSchema = Schema.Struct({ sourceRefName: TrimmedNonEmptyString, targetRefName: TrimmedNonEmptyString, status: Schema.String, + isDraft: Schema.optional(Schema.Boolean), creationDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), closedDate: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), _links: Schema.optional( @@ -161,13 +165,21 @@ function normalizeAzureDevOpsPullRequestUrl( function normalizeAzureDevOpsPullRequestRecord( raw: Schema.Schema.Type, ): NormalizedAzureDevOpsPullRequestRecord { + const state = normalizeAzureDevOpsPullRequestState(raw.status); + const terminalAt = Option.match(raw.closedDate ?? Option.none(), { + onNone: () => null, + onSome: DateTime.formatIso, + }); return { number: raw.pullRequestId, title: raw.title, url: normalizeAzureDevOpsPullRequestUrl(raw), baseRefName: normalizeRefName(raw.targetRefName), headRefName: normalizeRefName(raw.sourceRefName), - state: normalizeAzureDevOpsPullRequestState(raw.status), + state, + ...(raw.isDraft === true ? { isDraft: true } : {}), + closedAt: state === "closed" ? terminalAt : null, + mergedAt: state === "merged" ? terminalAt : null, updatedAt: (raw.closedDate ?? Option.none()).pipe( Option.orElse(() => raw.creationDate ?? Option.none()), ), diff --git a/apps/server/src/sourceControl/bitbucketPullRequests.ts b/apps/server/src/sourceControl/bitbucketPullRequests.ts index 6d67477bc..3b07334a8 100644 --- a/apps/server/src/sourceControl/bitbucketPullRequests.ts +++ b/apps/server/src/sourceControl/bitbucketPullRequests.ts @@ -10,6 +10,7 @@ export interface NormalizedBitbucketPullRequestRecord { readonly baseRefName: string; readonly headRefName: string; readonly state: "open" | "closed" | "merged"; + readonly isDraft?: boolean; readonly updatedAt: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -38,6 +39,7 @@ export const BitbucketPullRequestSchema = Schema.Struct({ id: PositiveInt, title: TrimmedNonEmptyString, state: Schema.optional(Schema.NullOr(Schema.String)), + draft: Schema.optional(Schema.Boolean), updated_on: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), links: Schema.Struct({ html: Schema.Struct({ @@ -98,6 +100,7 @@ export function normalizeBitbucketPullRequestRecord( baseRefName: raw.destination.branch.name, headRefName: raw.source.branch.name, state: normalizeBitbucketPullRequestState(raw.state), + ...(raw.draft === true ? { isDraft: true } : {}), updatedAt: raw.updated_on ?? Option.none(), ...(isCrossRepository ? { isCrossRepository: true } : {}), ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), diff --git a/apps/server/src/sourceControl/changeRequestLifecycle.test.ts b/apps/server/src/sourceControl/changeRequestLifecycle.test.ts new file mode 100644 index 000000000..9890761a5 --- /dev/null +++ b/apps/server/src/sourceControl/changeRequestLifecycle.test.ts @@ -0,0 +1,100 @@ +import { expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; +import { decodeGitHubPullRequestJson } from "./gitHubPullRequests.ts"; +import { decodeGitLabMergeRequestJson } from "./gitLabMergeRequests.ts"; +import { decodeAzureDevOpsPullRequestJson } from "./azureDevOpsPullRequests.ts"; +import { normalizeBitbucketPullRequestRecord } from "./bitbucketPullRequests.ts"; + +const encode = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const mergedAt = "2026-09-10T12:00:00Z"; +const closedAt = "2026-09-09T12:00:00Z"; + +it("preserves GitHub draft and lifecycle dates independently of last activity", () => { + expect( + decodeGitHubPullRequestJson( + encode({ + number: 1, + title: "Draft", + url: "https://github.com/org/repo/pull/1", + baseRefName: "main", + headRefName: "feature", + state: "OPEN", + isDraft: true, + mergedAt, + closedAt, + updatedAt: "2026-09-11T12:00:00Z", + }), + ), + ).toMatchObject({ + _tag: "Success", + success: { isDraft: true, state: "merged", mergedAt, closedAt }, + }); +}); + +it("accepts both GitLab draft spellings and retains terminal timestamps", () => { + for (const draft of [{ draft: true }, { work_in_progress: true }]) { + expect( + decodeGitLabMergeRequestJson( + encode({ + iid: 1, + title: "Draft", + web_url: "https://gitlab.com/org/repo/-/merge_requests/1", + source_branch: "feature", + target_branch: "main", + state: "merged", + ...draft, + merged_at: mergedAt, + closed_at: closedAt, + }), + ), + ).toMatchObject({ + _tag: "Success", + success: { isDraft: true, state: "merged", mergedAt, closedAt }, + }); + } +}); + +it("assigns Azure's terminal date to the actual completion state", () => { + for (const [status, state] of [ + ["completed", "merged"], + ["abandoned", "closed"], + ] as const) { + const terminal = "2026-09-10T12:00:00.000Z"; + expect( + decodeAzureDevOpsPullRequestJson( + encode({ + pullRequestId: 1, + title: "Change", + sourceRefName: "refs/heads/feature", + targetRefName: "refs/heads/main", + status, + isDraft: true, + closedDate: terminal, + url: "https://dev.azure.com/org/project/_git/repo/pullrequest/1", + }), + ), + ).toMatchObject({ + _tag: "Success", + success: { + state, + isDraft: true, + mergedAt: state === "merged" ? terminal : null, + closedAt: state === "closed" ? terminal : null, + }, + }); + } +}); + +it("retains Bitbucket draft state without inventing unavailable terminal dates", () => { + const normalized = normalizeBitbucketPullRequestRecord({ + id: 1, + title: "Draft", + state: "OPEN", + draft: true, + links: { html: { href: "https://bitbucket.org/org/repo/pull-requests/1" } }, + source: { branch: { name: "feature" } }, + destination: { branch: { name: "main" } }, + }); + expect(normalized).toMatchObject({ isDraft: true, state: "open" }); + expect(normalized).not.toHaveProperty("mergedAt"); +}); diff --git a/apps/server/src/sourceControl/gitHubPullRequests.ts b/apps/server/src/sourceControl/gitHubPullRequests.ts index ded3c0a90..822de1e02 100644 --- a/apps/server/src/sourceControl/gitHubPullRequests.ts +++ b/apps/server/src/sourceControl/gitHubPullRequests.ts @@ -14,6 +14,9 @@ export interface NormalizedGitHubPullRequestRecord { readonly baseRefName: string; readonly headRefName: string; readonly state: "open" | "closed" | "merged"; + readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -27,6 +30,8 @@ const GitHubPullRequestSchema = Schema.Struct({ baseRefName: TrimmedNonEmptyString, headRefName: TrimmedNonEmptyString, state: Schema.optional(Schema.NullOr(Schema.String)), + isDraft: Schema.optional(Schema.Boolean), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), isCrossRepository: Schema.optional(Schema.Boolean), @@ -93,6 +98,9 @@ function normalizeGitHubPullRequestRecord( baseRefName: raw.baseRefName, headRefName: raw.headRefName, state: normalizeGitHubPullRequestState(raw), + ...(raw.isDraft === true ? { isDraft: true } : {}), + closedAt: raw.closedAt ?? null, + mergedAt: raw.mergedAt ?? null, updatedAt: raw.updatedAt ?? Option.none(), ...(typeof raw.isCrossRepository === "boolean" ? { isCrossRepository: raw.isCrossRepository } diff --git a/apps/server/src/sourceControl/gitLabMergeRequests.ts b/apps/server/src/sourceControl/gitLabMergeRequests.ts index afd1eceae..0525260df 100644 --- a/apps/server/src/sourceControl/gitLabMergeRequests.ts +++ b/apps/server/src/sourceControl/gitLabMergeRequests.ts @@ -14,6 +14,9 @@ export interface NormalizedGitLabMergeRequestRecord { readonly baseRefName: string; readonly headRefName: string; readonly state: "open" | "closed" | "merged"; + readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -41,6 +44,10 @@ const GitLabMergeRequestSchema = Schema.Struct({ source_branch: TrimmedNonEmptyString, target_branch: TrimmedNonEmptyString, state: Schema.optional(Schema.NullOr(Schema.String)), + draft: Schema.optional(Schema.Boolean), + work_in_progress: Schema.optional(Schema.Boolean), + closed_at: Schema.optional(Schema.NullOr(Schema.String)), + merged_at: Schema.optional(Schema.NullOr(Schema.String)), updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), source_project_id: Schema.optional(Schema.NullOr(Schema.Number)), target_project_id: Schema.optional(Schema.NullOr(Schema.Number)), @@ -108,6 +115,9 @@ function normalizeGitLabMergeRequestRecord( baseRefName: raw.target_branch, headRefName: raw.source_branch, state: normalizeGitLabMergeRequestState(raw.state), + ...(raw.draft === true || raw.work_in_progress === true ? { isDraft: true } : {}), + closedAt: raw.closed_at ?? null, + mergedAt: raw.merged_at ?? null, updatedAt: raw.updated_at ?? Option.none(), ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}), ...(sourceProjectPath ? { headRepositoryNameWithOwner: sourceProjectPath } : {}), diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 981a5ed95..b1d6b4ab2 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1,7 +1,13 @@ +import * as Stream from "effect/Stream"; +import * as ServerSettings from "../serverSettings.ts"; +import { ServerSettingsError } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { DEFAULT_TERMINAL_ID, + ProviderInstanceId, + ProviderDriverKind, + TerminalProviderInstanceNotFoundError, type TerminalAttachStreamEvent, type TerminalEvent, type TerminalMetadataStreamEvent, @@ -203,6 +209,9 @@ const multiTerminalHistoryLogPath = ( ); interface CreateManagerOptions { + resolveProviderInstanceEnvironment?: Parameters< + typeof TerminalManager.makeWithOptions + >[0]["resolveProviderInstanceEnvironment"]; shellResolver?: () => string; env?: NodeJS.ProcessEnv; subprocessInspector?: (terminalPid: number) => Effect.Effect<{ @@ -249,6 +258,9 @@ const createManager = ( : {}), ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}), ...(options.env !== undefined ? { env: options.env } : {}), + ...(options.resolveProviderInstanceEnvironment === undefined + ? {} + : { resolveProviderInstanceEnvironment: options.resolveProviderInstanceEnvironment }), ...(options.subprocessInspector !== undefined ? { subprocessInspector: options.subprocessInspector } : {}), @@ -1974,6 +1986,303 @@ it.layer( }), ); + it.effect("resolves a provider instance environment before spawning", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("codex_work"); + const { manager, ptyAdapter } = yield* createManager(5, { + env: { T3CODE_SECRET: "server-only" }, + resolveProviderInstanceEnvironment: (requestedId, env) => + Effect.succeed({ + ...env, + PROVIDER_SECRET: requestedId === providerInstanceId ? "secret-value" : "wrong", + CODEX_HOME: "/accounts/codex-work", + }), + }); + + const snapshot = yield* manager.open( + openInput({ providerInstanceId, env: { CLIENT_FLAG: "1" } }), + ); + + expect(ptyAdapter.spawnInputs[0]?.env.PROVIDER_SECRET).toBe("secret-value"); + expect(ptyAdapter.spawnInputs[0]?.env.CODEX_HOME).toBe("/accounts/codex-work"); + expect(ptyAdapter.spawnInputs[0]?.env.CLIENT_FLAG).toBe("1"); + expect(ptyAdapter.spawnInputs[0]?.env.T3CODE_SECRET).toBeUndefined(); + expect(snapshot).not.toHaveProperty("env"); + expect(snapshot).not.toHaveProperty("providerInstanceId"); + }), + ); + + it.effect("fails closed when a provider instance is missing", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("deleted_instance"); + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: (requestedId) => + Effect.fail( + new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(requestedId), + }), + ), + }); + + const error = yield* manager.open(openInput({ providerInstanceId })).pipe(Effect.flip); + + assert.deepStrictEqual( + error, + new TerminalProviderInstanceNotFoundError({ providerInstanceId }), + ); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + }), + ); + + it.effect("preserves the settings failure when provider environment resolution fails", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const providerInstanceId = ProviderInstanceId.make("codex_work"); + const settingsCause = new Error("secret store read failed"); + const settingsError = new ServerSettingsError({ + settingsPath: "/test/settings.json", + operation: "read-secret", + providerInstanceId, + environmentVariable: "OPENROUTER_API_KEY", + cause: settingsCause, + }); + const serverSettings = ServerSettings.ServerSettingsService.of({ + start: Effect.void, + ready: Effect.void, + getSettings: Effect.fail(settingsError), + updateSettings: () => Effect.fail(settingsError), + streamChanges: Stream.empty, + subscribeChanges: Effect.succeed(Stream.empty), + }); + + const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: providerInstanceId, + env: undefined, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "TerminalProviderEnvironmentError", + providerInstanceId, + }); + expect(error.cause).toBe(settingsError); + expect(error.message).not.toContain(settingsError.message); + expect(error.message).not.toContain("OPENROUTER_API_KEY"); + }), + ); + + it.effect.each([ + { + name: "Codex home", + driver: "codex", + variable: "CODEX_HOME", + config: { homePath: "/configured/codex" }, + expectedHome: "/configured/codex", + }, + { + name: "Codex shadow home", + driver: "codex", + variable: "CODEX_HOME", + config: { homePath: "/configured/codex", shadowHomePath: "/configured/codex-shadow" }, + expectedHome: "/configured/codex-shadow", + }, + { + name: "Claude home", + driver: "claudeAgent", + variable: "CLAUDE_CONFIG_DIR", + config: { homePath: "/configured/claude" }, + expectedHome: "/configured/claude", + }, + ])("prefers $name over the instance environment", ({ driver, variable, config, expectedHome }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "configured_home", + env: undefined, + }); + + expect(environment[variable]).toBe(path.resolve(expectedHome)); + }).pipe( + Effect.provide( + ServerSettings.layerTest({ + providerInstances: { + [ProviderInstanceId.make("configured_home")]: { + driver: ProviderDriverKind.make(driver), + environment: [{ name: variable, value: "~/.environment-account", sensitive: false }], + config, + }, + }, + }), + ), + ), + ); + + it.effect("resolves the legacy Codex default instance", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "codex", + env: undefined, + }); + + expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-legacy$/); + }).pipe( + Effect.provide( + ServerSettings.layerTest({ + providerInstances: {}, + providers: { codex: { homePath: "~/.codex-legacy" } }, + }), + ), + ), + ); + + it.effect("resolves the legacy Claude default instance", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "claudeAgent", + env: undefined, + }); + + expect(environment.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-legacy$/); + }).pipe( + Effect.provide( + ServerSettings.layerTest({ + providerInstances: {}, + providers: { claudeAgent: { homePath: "~/.claude-legacy" } }, + }), + ), + ), + ); + + it.effect("prefers an explicit default instance over legacy provider settings", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "codex", + env: undefined, + }); + + expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-explicit$/); + }).pipe( + Effect.provide( + ServerSettings.layerTest({ + providers: { codex: { homePath: "~/.codex-legacy" } }, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: "codex", + config: { homePath: "~/.codex-explicit" }, + }, + }, + }), + ), + ), + ); + + it.effect("keeps unknown provider instance ids unavailable after legacy hydration", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "codex_unknown", + env: undefined, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "TerminalProviderInstanceNotFoundError", + providerInstanceId: "codex_unknown", + }); + }).pipe(Effect.provide(ServerSettings.layerTest())), + ); + + it.effect("restarts a running terminal when the resolved provider environment changes", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("codex_work"); + let providerSecret = "first-secret"; + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: () => + Effect.succeed({ PROVIDER_SECRET: providerSecret }), + }); + + yield* manager.open(openInput({ providerInstanceId })); + providerSecret = "second-secret"; + yield* manager.open(openInput({ providerInstanceId })); + + expect(ptyAdapter.processes[0]?.killed).toBe(true); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + expect(ptyAdapter.spawnInputs[1]?.env.PROVIDER_SECRET).toBe("second-secret"); + }), + ); + + it.effect("attaches to a running provider terminal without resolving the provider again", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("codex_work"); + let providerAvailable = true; + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: (requestedId) => + providerAvailable + ? Effect.succeed({ PROVIDER_SECRET: "secret-value" }) + : Effect.fail( + new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(requestedId), + }), + ), + }); + yield* manager.open(openInput({ providerInstanceId })); + providerAvailable = false; + const events: TerminalAttachStreamEvent[] = []; + + const unsubscribe = yield* manager.attachStream( + { ...openInput({ providerInstanceId }), restartIfNotRunning: true }, + (event) => Effect.sync(() => events.push(event)), + ); + unsubscribe(); + + expect(events[0]?.type).toBe("snapshot"); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(ptyAdapter.processes[0]?.killed).toBe(false); + }), + ); + + it.effect("fails closed when attaching would create a missing provider terminal", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("deleted_instance"); + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: (requestedId) => + Effect.fail( + new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(requestedId), + }), + ), + }); + + const error = yield* manager + .attachStream(openInput({ providerInstanceId }), () => Effect.void) + .pipe(Effect.flip); + + assert.deepStrictEqual( + error, + new TerminalProviderInstanceNotFoundError({ providerInstanceId }), + ); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + }), + ); + it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => Effect.gen(function* () { if ((yield* HostProcessPlatform) === "win32") return; diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index a5de54ca7..5bcdb83eb 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -8,6 +8,11 @@ */ import { DEFAULT_TERMINAL_ID, + ClaudeSettings, + CodexSettings, + ProviderInstanceId, + TerminalProviderInstanceNotFoundError, + TerminalProviderEnvironmentError, TerminalCwdError, TerminalCwdNotDirectoryError, TerminalCwdNotFoundError, @@ -55,6 +60,11 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as SynchronizedRef from "effect/SynchronizedRef"; +import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; +import { deriveProviderInstanceConfigMap } from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as ServerConfig from "../config.ts"; import { increment, @@ -1333,6 +1343,9 @@ function normalizedRuntimeEnv( return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right))); } +const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); +const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); + interface TerminalManagerOptions { logsDir: string; historyLineLimit?: number; @@ -1353,17 +1366,79 @@ interface TerminalManagerOptions { readonly threadId: string; readonly terminalId: string; }) => Effect.Effect; + resolveProviderInstanceEnvironment?: ( + providerInstanceId: string, + env: Record | undefined, + ) => Effect.Effect< + Record, + TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError + >; } +export const resolveProviderInstanceTerminalEnvironment = Effect.fn( + "terminal.resolveProviderInstanceTerminalEnvironment", +)(function* (input: { + readonly serverSettings: ServerSettings.ServerSettingsService["Service"]; + readonly path: Path.Path; + readonly rawProviderInstanceId: string; + readonly env: Record | undefined; +}) { + const providerInstanceId = ProviderInstanceId.make(input.rawProviderInstanceId); + const settings = yield* input.serverSettings.getSettings.pipe( + Effect.mapError((cause) => new TerminalProviderEnvironmentError({ providerInstanceId, cause })), + ); + const instance = deriveProviderInstanceConfigMap(settings)[providerInstanceId]; + if (instance === undefined) { + return yield* new TerminalProviderInstanceNotFoundError({ providerInstanceId }); + } + + let resolved = mergeProviderInstanceEnvironment(instance.environment, input.env ?? {}); + if (instance.driver === "codex") { + const config = decodeCodexSettings(instance.config ?? {}); + if (Option.isSome(config)) { + const layout = yield* resolveCodexHomeLayout(config.value).pipe( + Effect.provideService(Path.Path, input.path), + ); + if (layout.effectiveHomePath) + resolved = { ...resolved, CODEX_HOME: layout.effectiveHomePath }; + } + } else if (instance.driver === "claudeAgent") { + const config = decodeClaudeSettings(instance.config ?? {}); + if (Option.isSome(config)) { + resolved = yield* makeClaudeEnvironment(config.value, resolved).pipe( + Effect.provideService(Path.Path, input.path), + ); + } + } + + return Object.fromEntries( + Object.entries(resolved).filter((entry): entry is [string, string] => entry[1] !== undefined), + ); +}); + export const make = Effect.fn("TerminalManager.make")(function* () { const { terminalLogsDir } = yield* ServerConfig.ServerConfig; const ptyAdapter = yield* PtyAdapter.PtyAdapter; const portDiscovery = yield* PortScanner.PortDiscovery; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const path = yield* Path.Path; + const resolveProviderInstanceEnvironment = Effect.fn( + "terminal.resolveProviderInstanceEnvironment", + )((rawProviderInstanceId: string, env: Record | undefined) => + resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + env, + }), + ); + return yield* makeWithOptions({ logsDir: terminalLogsDir, ptyAdapter, registerTerminalProcesses: portDiscovery.registerTerminalProcesses, unregisterTerminal: portDiscovery.unregisterTerminal, + resolveProviderInstanceEnvironment, }); }); @@ -1375,6 +1450,24 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); + const resolveLaunchInputEnvironment = Effect.fn("terminal.resolveLaunchInputEnvironment")( + function* ( + input: Input, + ): Effect.fn.Return< + Input, + TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError + > { + if (input.providerInstanceId === undefined) return input; + const resolver = options.resolveProviderInstanceEnvironment; + if (resolver === undefined) { + return yield* new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(input.providerInstanceId), + }); + } + const env = yield* resolver(input.providerInstanceId, input.env); + return { ...input, env }; + }, + ); const logsDir = options.logsDir; const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; const historyByteLimit = options.historyByteLimit ?? DEFAULT_HISTORY_BYTE_LIMIT; @@ -2562,7 +2655,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); const open: TerminalManager["Service"]["open"] = (input) => - withThreadLock(input.threadId, openLocked(input)); + withThreadLock( + input.threadId, + resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(openLocked)), + ); const openOrAttachForStream = (input: TerminalAttachInput) => withThreadLock( @@ -2579,11 +2675,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); } - return yield* openLocked({ + const resolvedInput = yield* resolveLaunchInputEnvironment({ ...input, terminalId, cwd: input.cwd, }); + return yield* openLocked(resolvedInput); } const session = existing.value; @@ -2591,11 +2688,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const targetRows = input.rows ?? session.rows; if (!session.process && input.cwd && input.restartIfNotRunning === true) { - return yield* openLocked({ + const resolvedInput = yield* resolveLaunchInputEnvironment({ ...input, terminalId, cwd: input.cwd, }); + return yield* openLocked(resolvedInput); } if ( @@ -2868,10 +2966,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }), ); - const restart: TerminalManager["Service"]["restart"] = (input) => + const restart: TerminalManager["Service"]["restart"] = (rawInput) => withThreadLock( - input.threadId, + rawInput.threadId, Effect.gen(function* () { + const input = yield* resolveLaunchInputEnvironment(rawInput); yield* increment(terminalRestartsTotal, { scope: "thread" }); const terminalId = input.terminalId; yield* assertValidCwd(input.cwd); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index d6e11b693..841c1fb30 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -1,3 +1,7 @@ +import { + BUNDLED_CLAUDE_MODEL_CATALOG, + type ClaudeModelCatalog, +} from "../provider/ClaudeModelCatalog.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts"; @@ -108,6 +112,7 @@ function makeFakeClaudeBinary(dir: string) { function withFakeClaudeEnv( input: { output: string; + catalog?: ClaudeModelCatalog; exitCode?: number; stderr?: string; argsMustContain?: string; @@ -222,12 +227,59 @@ function withFakeClaudeEnv( ); const config = decodeClaudeSettings(input.claudeConfig ?? {}); - const textGeneration = yield* makeClaudeTextGeneration(config); + const textGeneration = yield* makeClaudeTextGeneration( + config, + undefined, + Effect.succeed(input.catalog ?? BUNDLED_CLAUDE_MODEL_CATALOG), + ); return yield* effectFn(textGeneration); }).pipe(Effect.scoped); } it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { + it.effect("uses a refreshed catalog for the model alias and CLI effort", () => + withFakeClaudeEnv( + { + output: JSON.stringify({ structured_output: { subject: "Catalog test", body: "" } }), + argsMustContain: "--model remote-model --effort max", + catalog: { + models: [ + { + model: { + slug: "remote-model", + aliases: ["remote"], + name: "Remote", + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Effort", + type: "select", + options: [{ id: "custom", label: "Custom", isDefault: true }], + }, + ], + }, + }, + runtime: { effortMap: { custom: "max" } }, + compatibility: {}, + }, + ], + }, + }, + (textGeneration) => + Effect.gen(function* () { + const result = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "topic", + stagedSummary: "M README.md", + stagedPatch: "diff", + modelSelection: createModelSelection(ProviderInstanceId.make("claudeAgent"), "remote"), + }); + expect(result.subject).toBe("Catalog test"); + }), + ), + ); it.effect("forwards Claude thinking settings for Haiku without passing effort", () => withFakeClaudeEnv( { diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index a9d25d194..96a735442 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -1,3 +1,10 @@ +import { + BUNDLED_CLAUDE_MODEL_CATALOG, + type ClaudeModelCatalog, + getClaudeCatalogModelCapabilities, + normalizeClaudeCatalogEffort, + resolveClaudeCatalogApiModelId, +} from "../provider/ClaudeModelCatalog.ts"; /** * ClaudeTextGeneration – Text generation layer using the Claude CLI. * @@ -38,13 +45,7 @@ import { getModelSelectionStringOptionValue, getProviderOptionDescriptors, } from "@t3tools/shared/model"; -import { - getClaudeModelCapabilities, - isClaudeUltracodeEffort, - normalizeClaudeCliEffort, - resolveClaudeApiModelId, - resolveClaudeEffort, -} from "../provider/Layers/ClaudeProvider.ts"; +import { isClaudeUltracodeEffort, resolveClaudeEffort } from "../provider/Layers/ClaudeProvider.ts"; import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; const CLAUDE_TIMEOUT_MS = 180_000; @@ -70,6 +71,7 @@ const decodeClaudeOutput = Schema.decodeEffect( export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, + modelCatalog: Effect.Effect = Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG), ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; @@ -138,7 +140,8 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu toJsonSchemaObject(outputSchemaJson), "Failed to encode structured output schema.", ); - const caps = getClaudeModelCapabilities(modelSelection.model); + const catalog = yield* modelCatalog; + const caps = getClaudeCatalogModelCapabilities(catalog, modelSelection.model); const descriptors = getProviderOptionDescriptors({ caps, selections: modelSelection.options, @@ -146,7 +149,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id); const rawEffortSelection = getModelSelectionStringOptionValue(modelSelection, "effort"); const resolvedEffort = resolveClaudeEffort(caps, rawEffortSelection); - const cliEffort = normalizeClaudeCliEffort(resolvedEffort, modelSelection.model); + const cliEffort = normalizeClaudeCatalogEffort(catalog, resolvedEffort, modelSelection.model); const ultracode = isClaudeUltracodeEffort(resolvedEffort); const thinkingDescriptor = findDescriptor("thinking"); const fastModeDescriptor = findDescriptor("fastMode"); @@ -187,7 +190,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu "--json-schema", jsonSchemaStr, "--model", - resolveClaudeApiModelId(modelSelection), + resolveClaudeCatalogApiModelId(catalog, modelSelection), ...(cliEffort ? ["--effort", cliEffort] : []), "--settings", settingsJson, diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 8fc86ee3d..111c14019 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -8,18 +8,20 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Scheduler from "effect/Scheduler"; +import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as UsageService from "./UsageService.ts"; -function claudeLine(id: number, outputTokens: number): string { +function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string { return `${JSON.stringify({ type: "assistant", timestamp: "2026-08-01T10:00:00Z", @@ -27,7 +29,7 @@ function claudeLine(id: number, outputTokens: number): string { sessionId: "session-1", message: { id: `msg_${id}`, - model: "claude-fable-5", + model, usage: { input_tokens: 10, output_tokens: outputTokens }, }, })}\n`; @@ -65,6 +67,8 @@ const serviceLayers = (input: { readonly home: string; readonly settings: Parameters[0]; readonly onRatesFetch?: () => void; + /** Defaults to an unparsable document so every scan retries the fetch. */ + readonly ratesDocument?: unknown; }) => ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( Layer.provideMerge(NodeServices.layer), @@ -77,7 +81,7 @@ const serviceLayers = (input: { input.onRatesFetch?.(); // Unparsable rates: every scan retries the fetch, which makes the // fetch count a boundary-level observation of how many scans ran. - return HttpClientResponse.fromWeb(request, Response.json({})); + return HttpClientResponse.fromWeb(request, Response.json(input.ratesDocument ?? {})); }), ), ), @@ -92,6 +96,49 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens } describe("UsageService", () => { + it.live("reprices unchanged transcripts when custom prices are added, edited, or removed", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5, "example-model"))); + + yield* Effect.gen(function* () { + const settingsService = yield* ServerSettings.ServerSettingsService; + const service = yield* UsageService.make; + + const original = yield* service.readSummary(WINDOW); + assert.strictEqual(original.buckets[0]?.costUsd, 0); + assert.strictEqual(original.buckets[0]?.unpricedRecords, 1); + + yield* settingsService.updateSettings({ + usagePriceOverrides: { + "example-model": { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }, + }, + }); + const overridden = yield* service.readSummary(WINDOW); + assert.closeTo(overridden.buckets[0]?.costUsd ?? -1, 0.00006, 1e-12); + assert.strictEqual(overridden.buckets[0]?.costSource, "modelPriced"); + assert.strictEqual(overridden.buckets[0]?.unpricedRecords, 0); + assert.deepStrictEqual(overridden.buckets[0]?.totals, original.buckets[0]?.totals); + + yield* settingsService.updateSettings({ + usagePriceOverrides: { + "example-model": { inputCostPerMillionTokens: 4, outputCostPerMillionTokens: 16 }, + }, + }); + const edited = yield* service.readSummary(WINDOW); + assert.closeTo(edited.buckets[0]?.costUsd ?? -1, 0.00012, 1e-12); + + yield* settingsService.updateSettings({ usagePriceOverrides: { "example-model": null } }); + const restored = yield* service.readSummary(WINDOW); + assert.deepStrictEqual(restored.buckets, original.buckets); + }).pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-price-overrides-test", home, settings }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live("counts appended usage on a rescan of a grown transcript", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -142,6 +189,48 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("refetches a rate table inside its TTL only when the client asks", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + + let ratesFetches = 0; + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-rates-refresh-test", + home, + settings, + ratesDocument: { + "claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 }, + }, + onRatesFetch: () => { + ratesFetches += 1; + }, + }), + ), + ); + + const first = yield* service.readSummary(WINDOW); + assert.strictEqual(ratesFetches, 1); + assert.strictEqual(first.pricing.status, "fresh"); + + // Inside the daily TTL a plain rescan keeps the cached table. + yield* TestClock.adjust(Duration.minutes(2)); + yield* service.readSummary(WINDOW); + assert.strictEqual(ratesFetches, 1); + + // An explicit refresh fetches again so a newly listed model gets priced. + // A burst of refreshes shares that one fetch. + const [refreshed] = yield* Effect.all([service.refreshRates, service.refreshRates], { + concurrency: 2, + }); + assert.strictEqual(ratesFetches, 2); + assert.strictEqual(refreshed.status, "fresh"); + assert.strictEqual(refreshed.knownModels, 1); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + it.live("does not orphan an in-flight scan when its first caller is interrupted", () => Effect.gen(function* () { const { settings, home } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 16a7478d9..3a27c94ee 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -1,3 +1,7 @@ +import type { + UsageModelPriceOverride, + ServerSettings as ServerSettingsValue, +} from "@t3tools/contracts"; /** * UsageService - scans provider transcripts and returns priced usage buckets. * @@ -18,6 +22,7 @@ import { USAGE_CONTRACT_VERSION, type UsageProviderKind, type UsageSource, + type UsagePricing, type UsageSummary, type UsageSummaryInput, UsageReadError, @@ -34,6 +39,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../config.ts"; @@ -42,7 +48,7 @@ import * as ServerSettings from "../serverSettings.ts"; import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { UsageAggregator } from "./usageAggregation.ts"; -import { parseRateTable, type RateTable } from "./usagePricing.ts"; +import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { listTranscriptFiles, readDirectoryVolumeId, @@ -63,6 +69,9 @@ const LITELLM_RATES_URL = /** Rates move rarely; a day-old table keeps the page working offline. */ const RATES_TTL_MS = 24 * 60 * 60 * 1000; +/** An explicit refresh ignores the TTL, but not a table fetched this recently. */ +const RATES_REFRESH_FLOOR_MS = 60 * 1000; + /** * Files are filtered by mtime before opening. The slack covers a session whose * last write lands just before local midnight on the window's first day. @@ -94,9 +103,18 @@ export class UsageService extends Context.Service< UsageService, { readonly readSummary: (input: UsageSummaryInput) => Effect.Effect; + /** Refetches the rate table ahead of its TTL. See `ensureRates`. */ + readonly refreshRates: Effect.Effect; } >()("t3/usage/UsageService") {} +const EMPTY_PRICING: UsagePricing = { + status: "unavailable", + source: LITELLM_RATES_URL, + fetchedAt: null, + knownModels: 0, +}; + /** Empty summary, for suites that only need the RPC surface to resolve. */ export const layerTest = Layer.succeed( UsageService, @@ -110,14 +128,10 @@ export const layerTest = Layer.succeed( untilDay: input.untilDay, buckets: [], sources: [], - pricing: { - status: "unavailable", - source: LITELLM_RATES_URL, - fetchedAt: null, - knownModels: 0, - }, + pricing: EMPTY_PRICING, scanDurationMs: 0, }), + refreshRates: Effect.succeed(EMPTY_PRICING), }), ); @@ -136,16 +150,29 @@ export const make = Effect.gen(function* () { const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json"); let rates: RateTable = new Map(); let ratesFetchedAtMs: number | null = null; - let ratesStatus: UsageSummary["pricing"]["status"] = "unavailable"; + let ratesStatus: UsagePricing["status"] = "unavailable"; + // One fetch at a time. A burst of refreshes from several clients waits on + // the first fetch and then sees a table young enough to skip its own. + const ratesLock = yield* Semaphore.make(1); + + const pricing = (): UsagePricing => ({ + status: ratesStatus, + source: LITELLM_RATES_URL, + fetchedAt: + ratesFetchedAtMs === null ? null : DateTime.formatIso(DateTime.makeUnsafe(ratesFetchedAtMs)), + knownModels: rates.size, + }); /** * Loads the LiteLLM rate table, preferring a fresh copy and falling back to * the on-disk snapshot. With neither, every model reports as unpriced rather - * than the page failing. + * than the page failing. `force` refetches inside the TTL so a model that + * LiteLLM added since the last fetch gets priced now. */ - const ensureRates = Effect.fn("UsageService.ensureRates")(function* () { + const loadRates = Effect.fn("UsageService.loadRates")(function* (force: boolean) { const now = yield* Clock.currentTimeMillis; - if (ratesFetchedAtMs !== null && now - ratesFetchedAtMs < RATES_TTL_MS) return; + const maxAgeMs = force ? RATES_REFRESH_FLOOR_MS : RATES_TTL_MS; + if (ratesFetchedAtMs !== null && now - ratesFetchedAtMs < maxAgeMs) return; if (ratesFetchedAtMs === null) { const fromDisk = yield* fileSystem.readFileString(ratesCachePath).pipe( @@ -158,7 +185,7 @@ export const make = Effect.gen(function* () { rates = parsed; ratesFetchedAtMs = fromDisk.fetchedAtMs; ratesStatus = "cached"; - if (now - fromDisk.fetchedAtMs < RATES_TTL_MS) return; + if (now - fromDisk.fetchedAtMs < maxAgeMs) return; } } } @@ -189,6 +216,13 @@ export const make = Effect.gen(function* () { ); }); + const ensureRates = (force: boolean) => ratesLock.withPermit(loadRates(force)); + + const refreshRates = ensureRates(true).pipe( + Effect.map(pricing), + Effect.withSpan("UsageService.refreshRates"), + ); + /** * Claude's config dir is the home itself when overridden, but a default * install nests transcripts under `~/.claude/projects`. Probe both. @@ -202,24 +236,22 @@ export const make = Effect.gen(function* () { return nestedExists ? nested : path.join(homePath, "projects"); }); - /** Resolves the transcript directory for each provider. */ - const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* () { - // A settings failure must surface as an error: swallowing it here would - // present "zero usage from every provider" as a valid answer. - const settings = yield* settingsService.getSettings.pipe( - Effect.catchCause( - (cause) => - new UsageReadError({ - reason: "scanFailed", - // Bounded description; the squashed failure travels as the cause. - // Squashed, not the Cause tree: a full tree in a Defect field is - // the unbounded wire payload the bounded detail exists to avoid. - detail: "Server settings could not be read.", - cause: Cause.squash(cause), - }), - ), - ); + // Price overrides and transcript homes come from one settings snapshot per scan. + const readSettings = settingsService.getSettings.pipe( + Effect.catchCause( + (cause) => + new UsageReadError({ + reason: "scanFailed", + detail: "Server settings could not be read.", + cause: Cause.squash(cause), + }), + ), + ); + /** Resolves the transcript directory for each provider. */ + const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* ( + settings: ServerSettingsValue, + ) { const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent); const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome); const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex); @@ -349,10 +381,15 @@ export const make = Effect.gen(function* () { | null; } - const collectDirs = Effect.fn("UsageService.collectDirs")(function* (windowStartMs: number) { + const collectDirs = Effect.fn("UsageService.collectDirs")(function* ( + windowStartMs: number, + settings: ServerSettingsValue, + ) { // The home resolvers ask for `Path` themselves; satisfy them from the // instance we already hold so the scan stays context-free. - const dirs = yield* resolveTranscriptDirs().pipe(Effect.provideService(Path.Path, path)); + const dirs = yield* resolveTranscriptDirs(settings).pipe( + Effect.provideService(Path.Path, path), + ); const scanned: ScannedDir[] = []; for (const { provider, dir, fileName } of dirs) { const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); @@ -376,7 +413,10 @@ export const make = Effect.gen(function* () { return scanned; }); - const scanSummary = Effect.fn("UsageService.scanSummary")(function* (input: UsageSummaryInput) { + const scanSummary = Effect.fn("UsageService.scanSummary")(function* ( + input: UsageSummaryInput, + settings: ServerSettingsValue, + ) { if (input.sinceDay > input.untilDay) { return yield* new UsageReadError({ reason: "invalidWindow", @@ -425,9 +465,12 @@ export const make = Effect.gen(function* () { // Pricing only matters once records are aggregated, so the rate table // loads while transcripts stream instead of gating them: a cold rates // fetch on a slow network no longer delays the scan by its own timeout. - const [, scannedDirs] = yield* Effect.all([ensureRates(), collectDirs(windowStartMs)], { - concurrency: 2, - }); + const [, scannedDirs] = yield* Effect.all( + [ensureRates(false), collectDirs(windowStartMs, settings)], + { + concurrency: 2, + }, + ); const aggregator = new UsageAggregator({ timeZone: input.timeZone, @@ -436,6 +479,7 @@ export const make = Effect.gen(function* () { resolution: input.resolution ?? "day", ...hourlyWindow, rates, + priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), }); const sources: UsageSource[] = []; @@ -511,15 +555,7 @@ export const make = Effect.gen(function* () { untilDay: input.untilDay, buckets: aggregated.buckets, sources, - pricing: { - status: ratesStatus, - source: LITELLM_RATES_URL, - fetchedAt: - ratesFetchedAtMs === null - ? null - : DateTime.formatIso(DateTime.makeUnsafe(ratesFetchedAtMs)), - knownModels: rates.size, - }, + pricing: pricing(), scanDurationMs: Math.max(0, finishedAtMs - startedAtMs), } satisfies UsageSummary; }); @@ -531,7 +567,10 @@ export const make = Effect.gen(function* () { */ const inflightScans = new Map>(); - const scanKey = (input: UsageSummaryInput): string => + const scanKey = ( + input: UsageSummaryInput, + prices: Readonly>, + ): string => JSON.stringify([ input.timeZone, input.sinceDay, @@ -539,10 +578,12 @@ export const make = Effect.gen(function* () { input.resolution ?? "day", input.sinceTime ?? null, input.untilTime ?? null, + prices, ]); const readSummary = Effect.fn("UsageService.readSummary")(function* (input: UsageSummaryInput) { - const key = scanKey(input); + const settings = yield* readSettings; + const key = scanKey(input, settings.usagePriceOverrides); const deferred = yield* Effect.uninterruptible( Effect.gen(function* () { const existing = inflightScans.get(key); @@ -554,7 +595,7 @@ export const make = Effect.gen(function* () { inflightScans.set(key, created); // Detached so one departing client cannot tear the scan out from under // the fibers awaiting it; a finished scan warms the cache either way. - yield* scanSummary(input).pipe( + yield* scanSummary(input, settings).pipe( Effect.onExit((exit) => Effect.sync(() => inflightScans.delete(key)).pipe( Effect.andThen(Deferred.done(created, exit)), @@ -570,7 +611,7 @@ export const make = Effect.gen(function* () { return yield* Deferred.await(deferred); }); - return { readSummary } as const; + return { readSummary, refreshRates } as const; }); export const layer = Layer.effect(UsageService, make); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index e100be76e..01a1195ef 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -61,6 +61,7 @@ export interface AggregateOptions { readonly sinceDay: string; readonly untilDay: string; readonly rates: RateTable; + readonly priceOverrides?: RateTable; readonly resolution?: UsageResolution; readonly sinceTimeMs?: number; readonly untilTimeMs?: number; @@ -165,11 +166,17 @@ export class UsageAggregator { record.model, record.totals, record.reportedCostUsd, + this.#options.priceOverrides, ); bucket.totals = addTotals(bucket.totals, record.totals); bucket.costUsd += priced.costUsd; - bucket.cacheSavingsUsd += cacheSavingsUsd(this.#options.rates, record.model, record.totals); + bucket.cacheSavingsUsd += cacheSavingsUsd( + this.#options.rates, + record.model, + record.totals, + this.#options.priceOverrides, + ); bucket.records += 1; if (priced.costSource === "unpriced") bucket.unpricedRecords += 1; if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1; diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index 2ea27375b..937e352b6 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -1,3 +1,4 @@ +import { createOverrideRateTable, priceUsage, cacheSavingsUsd } from "./usagePricing.ts"; import { describe, expect, it } from "@effect/vitest"; import { lookupRate, normalizeModelName, parseRateTable } from "./usagePricing.ts"; @@ -31,6 +32,15 @@ describe("usage pricing", () => { } }); + it("prices a bracketed context-tier variant at the base model's rate", () => { + const table = parseRateTable({ "claude-fable-5-1": rate(1e-5, 2.5e-7) }); + + expect(lookupRate(table, "claude-fable-5-1[1m]")).toEqual( + lookupRate(table, "claude-fable-5-1"), + ); + expect(lookupRate(table, "anthropic/Claude-Fable-5-1[1m]")).toBeNull(); + }); + it("adds a bare alias when every qualified entry has the same rate", () => { const table = parseRateTable({ "provider-a/example-model": rate(1), @@ -53,3 +63,72 @@ describe("usage pricing", () => { expect(lookupRate(table, "example-model")).toBeNull(); }); }); + +describe("custom model pricing", () => { + const totals = { + uncachedInputTokens: 1_000_000, + cachedInputTokens: 1_000_000, + cacheCreationTokens: 1_000_000, + outputTokens: 1_000_000, + reasoningTokens: 500_000, + }; + + it("uses custom token rates ahead of public and provider-reported costs", () => { + const table = parseRateTable({ "example-model": rate(1) }); + const overrides = createOverrideRateTable({ + "example-model": { + inputCostPerMillionTokens: 2, + outputCostPerMillionTokens: 8, + cacheReadCostPerMillionTokens: 0.5, + cacheWriteCostPerMillionTokens: 3, + }, + }); + + for (const reportedCostUsd of [null, 99]) { + expect(priceUsage(table, "example-model", totals, reportedCostUsd, overrides)).toEqual({ + costUsd: 13.5, + costSource: "modelPriced", + }); + } + expect(cacheSavingsUsd(table, "example-model", totals, overrides)).toBe(1.5); + }); + + it("prices unknown models offline and uses input prices for omitted cache rates", () => { + const table = parseRateTable({}); + const overrides = createOverrideRateTable({ + "example-model": { inputCostPerMillionTokens: 2, outputCostPerMillionTokens: 8 }, + }); + + expect(priceUsage(table, "example-model", totals, null, overrides)).toEqual({ + costUsd: 14, + costSource: "modelPriced", + }); + expect(cacheSavingsUsd(table, "example-model", totals, overrides)).toBe(0); + }); + + it("preserves explicit zero rates and matches only the exact trimmed model ID", () => { + const table = parseRateTable({}); + const overrides = createOverrideRateTable({ + " vendor/example-model[1m] ": { + inputCostPerMillionTokens: 0, + outputCostPerMillionTokens: 0, + }, + }); + expect(priceUsage(table, " vendor/example-model[1m] ", totals, 99, overrides)).toEqual({ + costUsd: 0, + costSource: "modelPriced", + }); + for (const model of [ + "example-model[1m]", + "vendor/example-model", + "vendor/Example-model[1m]", + "other/example-model[1m]", + ]) { + expect(priceUsage(table, model, totals, null, overrides).costSource).toBe("unpriced"); + expect(priceUsage(table, model, totals, 99, overrides)).toEqual({ + costUsd: 99, + costSource: "providerReported", + }); + } + }); +}); diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 3d7f5fd29..f2cc50949 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -7,7 +7,11 @@ * * @module usagePricing */ -import type { UsageCostSource, UsageTokenTotals } from "@t3tools/contracts"; +import type { + UsageCostSource, + UsageTokenTotals, + UsageModelPriceOverride, +} from "@t3tools/contracts"; /** * The subset of a LiteLLM entry we price against. All values are USD per token. @@ -26,6 +30,25 @@ export interface ModelRate { export type RateTable = ReadonlyMap; +/** Custom IDs keep their case, provider prefix, and variant suffix. */ +export function createOverrideRateTable( + overrides: Readonly>, +): RateTable { + return new Map( + Object.entries(overrides).map(([model, prices]) => [ + model.trim(), + { + inputCostPerToken: prices.inputCostPerMillionTokens / 1_000_000, + outputCostPerToken: prices.outputCostPerMillionTokens / 1_000_000, + cacheReadCostPerToken: + (prices.cacheReadCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000, + cacheCreationCostPerToken: + (prices.cacheWriteCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000, + }, + ]), + ); +} + /** Raw shape of one LiteLLM entry, narrowed to the fields we read. */ interface LiteLlmEntry { readonly input_cost_per_token?: unknown; @@ -119,6 +142,16 @@ function bareModelName(key: string): string { return slash === -1 ? key : key.slice(slash + 1); } +/** + * Drops a bracketed variant suffix such as `claude-fable-5-1[1m]`, which + * Claude Code writes for the 1M context tier. The rate table only knows the + * base name, and we price at the base tier anyway. + */ +function stripVariantSuffix(key: string): string { + const bracket = key.indexOf("["); + return bracket === -1 ? key : key.slice(0, bracket); +} + /** * Models we never price, regardless of the table. * @@ -136,7 +169,7 @@ const UNPRICEABLE_MODELS = new Set([ ]); export function lookupRate(table: RateTable, model: string): ModelRate | null { - const key = normalizeRateKey(model); + const key = stripVariantSuffix(normalizeRateKey(model)); const bareName = bareModelName(key); if (bareName.length === 0 || UNPRICEABLE_MODELS.has(bareName)) return null; return table.get(key) ?? null; @@ -158,12 +191,14 @@ export function priceUsage( model: string, totals: UsageTokenTotals, reportedCostUsd: number | null, + overrides?: RateTable, ): PricedUsage { - if (reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) { + const override = overrides?.get(model.trim()); + if (override === undefined && reportedCostUsd !== null && Number.isFinite(reportedCostUsd)) { return { costUsd: reportedCostUsd, costSource: "providerReported" }; } - const rate = lookupRate(table, model); + const rate = override ?? lookupRate(table, model); if (rate === null) return { costUsd: 0, costSource: "unpriced" }; const costUsd = @@ -179,8 +214,13 @@ export function priceUsage( * What the cached input would have cost at full input rates, minus what it * actually cost. Drives the "cache savings" figure. */ -export function cacheSavingsUsd(table: RateTable, model: string, totals: UsageTokenTotals): number { - const rate = lookupRate(table, model); +export function cacheSavingsUsd( + table: RateTable, + model: string, + totals: UsageTokenTotals, + overrides?: RateTable, +): number { + const rate = overrides?.get(model.trim()) ?? lookupRate(table, model); if (rate === null) return 0; return totals.cachedInputTokens * (rate.inputCostPerToken - rate.cacheReadCostPerToken); } diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 544ddaaa4..7116172b5 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -1,3 +1,4 @@ +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import { assert, it, describe } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Cause from "effect/Cause"; @@ -6,6 +7,7 @@ import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; @@ -21,7 +23,7 @@ import type { VcsStatusResult, VcsStatusStreamEvent, } from "@t3tools/contracts"; -import { GitManagerError } from "@t3tools/contracts"; +import { GitCommandError, GitManagerError } from "@t3tools/contracts"; import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; @@ -142,6 +144,176 @@ function makeBackgroundPolicyLayer(shouldRunScopeWork: (scope: BackgroundScope) } describe("VcsStatusBroadcaster", () => { + it.effect("an initial status read cannot overwrite an explicit refresh", () => { + const firstReadStarted = Deferred.makeUnsafe(); + const releaseFirstRead = Deferred.makeUnsafe(); + let remoteReads = 0; + const layer = VcsStatusBroadcaster.layer.pipe( + Layer.provide(FileSystem.layerNoop({ realPath: (path) => Effect.succeed(path) })), + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => Effect.succeed(baseLocalStatus), + remoteStatus: () => + Effect.gen(function* () { + remoteReads += 1; + if (remoteReads === 1) { + yield* Deferred.succeed(firstReadStarted, undefined); + yield* Deferred.await(releaseFirstRead); + return baseRemoteStatus; + } + return remoteStatusWithPr; + }), + invalidateStatus: () => Effect.void, + }), + ), + ); + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const initial = yield* broadcaster.getStatus({ cwd: "/repo" }).pipe(Effect.forkScoped); + yield* Deferred.await(firstReadStarted); + const refresh = yield* broadcaster.refreshStatus("/repo").pipe(Effect.forkScoped); + // Run ready fibers before releasing the delayed first read. + yield* TestClock.adjust(Duration.zero); + yield* Deferred.succeed(releaseFirstRead, undefined); + yield* Fiber.join(initial); + yield* Fiber.join(refresh); + assert.deepStrictEqual( + (yield* broadcaster.getStatus({ cwd: "/repo" })).pr, + remoteStatusWithPr.pr, + ); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + + for (const scenario of [ + "disabled", + "dirty", + "feature", + "ahead", + "no-upstream", + "current", + "failure", + ] as const) { + it.effect(`automatic pull respects ${scenario}`, () => { + let pulls = 0; + const local = { + ...baseLocalStatus, + isDefaultRef: scenario !== "feature", + hasWorkingTreeChanges: scenario === "dirty", + }; + const remote = { + ...baseRemoteStatus, + behindCount: scenario === "current" ? 0 : 2, + aheadCount: scenario === "ahead" ? 1 : 0, + hasUpstream: scenario !== "no-upstream", + }; + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const result = yield* broadcaster.refreshStatus("/repo"); + assert.equal(result.behindCount, remote.behindCount); + assert.equal(pulls, scenario === "failure" ? 1 : 0); + }).pipe( + Effect.provide( + VcsStatusBroadcaster.layer.pipe( + Layer.provide(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide( + Layer.succeed(VcsStatusBroadcaster.VcsAutoPullPolicy, { + isEnabled: () => Effect.succeed(scenario !== "disabled"), + }), + ), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => Effect.succeed(local), + remoteStatus: () => Effect.succeed(remote), + invalidateStatus: () => Effect.void, + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + pullCurrentBranch: () => + Effect.sync(() => { + pulls++; + }).pipe( + Effect.andThen( + Effect.fail( + new GitCommandError({ + operation: "pull", + command: "git pull", + cwd: "/repo", + detail: "offline", + }), + ), + ), + ), + }), + ), + ), + ), + ); + }); + } + + it.effect.skipIf(!symlinksSupported)( + "automatically pulls an enabled clean default branch when status detects it is behind", + () => { + let remoteStatus: VcsStatusRemoteResult = { ...baseRemoteStatus, behindCount: 2 }; + let pullCalls = 0; + let configuredWorkspaceRoot = ""; + const localStatus: VcsStatusLocalResult = { + ...baseLocalStatus, + isDefaultRef: true, + refName: "main", + }; + const testLayer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide( + Layer.succeed(VcsStatusBroadcaster.VcsAutoPullPolicy, { + isEnabled: (cwd) => Effect.succeed(cwd === configuredWorkspaceRoot), + }), + ), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => Effect.succeed(localStatus), + remoteStatus: () => Effect.succeed(remoteStatus), + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, + pullCurrentBranch: () => + Effect.sync(() => { + pullCalls += 1; + remoteStatus = { ...remoteStatus, behindCount: 0 }; + return { + status: "pulled" as const, + refName: "main", + upstreamRef: "origin/main", + }; + }), + }), + ), + ); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const realDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-auto-pull-real-", + }); + const linkParent = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-auto-pull-link-", + }); + configuredWorkspaceRoot = path.join(linkParent, "repo-link"); + yield* fileSystem.symlink(realDir, configuredWorkspaceRoot); + + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const status = yield* broadcaster.refreshStatus(configuredWorkspaceRoot); + + assert.equal(pullCalls, 1); + assert.equal(status.behindCount, 0); + }).pipe(Effect.provide(testLayer)); + }, + ); + it.effect("reuses the cached VCS status across repeated reads", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index f28069f6d..4d72e7230 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -10,6 +10,7 @@ import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; import type { @@ -22,6 +23,10 @@ import type { } from "@t3tools/contracts"; import { mergeGitStatusParts } from "@t3tools/shared/git"; +import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; +import { ProjectService } from "../project/ProjectService.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; + import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; @@ -139,6 +144,31 @@ interface StreamStatusOptions { readonly automaticRemoteRefreshInterval?: Effect.Effect; } +export class VcsAutoPullPolicy extends Context.Reference<{ + readonly isEnabled: (cwd: string) => Effect.Effect; +}>("t3/vcs/VcsAutoPullPolicy", { + defaultValue: () => ({ isEnabled: () => Effect.succeed(false) }), +}) {} + +export const autoPullPolicyLayer = Layer.effect( + VcsAutoPullPolicy, + Effect.gen(function* () { + const projects = yield* ProjectService; + const serverSettings = yield* ServerSettingsService; + return { + isEnabled: Effect.fn("VcsAutoPullPolicy.isEnabled")( + function* (cwd: string) { + const project = yield* projects.getByWorkspaceRoot(cwd); + if (project._tag === "None") return false; + const settings = yield* serverSettings.getSettings; + return resolveProjectAutoPull(settings, project.value.id); + }, + Effect.orElseSucceed(() => false), + ), + }; + }), +); + export function remoteRefreshFailureDelay( consecutiveFailures: number, configuredInterval: Duration.Duration, @@ -181,6 +211,7 @@ const normalizeCwd = (cwd: string) => ); export const make = Effect.gen(function* () { + const autoPullPolicy = yield* VcsAutoPullPolicy; const workflow = yield* GitWorkflowService.GitWorkflowService; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const fs = yield* FileSystem.FileSystem; @@ -192,6 +223,16 @@ export const make = Effect.gen(function* () { Scope.close(scope, Exit.void), ); const cacheRef = yield* Ref.make(new Map()); + // Serialize remote refreshes and automatic pulls for each canonical workspace. + const remoteWriteLocks = new Map(); + const withRemoteWriteLock = (cwd: string, effect: Effect.Effect) => { + let lock = remoteWriteLocks.get(cwd); + if (lock === undefined) { + lock = Semaphore.makeUnsafe(1); + remoteWriteLocks.set(cwd, lock); + } + return lock.withPermits(1)(effect); + }; const pollersRef = yield* SynchronizedRef.make(new Map()); const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( @@ -329,14 +370,20 @@ export const make = Effect.gen(function* () { if (cached?.local && cached.remote) { return mergeGitStatusParts(cached.local.value, cached.remote.value); } - const [local, remote] = yield* Effect.all( - [ - cached?.local ? Effect.succeed(cached.local.value) : workflow.localStatus({ cwd }), - cached?.remote ? Effect.succeed(cached.remote.value) : workflow.remoteStatus({ cwd }), - ], - { concurrency: "unbounded" }, + return yield* withRemoteWriteLock( + cwd, + Effect.gen(function* () { + const latest = yield* getCachedStatus(cwd); + const [local, remote] = yield* Effect.all( + [ + latest?.local ? Effect.succeed(latest.local.value) : workflow.localStatus({ cwd }), + latest?.remote ? Effect.succeed(latest.remote.value) : workflow.remoteStatus({ cwd }), + ], + { concurrency: "unbounded" }, + ); + return yield* updateCachedStatus(cwd, local, remote); + }), ); - return yield* updateCachedStatus(cwd, local, remote); }); const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")( @@ -354,15 +401,63 @@ export const make = Effect.gen(function* () { return yield* refreshLocalStatusCore(cwd); }); + const maybeAutoPull = Effect.fn("VcsStatusBroadcaster.maybeAutoPull")(function* ( + cwd: string, + remote: VcsStatusRemoteResult | null, + policyCwds: ReadonlyArray, + ) { + return yield* Effect.gen(function* () { + const autoPullEnabled = (yield* Effect.forEach(policyCwds, autoPullPolicy.isEnabled, { + concurrency: "unbounded", + })).some(Boolean); + if ( + remote === null || + !remote.hasUpstream || + remote.aheadCount > 0 || + remote.behindCount <= 0 || + !autoPullEnabled + ) { + return null; + } + + yield* workflow.invalidateLocalStatus(cwd); + const local = yield* workflow.localStatus({ cwd }); + if (!local.isRepo || !local.isDefaultRef || local.hasWorkingTreeChanges) return null; + + yield* workflow.pullCurrentBranch(cwd); + yield* workflow.invalidateStatus(cwd); + const [refreshedLocal, refreshedRemote] = yield* Effect.all( + [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd }, { refreshUpstream: false })], + { concurrency: "unbounded" }, + ); + yield* updateCachedStatus(cwd, refreshedLocal, refreshedRemote, { publish: true }); + return { local: refreshedLocal, remote: refreshedRemote }; + }).pipe( + Effect.catch(() => + Effect.logWarning("Automatic project pull failed", { cwd }).pipe(Effect.as(null)), + ), + ); + }); + const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( cwd: string, - options?: { readonly refreshUpstream?: boolean }, + options?: { + readonly refreshUpstream?: boolean; + readonly policyCwds?: ReadonlyArray; + }, ) { - if (options?.refreshUpstream !== false) { - yield* workflow.invalidateRemoteStatus(cwd); - } - const remote = yield* workflow.remoteStatus({ cwd }, options); - return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + return yield* withRemoteWriteLock( + cwd, + Effect.gen(function* () { + if (options?.refreshUpstream !== false) { + yield* workflow.invalidateRemoteStatus(cwd); + } + const remote = yield* workflow.remoteStatus({ cwd }, options); + const pulled = yield* maybeAutoPull(cwd, remote, options?.policyCwds ?? [cwd]); + if (pulled !== null) return pulled.remote; + return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + }), + ); }); const refreshStatus: VcsStatusBroadcaster["Service"]["refreshStatus"] = Effect.fn( @@ -371,12 +466,19 @@ export const make = Effect.gen(function* () { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); // invalidateStatus (not the two partial invalidations) so an explicit // refresh also bypasses GitManager's slow PR-lookup cache. - yield* workflow.invalidateStatus(cwd); - const [local, remote] = yield* Effect.all( - [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], - { concurrency: "unbounded" }, + return yield* withRemoteWriteLock( + cwd, + Effect.gen(function* () { + yield* workflow.invalidateStatus(cwd); + const [local, remote] = yield* Effect.all( + [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], + { concurrency: "unbounded" }, + ); + const pulled = yield* maybeAutoPull(cwd, remote, [rawCwd]); + if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote); + return yield* updateCachedStatus(cwd, local, remote, { publish: true }); + }), ); - return yield* updateCachedStatus(cwd, local, remote, { publish: true }); }); const makeRemoteRefreshLoop = ( @@ -416,6 +518,7 @@ export const make = Effect.gen(function* () { const exit = yield* refreshRemoteStatus(cwd, { refreshUpstream: !Duration.isZero(configuredInterval), + policyCwds: [...demandCwds.keys()], }).pipe(Effect.exit); if (Exit.isSuccess(exit)) { yield* Ref.set(needsInitialRefreshRef, false); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index cecffbc19..bd56b4c84 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -1,3 +1,6 @@ +// @effect-diagnostics nodeBuiltinImport:off - FileSystem cannot create a FIFO. +import * as NodeChildProcess from "node:child_process"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -73,6 +76,56 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); + it.effect("reads host files outside the workspace root by absolute path", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + yield* writeTextFile(outsideDir, "cleanup-report.md", "# Report\n"); + const absolutePath = path.join(outsideDir, "cleanup-report.md"); + + const result = yield* workspaceFileSystem.readFile({ + cwd, + relativePath: absolutePath, + }); + + expect(result).toEqual({ + relativePath: absolutePath, + contents: "# Report\n", + byteLength: 9, + truncated: false, + }); + }), + ); + + // Needs mkfifo; Windows has no FIFOs to reject. + it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "rejects a FIFO without blocking on open", + () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + const fifoPath = path.join(outsideDir, "pipe"); + yield* Effect.promise( + () => + new Promise((resolve, reject) => + NodeChildProcess.execFile("mkfifo", [fifoPath], (error) => + error ? reject(error) : resolve(), + ), + ), + ); + + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: fifoPath }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError); + }), + ); + it.effect("rejects reads outside the workspace root", () => Effect.gen(function* () { const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; @@ -212,6 +265,22 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); + it.effect("rejects writes by absolute path", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + const absolutePath = path.join(outsideDir, "cleanup-report.md"); + + const error = yield* workspaceFileSystem + .writeFile({ cwd, relativePath: absolutePath, contents: "# Edited\n" }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError); + }), + ); + it.effect("invalidates workspace entry search cache after writes", () => Effect.gen(function* () { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index e2dc9cbbb..2dcc5ab4b 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -3,10 +3,12 @@ * WorkspaceFileSystem - Effect service contract for workspace file mutations. * * Owns workspace-root-relative file read/write operations and their associated - * safety checks and cache invalidation hooks. + * safety checks and cache invalidation hooks. Explicit absolute paths can be read + * as host files; writes remain restricted to the workspace. * * @module WorkspaceFileSystem */ +import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import type { @@ -104,7 +106,7 @@ export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type; export class WorkspaceFileSystem extends Context.Service< WorkspaceFileSystem, { - /** Read a UTF-8 text file relative to the workspace root. */ + /** Read workspace-relative UTF-8 text, or an explicitly named absolute host file. */ readonly readFile: ( input: ProjectReadFileInput, ) => Effect.Effect< @@ -132,9 +134,31 @@ export const make = Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn( - "WorkspaceFileSystem.readFile", - )(function* (input) { + /** + * Resolves the file a read targets. Workspace-relative paths must stay inside the + * root, symlinks included. An absolute path reads a host file in place, such as a + * report an agent wrote to a temp directory; it gets no root check. + */ + const resolveReadTarget = Effect.fn("WorkspaceFileSystem.resolveReadTarget")(function* ( + input: ProjectReadFileInput, + ) { + const requestedPath = input.relativePath.trim(); + if (path.isAbsolute(requestedPath)) { + const realTargetPath = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(requestedPath), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: requestedPath, + operationPath: requestedPath, + operation: "realpath-target", + cause, + }), + }); + return { relativePath: requestedPath, realTargetPath }; + } + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ workspaceRoot: input.cwd, relativePath: input.relativePath, @@ -177,10 +201,22 @@ export const make = Effect.gen(function* () { resolvedPath: realTargetPath, }); } + return { relativePath: target.relativePath, realTargetPath }; + }); + + const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn( + "WorkspaceFileSystem.readFile", + )(function* (input) { + const target = yield* resolveReadTarget(input); + const realTargetPath = target.realTargetPath; return yield* Effect.acquireUseRelease( Effect.tryPromise({ - try: () => NodeFSP.open(realTargetPath, "r"), + try: () => + NodeFSP.open( + realTargetPath, + NodeFS.constants.O_RDONLY | (NodeFS.constants.O_NONBLOCK ?? 0), + ), catch: (cause) => new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7d3a0aa68..4fe97ada2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,3 +1,8 @@ +import { consumeInstanceResetCredit } from "./provider/consumeResetCredit.ts"; +import * as HostResources from "./resourceTelemetry/HostResources.ts"; +import { withCreatedPullRequestLink } from "./git/linkCreatedPullRequest.ts"; +import { AgentSessionScanner } from "./project/AgentSessionScanner.ts"; +import { AgentSessionImporter } from "./project/AgentSessionImporter.ts"; import * as DateTime from "effect/DateTime"; import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; @@ -131,6 +136,7 @@ import { observeRpcStream as instrumentRpcStream, observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; +import * as ProviderInstanceRegistry from "./provider/Services/ProviderInstanceRegistry.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; @@ -713,6 +719,8 @@ const makeWsRpcLayer = ( const hermesCron = yield* HermesCron.HermesCron; const hermesSkills = yield* HermesSkills.HermesSkills; const hermesSessions = yield* HermesSessionImport.make; + const agentSessionScanner = yield* AgentSessionScanner; + const agentSessionImporter = yield* AgentSessionImporter; const hermesProactiveInbox = yield* HermesProactiveInbox; // Shared with the server, which owns the sweep fiber. Building one here // would give every connected client its own residency loop and its own @@ -842,6 +850,7 @@ const makeWsRpcLayer = ( const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; + const hostResources = yield* HostResources.HostResources; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; const usage = yield* UsageService.UsageService; const relayClient = yield* RelayClient.RelayClient; @@ -1972,6 +1981,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverGetProcessDiagnostics, processDiagnostics.read, { "rpc.aggregate": "server", }), + [WS_METHODS.serverGetHostResources]: (_input) => + observeRpcEffect(WS_METHODS.serverGetHostResources, hostResources.read, { + "rpc.aggregate": "server", + }), [WS_METHODS.serverGetProcessResourceHistory]: (input) => observeRpcEffect( WS_METHODS.serverGetProcessResourceHistory, @@ -1988,10 +2001,24 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.providerConsumeResetCredit]: (input) => + observeRpcEffect( + WS_METHODS.providerConsumeResetCredit, + Effect.gen(function* () { + const providerInstances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; + const instance = yield* providerInstances.getInstance(input.instanceId); + return yield* consumeInstanceResetCredit(instance, input); + }), + { "rpc.aggregate": "provider" }, + ), [WS_METHODS.serverGetUsageSummary]: (input) => observeRpcEffect(WS_METHODS.serverGetUsageSummary, usage.readSummary(input), { "rpc.aggregate": "server", }), + [WS_METHODS.serverRefreshUsageRates]: (_input) => + observeRpcEffect(WS_METHODS.serverRefreshUsageRates, usage.refreshRates, { + "rpc.aggregate": "server", + }), [WS_METHODS.serverRetryResourceTelemetry]: (_input) => observeRpcEffect(WS_METHODS.serverRetryResourceTelemetry, resourceTelemetry.retry, { "rpc.aggregate": "server", @@ -2069,6 +2096,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.pullRequestsListStats, pullRequests.listStats(input), { "rpc.aggregate": "pull-requests", }), + [WS_METHODS.pullRequestsStack]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsStack, pullRequests.stack(input), { + "rpc.aggregate": "pull-requests", + }), [WS_METHODS.pullRequestsDetail]: (input) => observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), { "rpc.aggregate": "pull-requests", @@ -2135,6 +2166,16 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.pullRequestsInvalidate, pullRequests.invalidate(input), { "rpc.aggregate": "pull-requests", }), + [WS_METHODS.pullRequestsLabelCandidates]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsLabelCandidates, + pullRequests.labelCandidates(input), + { "rpc.aggregate": "pull-requests" }, + ), + [WS_METHODS.pullRequestsSetLabels]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsSetLabels, pullRequests.setLabels(input), { + "rpc.aggregate": "pull-requests", + }), [WS_METHODS.pullRequestsReviewerCandidates]: (input) => observeRpcEffect( WS_METHODS.pullRequestsReviewerCandidates, @@ -2273,6 +2314,16 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.shellOpenInEditor, externalLauncher.launchEditor(input), { "rpc.aggregate": "workspace", }), + [WS_METHODS.agentSessionsScan]: () => + observeRpcEffect(WS_METHODS.agentSessionsScan, agentSessionScanner.scan, { + "rpc.aggregate": "workspace", + }), + [WS_METHODS.agentSessionsImport]: (input) => + observeRpcEffect( + WS_METHODS.agentSessionsImport, + agentSessionImporter.importRecent(input), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.filesystemBrowse]: (input) => observeRpcEffect( WS_METHODS.filesystemBrowse, @@ -2324,7 +2375,10 @@ const makeWsRpcLayer = ( : {}), }); } - if (input.resource._tag !== "workspace-file") { + if ( + input.resource._tag !== "workspace-file" && + input.resource._tag !== "media-file" + ) { return yield* issueAssetUrl({ resource: input.resource }); } const thread = yield* threadManagement @@ -2430,22 +2484,29 @@ const makeWsRpcLayer = ( observeRpcStream( WS_METHODS.gitRunStackedAction, Stream.callback((queue) => - gitWorkflow - .runStackedAction(input, { + withCreatedPullRequestLink( + input, + gitWorkflow.runStackedAction(input, { actionId: input.actionId, progressReporter: { publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), }, - }) - .pipe( - Effect.matchCauseEffect({ - onFailure: (cause) => Queue.failCause(queue, cause), - onSuccess: () => - refreshGitStatus(input.cwd).pipe( - Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), - ), - }), + }), + ).pipe( + Effect.provideService( + ThreadManagementService.ThreadManagementService, + threadManagement, ), + Effect.provideService(ProjectService.ProjectService, projectService), + Effect.provideService(Path.Path, path), + Effect.matchCauseEffect({ + onFailure: (cause) => Queue.failCause(queue, cause), + onSuccess: () => + refreshGitStatus(input.cwd).pipe( + Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), + ), + }), + ), ), { "rpc.aggregate": "vcs" }, ), diff --git a/apps/swift-ios/App/NativeAgentSetupTerminal.swift b/apps/swift-ios/App/NativeAgentSetupTerminal.swift new file mode 100644 index 000000000..41fd55a6d --- /dev/null +++ b/apps/swift-ios/App/NativeAgentSetupTerminal.swift @@ -0,0 +1,35 @@ +import Foundation + +/// An isolated setup PTY; never borrows a conversation's terminal or changes its runtime. +@MainActor +final class NativeAgentSetupTerminal: FeatureAgentSetupTerminal { + let id = "onboarding-" + UUID().uuidString + let command: String + private let client: T3Client + private let cwd: String + private let providerInstanceID: String + private let threadID = "onboarding-agent-setup" + private var closed = false + private var started = false + + init(client: T3Client, cwd: String, providerInstanceID: String, command: String) { + self.client = client; self.cwd = cwd; self.providerInstanceID = providerInstanceID; self.command = command + } + func start() async throws -> AsyncThrowingStream { + guard !closed, !started else { throw CancellationError() } + started = true + _ = try await client.openTerminal(threadID: threadID, terminalID: id, cwd: cwd, providerInstanceID: providerInstanceID) + guard !closed, !Task.isCancelled else { await close(); throw CancellationError() } + // No newline: the user reviews and presses Enter in the terminal. + try await client.writeTerminal(threadID: threadID, terminalID: id, data: command) + guard !closed, !Task.isCancelled else { await close(); throw CancellationError() } + return try await client.attachTerminal(threadID: threadID, terminalID: id) + } + func write(_ data: String) async throws { guard !closed else { throw CancellationError() }; try await client.writeTerminal(threadID: threadID, terminalID: id, data: data) } + func resize(columns: Int, rows: Int) async throws { guard !closed else { return }; try await client.resizeTerminal(threadID: threadID, terminalID: id, columns: columns, rows: rows) } + func clear() async throws { try await client.clearTerminal(threadID: threadID, terminalID: id) } + func close() async { + closed = true + try? await client.closeTerminal(threadID: threadID, terminalID: id, deleteHistory: true) + } +} diff --git a/apps/swift-ios/App/NativeFeatureClient.swift b/apps/swift-ios/App/NativeFeatureClient.swift index 5a37e6fd6..e4fa37f4b 100644 --- a/apps/swift-ios/App/NativeFeatureClient.swift +++ b/apps/swift-ios/App/NativeFeatureClient.swift @@ -15,8 +15,8 @@ extension FeatureInputAnswer { /// Composes the transport-focused Core layer with the UI-focused Features layer. @MainActor final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, - FeatureProjectCreationClient, FeatureWorkspaceAssetResolving, - FeatureProjectFaviconResolving, FeatureThreadRoleAssigning, FeatureUsageReading, FeatureUsageLimitsReading, + FeatureDocumentAttachmentResolving, FeatureAgentSetupTerminalProviding, FeatureAgentSessionImporting, FeaturePullRequestThreadPreparing, FeatureProjectCreationClient, FeatureProjectIconManaging, FeatureProjectPullRequestManaging, FeaturePullRequestCodeReading, FeaturePullRequestReviewWriting, FeaturePullRequestCacheInvalidating, FeatureWorkspaceAssetResolving, + FeatureNativeAppIconResolving, FeatureProjectFaviconResolving, FeatureThreadRoleAssigning, FeatureUsageReading, FeatureUsageLimitsReading, T3ConnectCapable { /// Visible turn items requested on a cold load. The server reports what it @@ -435,6 +435,15 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, && environmentClients[environmentID] === client } + func setProjectIcon(projectID: String, icon: ProjectIconOverride?) async throws { + let route = try projectRoute(for: projectID) + guard (try await runtime.environments()).first(where: { $0.id == route.environmentID })?.descriptor?.capabilities.projectIcons == true else { + throw FeatureCapabilityUnavailable("Project icons") + } + try await route.client.setProjectIcon(projectID: route.wireID, icon: icon) + try? await refresh(client: route.client) + } + func addProject(path: String) async throws { guard let environmentID = activeEnvironment?.id else { throw NativeFeatureClientError.notConnected @@ -447,6 +456,58 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, try await createProject(client: client, path: path) } + func refreshSetupProviders(environmentID: String) async throws -> [ServerProviderSnapshot] { + let client = try await environmentClient(id: environmentID) + return try await client.refreshProviderSnapshots() + } + + func makeAgentSetupTerminal(environmentID: String, providerInstanceID: String) async throws -> any FeatureAgentSetupTerminal { + let client = try await environmentClient(id: environmentID) + let config = try await client.serverConfig() + guard config.environment?.capabilities.providerTerminalEnvironment == true, + let provider = config.providers.first(where: { $0.instanceId == providerInstanceID }), provider.enabled, + let cwd = config.cwd else { throw FeatureCapabilityUnavailable("Agent setup") } + let instance = config.settings?.providerInstances[providerInstanceID] + let binary = instance != nil ? instance?["config"]?["binaryPath"]?.stringValue : config.settings?.providerDefinitions[provider.driver]?["binaryPath"]?.stringValue + guard let command = AgentSetupCommand.resolve(driver: provider.driver, installed: provider.installed, binaryPath: binary, platform: config.environment?.platform.os ?? "unknown") else { throw FeatureCapabilityUnavailable("Agent setup") } + return NativeAgentSetupTerminal(client: client, cwd: cwd, providerInstanceID: providerInstanceID, command: command) + } + + func scanAgentSessions(environmentID: String) async throws -> AgentSessionScanResult { + let client = try await environmentClient(id: environmentID) + guard try await client.serverConfig().environment?.capabilities.agentSessionImport == true else { throw FeatureCapabilityUnavailable("CLI history import; update this server") } + return try await client.scanAgentSessions() + } + + func importAgentSessions(environmentID: String, candidate: AgentSessionProjectCandidate, proposedProjectID: String) async throws -> AgentSessionImportResult { + let client = try await environmentClient(id: environmentID) + try await requireScope("orchestration:operate", client: client) + guard try await client.serverConfig().environment?.capabilities.agentSessionImport == true else { throw FeatureCapabilityUnavailable("CLI history import; update this server") } + let shell = try await client.shellSnapshot() + let existing = shell.projects.first { + ProjectCreationPath.normalizedForComparison($0.workspaceRoot) == ProjectCreationPath.normalizedForComparison(candidate.path) + } + var projectID = candidate.projectId ?? existing?.id ?? proposedProjectID + if candidate.projectId == nil && existing == nil { + do { + try await client.createProject(projectID: projectID, title: candidate.title, workspaceRoot: candidate.path) + } catch { + // A lost create response can still have committed. Reconcile the stable + // attempted ID before allowing a retry to create another project. + guard await recoverCreatedProject(client: client, projectID: projectID, path: candidate.path) else { throw error } + // A concurrent creation may have chosen another ID for the same folder. + let recovered = try await client.shellSnapshot() + guard let project = recovered.projects.first(where: { + ProjectCreationPath.normalizedForComparison($0.workspaceRoot) == ProjectCreationPath.normalizedForComparison(candidate.path) + }) else { throw error } + projectID = project.id + } + } + let result = try await client.importAgentSessions(projectID: projectID, expectedWorkspaceRoot: candidate.path) + try? await refresh(client: client) + return result + } + func browseProjectFolders( environmentID: String, partialPath: String @@ -457,9 +518,27 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, func workspaceAssetURL(threadID: String, path: String) async throws -> URL { let route = try threadRoute(for: threadID) - return try await route.client.resolvedAssetURL( - resource: .workspaceFile(threadID: route.wireID, path: path) - ) + let config = try await route.client.serverConfig() + let hostFiles = config.environment?.capabilities.fileDocumentPreviews == true + let root = try workspaceContext(route: route).cwd + let absolute = FeatureFilePreviewPath.isAbsolute(path) + let normalized = ProjectCreationPath.normalizedForComparison(path) + let normalizedRoot = ProjectCreationPath.normalizedForComparison(root) + let outside = absolute && normalized != normalizedRoot && !normalized.hasPrefix(normalizedRoot + "/") + if outside && !hostFiles { throw FeatureCapabilityUnavailable("Host file previews; update this server") } + // Workspace HTML keeps sibling resources; host files authorize only the selected file. + let resource: AssetResource = outside || (hostFiles && FeatureFilePreviewKind.infer(path: path) == .video) + ? .mediaFile(threadID: route.wireID, path: path) + : .workspaceFile(threadID: route.wireID, path: path) + return try await route.client.resolvedAssetURL(resource: resource) + } + + func documentAttachmentURL(threadID: String, attachment: FeatureMessageAttachment) async throws -> URL { + let route = try threadRoute(for: threadID) + guard try await route.client.serverConfig().environment?.capabilities.fileDocumentPreviews == true else { + throw FeatureCapabilityUnavailable("Document previews; update this server") + } + return try await route.client.resolvedAssetURL(resource: .documentAttachment(id: attachment.id, name: attachment.name, mimeType: attachment.mimeType)) } func browserArtifactAssetURL(threadID: String, fileName: String) async throws -> URL { @@ -469,12 +548,27 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, ) } + func consumeResetCredit(environmentID: String, instanceID: String) async throws -> ProviderConsumeResetCreditResult { + let client = try await environmentClient(id: environmentID) + return try await client.consumeResetCredit(instanceID: instanceID) + } + func usageLimits(environmentID: String, refresh: Bool) async throws -> [ServerProviderSnapshot] { let client = try await environmentClient(id: environmentID) if refresh { return try await client.refreshProviderSnapshots() } return try await client.serverConfig().providers } + func usageSummary(environmentID: String, input: UsageSummaryInput) async throws -> UsageSummary { + let client = try await environmentClient(id: environmentID) + return try await client.getUsageSummary(input: input) + } + + func refreshUsageRates(environmentID: String) async throws { + let client = try await environmentClient(id: environmentID) + _ = try await client.refreshUsageRates() + } + func usageSummary( environmentID: String, sinceDay: String, @@ -489,6 +583,11 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, ) } + func nativeAppIconURL(environmentID: String, app: ToolActivityNativeAppReference) async throws -> URL? { + let client = try await environmentClient(id: environmentID) + return try await client.resolvedAssetURL(resource: .nativeAppIcon(app)) + } + func projectFaviconURL( environmentID: String, cwd: String, @@ -1259,12 +1358,41 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, try? await refresh(client: route.client) } + func addThreadPullRequest(threadID: String, number: Int) async throws -> FeatureLinkedPullRequest? { + try await changeThreadLinkedPullRequest(threadID: threadID, number: number, adding: true) + } + + func removeThreadPullRequest(threadID: String, link: FeatureLinkedPullRequest) async throws { + let route = try threadRoute(for: threadID) + guard (try await runtime.environments()).first(where: { $0.id == route.environmentID })?.descriptor?.capabilities.threadPullRequestsV2 == true else { + throw FeatureCapabilityUnavailable("Multiple pull requests") + } + guard let shell = shellsByEnvironmentID[route.environmentID], + let thread = shell.threads.first(where: { $0.id == route.wireID }), + let wire = (thread.linkedPullRequests ?? thread.linkedPullRequest.map { [$0] } ?? []).first(where: { $0.number == link.number && $0.url == link.url }) else { + throw NativeFeatureClientError.workspaceNotFound + } + _ = try await route.client.dispatch(OrchestrationCommands.updateMetadata(threadID: route.wireID, fields: ["unlinkPullRequest": try JSONValue.encode(wire)])) + try? await refresh(client: route.client) + } + + @discardableResult + func setThreadLinkedPullRequest(threadID: String, number: Int?) async throws -> FeatureLinkedPullRequest? { + try await changeThreadLinkedPullRequest(threadID: threadID, number: number, adding: false) + } + @discardableResult - func setThreadLinkedPullRequest( + private func changeThreadLinkedPullRequest( threadID: String, - number: Int? + number: Int?, + adding: Bool ) async throws -> FeatureLinkedPullRequest? { let route = try threadRoute(for: threadID) + if adding { + guard (try await runtime.environments()).first(where: { $0.id == route.environmentID })?.descriptor?.capabilities.threadPullRequestsV2 == true else { + throw FeatureCapabilityUnavailable("Multiple pull requests") + } + } guard let number else { _ = try await route.client.setLinkedPullRequest( threadID: route.wireID, @@ -1290,15 +1418,9 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, repository: repository, number: number ) - _ = try await route.client.setLinkedPullRequest( - threadID: route.wireID, - pullRequest: OrchestrationV2ThreadLinkedPullRequest( - projectId: project.id, - repository: repository, - number: detail.number, - url: detail.url - ) - ) + let link = OrchestrationV2ThreadLinkedPullRequest(projectId: project.id, repository: repository, number: detail.number, url: detail.url) + _ = try await route.client.dispatch(OrchestrationCommands.updateMetadata(threadID: route.wireID, + fields: [adding ? "linkPullRequest" : "linkedPullRequest": try JSONValue.encode(link)])) try? await refresh(client: route.client) return FeatureLinkedPullRequest( projectID: FeatureScopedID.project( @@ -1682,6 +1804,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, let wireDecision = switch decision { case .allowOnce: "accept" case .allowForSession: "acceptForSession" + case .allowAlways: "acceptAlways" + case .cancel: "cancel" case .deny: "decline" } _ = try await route.client.respondToApproval( @@ -1983,6 +2107,235 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, return detail } + func pullRequestLabelCandidates(threadID: String, number: Int) async throws -> PullRequestLabelCandidateList { + let route = try threadRoute(for: threadID) + guard let shell = shellsByEnvironmentID[route.environmentID], + let thread = shell.threads.first(where: { $0.id == route.wireID }), + let project = shell.projects.first(where: { $0.id == thread.projectId }), + let repository = project.repositoryIdentity?.displayName else { throw NativeFeatureClientError.repositoryIdentityUnavailable } + return try await route.client.pullRequestLabelCandidates(projectID: project.id, repository: repository, number: number) + } + + func setPullRequestLabels(threadID: String, number: Int, labels: [String], applied: Bool) async throws { + let route = try threadRoute(for: threadID) + guard let shell = shellsByEnvironmentID[route.environmentID], + let thread = shell.threads.first(where: { $0.id == route.wireID }), + let project = shell.projects.first(where: { $0.id == thread.projectId }), + let repository = project.repositoryIdentity?.displayName else { throw NativeFeatureClientError.repositoryIdentityUnavailable } + try await route.client.setPullRequestLabels(projectID: project.id, repository: repository, number: number, labels: labels, applied: applied) + pullRequestPreviewCache.removeAll(keepingCapacity: true) + } + + func pullRequestStack(threadID: String, number: Int) async throws -> PullRequestStack? { + let route = try threadRoute(for: threadID) + guard (try await runtime.environments()).first(where: { $0.id == route.environmentID })?.descriptor?.capabilities.pullRequestStackActions == true else { return nil } + guard let shell = shellsByEnvironmentID[route.environmentID], + let thread = shell.threads.first(where: { $0.id == route.wireID }), + let project = shell.projects.first(where: { $0.id == thread.projectId }), + let repository = project.repositoryIdentity?.displayName else { throw NativeFeatureClientError.repositoryIdentityUnavailable } + return try await route.client.pullRequestStack(projectID: project.id, repository: repository, number: number) + } + + func runPullRequestStackAction(threadID: String, number: Int, stack: PullRequestStack, action: String, mergeMethod: String?) async throws { + let route = try threadRoute(for: threadID) + guard (try await runtime.environments()).first(where: { $0.id == route.environmentID })?.descriptor?.capabilities.pullRequestStackActions == true else { + throw FeatureCapabilityUnavailable("Stack actions") + } + guard let shell = shellsByEnvironmentID[route.environmentID], + let thread = shell.threads.first(where: { $0.id == route.wireID }), + let project = shell.projects.first(where: { $0.id == thread.projectId }), + let repository = project.repositoryIdentity?.displayName else { throw NativeFeatureClientError.repositoryIdentityUnavailable } + try await route.client.runPullRequestStackAction(projectID: project.id, repository: repository, number: number, + stack: stack, action: action, mergeMethod: mergeMethod) + } + + func listPullRequests(environmentID: String, input: PullRequestListInput) async throws -> PullRequestListResult { + guard (try await runtime.environments()).first(where: { $0.id == environmentID })?.descriptor?.capabilities.pullRequests == true else { + throw FeatureCapabilityUnavailable("Pull requests") + } + return try await environmentClient(id: environmentID).listPullRequests(input) + } + + func pullRequestStats(environmentID: String, entries: [PullRequestListEntry]) async throws -> PullRequestListStatsResult { + try await environmentClient(id: environmentID).pullRequestStats(Array(entries.prefix(500))) + } + + private func projectPullRequestRoute(_ scope: FeaturePullRequestProjectScope) throws -> (NativeProjectRoute, String) { + let route = try projectRoute(for: scope.projectID) + let identity = try project(for: route).repositoryIdentity + guard identity?.canonicalKey.lowercased() == scope.canonicalKey else { + throw FeatureCapabilityUnavailable("The project repository changed. Refresh the pull-request list") + } + guard let repository = identity?.displayName, !repository.isEmpty else { + throw NativeFeatureClientError.repositoryIdentityUnavailable + } + return (route, repository) + } + + private func pullRequestRoute(scope: FeaturePullRequestScope) throws -> (client: T3Client, projectID: String, repository: String) { + switch scope { + case let .project(scope): + let (route, repository) = try projectPullRequestRoute(scope) + return (route.client, route.wireID, repository) + case let .thread(threadID): + let route = try threadRoute(for: threadID) + guard let shell = shellsByEnvironmentID[route.environmentID], + let thread = shell.threads.first(where: { $0.id == route.wireID }), + let project = shell.projects.first(where: { $0.id == thread.projectId }), + let repository = project.repositoryIdentity?.displayName, !repository.isEmpty else { + throw NativeFeatureClientError.repositoryIdentityUnavailable + } + return (route.client, project.id, repository) + } + } + + func preparePullRequestAgentThread(scope: FeaturePullRequestScope, number: Int, expectedURL: String, title: String, mode: PullRequestCheckoutMode?) async throws -> FeaturePullRequestPreparedThread { + let route = try await validatedPullRequestRoute(scope: scope, number: number, expectedURL: expectedURL) + let environmentID = route.client.environment.id + let generation = environmentGeneration + guard let project = shellsByEnvironmentID[environmentID]?.projects.first(where: { $0.id == route.projectID }) else { + throw NativeFeatureClientError.repositoryIdentityUnavailable + } + let projectID = FeatureScopedID.project(environmentID: environmentID, wireID: route.projectID) + let thread = try await createThread(projectID: projectID, title: title, selection: nil) + guard let wireID = thread.wireID else { throw CancellationError() } + var checkout: PullRequestCheckoutResult? + do { + if let mode { + checkout = try await route.client.preparePullRequestCheckout(cwd: project.workspaceRoot, reference: expectedURL, mode: mode, threadID: wireID) + } + guard isKnownClient(route.client, environmentID: environmentID, generation: generation) else { throw CancellationError() } + var fields: [String: JSONValue] = ["linkedPullRequest": try JSONValue.encode(OrchestrationV2ThreadLinkedPullRequest(projectId: route.projectID, repository: route.repository, number: number, url: expectedURL))] + if let checkout { + fields["branch"] = .string(checkout.branch) + fields["worktreePath"] = checkout.worktreePath.map(JSONValue.string) ?? .null + fields["expectedWorktreePath"] = .null + } + _ = try await route.client.dispatch(OrchestrationCommands.updateMetadata(threadID: wireID, fields: fields)) + } catch { + let recovery = checkout.map { "The checkout is ready on \($0.branch), but the new thread could not be attached. Select that branch in the new thread before sending a task." } + ?? "The new empty thread was kept. No agent task was sent." + throw FeatureCapabilityUnavailable("\(error.localizedDescription) \(recovery)") + } + guard isKnownClient(route.client, environmentID: environmentID, generation: generation) else { throw CancellationError() } + return FeaturePullRequestPreparedThread(thread: thread, staleCheckout: checkout?.isOnPullRequestHead == false) + } + + func pullRequestFileContents(scope: FeaturePullRequestScope, number: Int, expectedURL: String, input: PullRequestDiffFileInput) async throws -> PullRequestDiffFileContents { + let route = try await validatedPullRequestRoute(scope: scope, number: number, expectedURL: expectedURL) + return try await route.client.pullRequestDiffFileContents(projectID: route.projectID, repository: route.repository, number: number, input: input) + } + + func pullRequestDiff(scope: FeaturePullRequestScope, number: Int, cursor: String?, commit: String?) async throws -> PullRequestDiffResult { + let route = try pullRequestRoute(scope: scope) + return try await route.client.pullRequestDiff(projectID: route.projectID, repository: route.repository, number: number, cursor: cursor, commit: commit) + } + + func pullRequestThreadComments(scope: FeaturePullRequestScope, number: Int, threadID: String, cursor: String) async throws -> PullRequestThreadCommentsResult { + let route = try pullRequestRoute(scope: scope) + return try await route.client.pullRequestThreadComments(projectID: route.projectID, repository: route.repository, number: number, threadID: threadID, cursor: cursor) + } + + func invalidatePullRequest(scope: FeaturePullRequestScope, number: Int) async throws { + let route = try pullRequestRoute(scope: scope) + try await route.client.invalidatePullRequest(projectID: route.projectID, repository: route.repository, number: number) + } + + func invalidatePullRequestListings(environmentID: String) async throws { + try await environmentClient(id: environmentID).invalidatePullRequestListings() + } + + private func validatedPullRequestRoute(scope: FeaturePullRequestScope, number: Int, expectedURL: String) async throws -> (client: T3Client, projectID: String, repository: String) { + let route = try pullRequestRoute(scope: scope) + try await route.client.invalidatePullRequest(projectID: route.projectID, repository: route.repository, number: number) + let current = try await route.client.pullRequestDetail(projectID: route.projectID, repository: route.repository, number: number) + guard current.url == expectedURL else { throw FeatureCapabilityUnavailable("The pull request repository changed. Reopen the review") } + return route + } + + func replyToPullRequestThread(scope: FeaturePullRequestScope, number: Int, expectedURL: String, threadID: String, body: String) async throws { + let route = try await validatedPullRequestRoute(scope: scope, number: number, expectedURL: expectedURL) + try await route.client.replyToPullRequestThread(projectID: route.projectID, repository: route.repository, number: number, threadID: threadID, body: body) + } + + func setPullRequestThreadResolution(scope: FeaturePullRequestScope, number: Int, expectedURL: String, threadID: String, resolved: Bool) async throws { + let route = try await validatedPullRequestRoute(scope: scope, number: number, expectedURL: expectedURL) + try await route.client.setPullRequestThreadResolution(projectID: route.projectID, repository: route.repository, number: number, threadID: threadID, resolved: resolved) + } + + func pullRequestReviewerCandidates(scope: FeaturePullRequestScope, number: Int, expectedURL: String) async throws -> PullRequestReviewerCandidateList { + let route = try await validatedPullRequestRoute(scope: scope, number: number, expectedURL: expectedURL) + return try await route.client.pullRequestReviewerCandidates(projectID: route.projectID, repository: route.repository, number: number) + } + + func requestPullRequestReviewers(scope: FeaturePullRequestScope, number: Int, expectedURL: String, request: PullRequestReviewerRequest) async throws { + let route = try await validatedPullRequestRoute(scope: scope, number: number, expectedURL: expectedURL) + try await route.client.requestPullRequestReviewers(projectID: route.projectID, repository: route.repository, number: number, request: request) + } + + func setPullRequestReaction(scope: FeaturePullRequestScope, number: Int, expectedURL: String, request: PullRequestReactionRequest) async throws { + let route = try await validatedPullRequestRoute(scope: scope, number: number, expectedURL: expectedURL) + try await route.client.setPullRequestReaction(projectID: route.projectID, repository: route.repository, number: number, request: request) + } + + func updatePullRequestText(scope: FeaturePullRequestScope, number: Int, expectedURL: String, update: PullRequestTextUpdate) async throws { + let route = try await validatedPullRequestRoute(scope: scope, number: number, expectedURL: expectedURL) + try await route.client.updatePullRequestText(projectID: route.projectID, repository: route.repository, number: number, update: update) + pullRequestPreviewCache.removeAll(keepingCapacity: true) + } + + func updatePullRequestComment(scope: FeaturePullRequestScope, number: Int, expectedURL: String, commentID: String, kind: String, body: String) async throws { + let route = try await validatedPullRequestRoute(scope: scope, number: number, expectedURL: expectedURL) + try await route.client.updatePullRequestComment(projectID: route.projectID, repository: route.repository, number: number, commentID: commentID, kind: kind, body: body) + } + + func commentOnPullRequest(scope: FeaturePullRequestScope, number: Int, expectedURL: String, body: String) async throws { + let route = try await validatedPullRequestRoute(scope: scope, number: number, expectedURL: expectedURL) + try await route.client.commentOnPullRequest(projectID: route.projectID, repository: route.repository, number: number, body: body) + } + + func runPullRequestAction(scope: FeaturePullRequestScope, number: Int, expectedURL: String, request: PullRequestActionRequest) async throws { + let route = try await validatedPullRequestRoute(scope: scope, number: number, expectedURL: expectedURL) + try await route.client.runPullRequestAction(projectID: route.projectID, repository: route.repository, number: number, request: request) + pullRequestPreviewCache.removeAll(keepingCapacity: true) + } + + func submitPullRequestReview(scope: FeaturePullRequestScope, number: Int, expectedURL: String, submission: PullRequestReviewSubmission) async throws { + let route = try await validatedPullRequestRoute(scope: scope, number: number, expectedURL: expectedURL) + try await route.client.submitPullRequestReview(projectID: route.projectID, repository: route.repository, number: number, submission: submission) + } + + func projectPullRequestOverview(scope: FeaturePullRequestProjectScope, number: Int) async throws -> FeaturePullRequestOverview { + let (route, repository) = try projectPullRequestRoute(scope) + let detail = try await route.client.pullRequestDetail(projectID: route.wireID, repository: repository, number: number) + let activity = try? await route.client.pullRequestActivity(projectID: route.wireID, repository: repository, number: number) + return FeaturePullRequestOverview(detail: detail, activity: activity) + } + + func projectPullRequestLabels(scope: FeaturePullRequestProjectScope, number: Int) async throws -> PullRequestLabelCandidateList { + let (route, repository) = try projectPullRequestRoute(scope) + return try await route.client.pullRequestLabelCandidates(projectID: route.wireID, repository: repository, number: number) + } + + func setProjectPullRequestLabels(scope: FeaturePullRequestProjectScope, number: Int, labels: [String], applied: Bool) async throws { + let (route, repository) = try projectPullRequestRoute(scope) + try await route.client.setPullRequestLabels(projectID: route.wireID, repository: repository, number: number, labels: labels, applied: applied) + pullRequestPreviewCache.removeAll(keepingCapacity: true) + } + + func projectPullRequestStack(scope: FeaturePullRequestProjectScope, number: Int) async throws -> PullRequestStack? { + let (route, repository) = try projectPullRequestRoute(scope) + guard (try await runtime.environments()).first(where: { $0.id == route.environmentID })?.descriptor?.capabilities.pullRequestStackActions == true else { return nil } + return try await route.client.pullRequestStack(projectID: route.wireID, repository: repository, number: number) + } + + func runProjectPullRequestStackAction(scope: FeaturePullRequestProjectScope, number: Int, stack: PullRequestStack, action: String, mergeMethod: String?) async throws { + let (route, repository) = try projectPullRequestRoute(scope) + guard (try await runtime.environments()).first(where: { $0.id == route.environmentID })?.descriptor?.capabilities.pullRequestStackActions == true else { throw FeatureCapabilityUnavailable("Stack actions") } + try await route.client.runPullRequestStackAction(projectID: route.wireID, repository: repository, number: number, + stack: stack, action: action, mergeMethod: mergeMethod) + } + func pullRequestOverview(threadID: String, number: Int) async throws -> FeaturePullRequestOverview { @@ -2049,19 +2402,36 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, // A linked pull request is the thread's own answer and outranks whatever // its worktree's branch happens to point at: the same branch can back // several requests, and a thread whose worktree is gone still has one. - var linkedByThreadID: [String: LinkedChangeRequestSubscription] = [:] + var linkedByThreadID: [String: [LinkedChangeRequestSubscription]] = [:] + var cachedByThreadID: [String: FeaturePullRequest] = [:] for threadID in threadIDs { guard let route = try? threadRoute(for: threadID), let shell = shellsByEnvironmentID[route.environmentID], let thread = shell.threads.first(where: { $0.id == route.wireID }) else { continue } - if let linked = thread.linkedPullRequest { - linkedByThreadID[threadID] = LinkedChangeRequestSubscription( - environmentID: route.environmentID, - projectWireID: linked.projectId, - repository: linked.repository, - number: linked.number - ) + if let metadata = thread.pullRequests { + let visible = metadata.filter { $0.source != "stack-dismissed" } + if let first = visible.first { + let reads = visible.map { link in + link.snapshot.map { snapshot in + FeaturePullRequest(number: link.number, title: snapshot.title, + state: snapshot.state.rawValue, url: URL(string: link.url), + updatedAt: snapshot.updatedAt.flatMap(NativeWorkspaceMapper.isoDate), + isDraft: snapshot.isDraft) + } + } + cachedByThreadID[threadID] = FeatureLinkedPullRequestSettlement.aggregate(reads) + ?? FeaturePullRequest(number: first.number, title: "Pull request status pending", state: "unknown", url: URL(string: first.url)) + continue + } + } + let explicit = thread.pullRequests == nil ? thread.linkedPullRequests ?? thread.linkedPullRequest.map { [$0] } ?? [] : [] + let links = explicit.isEmpty ? thread.branchPullRequest.map { [$0] } ?? [] : explicit + if !links.isEmpty { + linkedByThreadID[threadID] = links.map { linked in + LinkedChangeRequestSubscription(environmentID: route.environmentID, + projectWireID: linked.projectId, repository: linked.repository, number: linked.number) + } continue } guard let context = try? workspaceContext(route: route), @@ -2074,16 +2444,16 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, ) threadIDsBySubscription[subscription, default: []].append(threadID) } - guard !threadIDsBySubscription.isEmpty || !linkedByThreadID.isEmpty else { return } // Threads that lost their route (or their branch, or their link) since // the seed was captured have no subscription to correct a stale entry, // so they are dropped rather than carried forward indefinitely. - let accumulator = ChangeRequestAccumulator( - seed: seed.filter { - branchesByThreadID[$0.key] != nil || linkedByThreadID[$0.key] != nil - } - ) + var initial = seed.filter { + branchesByThreadID[$0.key] != nil || linkedByThreadID[$0.key] != nil + } + initial.merge(cachedByThreadID) { _, snapshot in snapshot } + let accumulator = ChangeRequestAccumulator(seed: initial) + continuation.yield(initial) await withTaskGroup(of: Void.self) { group in for (threadID, linked) in linkedByThreadID { group.addTask { @MainActor [weak self] in @@ -2133,39 +2503,24 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, } } - /// Keeps a linked pull request current for as long as its thread is on - /// screen. - /// - /// There is no push channel for change requests — the branch-derived path - /// only gets updates because it rides the workspace's VCS status - /// subscription, and a linked request may belong to a repository no open - /// worktree points at. Polling is therefore the mechanism, at web's cadence - /// (`createLinkedPullRequestDetailAtomFamily`), so a merge settles the row - /// within half a minute instead of at the next app launch. + /// Legacy environments and branch-only candidates still need host reads. + /// Explicit V2 links above use pushed projection snapshots instead. private func pollLinkedChangeRequest( threadID: String, - subscription: LinkedChangeRequestSubscription, + subscription: [LinkedChangeRequestSubscription], accumulator: ChangeRequestAccumulator, into continuation: AsyncStream<[String: FeaturePullRequest]>.Continuation ) async { while !Task.isCancelled { - guard let client = environmentClients[subscription.environmentID] else { return } - let detail = try? await client.pullRequestDetail( - projectID: subscription.projectWireID, - repository: subscription.repository, - number: subscription.number - ) - if Task.isCancelled { return } - // A failed read leaves the previous answer in place. The host is - // reached through the `gh` CLI, so a flaky read is ordinary; blanking - // the badge on one would make a merged row bounce back to Active. - if let detail, - let merged = accumulator.applyLinked( - threadID: threadID, - pullRequest: NativeWorkspaceMapper.pullRequest(detail) - ) { - continuation.yield(merged) + guard let first = subscription.first, let client = environmentClients[first.environmentID] else { return } + var reads: [FeaturePullRequest?] = [] + for link in subscription { + let detail = try? await client.pullRequestDetail(projectID: link.projectWireID, repository: link.repository, number: link.number) + if Task.isCancelled { return } + reads.append(detail.map(NativeWorkspaceMapper.pullRequest)) } + let summary = FeatureLinkedPullRequestSettlement.aggregate(reads) ?? FeaturePullRequest(number: first.number, title: "Pull requests unavailable", state: "unknown") + if let merged = accumulator.applyLinked(threadID: threadID, pullRequest: summary) { continuation.yield(merged) } try? await Task.sleep(for: .seconds(30)) } } @@ -2184,6 +2539,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, } else { let progress = try await client.runGitAction( cwd: context.cwd, + threadID: route.wireID, action: NativeWorkspaceMapper.gitAction(action), commitMessage: message ) @@ -2442,6 +2798,20 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, return client } + func hostResources(environmentID: String) async throws -> HostResourcesSnapshot { + let client = try await environmentClient(id: environmentID) + return try await withThrowingTaskGroup(of: HostResourcesSnapshot.self) { group in + group.addTask { try await client.hostResources() } + group.addTask { + try await Task.sleep(for: .seconds(5)) + throw FeatureCapabilityUnavailable("Machine capacity timed out") + } + defer { group.cancelAll() } + guard let result = try await group.next() else { throw CancellationError() } + return result + } + } + private func projectCreationClient(environmentID: String) async throws -> T3Client { try await environmentClient(id: environmentID) } @@ -2695,6 +3065,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, if let coreSnapshot = event.snapshot { var snapshot = NativeWorkspaceMapper.terminal(coreSnapshot) snapshot.threadID = threadID + snapshot.outputCursor = FeatureTerminalOutputCursor(byteOffset: snapshot.buffer.utf8.count) snapshot.buffer = Self.cappedTerminalBuffer(snapshot.buffer) terminalSnapshots[key] = snapshot return snapshot @@ -2704,7 +3075,11 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, ?? FeatureTerminalSnapshot(threadID: threadID, terminalID: terminalID) switch event.type { case "output": - snapshot.buffer.append(event.data ?? "") + let data = event.data ?? "" + var cursor = snapshot.outputCursor ?? FeatureTerminalOutputCursor(byteOffset: snapshot.buffer.utf8.count) + cursor.byteOffset += data.utf8.count + snapshot.outputCursor = cursor + snapshot.buffer.append(data) snapshot.buffer = Self.cappedTerminalBuffer(snapshot.buffer) case "exited": snapshot.state = .exited @@ -2716,6 +3091,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, snapshot.error = event.message case "cleared": snapshot.buffer = "" + snapshot.outputCursor = FeatureTerminalOutputCursor() case "activity": snapshot.title = event.label ?? snapshot.title snapshot.hasRunningSubprocess = event.hasRunningSubprocess @@ -2736,6 +3112,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, snapshot.threadID = threadID if let cached = terminalSnapshots[key] { snapshot.buffer = cached.buffer + snapshot.outputCursor = cached.outputCursor snapshot.error = cached.error } terminalSnapshots[key] = snapshot @@ -4056,10 +4433,13 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, name: project.title, path: project.workspaceRoot, threadCount: threadCountByProjectID[uiID, default: 0], - defaultSelection: project.defaultModelSelection.map(mapSelection), - scripts: project.scripts, + defaultSelection: (project.defaultModelSelection ?? serverConfigsByEnvironmentID[environment.id]?.settings?.defaultModelSelection).map(mapSelection), + scripts: serverConfigsByEnvironmentID[environment.id]?.settings?.resolvedProjectScripts(projectID: project.id, legacyScripts: project.scripts) ?? project.scripts, + scriptsInheritDefaults: serverConfigsByEnvironmentID[environment.id]?.settings?.projectScriptsInheritDefaults(projectID: project.id, legacyScripts: project.scripts), previewUrl: pinnedPreviewURLs[uiID], - faviconPath: project.faviconPath + faviconPath: project.faviconPath, + projectIcon: project.projectIcon, + repositoryCanonicalKey: project.repositoryIdentity?.canonicalKey ) } } @@ -4119,7 +4499,12 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, isActive: environment.id == activeID, connectionState: environmentConnectionStates[environment.id], connectionDetail: environmentConnectionDetails[environment.id], - supportsPullRequests: environment.descriptor?.capabilities.pullRequests + supportsPullRequests: environment.descriptor?.capabilities.pullRequests, + machineKind: serverConfigsByEnvironmentID[environment.id]?.settings?.environmentIcon.flatMap(EnvironmentMachineKind.init(rawValue:))?.rawValue ?? environment.descriptor?.platform.machine, + supportsEnvironmentIcon: environment.descriptor?.capabilities.environmentIcon, + supportsAssistantCitations: environment.descriptor?.capabilities.assistantCitations, + supportsCustomModelDefinitions: environment.descriptor?.capabilities.customModelDefinitions, + supportsProjectIcons: environment.descriptor?.capabilities.projectIcons ) } @@ -4164,7 +4549,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, } switch item.payload { - case let .approvalRequest(requestID, requestKind, prompt): + case let .approvalRequest(requestID, requestKind, prompt, options): // The item's own status is the authority on whether the request // is still open; V1 had to pair requested/resolved activities. guard !item.status.isTerminal else { break } @@ -4183,7 +4568,10 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, threadID: threadID, kind: mapApprovalKind(requestKind), title: item.base.title ?? approvalTitle(for: requestKind), - detail: prompt ?? "" + detail: prompt ?? "", + options: options?.compactMap { option in + FeatureApprovalDecision(providerDecision: option.decision).map { FeatureApprovalOption(decision: $0, label: option.label) } + } ) ) @@ -4402,7 +4790,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, _ role: FeatureMessageRole, _ text: String, tool: String? = nil, - state: FeatureMessageState? = nil + state: FeatureMessageState? = nil, + wireMessageID: String? = nil ) -> FeatureMessage { FeatureMessage( id: item.id, @@ -4410,7 +4799,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, text: text, createdAt: createdAt, state: state ?? (item.status.isTerminal ? .complete : .streaming), - toolName: tool + toolName: tool, + wireMessageID: wireMessageID ) } @@ -4436,8 +4826,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, createdBy: item.base.createdBy ) - case let .assistantMessage(_, text, streaming): - return message(.assistant, text, state: streaming ? .streaming : .complete) + case let .assistantMessage(messageID, text, streaming): + return message(.assistant, text, state: streaming ? .streaming : .complete, wireMessageID: messageID) case let .reasoning(text, streaming): return message(.tool, text, tool: "Thinking", state: streaming ? .streaming : .complete) @@ -4585,6 +4975,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, activeOrderKey: thread.activeOrderKey, supportsActiveOrder: environment.descriptor?.capabilities.threadActiveOrderV2, supportsSettlement: environment.descriptor?.capabilities.threadSettlement, + serverAutoSettlement: environment.descriptor?.capabilities.threadAutoSettlement, supportsSnooze: environment.descriptor?.capabilities.threadSnooze, workInboxRole: thread.workInboxRole, relationshipToParent: thread.lineage.relationshipToParent, @@ -4593,8 +4984,13 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, .threadTitleRegeneration, linkedPullRequest: mapLinkedPullRequest( thread.linkedPullRequest, - environment: environment + environment: environment, + metadata: thread.pullRequests?.first { $0.number == thread.linkedPullRequest?.number && $0.url == thread.linkedPullRequest?.url } ), + linkedPullRequests: mapThreadPullRequests(thread.pullRequests, legacy: thread.linkedPullRequests, projectID: thread.projectId, environment: environment), + branchPullRequest: mapLinkedPullRequest(thread.branchPullRequest, environment: environment), + supportsMultiplePullRequests: environment.descriptor?.capabilities.threadPullRequestsV2, + supportsPullRequestStackActions: environment.descriptor?.capabilities.pullRequestStackActions, supportsPullRequestLinking: environment.descriptor?.capabilities .threadPullRequestLinking, attentionAt: latestRun?.status == "failed" @@ -4614,7 +5010,8 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, /// than at each of the three places that read it back. private func mapLinkedPullRequest( _ linked: OrchestrationV2ThreadLinkedPullRequest?, - environment: Environment + environment: Environment, + metadata: OrchestrationV2ThreadPullRequestLink? = nil ) -> FeatureLinkedPullRequest? { guard let linked else { return nil } return FeatureLinkedPullRequest( @@ -4624,10 +5021,42 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, ), repository: linked.repository, number: linked.number, - url: linked.url + url: linked.url, + host: metadata?.host, + source: metadata?.source, + linkedAt: metadata?.linkedAt, + snapshot: metadata?.snapshot.map { snapshot in + FeaturePullRequestSnapshot( + state: snapshot.state.rawValue, title: snapshot.title, + headBranch: snapshot.headBranch, baseBranch: snapshot.baseBranch, + isDraft: snapshot.isDraft, updatedAt: snapshot.updatedAt, + author: snapshot.author?.login, additions: snapshot.additions, deletions: snapshot.deletions, + checksState: snapshot.checksState, reviewDecision: snapshot.reviewDecision, + mergeability: snapshot.mergeability?.rawValue + ) + }, + stack: metadata?.stack.map { stack in + FeaturePullRequestStack(id: stack.id, number: stack.number, url: stack.url, + base: stack.base, numbers: stack.layers.map(\.number)) + } ) } + private func mapThreadPullRequests( + _ links: [OrchestrationV2ThreadPullRequestLink]?, + legacy: [OrchestrationV2ThreadLinkedPullRequest]?, + projectID: String, + environment: Environment + ) -> [FeatureLinkedPullRequest]? { + guard let links else { return legacy.map { $0.compactMap { mapLinkedPullRequest($0, environment: environment) } } } + return links.filter(\.isVisible).compactMap { link in + mapLinkedPullRequest( + OrchestrationV2ThreadLinkedPullRequest(projectId: link.projectId ?? projectID, repository: link.repository, number: link.number, url: link.url), + environment: environment, metadata: link + ) + } + } + private func mapApprovalKind(_ requestKind: String) -> FeatureApprovalKind { switch requestKind { case "command": .command @@ -4681,7 +5110,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, updatedAt: parseDate(thread.updatedAt), state: mapThreadState( status: thread.status, - pendingRequestKind: thread.pendingRuntimeRequest?.kind + pendingRequestKind: thread.pendingRuntimeRequest?.responseMode == "message" ? nil : thread.pendingRuntimeRequest?.kind ), providerID: thread.modelSelection.instanceId, providerName: threadProviderName(modelSelection: thread.modelSelection), @@ -4710,6 +5139,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, activeOrderKey: thread.activeOrderKey, supportsActiveOrder: environment.descriptor?.capabilities.threadActiveOrderV2, supportsSettlement: environment.descriptor?.capabilities.threadSettlement, + serverAutoSettlement: environment.descriptor?.capabilities.threadAutoSettlement, supportsSnooze: environment.descriptor?.capabilities.threadSnooze, // The two fields the workspaces sort on: `workInboxRole` is what // gives the T3 Work inbox a Main section at all, and @@ -4722,8 +5152,13 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, .threadTitleRegeneration, linkedPullRequest: mapLinkedPullRequest( thread.linkedPullRequest, - environment: environment + environment: environment, + metadata: thread.pullRequests?.first { $0.number == thread.linkedPullRequest?.number && $0.url == thread.linkedPullRequest?.url } ), + linkedPullRequests: mapThreadPullRequests(thread.pullRequests, legacy: thread.linkedPullRequests, projectID: thread.projectId, environment: environment), + branchPullRequest: mapLinkedPullRequest(thread.branchPullRequest, environment: environment), + supportsMultiplePullRequests: environment.descriptor?.capabilities.threadPullRequestsV2, + supportsPullRequestStackActions: environment.descriptor?.capabilities.pullRequestStackActions, supportsPullRequestLinking: environment.descriptor?.capabilities .threadPullRequestLinking, // A failed run is the only thing that earns an attention marker; a @@ -4875,14 +5310,9 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, environmentID: String ) -> ServerConfigSnapshot { let previous = serverConfigsByEnvironmentID[environmentID] - return ServerConfigSnapshot( - providers: previous?.providers ?? latestServerConfig?.providers ?? [], - settings: settings, - t3WorkDirectory: previous?.t3WorkDirectory, - threadSnapshotWindow: previous?.threadSnapshotWindow, - threadResumeCompletionMarker: previous?.threadResumeCompletionMarker, - shellResumeCompletionMarker: previous?.shellResumeCompletionMarker - ) + ?? (environmentID == activeEnvironment?.id ? latestServerConfig : nil) + ?? ServerConfigSnapshot(providers: []) + return previous.replacingSettings(settings) } /// Single write path for server configs so the provider catalog cache can @@ -4948,6 +5378,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, }, isDefault: model.isDefault ?? false, isLegacy: model.isLegacy, + badge: model.badge, options: options ) }, @@ -5054,6 +5485,10 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, shell: OrchestrationV2ShellSnapshot? ) -> ModelSelection { let config = serverConfigsByEnvironmentID[environmentID] + if let configured = config?.settings?.defaultModelSelection, + let config, configSupports(mapSelection(configured), config: config) { + return configured + } let appSelection = loadSettings().defaultSelection if let selection = appSelection, let config { if configSupports(selection, config: config) { @@ -5460,7 +5895,7 @@ final class NativeFeatureClient: FeatureClient, FeatureDeviceManaging, private func previewText(_ text: String?) -> String? { guard let text else { return nil } - let compact = text.split(whereSeparator: \.isWhitespace).joined(separator: " ") + let compact = AssistantCitation.plainText(text).split(whereSeparator: \.isWhitespace).joined(separator: " ") guard !compact.isEmpty else { return nil } return compact.count > 160 ? "\(compact.prefix(157))..." : compact } @@ -5866,7 +6301,7 @@ private struct ProjectionItemSupportIndex { // An approval or user-input row names its own request; every other row // reaches it through the execution node that raised it. let requestID: String? = switch item.payload { - case let .approvalRequest(requestID, _, _): requestID + case let .approvalRequest(requestID, _, _, _): requestID case let .userInputRequest(requestID, _): requestID default: node?.runtimeRequestId } @@ -6239,7 +6674,9 @@ extension NativeFeatureClient: FeatureHermesInboxManaging { extension NativeFeatureClient: FeatureServerSettingsManaging { func providerModelConfiguration(environmentID: String) async throws -> ServerConfigSnapshot { let client = try await environmentClient(id: environmentID) - return try await client.serverConfig() + let config = try await client.serverConfig() + setServerConfig(config, environmentID: environmentID) + return config } @discardableResult @@ -6247,7 +6684,24 @@ extension NativeFeatureClient: FeatureServerSettingsManaging { environmentID: String, patch: ServerSettingsPatchInput ) async throws -> FeatureEnvironmentPreferences { + if patch.providerInstances != nil || patch.customModelsByDriver != nil, + (try await runtime.environments()).first(where: { $0.id == environmentID })?.descriptor?.capabilities.customModelDefinitions != true { + throw FeatureCapabilityUnavailable("Custom model definitions") + } + if patch.environmentIcon != nil, + (try await runtime.environments()).first(where: { $0.id == environmentID })?.descriptor?.capabilities.environmentIcon != true { + throw FeatureCapabilityUnavailable("Environment icons") + } + if patch.usagePriceOverrides != nil, + (try await runtime.environments()).first(where: { $0.id == environmentID })?.descriptor?.capabilities.usagePriceOverrides != true { + throw FeatureCapabilityUnavailable("Custom model pricing") + } let client = try await environmentClient(id: environmentID) + let sourceConfig = try await client.serverConfig() + if patch.continueThreadsAfterServerUpdate != nil, sourceConfig.environment?.capabilities.threadRestartContinuation != true { + throw FeatureCapabilityUnavailable("Restart recovery") + } + setServerConfig(sourceConfig, environmentID: environmentID) let settings = try await client.updateServerSettings(patch: patch) // Fold the server's answer into the cached config now. The active // environment would also hear it on the config subscription, but a @@ -6256,7 +6710,31 @@ extension NativeFeatureClient: FeatureServerSettingsManaging { let config = mergingSettings(settings, environmentID: environmentID) if environmentID == activeEnvironment?.id { latestServerConfig = config } setServerConfig(config, environmentID: environmentID) + let shared = SharedServerSettings.split(patch).shared + var failedTargets: [String] = [] + if !SharedServerSettings.isEmpty(shared) { + for environment in try await runtime.environments() where environment.id != environmentID { + guard environmentConnectionStates[environment.id] == .connected else { continue } + do { + let targetClient = try await environmentClient(id: environment.id) + let targetConfig = try await targetClient.serverConfig() + guard targetConfig.environment?.capabilities.threadAutoSettlement == true, + let targetSettings = targetConfig.settings else { continue } + let targetPatch = SharedServerSettings.filter(shared, + restartSupported: targetConfig.environment?.capabilities.threadRestartContinuation == true, + target: targetSettings, source: settings) + guard !SharedServerSettings.isEmpty(targetPatch) else { continue } + let saved = try await targetClient.updateServerSettings(patch: targetPatch) + setServerConfig(targetConfig, environmentID: environment.id) + setServerConfig(mergingSettings(saved, environmentID: environment.id), environmentID: environment.id) + } catch is CancellationError { throw CancellationError() } + catch { failedTargets.append(environment.label) } + } + } if let shell = latestShell { await emitSnapshot(shell) } + if !failedTargets.isEmpty { + throw SharedSettingsWriteFailure(machines: failedTargets) + } return FeatureEnvironmentPreferences( defaultWorkspaceMode: settings.defaultThreadEnvMode == .worktree ? .worktree : .local, newWorktreesStartFromOrigin: settings.newWorktreesStartFromOrigin, @@ -6264,6 +6742,33 @@ extension NativeFeatureClient: FeatureServerSettingsManaging { claudeAutoCompactWindow: settings.claudeAutoCompactWindow ) } + func sharedSettingsMismatches(environmentID: String) async throws -> [FeatureSharedSettingsMismatch] { + let source = try await providerModelConfiguration(environmentID: environmentID) + guard source.environment?.capabilities.threadAutoSettlement == true, let sourceSettings = source.settings else { return [] } + var mismatches: [FeatureSharedSettingsMismatch] = [] + for environment in try await runtime.environments() where environment.id != environmentID { + guard environmentConnectionStates[environment.id] == .connected, + let config = serverConfigsByEnvironmentID[environment.id], + config.environment?.capabilities.threadAutoSettlement == true, + let settings = config.settings else { continue } + if SharedServerSettings.differs(source: sourceSettings, + sourceRestart: source.environment?.capabilities.threadRestartContinuation == true, + target: settings, targetRestart: config.environment?.capabilities.threadRestartContinuation == true) { + mismatches.append(.init(id: environment.id, name: environment.label)) + } + } + return mismatches + } + + func applySharedSettings(environmentID: String) async throws { + let config = try await providerModelConfiguration(environmentID: environmentID) + guard config.environment?.capabilities.threadAutoSettlement == true, let settings = config.settings else { + throw FeatureCapabilityUnavailable("Shared preferences") + } + try await updateServerSettings(environmentID: environmentID, + patch: SharedServerSettings.pick(settings, restartSupported: config.environment?.capabilities.threadRestartContinuation == true)) + } + } // MARK: - Voice Input @@ -6627,3 +7132,8 @@ struct NativeVoiceRelayClient: Sendable { let models: [Model] } } + +private struct SharedSettingsWriteFailure: LocalizedError { + let machines: [String] + var errorDescription: String? { "Saved on the selected machine, but could not update shared preferences on " + machines.joined(separator: ", ") + ". Reload and apply to all to retry." } +} diff --git a/apps/swift-ios/App/NativeWorkspaceMapper.swift b/apps/swift-ios/App/NativeWorkspaceMapper.swift index a1bd5e1ae..4e91d3e93 100644 --- a/apps/swift-ios/App/NativeWorkspaceMapper.swift +++ b/apps/swift-ios/App/NativeWorkspaceMapper.swift @@ -61,7 +61,7 @@ enum NativeWorkspaceMapper { FeatureReview( title: "Working tree", baseReference: preview.sources.compactMap(\.baseRef).first, - files: preview.sources.flatMap(parseDiff), + files: preview.sources.flatMap(NativeUnifiedDiffMapper.parseDiff), isTruncated: preview.sources.contains(where: \.truncated) ) } @@ -110,7 +110,8 @@ enum NativeWorkspaceMapper { title: changeRequest.title, state: changeRequest.state, url: URL(string: changeRequest.url), - updatedAt: changeRequest.updatedAt.flatMap(isoDate) + updatedAt: changeRequest.updatedAt.flatMap(isoDate), + isDraft: changeRequest.isDraft ) } @@ -124,7 +125,8 @@ enum NativeWorkspaceMapper { title: detail.title, state: detail.state.rawValue, url: URL(string: detail.url), - updatedAt: isoDate(detail.updatedAt) + updatedAt: isoDate(detail.updatedAt), + isDraft: detail.isDraft ) } @@ -235,201 +237,4 @@ enum NativeWorkspaceMapper { .joined(separator: "/") } - private static func parseDiff(_ source: ReviewDiffSource) -> [FeatureReviewFile] { - let rawLines = source.diff.split( - separator: "\n", - omittingEmptySubsequences: false - ).map(String.init) - var files: [FeatureReviewFile] = [] - var currentPath: String? - var previousPath: String? - var change = FeatureReviewChangeKind.modified - var lines: [FeatureDiffLine] = [] - var oldLine: Int? - var newLine: Int? - var additions = 0 - var deletions = 0 - - func finishFile() { - guard let currentPath else { return } - files.append( - FeatureReviewFile( - path: currentPath, - previousPath: previousPath, - change: change, - additions: additions, - deletions: deletions, - lines: annotateChangedSpans(lines), - sourceKind: source.kind, - sourceBaseReference: source.baseRef, - sourceHeadReference: source.headRef - ) - ) - } - - for (index, line) in rawLines.enumerated() { - if line.hasPrefix("diff --git ") { - finishFile() - let parts = line.split(separator: " ") - currentPath = parts.count > 3 ? stripDiffPrefix(String(parts[3])) : source.title - previousPath = parts.count > 2 ? stripDiffPrefix(String(parts[2])) : nil - change = .modified - lines = [] - oldLine = nil - newLine = nil - additions = 0 - deletions = 0 - continue - } - if line.hasPrefix("new file mode ") { - change = .added - continue - } - if line.hasPrefix("deleted file mode ") { - change = .deleted - continue - } - if line.hasPrefix("rename from ") { - previousPath = String(line.dropFirst("rename from ".count)) - change = .renamed - continue - } - if line.hasPrefix("rename to ") { - currentPath = String(line.dropFirst("rename to ".count)) - change = .renamed - continue - } - if line.hasPrefix("Binary files ") || line == "GIT binary patch" { - change = .binary - continue - } - if line.hasPrefix("+++ ") { - let path = String(line.dropFirst(4)) - if path != "/dev/null" { currentPath = stripDiffPrefix(path) } - continue - } - if line.hasPrefix("--- ") { - let path = String(line.dropFirst(4)) - if path != "/dev/null" { previousPath = stripDiffPrefix(path) } - continue - } - if line.hasPrefix("@@") { - let ranges = line.split(separator: " ") - oldLine = ranges.count > 1 ? rangeStart(String(ranges[1])) : nil - newLine = ranges.count > 2 ? rangeStart(String(ranges[2])) : nil - lines.append( - FeatureDiffLine( - id: "\(source.id)-\(index)", - kind: .hunk, - text: line - ) - ) - continue - } - - let kind: FeatureDiffLineKind - let rendered: String - let renderedOld: Int? - let renderedNew: Int? - if line.hasPrefix("+") { - kind = .addition - rendered = String(line.dropFirst()) - renderedOld = nil - renderedNew = newLine - newLine = newLine.map { $0 + 1 } - additions += 1 - } else if line.hasPrefix("-") { - kind = .deletion - rendered = String(line.dropFirst()) - renderedOld = oldLine - renderedNew = nil - oldLine = oldLine.map { $0 + 1 } - deletions += 1 - } else if line.hasPrefix(" ") { - kind = .context - rendered = String(line.dropFirst()) - renderedOld = oldLine - renderedNew = newLine - oldLine = oldLine.map { $0 + 1 } - newLine = newLine.map { $0 + 1 } - } else { - continue - } - lines.append( - FeatureDiffLine( - id: "\(source.id)-\(index)", - kind: kind, - oldLine: renderedOld, - newLine: renderedNew, - text: rendered - ) - ) - } - finishFile() - - if files.isEmpty, !source.diff.isEmpty { - return [ - FeatureReviewFile( - path: source.title, - change: .modified, - additions: additions, - deletions: deletions, - lines: annotateChangedSpans(lines), - sourceKind: source.kind, - sourceBaseReference: source.baseRef, - sourceHeadReference: source.headRef - ), - ] - } - return files - } - - /// Git presents replacements as adjacent deletion/addition blocks. Pairing those - /// lines here keeps the view dumb and makes word-level highlighting stable on scroll. - private static func annotateChangedSpans( - _ source: [FeatureDiffLine] - ) -> [FeatureDiffLine] { - var lines = source - var index = 0 - while index < lines.count { - guard lines[index].kind == .deletion || lines[index].kind == .addition else { - index += 1 - continue - } - let start = index - while index < lines.count, - lines[index].kind == .deletion || lines[index].kind == .addition { - index += 1 - } - let changedIndices = start ..< index - let deletions = changedIndices.filter { lines[$0].kind == .deletion } - let additions = changedIndices.filter { lines[$0].kind == .addition } - for (deletionIndex, additionIndex) in zip(deletions, additions) { - let spans = FeatureDiffWordHighlighter.spans( - old: lines[deletionIndex].text, - new: lines[additionIndex].text - ) - lines[deletionIndex].spans = spans.old - lines[additionIndex].spans = spans.new - } - } - return lines - } - - private static func stripDiffPrefix(_ path: String) -> String { - if path.hasPrefix("a/") || path.hasPrefix("b/") { - return String(path.dropFirst(2)) - } - return path - } - - private static func rangeStart(_ range: String) -> Int? { - Int( - range - .drop(while: { $0 == "-" || $0 == "+" }) - .split(separator: ",", maxSplits: 1) - .first - ?? "" - ) - } } diff --git a/apps/swift-ios/Core/AgentSessionModels.swift b/apps/swift-ios/Core/AgentSessionModels.swift new file mode 100644 index 000000000..64cc3eda7 --- /dev/null +++ b/apps/swift-ios/Core/AgentSessionModels.swift @@ -0,0 +1,58 @@ +import Foundation + +public struct AgentSessionProjectGit: Codable, Sendable, Equatable { + public let remoteKey: String? + public let repository: String? +} + +public struct AgentSessionProjectCandidate: Decodable, Sendable, Equatable, Identifiable { + public let path: String + public let title: String + public let projectId: String? + public let sources: [String] + public let threadCount: Int + public let lastActiveAt: String? + public let alreadyImported: Bool + public let git: AgentSessionProjectGit? + public let reportsGitIdentity: Bool + public var id: String { path } + + enum CodingKeys: String, CodingKey { case path, title, projectId, sources, threadCount, lastActiveAt, alreadyImported, git } + public init(from decoder: any Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + path = try values.decode(String.self, forKey: .path) + title = try values.decode(String.self, forKey: .title) + projectId = try values.decodeIfPresent(String.self, forKey: .projectId) + sources = try values.decode([String].self, forKey: .sources) + threadCount = try values.decode(Int.self, forKey: .threadCount) + lastActiveAt = try values.decodeIfPresent(String.self, forKey: .lastActiveAt) + alreadyImported = try values.decode(Bool.self, forKey: .alreadyImported) + reportsGitIdentity = values.contains(.git) + git = try values.decodeIfPresent(AgentSessionProjectGit.self, forKey: .git) + } + + public func selectedByDefault(now: Date = .now) -> Bool { + guard (!reportsGitIdentity || git != nil), threadCount >= 3, + let lastActiveAt, let date = Self.activityDate(lastActiveAt) else { return false } + return date <= now && date >= now.addingTimeInterval(-30 * 86_400) + } + + public static func activityDate(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } +} + +public struct AgentSessionScanResult: Decodable, Sendable, Equatable { + public let candidates: [AgentSessionProjectCandidate] + public let scannedAt: String + public let truncated: Bool? +} + +public struct AgentSessionImportResult: Codable, Sendable, Equatable { + public let importedCount: Int + public let skippedCount: Int +} diff --git a/apps/swift-ios/Core/AgentSetupCommand.swift b/apps/swift-ios/Core/AgentSetupCommand.swift new file mode 100644 index 000000000..7443743ec --- /dev/null +++ b/apps/swift-ios/Core/AgentSetupCommand.swift @@ -0,0 +1,24 @@ +import Foundation + +public enum AgentSetupCommand { + public static func resolve(driver: String, installed: Bool, binaryPath: String?, platform: String) -> String? { + guard driver == "codex" || driver == "claudeAgent" else { return nil } + if !installed { + if driver == "claudeAgent" { + return platform == "windows" ? "irm https://claude.ai/install.ps1 | iex" : "curl -fsSL https://claude.ai/install.sh | bash" + } + return platform == "windows" ? "irm https://chatgpt.com/codex/install.ps1 | iex" : "curl -fsSL https://chatgpt.com/codex/install.sh | sh" + } + let fallback = driver == "codex" ? "codex" : "claude" + let binary = binaryPath.flatMap { $0.isEmpty ? nil : $0 } ?? fallback + let quoted: String + if binary.range(of: #"^[A-Za-z0-9_./:\\-]+$"#, options: .regularExpression) != nil && (platform == "windows" || !binary.contains("\\")) { quoted = binary } + else if platform == "windows" { quoted = "& '" + binary.replacingOccurrences(of: "'", with: "''") + "'" } + else if platform == "darwin" || platform == "linux" { + let homeRelative = binary.hasPrefix("~/") || binary.hasPrefix("~\\") + let value = homeRelative ? String(binary.dropFirst(2)) : binary + quoted = (homeRelative ? "~/" : "") + "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } else { quoted = fallback } + return quoted + (driver == "codex" ? " login" : " auth login") + } +} diff --git a/apps/swift-ios/Core/Attachments.swift b/apps/swift-ios/Core/Attachments.swift index 3c5349fb3..323272b77 100644 --- a/apps/swift-ios/Core/Attachments.swift +++ b/apps/swift-ios/Core/Attachments.swift @@ -22,9 +22,8 @@ public enum ImageAttachmentError: LocalizedError, Equatable, Sendable { /// Inline upload shape accepted by `thread.turn.start`. /// -/// There is intentionally no standalone attachment-upload endpoint in the T3 -/// contract. New image bytes travel as a base64 data URL on the turn command; -/// the server normalizes them into a persisted `ChatAttachment`. +/// Local draft representation. T3Client uses signed HTTP uploads when the +/// environment supports them, with inline persistence for older servers. public struct UploadChatImageAttachment: Codable, Equatable, Sendable { public static let maximumBytes = 10 * 1024 * 1024 @@ -69,7 +68,10 @@ public struct UploadChatImageAttachment: Codable, Equatable, Sendable { } public enum AssetResource: Equatable, Sendable { + case nativeAppIcon(ToolActivityNativeAppReference) case workspaceFile(threadID: String, path: String) + case mediaFile(threadID: String, path: String) + case documentAttachment(id: String, name: String, mimeType: String) case attachment(id: String) /// A file in the server's browser-artifacts directory: Hermes screenshots /// and recordings. Without this, an assistant message referencing one has @@ -82,12 +84,19 @@ public enum AssetResource: Equatable, Sendable { var jsonValue: JSONValue { switch self { + case let .nativeAppIcon(app): + .object(["_tag": .string("native-app-icon"), "app": .object(["_tag": .string(app._tag)].merging(app.appId.map { ["appId": JSONValue.string($0)] } ?? [:]) { current, _ in current }.merging(app.displayName.map { ["displayName": JSONValue.string($0)] } ?? [:]) { current, _ in current })]) case let .workspaceFile(threadID, path): .object([ "_tag": .string("workspace-file"), "threadId": .string(threadID), "path": .string(path), ]) + case let .mediaFile(threadID, path): + .object(["_tag": .string("media-file"), "threadId": .string(threadID), "path": .string(path)]) + case let .documentAttachment(id, name, mimeType): + .object(["_tag": .string("attachment"), "attachmentId": .string(id), + "fileName": .string(name), "mimeType": .string(mimeType), "disposition": .string("inline")]) case let .attachment(id): .object([ "_tag": .string("attachment"), @@ -111,15 +120,22 @@ public enum AssetResource: Equatable, Sendable { } } +public struct AssetImageDimensions: Codable, Equatable, Sendable { + public let width: Int + public let height: Int +} + public struct AssetCreateURLResult: Codable, Equatable, Sendable { public let relativeUrl: String /// Unix epoch milliseconds from the server contract. public let expiresAt: Double + public var imageDimensions: AssetImageDimensions? = nil } public struct ResolvedAssetURL: Equatable, Sendable { public let url: URL public let expiresAt: Date + public var imageDimensions: AssetImageDimensions? = nil } /// A composer attachment of any kind. @@ -188,3 +204,9 @@ public struct UploadChatAttachment: Codable, Equatable, Sendable { ]) } } + +public struct AttachmentUploadURLResult: Codable, Equatable, Sendable { + public let attachmentId: String + public let relativeUrl: String + public let expiresAt: Double +} diff --git a/apps/swift-ios/Core/FeatureFilePreviewPath.swift b/apps/swift-ios/Core/FeatureFilePreviewPath.swift new file mode 100644 index 000000000..86a1c1d68 --- /dev/null +++ b/apps/swift-ios/Core/FeatureFilePreviewPath.swift @@ -0,0 +1,18 @@ +import Foundation + +public enum FeatureFilePreviewPath { + public static func isAbsolute(_ path: String) -> Bool { + path.hasPrefix("/") || path.hasPrefix("\\") || path.range(of: #"^[A-Za-z]:[/\\]"#, options: .regularExpression) != nil + } + public static func parent(_ path: String) -> String? { + guard let separator = path.lastIndex(where: { $0 == "/" || $0 == "\\" }) else { return nil } + return separator == path.startIndex ? String(path.prefix(1)) : String(path[.. String { + guard let directory, !directory.isEmpty, !isAbsolute(path) else { return path } + return directory + (directory.hasSuffix("/") || directory.hasSuffix("\\") ? "" : "/") + path + } + public static func isDocument(_ path: String) -> Bool { + ["pdf", "html", "htm"].contains(URL(fileURLWithPath: path).pathExtension.lowercased()) + } +} diff --git a/apps/swift-ios/Core/HTTP.swift b/apps/swift-ios/Core/HTTP.swift index 815ea5ae9..6d57ac330 100644 --- a/apps/swift-ios/Core/HTTP.swift +++ b/apps/swift-ios/Core/HTTP.swift @@ -95,6 +95,8 @@ public enum HTTPError: LocalizedError, Sendable { } } +private struct EmptyHTTPResponse: Decodable, Sendable {} + private struct ErrorBody: Decodable { let message: String? let reason: String? @@ -230,12 +232,33 @@ public actor EnvironmentAPI { ) } + /// Uses the environment's authenticated transport so relay-bound sessions + /// retain their request proof and credential refresh behavior. + public func uploadAttachment( + for environment: Environment, relativeURL: String, data: Data, mimeType: String + ) async throws { + guard let url = URL(string: relativeURL, relativeTo: environment.httpBaseURL)?.absoluteURL, + url.scheme == environment.httpBaseURL.scheme, + url.host == environment.httpBaseURL.host, + url.port == environment.httpBaseURL.port, + url.path.hasPrefix("/api/attachments/upload/"), + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + throw RPCError.protocolViolation("The server returned an invalid attachment upload URL.") + } + let _: EmptyHTTPResponse = try await authorized( + environment: environment, path: url.path, queryItems: components.queryItems ?? [], + method: "POST", body: data, contentType: mimeType, timeoutInterval: 300, + as: EmptyHTTPResponse.self + ) + } + private func authorized( environment: Environment, path: String, queryItems: [URLQueryItem] = [], method: String, body: Data? = nil, + contentType: String = "application/json", timeoutInterval: TimeInterval? = nil, as type: Result.Type ) async throws -> Result { @@ -253,7 +276,8 @@ public actor EnvironmentAPI { path: path, queryItems: queryItems, method: method, - body: body + body: body, + contentType: contentType ) if let timeoutInterval { request.timeoutInterval = timeoutInterval @@ -290,7 +314,8 @@ public actor EnvironmentAPI { path: path, queryItems: queryItems, method: method, - body: body + body: body, + contentType: contentType ), environment: environment, credential: current @@ -320,7 +345,8 @@ public actor EnvironmentAPI { path: path, queryItems: queryItems, method: method, - body: body + body: body, + contentType: contentType ), environment: environment, credential: current @@ -338,7 +364,8 @@ public actor EnvironmentAPI { path: String, queryItems: [URLQueryItem], method: String, - body: Data? + body: Data?, + contentType: String = "application/json" ) -> URLRequest { var request = URLRequest( url: endpoint(environment.httpBaseURL, path: path, queryItems: queryItems) @@ -346,7 +373,7 @@ public actor EnvironmentAPI { request.httpMethod = method request.httpBody = body if body != nil { - request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(contentType, forHTTPHeaderField: "Content-Type") } return request } @@ -410,7 +437,7 @@ public actor EnvironmentAPI { traceID: body?.traceId ) } - return try JSONDecoder.t3.decode(type, from: data) + return try JSONDecoder.t3.decode(type, from: data.isEmpty && type == EmptyHTTPResponse.self ? Data("{}".utf8) : data) } } diff --git a/apps/swift-ios/Core/HostResources.swift b/apps/swift-ios/Core/HostResources.swift new file mode 100644 index 000000000..3b281d09b --- /dev/null +++ b/apps/swift-ios/Core/HostResources.swift @@ -0,0 +1,36 @@ +import Foundation + +public struct HostResourcesSnapshot: Codable, Equatable, Sendable { + public let sampledAt: Double + public let cpuUtilization: Double? + public let cpuCount: Int + public let availableMemoryBytes: Double + public let totalMemoryBytes: Double +} + +struct LoadBalancingCandidate: Sendable { + let environmentID: String + let resources: HostResourcesSnapshot? + /// Milliseconds on this client; host clocks are not compared across machines. + let receivedAt: Double + let weight: Double +} + +enum LoadBalancedEnvironment { + static func choose(_ candidates: [LoadBalancingCandidate], now: Double) -> String? { + var selected: String? + var bestScore = 0.0 + for candidate in candidates { + guard let resources = candidate.resources, + candidate.weight.isFinite, candidate.weight > 0, + now - candidate.receivedAt <= 15_000, candidate.receivedAt <= now + 5_000, + let cpu = resources.cpuUtilization, cpu.isFinite, cpu >= 0, cpu < 0.95, + resources.cpuCount > 0, resources.totalMemoryBytes > 0 else { continue } + let available = resources.availableMemoryBytes / resources.totalMemoryBytes + guard available.isFinite, available > 0.05 else { continue } + let score = candidate.weight * Double(resources.cpuCount) * (1 - cpu) * available + if score > bestScore { selected = candidate.environmentID; bestScore = score } + } + return selected + } +} diff --git a/apps/swift-ios/Core/Models.swift b/apps/swift-ios/Core/Models.swift index bebbbfbcf..d01e9c38c 100644 --- a/apps/swift-ios/Core/Models.swift +++ b/apps/swift-ios/Core/Models.swift @@ -35,12 +35,15 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { public struct Platform: Codable, Equatable, Sendable { public let os: String public let arch: String + public var machine: String? = nil } public struct Capabilities: Codable, Equatable, Sendable { public let repositoryIdentity: Bool public let connectionProbe: Bool? public let threadSettlement: Bool? + public var threadRestartContinuation: Bool? = nil + public var threadAutoSettlement: Bool? = nil public let threadSnooze: Bool? public let threadPinning: Bool? public let threadActiveOrderV2: Bool? @@ -50,6 +53,23 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { /// Absent on older servers, so the link action stays hidden rather than /// sending a command the server will reject. public let threadPullRequestLinking: Bool? + public let threadPullRequestsV2: Bool? + public struct FileAttachments: Codable, Equatable, Sendable { public let maxUploadBytes: Int } + public let attachmentUploads: Bool? + public let fileAttachments: FileAttachments? + public let assistantCitations: Bool? + public let customModelDefinitions: Bool? + public let projectActionDefaults: Bool? + public let projectDefaults: Bool? + public let projectBrowserAccess: Bool? + public let projectAutoPull: Bool? + public let fileDocumentPreviews: Bool? + public let agentSessionImport: Bool? + public let providerTerminalEnvironment: Bool? + public let projectIcons: Bool? + public let environmentIcon: Bool? + public let usagePriceOverrides: Bool? + public let pullRequestStackActions: Bool? public let pullRequests: Bool? public let serverSelfUpdate: String? public let serverSelfUpdateProgress: Bool? @@ -57,11 +77,27 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { private enum CodingKeys: String, CodingKey { case repositoryIdentity case connectionProbe + case threadRestartContinuation + case threadAutoSettlement case threadSettlement case threadSnooze case threadPinning, threadActiveOrderV2, threadQuestionActionsV2 case threadTitleRegeneration case threadPullRequestLinking + case threadPullRequestsV2 + case attachmentUploads, fileAttachments + case assistantCitations + case projectActionDefaults + case projectDefaults + case projectBrowserAccess + case projectAutoPull + case fileDocumentPreviews + case agentSessionImport, providerTerminalEnvironment + case projectIcons + case customModelDefinitions + case environmentIcon + case usagePriceOverrides + case pullRequestStackActions case pullRequests case serverSelfUpdate case serverSelfUpdateProgress @@ -72,6 +108,8 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { repositoryIdentity = try container.decodeIfPresent(Bool.self, forKey: .repositoryIdentity) ?? false connectionProbe = try container.decodeIfPresent(Bool.self, forKey: .connectionProbe) + threadRestartContinuation = try container.decodeIfPresent(Bool.self, forKey: .threadRestartContinuation) + threadAutoSettlement = try container.decodeIfPresent(Bool.self, forKey: .threadAutoSettlement) threadSettlement = try container.decodeIfPresent(Bool.self, forKey: .threadSettlement) threadSnooze = try container.decodeIfPresent(Bool.self, forKey: .threadSnooze) threadPinning = try container.decodeIfPresent(Bool.self, forKey: .threadPinning) @@ -81,6 +119,22 @@ public struct EnvironmentDescriptor: Codable, Equatable, Sendable { Bool.self, forKey: .threadTitleRegeneration ) + threadPullRequestsV2 = try container.decodeIfPresent(Bool.self, forKey: .threadPullRequestsV2) + attachmentUploads = try container.decodeIfPresent(Bool.self, forKey: .attachmentUploads) + fileAttachments = try container.decodeIfPresent(FileAttachments.self, forKey: .fileAttachments) + assistantCitations = try container.decodeIfPresent(Bool.self, forKey: .assistantCitations) + projectActionDefaults = try container.decodeIfPresent(Bool.self, forKey: .projectActionDefaults) + projectDefaults = try container.decodeIfPresent(Bool.self, forKey: .projectDefaults) + projectBrowserAccess = try container.decodeIfPresent(Bool.self, forKey: .projectBrowserAccess) + projectAutoPull = try container.decodeIfPresent(Bool.self, forKey: .projectAutoPull) + fileDocumentPreviews = try container.decodeIfPresent(Bool.self, forKey: .fileDocumentPreviews) + agentSessionImport = try container.decodeIfPresent(Bool.self, forKey: .agentSessionImport) + providerTerminalEnvironment = try container.decodeIfPresent(Bool.self, forKey: .providerTerminalEnvironment) + projectIcons = try container.decodeIfPresent(Bool.self, forKey: .projectIcons) + customModelDefinitions = try container.decodeIfPresent(Bool.self, forKey: .customModelDefinitions) + environmentIcon = try container.decodeIfPresent(Bool.self, forKey: .environmentIcon) + usagePriceOverrides = try container.decodeIfPresent(Bool.self, forKey: .usagePriceOverrides) + pullRequestStackActions = try container.decodeIfPresent(Bool.self, forKey: .pullRequestStackActions) threadPullRequestLinking = try container.decodeIfPresent( Bool.self, forKey: .threadPullRequestLinking @@ -287,8 +341,25 @@ public struct ProjectScript: Codable, Identifiable, Equatable, Sendable { public let command: String public let icon: String public let runOnWorktreeCreate: Bool + public let runOnWorktreeDelete: Bool? public let previewUrl: String? public let autoOpenPreview: Bool? + public let singleRun: Bool? + + public init(id: String, name: String, command: String, icon: String, runOnWorktreeCreate: Bool, runOnWorktreeDelete: Bool? = nil, previewUrl: String? = nil, autoOpenPreview: Bool? = nil, singleRun: Bool? = nil) { + self.id = id; self.name = name; self.command = command; self.icon = icon + self.runOnWorktreeCreate = runOnWorktreeCreate; self.runOnWorktreeDelete = runOnWorktreeDelete + self.previewUrl = previewUrl; self.autoOpenPreview = autoOpenPreview; self.singleRun = singleRun + } + + public var json: JSONValue { + var fields: [String: JSONValue] = ["id": .string(id), "name": .string(name), "command": .string(command), "icon": .string(icon), "runOnWorktreeCreate": .bool(runOnWorktreeCreate)] + if let runOnWorktreeDelete { fields["runOnWorktreeDelete"] = .bool(runOnWorktreeDelete) } + if let previewUrl { fields["previewUrl"] = .string(previewUrl) } + if let autoOpenPreview { fields["autoOpenPreview"] = .bool(autoOpenPreview) } + if let singleRun { fields["singleRun"] = .bool(singleRun) } + return .object(fields) + } } public struct OrchestrationProject: Codable, Identifiable, Equatable, Sendable { @@ -300,6 +371,7 @@ public struct OrchestrationProject: Codable, Identifiable, Equatable, Sendable { /// A manually chosen project icon, workspace-relative. Absent on servers /// predating manual icons and on projects that rely on auto-discovery. public let faviconPath: String? + public var projectIcon: ProjectIconOverride? = nil public let scripts: [ProjectScript] public let createdAt: String public let updatedAt: String diff --git a/apps/swift-ios/Core/OrchestrationV2Models.swift b/apps/swift-ios/Core/OrchestrationV2Models.swift index 4e73e22ad..ac82f2e99 100644 --- a/apps/swift-ios/Core/OrchestrationV2Models.swift +++ b/apps/swift-ios/Core/OrchestrationV2Models.swift @@ -501,6 +501,9 @@ public struct OrchestrationV2CommandLiveness: Codable, Equatable, Sendable { public struct OrchestrationV2TurnItem: Codable, Equatable, Sendable, Identifiable { public let base: OrchestrationV2TurnItemBase public let payload: Payload + public var toolSurface: String? = nil + public var toolIcon: ToolActivityIcon? = nil + public var toolSource: ToolActivitySource? = nil public init(type: String, base: OrchestrationV2TurnItemBase, payload: Payload) { self.type = type @@ -523,7 +526,7 @@ public struct OrchestrationV2TurnItem: Codable, Equatable, Sendable, Identifiabl case commandExecution(input: String, output: String?, exitCode: Int?, liveness: OrchestrationV2CommandLiveness) case fileSearch(pattern: String?, results: [OrchestrationV2FileSearchResult]?) case webSearch(patterns: [String]?, results: [OrchestrationV2WebSearchResult]?) - case approvalRequest(requestID: String, requestKind: String, prompt: String?) + case approvalRequest(requestID: String, requestKind: String, prompt: String?, options: [ProviderApprovalOption]?) case checkpoint(checkpointID: String, scopeID: String, files: [OrchestrationV2CheckpointFileSummary]) case checkpointRollback(checkpointID: String, scopeID: String, restoredFileCount: Int, rolledBackRunCount: Int) case runInterruptRequest(message: String) @@ -548,7 +551,7 @@ public struct OrchestrationV2TurnItem: Codable, Equatable, Sendable, Identifiabl case type case messageId, inputIntent, text, attachments case streaming, planId, markdown, steps, explanation - case requestId, questions, requestKind, prompt + case requestId, questions, requestKind, prompt, options case fileName, additions, deletions, diffStr, oldStr, newStr case input, output, exitCode case pattern, results, patterns @@ -560,7 +563,7 @@ public struct OrchestrationV2TurnItem: Codable, Equatable, Sendable, Identifiabl case source, targetThreadId, providerThreadId case targetRunId, targetProviderInstanceId, targetModel case subagentId, origin, providerInstanceId, childThreadId, progress, result - case toolName + case toolName, toolSurface, toolIcon, toolSource } public init(from decoder: any Decoder) throws { @@ -568,6 +571,11 @@ public struct OrchestrationV2TurnItem: Codable, Equatable, Sendable, Identifiabl let container = try decoder.container(keyedBy: CodingKeys.self) let type = try container.decode(String.self, forKey: .type) self.type = type + if type == "dynamic_tool" { + toolSurface = try container.decodeIfPresent(String.self, forKey: .toolSurface) + toolIcon = try container.decodeIfPresent(ToolActivityIcon.self, forKey: .toolIcon) + toolSource = try container.decodeIfPresent(ToolActivitySource.self, forKey: .toolSource) + } switch type { case "user_message": @@ -635,7 +643,8 @@ public struct OrchestrationV2TurnItem: Codable, Equatable, Sendable, Identifiabl payload = .approvalRequest( requestID: try container.decode(String.self, forKey: .requestId), requestKind: try container.decode(String.self, forKey: .requestKind), - prompt: try container.decodeIfPresent(String.self, forKey: .prompt) + prompt: try container.decodeIfPresent(String.self, forKey: .prompt), + options: try container.decodeIfPresent([ProviderApprovalOption].self, forKey: .options) ) case "checkpoint": payload = .checkpoint( @@ -720,6 +729,11 @@ public struct OrchestrationV2TurnItem: Codable, Equatable, Sendable, Identifiabl try base.encode(to: encoder) var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(type, forKey: .type) + if type == "dynamic_tool" { + try container.encodeIfPresent(toolSurface, forKey: .toolSurface) + try container.encodeIfPresent(toolIcon, forKey: .toolIcon) + try container.encodeIfPresent(toolSource, forKey: .toolSource) + } switch payload { case let .userMessage(messageID, intent, text, attachments): @@ -763,10 +777,11 @@ public struct OrchestrationV2TurnItem: Codable, Equatable, Sendable, Identifiabl case let .webSearch(patterns, results): try container.encodeIfPresent(patterns, forKey: .patterns) try container.encodeIfPresent(results, forKey: .results) - case let .approvalRequest(requestID, requestKind, prompt): + case let .approvalRequest(requestID, requestKind, prompt, options): try container.encode(requestID, forKey: .requestId) try container.encode(requestKind, forKey: .requestKind) try container.encodeIfPresent(prompt, forKey: .prompt) + try container.encodeIfPresent(options, forKey: .options) case let .checkpoint(checkpointID, scopeID, files): try container.encode(checkpointID, forKey: .checkpointId) try container.encode(scopeID, forKey: .scopeId) @@ -880,6 +895,9 @@ public struct OrchestrationV2AppThread: Codable, Equatable, Sendable, Identifiab public let worktreePath: String? /// See `OrchestrationV2ThreadShell.linkedPullRequest`. public let linkedPullRequest: OrchestrationV2ThreadLinkedPullRequest? + public var linkedPullRequests: [OrchestrationV2ThreadLinkedPullRequest]? = nil + public var pullRequests: [OrchestrationV2ThreadPullRequestLink]? = nil + public var branchPullRequest: OrchestrationV2ThreadLinkedPullRequest? = nil public let activeProviderThreadId: String? public let historyOrigin: String? public let lineage: OrchestrationV2AppThreadLineage @@ -1027,7 +1045,14 @@ public struct OrchestrationV2ContextTransfer: Codable, Equatable, Sendable, Iden /// A run, narrowed to what drives the thread header and the queue control. The /// projection carries far more per run; the rest is modeled when a feature /// needs it. +public struct OrchestrationV2RestartContinuation: Codable, Equatable, Sendable { + public let messageId: String + public let reason: String + public let status: String +} + public struct OrchestrationV2Run: Codable, Equatable, Sendable, Identifiable { + public var restartContinuation: OrchestrationV2RestartContinuation? = nil public let id: String public let ordinal: Int public let status: String @@ -1061,6 +1086,7 @@ public struct OrchestrationV2Run: Codable, Equatable, Sendable, Identifiable { /// table, which is what a queued run's `userMessageId` resolves against and /// what survives a timeline clear. public struct OrchestrationV2ConversationMessage: Codable, Equatable, Sendable, Identifiable { + public var restartContinuation: Bool? = nil public let id: String public let threadId: String public let runId: String? @@ -1077,12 +1103,14 @@ public struct OrchestrationV2ConversationMessage: Codable, Equatable, Sendable, public let updatedAt: OrchestrationV2Timestamp private enum CodingKeys: String, CodingKey { + case restartContinuation case id, threadId, runId, nodeId, role, text, attachments, streaming case createdBy, creationSource, createdAt, updatedAt } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + restartContinuation = try container.decodeIfPresent(Bool.self, forKey: .restartContinuation) id = try container.decode(String.self, forKey: .id) threadId = try container.decode(String.self, forKey: .threadId) runId = try container.decodeIfPresent(String.self, forKey: .runId) @@ -1247,6 +1275,7 @@ public struct OrchestrationV2ThreadDetailSnapshot: Codable, Equatable, Sendable // MARK: - Shell public struct OrchestrationV2PendingRuntimeRequestSummary: Codable, Equatable, Sendable { + public var responseMode: String? = nil public let id: String public let kind: String public let createdAt: OrchestrationV2Timestamp @@ -1299,6 +1328,9 @@ public struct OrchestrationV2ThreadShell: Codable, Equatable, Sendable, Identifi /// Absent on servers that predate pull-request linking, and on threads with /// nothing linked. Nil means "resolve the pull request from the branch". public var linkedPullRequest: OrchestrationV2ThreadLinkedPullRequest? + public var linkedPullRequests: [OrchestrationV2ThreadLinkedPullRequest]? = nil + public var pullRequests: [OrchestrationV2ThreadPullRequestLink]? = nil + public var branchPullRequest: OrchestrationV2ThreadLinkedPullRequest? = nil public var lineage: OrchestrationV2AppThreadLineage public var forkedFrom: OrchestrationV2ForkSource? public var activeProviderThreadId: String? @@ -1513,3 +1545,8 @@ public extension OrchestrationV2ThreadProjection { } } } + +public struct ProviderApprovalOption: Codable, Equatable, Hashable, Sendable { + public let decision: String + public let label: String +} diff --git a/apps/swift-ios/Core/ProjectIconDefaults.swift b/apps/swift-ios/Core/ProjectIconDefaults.swift new file mode 100644 index 000000000..27dc69b1c --- /dev/null +++ b/apps/swift-ios/Core/ProjectIconDefaults.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Matches the web's deterministic name classifier, including UTF-16 hashing. +enum ProjectIconDefaults { + private static let classes: [(String, String, [String])] = [ + ("bot", "violet", ["ai", "agent", "bot", "gpt", "llm", "ml", "model", "neural"]), + ("smartphone", "lime", ["android", "expo", "ios", "mobile", "native", "reactnative", "swift"]), + ("monitor", "indigo", ["desktop", "electron", "linux", "mac", "macos", "tauri", "windows"]), + ("book-open", "amber", ["book", "docs", "documentation", "guide", "handbook", "manual", "wiki"]), + ("shield-check", "teal", ["auth", "identity", "oauth", "security", "sso", "vault"]), + ("database", "cyan", ["analytics", "data", "database", "db", "mongo", "mysql", "postgres", "redis", "sql", "storage"]), + ("cloud-cog", "sky", ["aws", "azure", "cloud", "deploy", "devops", "docker", "gcp", "infra", "kubernetes", "terraform"]), + ("server", "blue", ["api", "backend", "gateway", "server", "service", "worker"]), + ("terminal", "green", ["automation", "bash", "cli", "command", "script", "shell", "terminal"]), + ("package", "orange", ["component", "kit", "lib", "library", "package", "plugin", "sdk", "toolkit"]), + ("flask-conical", "yellow", ["benchmark", "e2e", "fixture", "spec", "test", "testing"]), + ("shopping-bag", "rose", ["cart", "commerce", "market", "shop", "store"]), + ("gamepad-2", "emerald", ["game", "gaming", "play"]), + ("music", "fuchsia", ["audio", "music", "podcast", "radio", "sound"]), + ("video", "red", ["film", "movie", "stream", "video"]), + ("image", "pink", ["camera", "gallery", "image", "photo", "picture"]), + ("globe-2", "sky", ["browser", "frontend", "nextjs", "react", "site", "svelte", "ui", "vue", "web", "website"]), + ] + private static let generic = [("code-2", "blue"), ("braces", "purple"), ("circuit-board", "teal"), ("folder-code", "orange"), ("layers-3", "fuchsia")] + + static func select(title: String, workspaceRoot: String) -> ProjectIconOverride { + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + let name = trimmed.isEmpty ? (workspaceRoot.components(separatedBy: CharacterSet(charactersIn: "/\\")).last(where: { !$0.isEmpty }) ?? "project") : trimmed + let tokens = name.replacingOccurrences(of: "([a-z\\d])([A-Z])", with: "$1 $2", options: .regularExpression) + .lowercased().components(separatedBy: CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789").inverted).filter { !$0.isEmpty } + var best: (String, String)? + var bestScore = 0 + for (icon, color, terms) in classes { + let score = tokens.reduce(0) { total, token in + total + (terms.map { term in token == term ? 3 : (term.count >= 4 && (token.hasPrefix(term) || token.hasSuffix(term)) ? 1 : 0) }.max() ?? 0) + } + if score > bestScore { bestScore = score; best = (icon, color) } + } + let hash = name.lowercased().utf16.reduce(UInt32(2_166_136_261)) { ($0 ^ UInt32($1)) &* 16_777_619 } + let selected = best ?? generic[Int(hash % UInt32(generic.count))] + return ProjectIconOverride(kind: "lucide", name: selected.0, color: selected.1) + } +} diff --git a/apps/swift-ios/Core/ProjectIconEmoji.swift b/apps/swift-ios/Core/ProjectIconEmoji.swift new file mode 100644 index 000000000..26c5a1aa2 --- /dev/null +++ b/apps/swift-ios/Core/ProjectIconEmoji.swift @@ -0,0 +1,9 @@ +import Foundation + +enum ProjectIconEmoji { + static func first(in text: String) -> String? { + text.first(where: { character in + character.unicodeScalars.contains { $0.properties.isEmojiPresentation || ($0.properties.isEmoji && $0.value > 0x7F) } + }).map(String.init) + } +} diff --git a/apps/swift-ios/Core/ProjectIconOverride.swift b/apps/swift-ios/Core/ProjectIconOverride.swift new file mode 100644 index 000000000..a19b05a8b --- /dev/null +++ b/apps/swift-ios/Core/ProjectIconOverride.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Optional project metadata. Unknown future kinds remain decodable so the +/// client can use the automatic icon until it learns how to render them. +public struct ProjectIconOverride: Codable, Hashable, Sendable { + public let kind: String + public var name: String? + public var color: String? + public var emoji: String? + + public init(kind: String, name: String? = nil, color: String? = nil, emoji: String? = nil) { + self.kind = kind + self.name = name + self.color = color + self.emoji = emoji + } +} diff --git a/apps/swift-ios/Core/ProviderUsageLimits.swift b/apps/swift-ios/Core/ProviderUsageLimits.swift index 32a563fd3..467d6652f 100644 --- a/apps/swift-ios/Core/ProviderUsageLimits.swift +++ b/apps/swift-ios/Core/ProviderUsageLimits.swift @@ -14,7 +14,28 @@ public struct ServerProviderUsageLimits: Codable, Equatable, Sendable { public let reason: String public let message: String? } + public struct ResetCredits: Codable, Equatable, Sendable { + public let availableCount: Int + public let nextExpiresAt: String? + } + public let resetCredits: ResetCredits? public let checkedAt: String public let windows: [Window] public let unavailable: Unavailable? } + +public struct ProviderConsumeResetCreditResult: Codable, Equatable, Sendable { + public let outcome: String + public let warning: String? + + public var message: String { + if let warning { return warning } + switch outcome { + case "reset": return "Reset applied. Your windows have cleared." + case "nothingToReset": return "Nothing to reset right now." + case "noCredit": return "No reset credit left." + case "alreadyRedeemed": return "That credit was already redeemed." + default: return "The provider reported: \(outcome). Refresh to check your limits." + } + } +} diff --git a/apps/swift-ios/Core/PullRequestActionModels.swift b/apps/swift-ios/Core/PullRequestActionModels.swift new file mode 100644 index 000000000..770262772 --- /dev/null +++ b/apps/swift-ios/Core/PullRequestActionModels.swift @@ -0,0 +1,7 @@ +import Foundation + +public struct PullRequestActionRequest: Codable, Equatable, Sendable { + public let action: String + public let mergeMethod: String? + public let updateMethod: String? +} diff --git a/apps/swift-ios/Core/PullRequestCheckoutModels.swift b/apps/swift-ios/Core/PullRequestCheckoutModels.swift new file mode 100644 index 000000000..2f8153032 --- /dev/null +++ b/apps/swift-ios/Core/PullRequestCheckoutModels.swift @@ -0,0 +1,9 @@ +import Foundation + +public enum PullRequestCheckoutMode: String, Codable, Sendable { case worktree, local } + +public struct PullRequestCheckoutResult: Codable, Equatable, Sendable { + public let branch: String + public let worktreePath: String? + public let isOnPullRequestHead: Bool? +} diff --git a/apps/swift-ios/Core/PullRequestDiffModels.swift b/apps/swift-ios/Core/PullRequestDiffModels.swift new file mode 100644 index 000000000..01c65d071 --- /dev/null +++ b/apps/swift-ios/Core/PullRequestDiffModels.swift @@ -0,0 +1,26 @@ +import Foundation + +public struct PullRequestDiffResult: Codable, Equatable, Sendable { + public let patch: String + public let truncated: Bool + public let nextCursor: String? + public let omittedFileStats: [PullRequestOmittedFileStat]? +} + +public struct PullRequestOmittedFileStat: Codable, Equatable, Sendable { + public let path: String + public let additions: Int + public let deletions: Int +} + +public struct PullRequestDiffFileInput: Codable, Equatable, Sendable { + public let changeType: String + public let oldPath: String + public let newPath: String + public let commit: String? +} + +public struct PullRequestDiffFileContents: Codable, Equatable, Sendable { + public let oldContents: String + public let newContents: String +} diff --git a/apps/swift-ios/Core/PullRequestEditingModels.swift b/apps/swift-ios/Core/PullRequestEditingModels.swift new file mode 100644 index 000000000..643e07940 --- /dev/null +++ b/apps/swift-ios/Core/PullRequestEditingModels.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct NativePullRequestEditCapabilities: Codable, Equatable, Sendable { + public let changeRequest: Bool + public let comment: Bool +} + +public struct PullRequestTextUpdate: Codable, Equatable, Sendable { + public let title: String? + public let body: String? +} diff --git a/apps/swift-ios/Core/PullRequestModels.swift b/apps/swift-ios/Core/PullRequestModels.swift index f6ecf86c9..cf04d0272 100644 --- a/apps/swift-ios/Core/PullRequestModels.swift +++ b/apps/swift-ios/Core/PullRequestModels.swift @@ -2,9 +2,8 @@ import Foundation // Pull-request detail and activity, as `packages/contracts/src/pullRequest.ts` // reports them over the `pullRequests.detail` and `pullRequests.activity` WS -// RPCs. Only the fields the read-only sheet renders are modelled; the -// capability, permission and merge-method blocks the actions UI would need are -// left undeclared, which `JSONDecoder` simply skips. +// RPCs. The detail sheet also decodes host capabilities and viewer permissions +// to gate reviewed stack actions. Unused response fields are skipped. // // Dates stay ISO strings, matching how the other Core models carry // `IsoDateTime`. @@ -44,6 +43,7 @@ public enum PullRequestCommentKind: String, Codable, Sendable { } public struct PullRequestComment: Codable, Equatable, Sendable, Identifiable { + public var reactions: [PullRequestReaction]? = nil public let id: String public let kind: PullRequestCommentKind public let author: PullRequestActor? @@ -64,16 +64,16 @@ public struct PullRequestCommit: Codable, Equatable, Sendable { } public struct PullRequestThreadComment: Codable, Equatable, Sendable, Identifiable { + public var reactions: [PullRequestReaction]? = nil public let id: String public let author: PullRequestActor? - public let body: String + public var body: String public let createdAt: String public let url: String? } -/// A conversation anchored to a line of the diff. The sheet has no diff to pin -/// these to, so only what the timeline could ever show is carried; `side` stays -/// the wire string rather than an enum this client makes nothing of. +/// A host review conversation with its original diff coordinates. Outdated +/// conversations stay readable without being attached to a newer line. public struct PullRequestReviewThread: Codable, Equatable, Sendable, Identifiable { public let id: String public let path: String @@ -87,9 +87,7 @@ public struct PullRequestReviewThread: Codable, Equatable, Sendable, Identifiabl /// What the host says the thread holds, when it answered in pages. public let commentCount: Int? /// Feeds `pullRequests.threadComments`. Absent once the thread is whole, - /// so its presence is what says a page is missing. Nothing reads it yet — - /// review threads are decoded but not rendered — but dropping it here - /// would make a future reader believe short threads are whole threads. + /// so its presence is what says a page is missing. public let nextCommentsCursor: String? } @@ -106,6 +104,15 @@ public enum PullRequestMergeability: String, Codable, Sendable { } public struct PullRequestDetail: Codable, Equatable, Sendable { + public var provider: String? = nil + public var headRepositoryNameWithOwner: String? = nil + public var mergeCapabilities: [String: Bool]? = nil + public var baseComparison: String? = nil + public var behindBy: Int? = nil + public var autoMergeEnabled: Bool? = nil + public var viewer: String? = nil + public var capabilities: NativePullRequestCapabilities? = nil + public var viewerPermissions: NativePullRequestViewerPermissions? = nil public let projectId: String public let projectTitle: String public let repository: String @@ -134,6 +141,7 @@ public struct PullRequestDetail: Codable, Equatable, Sendable { /// The slower, conversation-shaped half of a change request, read separately so /// a deeply paginated review history cannot hold the summary off screen. public struct PullRequestActivity: Codable, Equatable, Sendable { + public var reactions: [PullRequestReaction]? = nil /// Optional enrichments: GitHub's conversation query carries avatars and /// completed reviewers that its basic detail does not. public let author: PullRequestActor? @@ -146,3 +154,115 @@ public struct PullRequestActivity: Codable, Equatable, Sendable { public let reviewThreads: [PullRequestReviewThread] public let commits: [PullRequestCommit] } + +public struct PullRequestStack: Codable, Equatable, Sendable { + public let id: String + public let number: Int + public let url: String + public let base: String + public let layers: [Layer] + + public struct Layer: Codable, Equatable, Sendable, Identifiable { + public var id: Int { number } + public let number: Int + public let title: String? + public let isDraft: Bool? + public let headSha: String? + public let headBranch: String + public let state: PullRequestState + } + + /// Only the reviewed open layers travel; the server revalidates each revision before writing. + public func affectedLayers(number: Int, action: String) -> [Layer] { + guard let index = layers.firstIndex(where: { $0.number == number }) else { return [] } + return (action == "merge" ? Array(layers.prefix(index + 1)) : layers).filter { $0.state != .merged } + } +} + +public struct NativePullRequestCapabilities: Codable, Equatable, Sendable { + public var reviewers: NativePullRequestReviewerCapabilities? = nil + public var reactions: Bool? = nil + public var comment: Bool? = nil + public var edit: NativePullRequestEditCapabilities? = nil + public var diff: Bool? = nil + public var review: NativePullRequestReviewCapabilities? = nil + public var labels: Bool? = nil + public let actions: [String] + public let mergeMethods: [String] + public let updateMethods: [String]? +} + +public struct NativePullRequestViewerPermissions: Codable, Equatable, Sendable { + public var requestReviewers: Bool? = nil + public var comment: Bool? = nil + public var resolve: Bool? = nil + public var verdicts: [String]? = nil + public var labels: Bool? = nil + public let stackRebase: Bool? + public let actions: [String] + public let updateMethods: [String]? +} + +public struct PullRequestLabelCandidate: Codable, Equatable, Sendable, Identifiable { + public let name: String + public let color: String? + public let description: String? + public let isApplied: Bool + public var id: String { name } +} + +public struct PullRequestLabelCandidateList: Codable, Equatable, Sendable { + public let candidates: [PullRequestLabelCandidate] + public let truncated: Bool +} + + +/// Persisted V2 link metadata. Dismissed stack members remain on the wire so discovery +/// can respect an unlink across restarts; client lists exclude them. +public struct OrchestrationV2ThreadPullRequestLink: Codable, Equatable, Sendable { + public let host: String + public let repository: String + public let number: Int + public let projectId: String? + public let url: String + public let source: String + public let linkedAt: String + public let snapshot: OrchestrationV2ThreadPullRequestSnapshot? + public let stack: OrchestrationV2ThreadPullRequestStack? + + public var isVisible: Bool { source != "stack-dismissed" } +} + +public struct OrchestrationV2ThreadPullRequestSnapshot: Codable, Equatable, Sendable { + public let state: PullRequestState + public let title: String + public let headBranch: String + public let baseBranch: String + public let isDraft: Bool + public let updatedAt: String? + public let syncedAt: String + public let closedAt: String? + public let mergedAt: String? + public let author: PullRequestActor? + public let additions: Int? + public let deletions: Int? + public let changedFiles: Int? + public let reviewDecision: String? + public let checksState: String? + public let mergeability: PullRequestMergeability? +} + +public struct OrchestrationV2ThreadPullRequestStack: Codable, Equatable, Sendable { + public let kind: String + public let id: String + public let number: Int + public let url: String + public let base: String + public let layers: [OrchestrationV2ThreadPullRequestStackLayer] +} + +public struct OrchestrationV2ThreadPullRequestStackLayer: Codable, Equatable, Sendable { + public let number: Int + public let headBranch: String + public let state: PullRequestState +} diff --git a/apps/swift-ios/Core/PullRequestReactionModels.swift b/apps/swift-ios/Core/PullRequestReactionModels.swift new file mode 100644 index 000000000..532ba8c5c --- /dev/null +++ b/apps/swift-ios/Core/PullRequestReactionModels.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct PullRequestReaction: Codable, Equatable, Sendable, Identifiable { + public let content: String + public var count: Int + public let actors: [String] + public var viewerHasReacted: Bool + public var id: String { content } +} + +public struct PullRequestReactionRequest: Codable, Equatable, Sendable { + public let subjectId: String? + public let content: String + public let reacted: Bool +} diff --git a/apps/swift-ios/Core/PullRequestReviewModels.swift b/apps/swift-ios/Core/PullRequestReviewModels.swift new file mode 100644 index 000000000..edc8ca169 --- /dev/null +++ b/apps/swift-ios/Core/PullRequestReviewModels.swift @@ -0,0 +1,33 @@ +import Foundation + +public struct NativePullRequestReviewCapabilities: Codable, Equatable, Sendable { + public let inlineComment: Bool + public let reply: Bool + public let resolve: Bool + public let verdicts: [String] +} + +public struct PullRequestReviewPosition: Codable, Equatable, Sendable { + public let kind: String + public let oldLine: Int? + public let newLine: Int? + public let side: String? +} + +public struct PullRequestReviewCommentDraft: Codable, Equatable, Sendable { + public let path: String + public let oldPath: String? + public let position: PullRequestReviewPosition + public let body: String +} + +public struct PullRequestReviewSubmission: Codable, Equatable, Sendable { + public let verdict: String + public let body: String + public let comments: [PullRequestReviewCommentDraft] +} + +public struct PullRequestThreadCommentsResult: Codable, Equatable, Sendable { + public let comments: [PullRequestThreadComment] + public let nextCursor: String? +} diff --git a/apps/swift-ios/Core/PullRequestReviewerModels.swift b/apps/swift-ios/Core/PullRequestReviewerModels.swift new file mode 100644 index 000000000..ff27c9fef --- /dev/null +++ b/apps/swift-ios/Core/PullRequestReviewerModels.swift @@ -0,0 +1,30 @@ +import Foundation + +public struct NativePullRequestReviewerCapabilities: Codable, Equatable, Sendable { + public let request: Bool + public let listCandidates: Bool +} + +public struct PullRequestReviewerCandidate: Codable, Equatable, Sendable { + public let id: String + public let kind: String + public let login: String + public let name: String? + public let avatarUrl: String? + public var isRequested: Bool + public var key: String { "\(kind):\(id)" } +} + +public struct PullRequestReviewerCandidateList: Codable, Equatable, Sendable { + public let candidates: [PullRequestReviewerCandidate] + public let truncated: Bool +} + +public struct PullRequestReviewerRequest: Codable, Equatable, Sendable { + public struct Reviewer: Codable, Equatable, Sendable { + public let id: String + public let kind: String + } + public let reviewers: [Reviewer] + public let requested: Bool +} diff --git a/apps/swift-ios/Core/PullRequestWorkspaceModels.swift b/apps/swift-ios/Core/PullRequestWorkspaceModels.swift new file mode 100644 index 000000000..d80810dff --- /dev/null +++ b/apps/swift-ios/Core/PullRequestWorkspaceModels.swift @@ -0,0 +1,85 @@ +import Foundation + +public struct PullRequestListFilters: Codable, Equatable, Sendable { + public var draft: String? + public var review: String? + public var checks: String? + public var labels: [[String]]? + public var excludedLabels: [String]? + public var author: String? +} + +public struct PullRequestListInput: Codable, Equatable, Sendable { + public var state = "open" + public var involvement = "all" + public var filters: PullRequestListFilters? + public var projectId: String? + public var projectIds: [String]? + public var host: String? + public var limit = 50 + public var cursors: [String: String]? + public var query: String? +} + +public struct PullRequestListEntry: Codable, Equatable, Sendable, Identifiable { + public let provider: String + public let host: String + public let projectId: String + public let projectTitle: String + public let repository: String + public let number: Int + public let title: String + public let url: String + public let author: PullRequestActor? + public let headBranch: String + public let baseBranch: String + public let state: PullRequestState + public let isDraft: Bool + public let mergeability: PullRequestMergeability + public var additions: Int + public var deletions: Int + public let createdAt: String + public let updatedAt: String + public let viewerReviewRequested: Bool + public let labels: [PullRequestLabel] + public let reviewDecision: String? + public let checksState: String? + /// The host is part of identity: Enterprise and public repositories may + /// have the same owner/name and change-request number. + public var id: String { "\(host.lowercased())|\(repository.lowercased())|\(number)" } +} + +public struct PullRequestProviderSummary: Codable, Equatable, Sendable { + public let host: String + public let kind: String + public let searchesOnHost: Bool + public let projectCount: Int + public let configured: Bool + public let detail: String? +} + +public struct PullRequestListProjectError: Codable, Equatable, Sendable { + public let projectId: String + public let projectTitle: String + public let message: String +} + +public struct PullRequestListResult: Codable, Equatable, Sendable { + public let viewers: [String: String] + public let providers: [PullRequestProviderSummary] + public var entries: [PullRequestListEntry] + public let errors: [PullRequestListProjectError] + public let truncated: Bool + public let nextCursors: [String: String] +} + +public struct PullRequestDiffStat: Codable, Equatable, Sendable { + public let projectId: String + public let repository: String + public let number: Int + public let additions: Int + public let deletions: Int +} +public struct PullRequestListStatsResult: Codable, Equatable, Sendable { + public let stats: [PullRequestDiffStat] +} diff --git a/apps/swift-ios/Core/ReviewCommentContext.swift b/apps/swift-ios/Core/ReviewCommentContext.swift new file mode 100644 index 000000000..924fffe08 --- /dev/null +++ b/apps/swift-ios/Core/ReviewCommentContext.swift @@ -0,0 +1,92 @@ +import Foundation + +/// The same review_comment message block rendered by web and desktop. It is +/// readable provider context, so this needs no new server capability or schema. +struct ReviewCommentContext: Equatable, Sendable { + var sectionID: String + var sectionTitle: String + var filePath: String + var startIndex: Int + var endIndex: Int + var rangeLabel: String + var text: String + var diff: String + var language = "diff" + + struct Match: Identifiable, Equatable, Sendable { + let range: NSRange + let source: String + let context: ReviewCommentContext + var id: String { "\(range.location):\(context.sectionID):\(context.filePath)" } + } + private static let block = try! NSRegularExpression(pattern: #"]*)>\s*([\s\S]*?)"#) + private static let attribute = try! NSRegularExpression(pattern: #"([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)""#) + private static let fence = try! NSRegularExpression(pattern: #"(`{3,})([^\s`]*)[^\n]*\n([\s\S]*?)\n\1"#) + private static let tag = try! NSRegularExpression(pattern: #" String { + text.replacingOccurrences(of: "&", with: "&").replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "<", with: "<").replacingOccurrences(of: ">", with: ">") + } + private static func unescaped(_ text: String) -> String { + text.replacingOccurrences(of: "<", with: "<").replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: """, with: "\"").replacingOccurrences(of: "&", with: "&") + } + var formatted: String? { + guard !filePath.isEmpty, !sectionID.isEmpty, startIndex >= 0, endIndex >= startIndex, + text.utf16.count <= 32_000, diff.utf16.count <= 32_000, + Self.tag.firstMatch(in: text + diff, range: NSRange(location: 0, length: (text + diff).utf16.count)) == nil else { return nil } + let longest = diff.split(omittingEmptySubsequences: true, whereSeparator: { $0 != "`" }).map(\.count).max() ?? 0 + let fence = String(repeating: "`", count: max(3, longest + 1)) + let language = self.language.range(of: "^[a-zA-Z0-9_+-]+$", options: .regularExpression) == nil ? "diff" : self.language + return "\n\(text.trimmingCharacters(in: .whitespacesAndNewlines))\n\(fence)\(language)\n\(diff)\n\(fence)\n" + } + + static func matches(in text: String) -> [Match] { + let source = text as NSString + return block.matches(in: text, range: NSRange(location: 0, length: source.length)).prefix(32).compactMap { match in + let rawAttributes = source.substring(with: match.range(at: 1)) as NSString + var values: [String: String] = [:] + for item in attribute.matches(in: rawAttributes as String, range: NSRange(location: 0, length: rawAttributes.length)) { + values[rawAttributes.substring(with: item.range(at: 1))] = unescaped(rawAttributes.substring(with: item.range(at: 2))) + } + func integer(_ key: String) -> Int? { + guard let value = values[key], value.range(of: "^[0-9]+$", options: .regularExpression) != nil else { return nil } + return Int(value) + } + guard let path = values["filePath"], !path.isEmpty, let section = values["sectionId"], !section.isEmpty, + let first = integer("startIndex"), let last = integer("endIndex") else { return nil } + let body = source.substring(with: match.range(at: 2)) as NSString + guard body.length <= 66_000 else { return nil } + let code = fence.matches(in: body as String, range: NSRange(location: 0, length: body.length)).last + let comment = body.substring(to: code?.range.location ?? body.length).trimmingCharacters(in: .whitespacesAndNewlines) + let context = Self(sectionID: section, sectionTitle: values["sectionTitle"] ?? "Review", filePath: path, + startIndex: min(first, last), endIndex: max(first, last), rangeLabel: values["rangeLabel"] ?? "line", + text: comment, diff: code.map { body.substring(with: $0.range(at: 3)) } ?? "", + language: code.map { body.substring(with: $0.range(at: 2)) }.flatMap { $0.isEmpty ? nil : $0 } ?? "diff") + return Match(range: match.range, source: source.substring(with: match.range), context: context) + } + } + + static func removingBlocks(from text: String) -> String { + var result = text as NSString + for match in matches(in: text).reversed() { + var range = match.range + if range.location >= 2, result.substring(with: NSRange(location: range.location - 2, length: 2)) == "\n\n" { + range.location -= 2; range.length += 2 + } + result = result.replacingCharacters(in: range, with: "") as NSString + } + return result as String + } + static func replacingPlainText(in stored: String, with text: String) -> String { + let blocks = matches(in: stored).map(\.source) + return blocks.isEmpty ? text : text + "\n\n" + blocks.joined(separator: "\n\n") + } + static func replacing(_ match: Match, in text: String, with context: Self?) -> String { + guard let current = matches(in: text).first(where: { $0.source == match.source }) else { return text } + if let context, let replacement = context.formatted { return (text as NSString).replacingCharacters(in: current.range, with: replacement) } + if context == nil { return (text as NSString).replacingCharacters(in: current.range, with: "") } + return text + } +} diff --git a/apps/swift-ios/Core/ServerConfigModels.swift b/apps/swift-ios/Core/ServerConfigModels.swift index 61ea5b0d1..01e073837 100644 --- a/apps/swift-ios/Core/ServerConfigModels.swift +++ b/apps/swift-ios/Core/ServerConfigModels.swift @@ -120,6 +120,8 @@ public struct ServerModelCapabilities: Codable, Equatable, Sendable { } public struct ServerProviderModelSnapshot: Codable, Identifiable, Equatable, Sendable { + public var aliases: [String]? = nil + public var badge: String? = nil public var id: String { slug } public let slug: String @@ -161,6 +163,7 @@ public struct ServerProviderSnapshot: Codable, Identifiable, Equatable, Sendable public let displayName: String? public let accentColor: String? public let badgeLabel: String? + public var reportsContextWindow: Bool? = nil public let showInteractionModeToggle: Bool? public let requiresNewThreadForModelChange: Bool? public let enabled: Bool @@ -245,7 +248,35 @@ public struct ProviderModelPreferencesSnapshot: Codable, Equatable, Sendable { /// New-thread preferences are server-authoritative, so every saved environment /// can resolve these differently even though they share one mobile client. +public struct UsageModelPriceOverride: Codable, Equatable, Sendable { + public let inputCostPerMillionTokens: Double + public let outputCostPerMillionTokens: Double + public let cacheReadCostPerMillionTokens: Double? + public let cacheWriteCostPerMillionTokens: Double? + + public var json: JSONValue { + var fields: [String: JSONValue] = ["inputCostPerMillionTokens": .number(inputCostPerMillionTokens), + "outputCostPerMillionTokens": .number(outputCostPerMillionTokens)] + if let cacheReadCostPerMillionTokens { fields["cacheReadCostPerMillionTokens"] = .number(cacheReadCostPerMillionTokens) } + if let cacheWriteCostPerMillionTokens { fields["cacheWriteCostPerMillionTokens"] = .number(cacheWriteCostPerMillionTokens) } + return .object(fields) + } +} + public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { + /// Opaque envelopes preserve unknown driver fields while editing one account's models. + public let providerInstances: [String: JSONValue] + public let providerDefinitions: [String: JSONValue] + public let textGenerationModelSelection: ModelSelection? + public let sourceControlWritingStyle: SourceControlWritingStyle? + public let defaultModelSelection: ModelSelection? + public let defaultProjectScripts: [ProjectScript] + public let projectScriptOverrides: [String: [ProjectScript]?] + public let defaultAutoPull: Bool + public let projectAgentBrowserAccessOverrides: [String: Bool] + public let projectAutoPullOverrides: [String: Bool] + public let environmentIcon: String? + public let usagePriceOverrides: [String: UsageModelPriceOverride]? /// The default window matching `DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS` in /// `packages/contracts`, applied when a server predates the setting. public static let defaultSidebarAutoSettleAfterDays: Double = 3 @@ -265,6 +296,7 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { public let sidebarAutoSettleAfterDays: Double? /// Whether a merged change request settles its thread on its own. A closed /// one always does; only the merge half is configurable. + public let continueThreadsAfterServerUpdate: Bool public let sidebarAutoSettleOnMerge: Bool /// Keyed by provider instance id (the default instance for a driver uses /// the driver kind, so `"hermes"`, `"codex"`, `"claudeAgent"`, …). Empty @@ -284,10 +316,23 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { public let defaultThemeSetAt: String public init( + providerInstances: [String: JSONValue] = [:], + providerDefinitions: [String: JSONValue] = [:], + textGenerationModelSelection: ModelSelection? = nil, + sourceControlWritingStyle: SourceControlWritingStyle? = nil, + defaultModelSelection: ModelSelection? = nil, + defaultProjectScripts: [ProjectScript] = [], + projectScriptOverrides: [String: [ProjectScript]?] = [:], + defaultAutoPull: Bool = false, + projectAutoPullOverrides: [String: Bool] = [:], + projectAgentBrowserAccessOverrides: [String: Bool] = [:], + environmentIcon: String? = nil, + usagePriceOverrides: [String: UsageModelPriceOverride]? = nil, defaultThreadEnvMode: ServerThreadEnvironmentMode = .local, newWorktreesStartFromOrigin: Bool = true, sidebarAutoSettleAfterDays: Double? = ServerSettingsSnapshot .defaultSidebarAutoSettleAfterDays, + continueThreadsAfterServerUpdate: Bool = false, sidebarAutoSettleOnMerge: Bool = ServerSettingsSnapshot .defaultSidebarAutoSettleOnMerge, providerModelPreferences: [String: ProviderModelPreferencesSnapshot] = [:], @@ -297,9 +342,22 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { defaultTheme: String = "", defaultThemeSetAt: String = "" ) { + self.providerInstances = providerInstances + self.providerDefinitions = providerDefinitions.isEmpty ? ["claudeAgent": .object(["autoCompactWindow": .string(claudeAutoCompactWindow)])] : providerDefinitions + self.textGenerationModelSelection = textGenerationModelSelection + self.sourceControlWritingStyle = sourceControlWritingStyle + self.defaultModelSelection = defaultModelSelection + self.defaultProjectScripts = defaultProjectScripts + self.projectScriptOverrides = projectScriptOverrides + self.defaultAutoPull = defaultAutoPull + self.projectAgentBrowserAccessOverrides = projectAgentBrowserAccessOverrides + self.projectAutoPullOverrides = projectAutoPullOverrides + self.environmentIcon = environmentIcon + self.usagePriceOverrides = usagePriceOverrides self.defaultThreadEnvMode = defaultThreadEnvMode self.newWorktreesStartFromOrigin = newWorktreesStartFromOrigin self.sidebarAutoSettleAfterDays = sidebarAutoSettleAfterDays + self.continueThreadsAfterServerUpdate = continueThreadsAfterServerUpdate self.sidebarAutoSettleOnMerge = sidebarAutoSettleOnMerge self.providerModelPreferences = providerModelPreferences self.enableAgentBrowserAccess = enableAgentBrowserAccess @@ -308,10 +366,28 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { self.defaultThemeSetAt = defaultThemeSetAt } + public func projectScriptsInheritDefaults(projectID: String, legacyScripts: [ProjectScript]) -> Bool { + if let override = projectScriptOverrides[projectID] { return override == nil } + return legacyScripts.isEmpty + } + + public func resolvedProjectScripts(projectID: String, legacyScripts: [ProjectScript]) -> [ProjectScript] { + if let override = projectScriptOverrides[projectID] { return override ?? defaultProjectScripts } + return legacyScripts.isEmpty ? defaultProjectScripts : legacyScripts + } + private enum CodingKeys: String, CodingKey { + case providerInstances + case textGenerationModelSelection, sourceControlWritingStyle + case defaultModelSelection + case defaultProjectScripts, projectScriptOverrides + case defaultAutoPull, projectAutoPullOverrides, projectAgentBrowserAccessOverrides + case environmentIcon + case usagePriceOverrides case defaultThreadEnvMode case newWorktreesStartFromOrigin case sidebarAutoSettleAfterDays + case continueThreadsAfterServerUpdate case sidebarAutoSettleOnMerge case providerModelPreferences case enableAgentBrowserAccess @@ -333,6 +409,17 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + providerInstances = try container.decodeIfPresent([String: JSONValue].self, forKey: .providerInstances) ?? [:] + textGenerationModelSelection = try container.decodeIfPresent(ModelSelection.self, forKey: .textGenerationModelSelection) + sourceControlWritingStyle = try container.decodeIfPresent(SourceControlWritingStyle.self, forKey: .sourceControlWritingStyle) + defaultModelSelection = try container.decodeIfPresent(ModelSelection.self, forKey: .defaultModelSelection) + defaultProjectScripts = try container.decodeIfPresent([ProjectScript].self, forKey: .defaultProjectScripts) ?? [] + projectScriptOverrides = try container.decodeIfPresent([String: [ProjectScript]?].self, forKey: .projectScriptOverrides) ?? [:] + defaultAutoPull = try container.decodeIfPresent(Bool.self, forKey: .defaultAutoPull) ?? false + projectAgentBrowserAccessOverrides = try container.decodeIfPresent([String: Bool].self, forKey: .projectAgentBrowserAccessOverrides) ?? [:] + projectAutoPullOverrides = try container.decodeIfPresent([String: Bool].self, forKey: .projectAutoPullOverrides) ?? [:] + environmentIcon = try container.decodeIfPresent(String.self, forKey: .environmentIcon) + usagePriceOverrides = try container.decodeIfPresent([String: UsageModelPriceOverride].self, forKey: .usagePriceOverrides) defaultThreadEnvMode = try container.decode( ServerThreadEnvironmentMode.self, forKey: .defaultThreadEnvMode @@ -344,6 +431,7 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { sidebarAutoSettleAfterDays = container.contains(.sidebarAutoSettleAfterDays) ? try container.decodeIfPresent(Double.self, forKey: .sidebarAutoSettleAfterDays) : Self.defaultSidebarAutoSettleAfterDays + continueThreadsAfterServerUpdate = try container.decodeIfPresent(Bool.self, forKey: .continueThreadsAfterServerUpdate) ?? false sidebarAutoSettleOnMerge = try container.decodeIfPresent( Bool.self, forKey: .sidebarAutoSettleOnMerge @@ -360,6 +448,8 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { ProvidersContainer.self, forKey: .providers )?.claudeAgent?.autoCompactWindow ?? "" + let rawProviders = try container.decodeIfPresent([String: JSONValue].self, forKey: .providers) ?? [:] + providerDefinitions = rawProviders.isEmpty ? ["claudeAgent": .object(["autoCompactWindow": .string(claudeAutoCompactWindow)])] : rawProviders defaultTheme = try container.decodeIfPresent(String.self, forKey: .defaultTheme) ?? "" defaultThemeSetAt = try container.decodeIfPresent( String.self, @@ -369,20 +459,31 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(providerInstances, forKey: .providerInstances) + try container.encodeIfPresent(textGenerationModelSelection, forKey: .textGenerationModelSelection) + try container.encodeIfPresent(sourceControlWritingStyle, forKey: .sourceControlWritingStyle) + try container.encode(defaultModelSelection, forKey: .defaultModelSelection) + try container.encode(defaultProjectScripts, forKey: .defaultProjectScripts) + try container.encode(projectScriptOverrides, forKey: .projectScriptOverrides) + try container.encode(defaultAutoPull, forKey: .defaultAutoPull) + try container.encode(projectAgentBrowserAccessOverrides, forKey: .projectAgentBrowserAccessOverrides) + try container.encode(projectAutoPullOverrides, forKey: .projectAutoPullOverrides) + try container.encodeIfPresent(environmentIcon, forKey: .environmentIcon) + try container.encodeIfPresent(usagePriceOverrides, forKey: .usagePriceOverrides) try container.encode(defaultThreadEnvMode, forKey: .defaultThreadEnvMode) try container.encode(newWorktreesStartFromOrigin, forKey: .newWorktreesStartFromOrigin) // Encoded as explicit null so "never" survives a round trip instead of // decoding back as the absent-key default. try container.encode(sidebarAutoSettleAfterDays, forKey: .sidebarAutoSettleAfterDays) + try container.encode(continueThreadsAfterServerUpdate, forKey: .continueThreadsAfterServerUpdate) try container.encode(sidebarAutoSettleOnMerge, forKey: .sidebarAutoSettleOnMerge) try container.encode(providerModelPreferences, forKey: .providerModelPreferences) try container.encode(enableAgentBrowserAccess, forKey: .enableAgentBrowserAccess) // Round-tripped under the same nested key the server sends, so an // encoded snapshot decodes back to itself. - try container.encode( - ProvidersContainer(claudeAgent: .init(autoCompactWindow: claudeAutoCompactWindow)), - forKey: .providers - ) + if providerDefinitions.isEmpty { + try container.encode(ProvidersContainer(claudeAgent: .init(autoCompactWindow: claudeAutoCompactWindow)), forKey: .providers) + } else { try container.encode(providerDefinitions, forKey: .providers) } try container.encode(defaultTheme, forKey: .defaultTheme) try container.encode(defaultThemeSetAt, forKey: .defaultThemeSetAt) } @@ -396,6 +497,27 @@ public struct ServerSettingsSnapshot: Codable, Equatable, Sendable { /// whatever another client changed in between. Add a field here — and one line /// to `json` — as each new server setting reaches this client. public struct ServerSettingsPatchInput: Equatable, Sendable { + /// Outer nil omits the preference; a present nil disables inactivity settlement. + public var sidebarAutoSettleAfterDays: Double?? + public var continueThreadsAfterServerUpdate: Bool? + public var sidebarAutoSettleOnMerge: Bool? + /// Outer nil omits the field; a present nil restores automatic selection. + public var textGenerationModelSelection: ModelSelection? + public var sourceControlWritingStyle: SourceControlWritingStylePatch? + public var newWorktreesStartFromOrigin: Bool? + public var defaultModelSelection: ModelSelection?? + public var defaultThreadEnvMode: ServerThreadEnvironmentMode? + public var defaultProjectScripts: [ProjectScript]? + public var projectScriptOverrides: [String: [ProjectScript]?]? + public var defaultAutoPull: Bool? + public var projectAgentBrowserAccessOverrides: [String: Bool?]? + public var projectAutoPullOverrides: [String: Bool?]? + public var providerInstances: [String: JSONValue]? + public var customModelsByDriver: [String: [JSONValue]]? + /// A present nil entry resets one model. Omitted models are unchanged. + public var environmentIcon: String?? + public var usagePriceOverrides: [String: UsageModelPriceOverride?]? + public var enableHermes: Bool? public var enableAgentBrowserAccess: Bool? /// Claude's auto-compaction threshold, as the string the server validates: /// an integer from 100000 to 1000000, or empty to fall back to Claude's own @@ -404,10 +526,44 @@ public struct ServerSettingsPatchInput: Equatable, Sendable { public var hiddenModelsByProvider: [String: [String]]? public init( + sidebarAutoSettleAfterDays: Double?? = nil, + continueThreadsAfterServerUpdate: Bool? = nil, + sidebarAutoSettleOnMerge: Bool? = nil, + textGenerationModelSelection: ModelSelection? = nil, + sourceControlWritingStyle: SourceControlWritingStylePatch? = nil, + newWorktreesStartFromOrigin: Bool? = nil, + defaultModelSelection: ModelSelection?? = nil, + defaultThreadEnvMode: ServerThreadEnvironmentMode? = nil, + defaultProjectScripts: [ProjectScript]? = nil, + projectScriptOverrides: [String: [ProjectScript]?]? = nil, + defaultAutoPull: Bool? = nil, + projectAutoPullOverrides: [String: Bool?]? = nil, + projectAgentBrowserAccessOverrides: [String: Bool?]? = nil, + providerInstances: [String: JSONValue]? = nil, + customModelsByDriver: [String: [JSONValue]]? = nil, + environmentIcon: String?? = nil, + usagePriceOverrides: [String: UsageModelPriceOverride?]? = nil, enableAgentBrowserAccess: Bool? = nil, claudeAutoCompactWindow: String? = nil, hiddenModelsByProvider: [String: [String]]? = nil ) { + self.sidebarAutoSettleAfterDays = sidebarAutoSettleAfterDays + self.continueThreadsAfterServerUpdate = continueThreadsAfterServerUpdate + self.sidebarAutoSettleOnMerge = sidebarAutoSettleOnMerge + self.textGenerationModelSelection = textGenerationModelSelection + self.sourceControlWritingStyle = sourceControlWritingStyle + self.newWorktreesStartFromOrigin = newWorktreesStartFromOrigin + self.defaultModelSelection = defaultModelSelection + self.defaultThreadEnvMode = defaultThreadEnvMode + self.defaultProjectScripts = defaultProjectScripts + self.projectScriptOverrides = projectScriptOverrides + self.defaultAutoPull = defaultAutoPull + self.projectAgentBrowserAccessOverrides = projectAgentBrowserAccessOverrides + self.projectAutoPullOverrides = projectAutoPullOverrides + self.providerInstances = providerInstances + self.customModelsByDriver = customModelsByDriver + self.environmentIcon = environmentIcon + self.usagePriceOverrides = usagePriceOverrides self.enableAgentBrowserAccess = enableAgentBrowserAccess self.claudeAutoCompactWindow = claudeAutoCompactWindow self.hiddenModelsByProvider = hiddenModelsByProvider @@ -415,6 +571,27 @@ public struct ServerSettingsPatchInput: Equatable, Sendable { public var json: JSONValue { var fields: [String: JSONValue] = [:] + if let sidebarAutoSettleAfterDays { fields["sidebarAutoSettleAfterDays"] = sidebarAutoSettleAfterDays.map(JSONValue.number) ?? .null } + if let continueThreadsAfterServerUpdate { fields["continueThreadsAfterServerUpdate"] = .bool(continueThreadsAfterServerUpdate) } + if let sidebarAutoSettleOnMerge { fields["sidebarAutoSettleOnMerge"] = .bool(sidebarAutoSettleOnMerge) } + if let textGenerationModelSelection { fields["textGenerationModelSelection"] = textGenerationModelSelection.settingsJSON } + if let sourceControlWritingStyle { fields["sourceControlWritingStyle"] = sourceControlWritingStyle.json } + if let newWorktreesStartFromOrigin { fields["newWorktreesStartFromOrigin"] = .bool(newWorktreesStartFromOrigin) } + if let defaultModelSelection { + if let selection = defaultModelSelection { + var value: [String: JSONValue] = ["instanceId": .string(selection.instanceId), "model": .string(selection.model)] + if let options = selection.options { value["options"] = .array(options.map { .object(["id": .string($0.id), "value": $0.value]) }) } + fields["defaultModelSelection"] = .object(value) + } else { fields["defaultModelSelection"] = .null } + } + if let defaultThreadEnvMode { fields["defaultThreadEnvMode"] = .string(defaultThreadEnvMode.rawValue) } + if let defaultProjectScripts { fields["defaultProjectScripts"] = .array(defaultProjectScripts.map(\.json)) } + if let projectScriptOverrides { fields["projectScriptOverrides"] = .object(projectScriptOverrides.mapValues { scripts in scripts.map { .array($0.map(\.json)) } ?? .null }) } + if let defaultAutoPull { fields["defaultAutoPull"] = .bool(defaultAutoPull) } + if let projectAgentBrowserAccessOverrides { fields["projectAgentBrowserAccessOverrides"] = .object(projectAgentBrowserAccessOverrides.mapValues { $0.map(JSONValue.bool) ?? .null }) } + if let projectAutoPullOverrides { fields["projectAutoPullOverrides"] = .object(projectAutoPullOverrides.mapValues { $0.map(JSONValue.bool) ?? .null }) } + if let environmentIcon { fields["environmentIcon"] = environmentIcon.map(JSONValue.string) ?? .null } + if let enableHermes { fields["enableHermes"] = .bool(enableHermes) } if let enableAgentBrowserAccess { fields["enableAgentBrowserAccess"] = .bool(enableAgentBrowserAccess) } @@ -427,6 +604,19 @@ public struct ServerSettingsPatchInput: Equatable, Sendable { ]), ]) } + if let providerInstances { fields["providerInstances"] = .object(providerInstances) } + if let customModelsByDriver { + var providers: [String: JSONValue] = [:] + if case let .object(existing) = fields["providers"] { providers = existing } + for (driver, models) in customModelsByDriver { + var config: [String: JSONValue] = [:] + if case let .object(existing) = providers[driver] { config = existing } + config["customModels"] = .array(models) + providers[driver] = .object(config) + } + fields["providers"] = .object(providers) + } + if let usagePriceOverrides { fields["usagePriceOverrides"] = .object(usagePriceOverrides.mapValues { $0?.json ?? .null }) } if let hiddenModelsByProvider { fields["providerModelPreferences"] = .object(hiddenModelsByProvider.mapValues { .object(["hiddenModels": .array($0.map(JSONValue.string))]) @@ -444,8 +634,10 @@ public struct ServerSettingsPatchInput: Equatable, Sendable { /// Narrow decode view of the much larger `ServerConfig` RPC result. public struct ServerConfigSnapshot: Codable, Equatable, Sendable { + public var environment: EnvironmentDescriptor? = nil + public var cwd: String? = nil public let providers: [ServerProviderSnapshot] - public let settings: ServerSettingsSnapshot? + public var settings: ServerSettingsSnapshot? /// The server's dedicated non-project workspace for projectless T3 Work /// conversations. Matching it against a project's `workspaceRoot` is what /// stops a Work launch attaching to an arbitrary project, so its absence @@ -475,13 +667,21 @@ public struct ServerConfigSnapshot: Codable, Equatable, Sendable { self.shellResumeCompletionMarker = shellResumeCompletionMarker } + public func replacingSettings(_ settings: ServerSettingsSnapshot?) -> Self { + var copy = self + copy.settings = settings + return copy + } + private enum CodingKeys: String, CodingKey { - case providers, settings, t3WorkDirectory + case providers, settings, t3WorkDirectory, cwd, environment case threadSnapshotWindow, threadResumeCompletionMarker, shellResumeCompletionMarker } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) + cwd = try container.decodeIfPresent(String.self, forKey: .cwd) + environment = try container.decodeIfPresent(EnvironmentDescriptor.self, forKey: .environment) providers = try container.decode( [LossyDecodableElement].self, forKey: .providers @@ -504,6 +704,8 @@ public struct ServerConfigSnapshot: Codable, Equatable, Sendable { public func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(cwd, forKey: .cwd) + try container.encodeIfPresent(environment, forKey: .environment) try container.encode(providers, forKey: .providers) try container.encodeIfPresent(settings, forKey: .settings) try container.encodeIfPresent(t3WorkDirectory, forKey: .t3WorkDirectory) diff --git a/apps/swift-ios/Core/SharedServerSettings.swift b/apps/swift-ios/Core/SharedServerSettings.swift new file mode 100644 index 000000000..aa16e9637 --- /dev/null +++ b/apps/swift-ios/Core/SharedServerSettings.swift @@ -0,0 +1,104 @@ +import Foundation + +public struct SourceControlWritingStyle: Codable, Equatable, Sendable { + public var mode: String + public var customInstructions: String + public var followChangeRequestTemplates: Bool + public init(mode: String = "repo_conventions", customInstructions: String = "", followChangeRequestTemplates: Bool = true) { + self.mode = mode; self.customInstructions = customInstructions + self.followChangeRequestTemplates = followChangeRequestTemplates + } + var json: JSONValue { .object([ + "mode": .string(mode), "customInstructions": .string(customInstructions), + "followChangeRequestTemplates": .bool(followChangeRequestTemplates), + ]) } +} + +public struct SourceControlWritingStylePatch: Equatable, Sendable { + public var mode: String? + public var customInstructions: String? + public var followChangeRequestTemplates: Bool? + public init(mode: String? = nil, customInstructions: String? = nil, followChangeRequestTemplates: Bool? = nil) { + self.mode = mode; self.customInstructions = customInstructions + self.followChangeRequestTemplates = followChangeRequestTemplates + } + init(_ value: SourceControlWritingStyle) { + self.init(mode: value.mode, customInstructions: value.customInstructions, followChangeRequestTemplates: value.followChangeRequestTemplates) + } + var json: JSONValue { + var fields: [String: JSONValue] = [:] + if let mode { fields["mode"] = .string(mode) } + if let customInstructions { fields["customInstructions"] = .string(customInstructions) } + if let followChangeRequestTemplates { fields["followChangeRequestTemplates"] = .bool(followChangeRequestTemplates) } + return .object(fields) + } +} + +extension ModelSelection { + var settingsJSON: JSONValue { + var fields: [String: JSONValue] = ["instanceId": .string(instanceId), "model": .string(model)] + if let options { fields["options"] = .array(options.map { .object(["id": .string($0.id), "value": $0.value]) }) } + return .object(fields) + } +} + +/// Matches client-runtime's shared preference whitelist. Account, project and machine config stay local. +enum SharedServerSettings { + static func pick(_ settings: ServerSettingsSnapshot, restartSupported: Bool) -> ServerSettingsPatchInput { + .init(sidebarAutoSettleAfterDays: .some(settings.sidebarAutoSettleAfterDays), + continueThreadsAfterServerUpdate: restartSupported ? settings.continueThreadsAfterServerUpdate : nil, + sidebarAutoSettleOnMerge: settings.sidebarAutoSettleOnMerge, + textGenerationModelSelection: settings.textGenerationModelSelection, + sourceControlWritingStyle: settings.sourceControlWritingStyle.map { SourceControlWritingStylePatch($0) }, + newWorktreesStartFromOrigin: settings.newWorktreesStartFromOrigin) + } + static func split(_ patch: ServerSettingsPatchInput) -> (shared: ServerSettingsPatchInput, local: ServerSettingsPatchInput) { + let shared = ServerSettingsPatchInput( + sidebarAutoSettleAfterDays: patch.sidebarAutoSettleAfterDays, + continueThreadsAfterServerUpdate: patch.continueThreadsAfterServerUpdate, + sidebarAutoSettleOnMerge: patch.sidebarAutoSettleOnMerge, + textGenerationModelSelection: patch.textGenerationModelSelection, + sourceControlWritingStyle: patch.sourceControlWritingStyle, + newWorktreesStartFromOrigin: patch.newWorktreesStartFromOrigin) + var local = patch + local.sidebarAutoSettleAfterDays = nil; local.continueThreadsAfterServerUpdate = nil + local.sidebarAutoSettleOnMerge = nil; local.textGenerationModelSelection = nil + local.sourceControlWritingStyle = nil; local.newWorktreesStartFromOrigin = nil + return (shared, local) + } + static func filter(_ patch: ServerSettingsPatchInput, restartSupported: Bool, + target: ServerSettingsSnapshot, source: ServerSettingsSnapshot?, targetIsSource: Bool = false) -> ServerSettingsPatchInput { + var result = patch + if !restartSupported { result.continueThreadsAfterServerUpdate = nil } + if !targetIsSource, let selection = patch.textGenerationModelSelection { + func provider(_ settings: ServerSettingsSnapshot?, _ instance: String) -> (driver: String, enabled: Bool)? { + guard let settings else { return nil } + if case let .object(config)? = settings.providerInstances[instance] { + let driver: String + if case let .string(value)? = config["driver"] { driver = value } else { driver = instance } + return (driver, config["enabled"] != .bool(false)) + } + if case let .object(config)? = settings.providerDefinitions[instance], config["enabled"] == .bool(true) { + return (instance, true) + } + return nil + } + let from = provider(source, selection.instanceId) + let to = provider(target, selection.instanceId) + if to?.enabled != true || from?.driver != to?.driver { result.textGenerationModelSelection = nil } + } + return result + } + static func differs(source: ServerSettingsSnapshot, sourceRestart: Bool, + target: ServerSettingsSnapshot, targetRestart: Bool) -> Bool { + let expected = filter(pick(source, restartSupported: sourceRestart), restartSupported: targetRestart, target: target, source: source) + var actual = pick(target, restartSupported: sourceRestart && targetRestart) + if expected.textGenerationModelSelection == nil { actual.textGenerationModelSelection = nil } + if expected.sourceControlWritingStyle == nil { actual.sourceControlWritingStyle = nil } + return expected != actual + } + static func isEmpty(_ patch: ServerSettingsPatchInput) -> Bool { + if case let .object(fields) = patch.json { return fields.isEmpty } + return true + } +} diff --git a/apps/swift-ios/Core/ShellSnapshotMerge.swift b/apps/swift-ios/Core/ShellSnapshotMerge.swift index 71e9d9ae5..afc169778 100644 --- a/apps/swift-ios/Core/ShellSnapshotMerge.swift +++ b/apps/swift-ios/Core/ShellSnapshotMerge.swift @@ -83,6 +83,7 @@ extension OrchestrationProject { repositoryIdentity: identity, defaultModelSelection: defaultModelSelection, faviconPath: faviconPath, + projectIcon: projectIcon, scripts: scripts, createdAt: createdAt, updatedAt: updatedAt, diff --git a/apps/swift-ios/Core/T3Client.swift b/apps/swift-ios/Core/T3Client.swift index d964d89e5..db22a3676 100644 --- a/apps/swift-ios/Core/T3Client.swift +++ b/apps/swift-ios/Core/T3Client.swift @@ -115,6 +115,11 @@ public actor T3Client { ) } + /// Samples whole-host CPU and available memory for new-task routing. + public func hostResources() async throws -> HostResourcesSnapshot { + try await rpc.request(RPCMethod.serverGetHostResources.rawValue, payload: .object([:]), as: HostResourcesSnapshot.self) + } + /// Writes one or more server-authoritative settings. /// /// The patch is deliberately sparse: `ServerSettingsPatch` is optional @@ -227,6 +232,13 @@ public actor T3Client { ) } + public func preparePullRequestCheckout(cwd: String, reference: String, mode: PullRequestCheckoutMode, threadID: String) async throws -> PullRequestCheckoutResult { + try await rpc.request("git.preparePullRequestThread", payload: .object([ + "cwd": .string(cwd), "reference": .string(reference), + "mode": .string(mode.rawValue), "threadId": .string(threadID), + ]), as: PullRequestCheckoutResult.self) + } + /// Sends an orchestration V2 command. /// /// WebSocket only. This fork's `EnvironmentOrchestrationHttpApi` serves the @@ -287,6 +299,14 @@ public actor T3Client { attachments: [UploadChatAttachment] ) async throws -> [JSONValue] { guard !attachments.isEmpty else { return [] } + let capabilities = environment.descriptor?.capabilities + if capabilities?.attachmentUploads == true, let fileLimit = capabilities?.fileAttachments?.maxUploadBytes { + return try await persistSignedAttachments(attachments, fileLimit: fileLimit) + } + for attachment in attachments where attachment.type != .image && attachment.sizeBytes > 20 * 1024 * 1024 { + throw ImageAttachmentError.tooLarge(actualBytes: attachment.sizeBytes, maximumBytes: 20 * 1024 * 1024) + } + let result = try await rpc.request( RPCMethod.assetsPersistChatAttachments.rawValue, payload: .object([ @@ -304,6 +324,49 @@ public actor T3Client { return persisted } + private func persistSignedAttachments(_ attachments: [UploadChatAttachment], fileLimit: Int) async throws -> [JSONValue] { + var minted: [String] = [] + do { + var persisted: [JSONValue] = [] + // Bound memory and transfers: a phone sends one file at a time. + for attachment in attachments { + try Task.checkCancellation() + let limit = attachment.type == .image ? ComposerAttachments.maximumImageBytes : min(fileLimit, ComposerAttachments.maximumFileBytes) + guard attachment.sizeBytes <= limit else { + throw ImageAttachmentError.tooLarge(actualBytes: attachment.sizeBytes, maximumBytes: limit) + } + guard let comma = attachment.dataUrl.firstIndex(of: ","), + let bytes = Data(base64Encoded: String(attachment.dataUrl[attachment.dataUrl.index(after: comma)...])), + bytes.count == attachment.sizeBytes else { + throw RPCError.protocolViolation("The attachment data is invalid.") + } + let result = try await rpc.request( + "attachments.createUploadUrl", + payload: .object([ + "type": .string(attachment.type == .image ? "image" : "file"), + "name": .string(attachment.name), "mimeType": .string(attachment.mimeType), + "sizeBytes": .number(Double(attachment.sizeBytes)), + ]), as: AttachmentUploadURLResult.self + ) + minted.append(result.attachmentId) + try await api.uploadAttachment(for: environment, relativeURL: result.relativeUrl, data: bytes, mimeType: attachment.mimeType) + persisted.append(.object([ + "type": .string(attachment.type.rawValue), "id": .string(result.attachmentId), + "name": .string(attachment.name), "mimeType": .string(attachment.mimeType), + "sizeBytes": .number(Double(attachment.sizeBytes)), + ])) + } + return persisted + } catch { + // Only pending uploads from this attempt are eligible for deletion. + // The server also expires abandoned uploads if the connection failed. + for id in minted { + _ = try? await rpc.request("attachments.delete", payload: .object(["attachmentId": .string(id)]), as: JSONValue.self) + } + throw error + } + } + /// Hands the thread to the provider as feedback and returns the identifier /// it filed the report under, which the reader is shown so they can quote /// it. Needs a live provider session: the upload is the running agent's own @@ -436,6 +499,11 @@ public actor T3Client { ) } + public func setProjectIcon(projectID: String, icon: ProjectIconOverride?) async throws { + let payload = try OrchestrationCommands.setProjectIcon(projectID: projectID, icon: icon) + let _: JSONValue = try await rpc.request(RPCMethod.projectsMutate.rawValue, payload: payload, as: JSONValue.self) + } + @discardableResult public func archive(threadID: String, archived: Bool) async throws -> DispatchResult { try await dispatch(OrchestrationCommands.archive(threadID: threadID, archived: archived)) @@ -726,6 +794,18 @@ public actor T3Client { // (`packages/contracts/src/pullRequest.ts`): the server project id, the // repository's display name, and the change request's number on the host. + public func listPullRequests(_ input: PullRequestListInput) async throws -> PullRequestListResult { + try await rpc.request("pullRequests.list", payload: try JSONValue.encode(input), as: PullRequestListResult.self) + } + + public func pullRequestStats(_ entries: [PullRequestListEntry]) async throws -> PullRequestListStatsResult { + try await rpc.request("pullRequests.listStats", payload: .object([ + "refs": .array(entries.map { .object([ + "projectId": .string($0.projectId), "repository": .string($0.repository), "number": .number(Double($0.number)), + ]) }), + ]), as: PullRequestListStatsResult.self) + } + public func pullRequestDetail( projectID: String, repository: String, @@ -742,6 +822,139 @@ public actor T3Client { ) } + public func pullRequestThreadComments(projectID: String, repository: String, number: Int, threadID: String, cursor: String) async throws -> PullRequestThreadCommentsResult { + try await rpc.request("pullRequests.threadComments", payload: .object([ + "projectId": .string(projectID), "repository": .string(repository), "number": .number(Double(number)), + "threadId": .string(threadID), "cursor": .string(cursor), + ]), as: PullRequestThreadCommentsResult.self) + } + + public func replyToPullRequestThread(projectID: String, repository: String, number: Int, threadID: String, body: String) async throws { + let _: JSONValue = try await rpc.request("pullRequests.replyToThread", payload: .object([ + "projectId": .string(projectID), "repository": .string(repository), "number": .number(Double(number)), + "threadId": .string(threadID), "body": .string(body), + ]), as: JSONValue.self) + } + + public func setPullRequestThreadResolution(projectID: String, repository: String, number: Int, threadID: String, resolved: Bool) async throws { + let _: JSONValue = try await rpc.request("pullRequests.setThreadResolution", payload: .object([ + "projectId": .string(projectID), "repository": .string(repository), "number": .number(Double(number)), + "threadId": .string(threadID), "resolved": .bool(resolved), + ]), as: JSONValue.self) + } + + public func invalidatePullRequest(projectID: String, repository: String, number: Int) async throws { + let _: JSONValue = try await rpc.request("pullRequests.invalidate", payload: .object(["reference": .object([ + "projectId": .string(projectID), "repository": .string(repository), "number": .number(Double(number)), + ])]), as: JSONValue.self) + } + + public func invalidatePullRequestListings() async throws { + let _: JSONValue = try await rpc.request("pullRequests.invalidate", payload: .object([:]), as: JSONValue.self) + } + + public func pullRequestReviewerCandidates(projectID: String, repository: String, number: Int) async throws -> PullRequestReviewerCandidateList { + try await rpc.request("pullRequests.reviewerCandidates", payload: .object([ + "projectId": .string(projectID), "repository": .string(repository), "number": .number(Double(number)), + ]), as: PullRequestReviewerCandidateList.self) + } + + public func requestPullRequestReviewers(projectID: String, repository: String, number: Int, request: PullRequestReviewerRequest) async throws { + let _: JSONValue = try await rpc.request("pullRequests.requestReviewers", payload: .object([ + "projectId": .string(projectID), "repository": .string(repository), "number": .number(Double(number)), + "reviewers": .array(request.reviewers.map { .object(["id": .string($0.id), "kind": .string($0.kind)]) }), "requested": .bool(request.requested), + ]), as: JSONValue.self) + } + + public func setPullRequestReaction(projectID: String, repository: String, number: Int, request: PullRequestReactionRequest) async throws { + var payload: [String: JSONValue] = ["projectId": .string(projectID), "repository": .string(repository), "number": .number(Double(number)), "content": .string(request.content), "reacted": .bool(request.reacted)] + if let subjectId = request.subjectId { payload["subjectId"] = .string(subjectId) } + let _: JSONValue = try await rpc.request("pullRequests.setReaction", payload: .object(payload), as: JSONValue.self) + } + + public func updatePullRequestText(projectID: String, repository: String, number: Int, update: PullRequestTextUpdate) async throws { + var payload: [String: JSONValue] = ["projectId": .string(projectID), "repository": .string(repository), "number": .number(Double(number))] + if let title = update.title { payload["title"] = .string(title) } + if let body = update.body { payload["body"] = .string(body) } + let _: JSONValue = try await rpc.request("pullRequests.update", payload: .object(payload), as: JSONValue.self) + } + + public func updatePullRequestComment(projectID: String, repository: String, number: Int, commentID: String, kind: String, body: String) async throws { + let _: JSONValue = try await rpc.request("pullRequests.updateComment", payload: .object([ + "projectId": .string(projectID), "repository": .string(repository), "number": .number(Double(number)), + "commentId": .string(commentID), "kind": .string(kind), "body": .string(body), + ]), as: JSONValue.self) + } + + public func commentOnPullRequest(projectID: String, repository: String, number: Int, body: String) async throws { + let _: JSONValue = try await rpc.request("pullRequests.comment", payload: .object([ + "projectId": .string(projectID), "repository": .string(repository), "number": .number(Double(number)), "body": .string(body), + ]), as: JSONValue.self) + } + + public func runPullRequestAction(projectID: String, repository: String, number: Int, request: PullRequestActionRequest) async throws { + var payload: [String: JSONValue] = ["projectId": .string(projectID), "repository": .string(repository), "number": .number(Double(number)), "action": .string(request.action)] + if let method = request.mergeMethod { payload["mergeMethod"] = .string(method) } + if let method = request.updateMethod { payload["updateMethod"] = .string(method) } + let _: JSONValue = try await rpc.request("pullRequests.runAction", payload: .object(payload), as: JSONValue.self) + } + + public func submitPullRequestReview(projectID: String, repository: String, number: Int, submission: PullRequestReviewSubmission) async throws { + let encoded = try JSONDecoder().decode(JSONValue.self, from: JSONEncoder().encode(submission)) + guard case var .object(payload) = encoded else { throw CocoaError(.coderInvalidValue) } + payload["projectId"] = .string(projectID); payload["repository"] = .string(repository); payload["number"] = .number(Double(number)) + let _: JSONValue = try await rpc.request("pullRequests.submitReview", payload: .object(payload), as: JSONValue.self) + } + + public func pullRequestDiffFileContents(projectID: String, repository: String, number: Int, input: PullRequestDiffFileInput) async throws -> PullRequestDiffFileContents { + var payload: [String: JSONValue] = ["projectId": .string(projectID), "repository": .string(repository), "number": .number(Double(number)), + "changeType": .string(input.changeType), "oldPath": .string(input.oldPath), "newPath": .string(input.newPath)] + if let commit = input.commit { payload["commit"] = .string(commit) } + return try await rpc.request("pullRequests.diffFileContents", payload: .object(payload), as: PullRequestDiffFileContents.self) + } + + public func pullRequestDiff(projectID: String, repository: String, number: Int, cursor: String?, commit: String?) async throws -> PullRequestDiffResult { + var payload: [String: JSONValue] = ["projectId": .string(projectID), "repository": .string(repository), "number": .number(Double(number))] + if let cursor { payload["cursor"] = .string(cursor) } + if let commit { payload["commit"] = .string(commit) } + return try await rpc.request("pullRequests.diff", payload: .object(payload), as: PullRequestDiffResult.self) + } + + public func pullRequestLabelCandidates(projectID: String, repository: String, number: Int) async throws -> PullRequestLabelCandidateList { + try await rpc.request("pullRequests.labelCandidates", payload: .object([ + "projectId": .string(projectID), "repository": .string(repository), "number": .number(Double(number)), + ]), as: PullRequestLabelCandidateList.self) + } + + public func setPullRequestLabels(projectID: String, repository: String, number: Int, labels: [String], applied: Bool) async throws { + let _: JSONValue = try await rpc.request("pullRequests.setLabels", payload: .object([ + "projectId": .string(projectID), "repository": .string(repository), "number": .number(Double(number)), + "labels": .array(labels.map { .string($0) }), "applied": .bool(applied), + ]), as: JSONValue.self) + } + + public func pullRequestStack(projectID: String, repository: String, number: Int) async throws -> PullRequestStack? { + try await rpc.request("pullRequests.stack", payload: .object([ + "projectId": .string(projectID), "repository": .string(repository), "number": .number(Double(number)), + ]), as: Optional.self) + } + + public func runPullRequestStackAction(projectID: String, repository: String, number: Int, + stack: PullRequestStack, action: String, mergeMethod: String?) async throws { + let heads = stack.affectedLayers(number: number, action: action) + guard !heads.isEmpty, heads.allSatisfy({ $0.headSha != nil }) else { + throw RPCError.remote("Refresh the stack before performing this action.") + } + var fields: [String: JSONValue] = [ + "projectId": .string(projectID), "repository": .string(repository), "number": .number(Double(number)), + "stackNumber": .number(Double(stack.number)), "action": .string(action), + "expectedStackHeads": .array(heads.map { .object(["number": .number(Double($0.number)), "headSha": .string($0.headSha!)]) }), + ] + if let mergeMethod { fields["mergeMethod"] = .string(mergeMethod) } + if action == "update-branch" { fields["updateMethod"] = .string("rebase") } + let _: JSONValue = try await rpc.request("pullRequests.runAction", payload: .object(fields), as: JSONValue.self) + } + public func pullRequestActivity( projectID: String, repository: String, @@ -894,6 +1107,16 @@ public actor T3Client { ) } + public func scanAgentSessions() async throws -> AgentSessionScanResult { + try await rpc.request(RPCMethod.agentSessionsScan.rawValue, payload: .object([:]), as: AgentSessionScanResult.self) + } + + public func importAgentSessions(projectID: String, expectedWorkspaceRoot: String) async throws -> AgentSessionImportResult { + try await rpc.request(RPCMethod.agentSessionsImport.rawValue, payload: .object([ + "projectId": .string(projectID), "expectedWorkspaceRoot": .string(expectedWorkspaceRoot), + ]), as: AgentSessionImportResult.self) + } + public func browseFilesystem( partialPath: String, cwd: String? = nil @@ -907,24 +1130,31 @@ public actor T3Client { ) } - /// Scans provider transcript directories on this server and returns the - /// usage summary for an inclusive `[sinceDay, untilDay]` window of - /// `YYYY-MM-DD` days bucketed in `timeZone` (IANA — an offset would be - /// wrong across a DST boundary). - public func getUsageSummary( - sinceDay: String, - untilDay: String, - timeZone: String - ) async throws -> UsageSummary { - try await rpc.request( - RPCMethod.serverGetUsageSummary.rawValue, - payload: .object([ - "sinceDay": .string(sinceDay), - "untilDay": .string(untilDay), - "timeZone": .string(timeZone), - ]), - as: UsageSummary.self - ) + /// Refreshes model pricing, retaining the server's cached rates on network failure. + public func consumeResetCredit(instanceID: String) async throws -> ProviderConsumeResetCreditResult { + try await rpc.request(RPCMethod.providerConsumeResetCredit.rawValue, + payload: .object(["instanceId": .string(instanceID)]), as: ProviderConsumeResetCreditResult.self) + } + + public func refreshUsageRates() async throws -> UsagePricing { + try await rpc.request(RPCMethod.serverRefreshUsageRates.rawValue, + payload: .object([:]), as: UsagePricing.self) + } + + /// Scans provider transcripts, preserving an exact hourly window when requested. + public func getUsageSummary(input: UsageSummaryInput) async throws -> UsageSummary { + var payload: [String: JSONValue] = ["sinceDay": .string(input.sinceDay), + "untilDay": .string(input.untilDay), "timeZone": .string(input.timeZone), + "resolution": .string(input.resolution)] + if let since = input.sinceTime { payload["sinceTime"] = .string(since) } + if let until = input.untilTime { payload["untilTime"] = .string(until) } + return try await rpc.request(RPCMethod.serverGetUsageSummary.rawValue, + payload: .object(payload), as: UsageSummary.self) + } + + public func getUsageSummary(sinceDay: String, untilDay: String, timeZone: String) async throws -> UsageSummary { + try await getUsageSummary(input: UsageSummaryInput(sinceDay: sinceDay, untilDay: untilDay, + timeZone: timeZone, resolution: "day", sinceTime: nil, untilTime: nil)) } /// Issues a short-lived authenticated URL for a persisted attachment, @@ -951,7 +1181,8 @@ public actor T3Client { } return ResolvedAssetURL( url: url, - expiresAt: Date(timeIntervalSince1970: result.expiresAt / 1_000) + expiresAt: Date(timeIntervalSince1970: result.expiresAt / 1_000), + imageDimensions: result.imageDimensions ) } @@ -1073,6 +1304,7 @@ public actor T3Client { public func runGitAction( cwd: String, + threadID: String? = nil, action: GitStackedAction, commitMessage: String? = nil, featureBranch: Bool? = nil, @@ -1084,6 +1316,7 @@ public actor T3Client { "cwd": .string(cwd), "action": .string(action.rawValue), ] + if let threadID { payload["threadId"] = .string(threadID) } if let commitMessage { payload["commitMessage"] = .string(commitMessage) } if let featureBranch { payload["featureBranch"] = .bool(featureBranch) } if let filePaths { payload["filePaths"] = .array(filePaths.map(JSONValue.string)) } @@ -1266,9 +1499,10 @@ public actor T3Client { worktreePath: String? = nil, columns: Int? = nil, rows: Int? = nil, - environmentVariables: [String: String]? = nil + environmentVariables: [String: String]? = nil, + providerInstanceID: String? = nil ) async throws -> TerminalSessionSnapshot { - let payload = try terminalPayload( + var payload = try terminalPayloadObject( threadID: threadID, terminalID: terminalID, cwd: cwd, @@ -1277,9 +1511,10 @@ public actor T3Client { rows: rows, environmentVariables: environmentVariables ) + if let providerInstanceID { payload["providerInstanceId"] = .string(providerInstanceID) } return try await rpc.request( RPCMethod.terminalOpen.rawValue, - payload: payload, + payload: .object(payload), as: TerminalSessionSnapshot.self ) } @@ -1746,6 +1981,7 @@ public actor EnvironmentRuntime { public enum RPCMethod: String, Sendable { case serverProbe = "server.probe" case serverGetConfig = "server.getConfig" + case serverGetHostResources = "server.getHostResources" case serverUpdateSettings = "server.updateSettings" case dispatchCommand = "orchestration.dispatchCommand" case launchThread = "orchestration.launchThread" @@ -1761,8 +1997,12 @@ public enum RPCMethod: String, Sendable { case projectsReadFile = "projects.readFile" case projectsWriteFile = "projects.writeFile" case filesystemBrowse = "filesystem.browse" + case agentSessionsScan = "agentSessions.scan" + case agentSessionsImport = "agentSessions.import" case assetsCreateURL = "assets.createUrl" case serverGetUsageSummary = "server.getUsageSummary" + case providerConsumeResetCredit = "provider.consumeResetCredit" + case serverRefreshUsageRates = "server.refreshUsageRates" case assetsPersistChatAttachments = "assets.persistChatAttachments" case providerUploadFeedback = "provider.uploadFeedback" case subscribeServerConfig @@ -2148,6 +2388,14 @@ public enum OrchestrationCommands { ]) } + public static func setProjectIcon(projectID: String, icon: ProjectIconOverride?, commandID: String = UUID().uuidString) throws -> JSONValue { + .object([ + "type": .string("project.update"), "commandId": .string(commandID), + "projectId": .string(projectID), "faviconPath": .null, + "projectIcon": try icon.map { try JSONValue.encode($0) } ?? .null, + ]) + } + /// A `ProjectMutation`, not an orchestration command: it is dispatched /// through `projects.mutate` and carries no `createdAt`. public static func createProject( diff --git a/apps/swift-ios/Core/ThreadWorkLogHistory.swift b/apps/swift-ios/Core/ThreadWorkLogHistory.swift new file mode 100644 index 000000000..b1e81cdf6 --- /dev/null +++ b/apps/swift-ios/Core/ThreadWorkLogHistory.swift @@ -0,0 +1,90 @@ +import Foundation +import Observation + +public struct ThreadWorkLogExpansion: Equatable, Sendable { + private var openedIDs: Set = [] + private var closedIDs: Set = [] + + public init() {} + + public func isExpanded(_ id: String, expandedByDefault: Bool) -> Bool { + if closedIDs.contains(id) { return false } + return expandedByDefault || openedIDs.contains(id) + } + + /// Records what the reader asked for, against what they can currently see: + /// toggling a row the preference opened has to register as a close, not as + /// the absence of an open. + public mutating func toggle(_ id: String, expandedByDefault: Bool) { + if isExpanded(id, expandedByDefault: expandedByDefault) { + openedIDs.remove(id) + closedIDs.insert(id) + } else { + closedIDs.remove(id) + openedIDs.insert(id) + } + } +} + + +/// Each transcript coordinator owns one cache, independent of recycled cells. +@MainActor @Observable +final class ThreadWorkLogHistory { + var groupExpanded: Bool? + var rowExpansion = ThreadWorkLogExpansion() + var anchorID: String? + var offsetWithinAnchor: Double = 0 + + func rememberViewport(rows: [ThreadWorkLogRowFrame], contentOffset: Double) { + guard let anchor = ThreadWorkLogViewportAnchor.capture(rows: rows, contentOffset: contentOffset) else { return } + if anchorID != anchor.id || abs(offsetWithinAnchor - anchor.offset) > 0.5 { + anchorID = anchor.id + offsetWithinAnchor = anchor.offset + } + } + + func clearViewport() { anchorID = nil; offsetWithinAnchor = 0 } +} + +@MainActor +final class ThreadWorkLogHistoryStore { + private var entries: [String: ThreadWorkLogHistory] = [:] + private var insertionOrder: [String] = [] + private let limit: Int + + init(limit: Int = 1000) { self.limit = max(1, limit) } + + func entry(_ key: String) -> ThreadWorkLogHistory { + if let existing = entries[key] { return existing } + let value = ThreadWorkLogHistory() + entries[key] = value + insertionOrder.append(key) + while insertionOrder.count > limit { entries.removeValue(forKey: insertionOrder.removeFirst()) } + return value + } +} + +struct ThreadWorkLogRowFrame: Equatable, Sendable { + let id: String + let minY: Double + let height: Double +} + +struct ThreadWorkLogViewportAnchor: Equatable, Sendable { + let id: String + let offset: Double + + static func capture(rows: [ThreadWorkLogRowFrame], contentOffset: Double) -> Self? { + guard contentOffset.isFinite else { return nil } + let frames = rows.filter { $0.minY.isFinite && $0.height.isFinite && $0.height > 0 } + let row = frames.filter { $0.minY <= contentOffset && $0.minY + $0.height > contentOffset }.max { $0.minY < $1.minY } + ?? frames.filter { $0.minY >= contentOffset }.min { $0.minY < $1.minY } + guard let row else { return nil } + return Self(id: row.id, offset: max(0, contentOffset - row.minY)) + } + + func restoredOffset(in row: ThreadWorkLogRowFrame) -> Double? { + guard row.id == id, row.minY.isFinite, row.height.isFinite, row.height > 0, offset.isFinite else { return nil } + return max(0, row.minY + min(max(0, offset), max(0, row.height - 1))) + } +} diff --git a/apps/swift-ios/Core/ToolActivity.swift b/apps/swift-ios/Core/ToolActivity.swift new file mode 100644 index 000000000..8408eed17 --- /dev/null +++ b/apps/swift-ios/Core/ToolActivity.swift @@ -0,0 +1,51 @@ +import Foundation + +public struct ToolActivityNativeAppReference: Codable, Equatable, Sendable { + public let _tag: String + public let appId: String? + public let displayName: String? +} + +public struct ToolActivityIcon: Codable, Equatable, Sendable { + public let _tag: String + public let pageUrl: String? + public let faviconUrl: String? + public let faviconUrlDark: String? + public let app: ToolActivityNativeAppReference? + public let logoUrl: String? + public let logoUrlDark: String? + + public func imageURL(dark: Bool) -> URL? { + func image(_ raw: String?) -> URL? { + guard let raw else { return nil } + if ToolIconImageData.inline(raw) != nil { return URL(string: raw) } + guard raw.count <= 4096, let url = URL(string: raw), + ["http", "https"].contains(url.scheme?.lowercased() ?? ""), + url.host != nil else { return nil } + return url + } + switch _tag { + case "themed-logo": return image(dark ? logoUrlDark ?? logoUrl : logoUrl) + case "website": + if dark, let explicit = image(faviconUrlDark) { return explicit } + if dark, let page = image(pageUrl), ["http", "https"].contains(page.scheme?.lowercased() ?? ""), page.host?.lowercased() == "github.com" { + return URL(string: "https://github.githubassets.com/favicons/favicon-dark.svg") + } + if let explicit = image(faviconUrl) { return explicit } + if let page = image(pageUrl), ["http", "https"].contains(page.scheme?.lowercased() ?? ""), page.host?.lowercased() == "github.com" { + return URL(string: "https://github.githubassets.com/favicons/favicon.svg") + } + guard let page = image(pageUrl), ["http", "https"].contains(page.scheme?.lowercased() ?? ""), var parts = URLComponents(url: page, resolvingAgainstBaseURL: false) else { return nil } + parts.path = "/favicon.ico"; parts.query = nil; parts.fragment = nil; parts.user = nil; parts.password = nil + return parts.url + default: return nil + } + } +} + +public struct ToolActivitySource: Codable, Equatable, Sendable { + public let key: String + public let name: String + public let kind: String + public let icon: ToolActivityIcon? +} diff --git a/apps/swift-ios/Core/ToolIconImageData.swift b/apps/swift-ios/Core/ToolIconImageData.swift new file mode 100644 index 000000000..a3d99b68a --- /dev/null +++ b/apps/swift-ios/Core/ToolIconImageData.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Inline logos share the same byte limit as fetched tool icons. +enum ToolIconImageData { + static let maximumBytes = 256 * 1024 + static let mimeTypes: Set = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/x-icon", "image/vnd.microsoft.icon", "image/svg+xml"] + + static func inline(_ raw: String) -> Data? { + guard raw.utf8.count <= maximumBytes * 3, + raw.lowercased().hasPrefix("data:"), let comma = raw.firstIndex(of: ",") else { return nil } + let fields = raw[raw.index(raw.startIndex, offsetBy: 5).. Bool { + guard data.count <= ToolIconImageData.maximumBytes, + let text = String(data: data, encoding: .utf8), + !text.localizedCaseInsensitiveContains(" 0 && !delegate.hasCycle() + } + + private final class Delegate: NSObject, XMLParserDelegate { + var valid = true + var nodes = 0 + var stack: [String?] = [] + var edges: [String: Set] = [:] + var ids: Set = [] + + func parser(_ parser: XMLParser, didStartElement name: String, namespaceURI: String?, qualifiedName: String?, attributes: [String: String]) { + nodes += 1 + if nodes > 512 || stack.count >= 32 || (stack.isEmpty && name != "svg") { + valid = false; parser.abortParsing(); return + } + let id = attributes["id"] + if let id, !ids.insert(id).inserted { valid = false; parser.abortParsing(); return } + if let id { + for parent in stack.compactMap({ $0 }) { edges[parent, default: []].insert(id) } + } + stack.append(id) + if let reference = attributes["href"] ?? attributes["xlink:href"], reference.hasPrefix("#") { + for parent in stack.compactMap({ $0 }) { edges[parent, default: []].insert(String(reference.dropFirst())) } + } + } + func parser(_ parser: XMLParser, didEndElement: String, namespaceURI: String?, qualifiedName: String?) { + if !stack.isEmpty { stack.removeLast() } + } + func hasCycle() -> Bool { + var visiting: Set = [] + var finished: Set = [] + func visit(_ id: String) -> Bool { + if visiting.contains(id) { return true } + if finished.contains(id) { return false } + visiting.insert(id) + for next in edges[id] ?? [] { if visit(next) { return true } } + visiting.remove(id); finished.insert(id) + return false + } + return edges.keys.contains(where: visit) + } + } +} diff --git a/apps/swift-ios/Core/Usage.swift b/apps/swift-ios/Core/Usage.swift index 4328f0834..c6ace1429 100644 --- a/apps/swift-ios/Core/Usage.swift +++ b/apps/swift-ios/Core/Usage.swift @@ -1,6 +1,6 @@ import Foundation -// Ported from packages/contracts/src/usage.ts (contract version 3): the +// Ported from packages/contracts/src/usage.ts (contract version 5): the // `server.getUsageSummary` shapes the web and Expo usage dashboards read. // Providers travel as plain strings rather than a closed enum so a server that // learns a new provider kind degrades to an unstyled series instead of a @@ -9,7 +9,8 @@ import Foundation /// The usage contract this client understands. A summary reporting a different /// version is excluded from merging (its semantics may have changed) and the /// UI reports partial coverage instead of silently mixing incompatibles. -public let usageContractVersion = 3 +public let usageContractVersion = 5 +public let usageMergeCompatibleSince = 4 /// Token totals for one bucket. `reasoningTokens` is a subset of /// `outputTokens` (Codex reports it separately, Claude folds thinking into @@ -46,6 +47,7 @@ public struct UsageTokenTotals: Codable, Equatable, Sendable { /// money spent — subscription plans bill separately. public struct UsageBucket: Codable, Equatable, Sendable { public let day: String + public let hourStart: String? public let provider: String public let model: String public let totals: UsageTokenTotals @@ -58,6 +60,7 @@ public struct UsageBucket: Codable, Equatable, Sendable { public init( day: String, + hourStart: String? = nil, provider: String, model: String, totals: UsageTokenTotals, @@ -69,6 +72,7 @@ public struct UsageBucket: Codable, Equatable, Sendable { sessions: Int ) { self.day = day + self.hourStart = hourStart self.provider = provider self.model = model self.totals = totals @@ -184,3 +188,30 @@ public struct UsageSummary: Codable, Equatable, Sendable { self.scanDurationMs = scanDurationMs } } + +/// An inclusive local-day window or an exact rolling hourly interval. +public struct UsageSummaryInput: Sendable, Equatable { + public let sinceDay: String + public let untilDay: String + public let timeZone: String + public let resolution: String + public let sinceTime: String? + public let untilTime: String? + + public static func window(days: Int, now: Date = Date(), timeZone: TimeZone = .current) -> Self { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.timeZone = timeZone + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + let hourly = days == 1 + let since = hourly ? now.addingTimeInterval(-24 * 60 * 60) + : calendar.date(byAdding: .day, value: -(max(1, days) - 1), to: calendar.startOfDay(for: now))! + return Self(sinceDay: formatter.string(from: since), untilDay: formatter.string(from: now), + timeZone: timeZone.identifier, resolution: hourly ? "hour" : "day", + sinceTime: hourly ? since.ISO8601Format() : nil, + untilTime: hourly ? now.ISO8601Format() : nil) + } +} diff --git a/apps/swift-ios/Core/WorkspaceModels.swift b/apps/swift-ios/Core/WorkspaceModels.swift index 46a4734ee..1274e9876 100644 --- a/apps/swift-ios/Core/WorkspaceModels.swift +++ b/apps/swift-ios/Core/WorkspaceModels.swift @@ -389,6 +389,7 @@ public struct VCSChangeRequest: Codable, Equatable, Sendable { public let baseRef: String public let headRef: String public let state: String + public var isDraft: Bool? = nil /// Last provider-side activity (ISO). For a merged or closed request this /// bounds when it reached that state, which is what lets the settle rules /// tell a request that terminated during a thread's life from one that was diff --git a/apps/swift-ios/Core/WorkspaceMutationRevision.swift b/apps/swift-ios/Core/WorkspaceMutationRevision.swift new file mode 100644 index 000000000..41c6635a3 --- /dev/null +++ b/apps/swift-ios/Core/WorkspaceMutationRevision.swift @@ -0,0 +1,44 @@ +import Foundation + +struct WorkspaceMutationItem: Sendable { + let sourceThreadID: String + let itemID: String + let type: String + let status: String + let updatedAt: String +} + +enum WorkspaceMutationRevision { + /// Completed shell commands count too: they can change paths the provider did not report. + static func latest(_ items: S) -> String? where S.Element == WorkspaceMutationItem { + var latestAt = -Double.infinity + var revision: String? + for item in items { + guard ["file_change", "command_execution"].contains(item.type), + ["completed", "failed", "cancelled", "interrupted"].contains(item.status), + let date = (try? Date.ISO8601FormatStyle(includingFractionalSeconds: true).parse(item.updatedAt)) + ?? (try? Date.ISO8601FormatStyle().parse(item.updatedAt)) else { continue } + let at = date.timeIntervalSince1970 + if at >= latestAt { + latestAt = at + revision = "\(item.sourceThreadID):\(item.itemID):\(at)" + } + } + return revision + } + + /// A changed signed asset must not reuse the previous image/document response. + static func assetURL(_ url: URL, revision: String?) -> URL { + guard let revision, var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url } + // Preserve the byte spelling of signed query values (notably %2B); rebuilding + // queryItems can turn an escaped plus into a form-decoded space on the server. + var items = (components.percentEncodedQuery ?? "").split(separator: "&").map(String.init).filter { + $0.split(separator: "=", maxSplits: 1).first?.removingPercentEncoding != "workspace-revision" + } + let allowed = CharacterSet.urlQueryAllowed.subtracting(CharacterSet(charactersIn: "+&=?#")) + guard let value = revision.addingPercentEncoding(withAllowedCharacters: allowed) else { return url } + items.append("workspace-revision=" + value) + components.percentEncodedQuery = items.joined(separator: "&") + return components.url ?? url + } +} diff --git a/apps/swift-ios/DesignSystem/T3Theme.swift b/apps/swift-ios/DesignSystem/T3Theme.swift index 162292383..4ebd80d91 100644 --- a/apps/swift-ios/DesignSystem/T3Theme.swift +++ b/apps/swift-ios/DesignSystem/T3Theme.swift @@ -38,6 +38,14 @@ enum T3Colors { static var primaryActionForeground: Color { Color(uiColor: palette.primaryActionForeground) } static var accent: Color { Color(uiColor: palette.accent) } static var danger: Color { Color(uiColor: palette.danger) } + static var diffAddition: Color { + T3ThemeStore.shared.diffColorScheme == .blueOrange + ? color(light: rgb(0x2563EB), dark: rgb(0x60A5FA)) : success + } + static var diffDeletion: Color { + T3ThemeStore.shared.diffColorScheme == .blueOrange + ? color(light: rgb(0xEA580C), dark: rgb(0xFB923C)) : danger + } // Fixed roles. These have no palette counterpart on the Expo client either // — it renders them from constant Tailwind classes — so a palette that diff --git a/apps/swift-ios/DesignSystem/T3ThemeStore.swift b/apps/swift-ios/DesignSystem/T3ThemeStore.swift index 3870d6715..524ac4bc9 100644 --- a/apps/swift-ios/DesignSystem/T3ThemeStore.swift +++ b/apps/swift-ios/DesignSystem/T3ThemeStore.swift @@ -147,6 +147,7 @@ struct T3ResolvedColors { @Observable final class T3ThemeStore { static let shared = T3ThemeStore() + var diffColorScheme: FeatureDiffColorScheme = .redGreen private(set) var lightPaletteID: String = T3Palette.defaultID private(set) var darkPaletteID: String = T3Palette.defaultID diff --git a/apps/swift-ios/Extensions/Shared/ShareInbox.swift b/apps/swift-ios/Extensions/Shared/ShareInbox.swift index 16216fed0..117aaec7b 100644 --- a/apps/swift-ios/Extensions/Shared/ShareInbox.swift +++ b/apps/swift-ios/Extensions/Shared/ShareInbox.swift @@ -222,7 +222,7 @@ enum T3IncomingShareStore { // ExtensionContractTests asserts they agree, the same way it pins the app // group identifier that also lives in three places. static let maximumImageBytes = 10 * 1_024 * 1_024 - static let maximumFileBytes = 20 * 1_024 * 1_024 + static let maximumFileBytes = 50 * 1_024 * 1_024 static func maximumBytes(isImage: Bool) -> Int { isImage ? maximumImageBytes : maximumFileBytes diff --git a/apps/swift-ios/Features/Chat/AssistantCitation.swift b/apps/swift-ios/Features/Chat/AssistantCitation.swift new file mode 100644 index 000000000..fcdcf9d8a --- /dev/null +++ b/apps/swift-ios/Features/Chat/AssistantCitation.swift @@ -0,0 +1,136 @@ +import Foundation + +/// Origin-independent quote links shared with the web composer. Offsets count +/// UTF-16 units in normalized rendered text, never Markdown source bytes. +struct AssistantCitation: Codable, Equatable, Identifiable, Sendable { + var id: String { href } + let version: Int + let environmentId: String + let threadId: String + let messageId: String + let text: String + var comment: String? + let start: Int + let end: Int + let prefix: String + let suffix: String + + var isValid: Bool { + version == 1 && [environmentId, threadId, messageId].allSatisfy { !$0.isEmpty && $0.utf16.count <= 512 } + && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && text.utf16.count <= 8_000 + && (comment?.utf16.count ?? 0) <= 8_000 && start >= 0 && end > start && end <= 9_007_199_254_740_991 + && prefix.utf16.count <= 32 && suffix.utf16.count <= 32 + } + + private static func encode(_ text: String, form: Bool) -> String { + text.utf8.map { byte in + if (65...90).contains(byte) || (97...122).contains(byte) || (48...57).contains(byte) || [45, 46, 95].contains(byte) + || (form ? byte == 42 : byte == 126) { return String(UnicodeScalar(byte)) } + if form && byte == 32 { return "+" } + return String(format: "%%%02X", byte) + }.joined() + } + + var href: String { + let path = [environmentId, threadId, messageId].map { Self.encode($0, form: false) }.joined(separator: "/") + var fields = [("text", text), ("start", String(start)), ("end", String(end)), ("prefix", prefix), ("suffix", suffix)] + if let comment { fields.append(("comment", comment)) } + return "t3-citation://v1/\(path)?" + fields.map { "\($0.0)=\(Self.encode($0.1, form: true))" }.joined(separator: "&") + } + var marker: String { "[Assistant quote](\(href))" } + + static func parse(_ href: String) -> Self? { + guard href.hasPrefix("t3-citation://v1/"), href.utf16.count <= 160_000, + href.range(of: "%[^0-9A-Fa-f]|%[0-9A-Fa-f][^0-9A-Fa-f]|%[0-9A-Fa-f]?$", options: .regularExpression) == nil, + let url = URLComponents(string: href), url.scheme == "t3-citation", url.host == "v1", + url.user == nil, url.password == nil, url.port == nil, url.fragment == nil, + let query = url.percentEncodedQuery else { return nil } + let parts = url.percentEncodedPath.dropFirst().split(separator: "/", omittingEmptySubsequences: false) + guard parts.count == 3 else { return nil } + var fields: [String: String] = [:] + for part in query.split(separator: "&", omittingEmptySubsequences: false) { + let pair = part.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + guard pair.count == 2, let key = String(pair[0]).replacingOccurrences(of: "+", with: " ").removingPercentEncoding, + let value = String(pair[1]).replacingOccurrences(of: "+", with: " ").removingPercentEncoding, + fields[key] == nil else { return nil } + fields[key] = value + } + guard Set(fields.keys).subtracting(["text", "start", "end", "prefix", "suffix", "comment"]).isEmpty, + let environment = String(parts[0]).removingPercentEncoding, + let thread = String(parts[1]).removingPercentEncoding, + let message = String(parts[2]).removingPercentEncoding, + let text = fields["text"], let prefix = fields["prefix"], let suffix = fields["suffix"], + let startText = fields["start"], let endText = fields["end"], + startText.range(of: "^[0-9]{1,16}$", options: .regularExpression) != nil, + endText.range(of: "^[0-9]{1,16}$", options: .regularExpression) != nil, + let start = Int(startText), let end = Int(endText) else { return nil } + let citation = Self(version: 1, environmentId: environment, threadId: thread, messageId: message, + text: text, comment: fields["comment"], start: start, end: end, prefix: prefix, suffix: suffix) + return citation.isValid ? citation : nil + } + + struct Match: Identifiable { + var id: Int { range.location } + let range: NSRange + let source: String + let citation: AssistantCitation + } + private static let links = try! NSRegularExpression(pattern: #"\[Assistant quote\]\((t3-citation://v1/[^\s)]{1,160000})\)"#) + static func matches(in text: String) -> [Match] { + let source = text as NSString + return links.matches(in: text, range: NSRange(location: 0, length: source.length)).compactMap { match in + guard let citation = parse(source.substring(with: match.range(at: 1))) else { return nil } + return Match(range: match.range, source: source.substring(with: match.range), citation: citation) + } + } + static func plainText(_ text: String) -> String { + var result = text as NSString + for match in matches(in: text).reversed() { + let comment = match.citation.comment.map { "\nComment: " + $0 } ?? "" + result = result.replacingCharacters(in: match.range, with: match.citation.text + comment) as NSString + } + return result as String + } + static func removingMarkers(from text: String) -> String { + var result = text as NSString + for match in matches(in: text).reversed() { result = result.replacingCharacters(in: match.range, with: "") as NSString } + return result as String + } + static func replacingPlainText(in stored: String, with text: String) -> String { + let markers = matches(in: stored).map(\.source) + guard !markers.isEmpty else { return text } + return text + markers.joined() + } + static func replacing(_ match: Match, in text: String, with citation: AssistantCitation?) -> String { + // Re-read the current draft: offsets from an open editor may have moved. + guard let current = matches(in: text).first(where: { $0.source == match.source }) else { return text } + return (text as NSString).replacingCharacters(in: current.range, with: citation?.marker ?? "") + } + + static func capture(text: String, range: NSRange, environmentId: String, threadId: String, messageId: String) -> Self? { + let raw = text as NSString + guard range.location >= 0, range.length > 0, range.location <= raw.length, range.length <= raw.length - range.location, + Range(range, in: text) != nil else { return nil } + func splitsSurrogate(_ offset: Int) -> Bool { + offset > 0 && offset < raw.length && (0xD800...0xDBFF).contains(raw.character(at: offset - 1)) + && (0xDC00...0xDFFF).contains(raw.character(at: offset)) + } + guard !splitsSurrogate(range.location), !splitsSurrogate(NSMaxRange(range)) else { return nil } + func normalize(_ value: String) -> String { value.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) } + let normalized = normalize(text) as NSString + var start = (normalize(raw.substring(to: range.location)) as NSString).length + if range.location > 0, + raw.substring(with: NSRange(location: range.location - 1, length: 1)).range(of: #"\s"#, options: .regularExpression) != nil, + raw.substring(with: NSRange(location: range.location, length: 1)).range(of: #"\s"#, options: .regularExpression) != nil { start -= 1 } + let end = (normalize(raw.substring(to: NSMaxRange(range))) as NSString).length + var prefixStart = max(0, start - 32) + var suffixEnd = min(normalized.length, end + 32) + if prefixStart > 0 && prefixStart < normalized.length && (0xDC00...0xDFFF).contains(normalized.character(at: prefixStart)) { prefixStart += 1 } + if suffixEnd > 0 && suffixEnd < normalized.length && (0xDC00...0xDFFF).contains(normalized.character(at: suffixEnd)) { suffixEnd -= 1 } + let citation = Self(version: 1, environmentId: environmentId, threadId: threadId, messageId: messageId, + text: raw.substring(with: range), start: start, end: end, + prefix: normalized.substring(with: NSRange(location: prefixStart, length: start - prefixStart)), + suffix: normalized.substring(with: NSRange(location: end, length: suffixEnd - end))) + return citation.isValid ? citation : nil + } +} diff --git a/apps/swift-ios/Features/Chat/AssistantCitationHighlight.swift b/apps/swift-ios/Features/Chat/AssistantCitationHighlight.swift new file mode 100644 index 000000000..0dd97fe7f --- /dev/null +++ b/apps/swift-ios/Features/Chat/AssistantCitationHighlight.swift @@ -0,0 +1,87 @@ +import Observation +import SwiftUI + +@MainActor @Observable +final class AssistantCitationHighlight { + var citation: AssistantCitation? + @ObservationIgnored private var clearTask: Task? + func show(_ citation: AssistantCitation) { + clearTask?.cancel() + self.citation = citation + clearTask = Task { [weak self] in + do { try await Task.sleep(for: .seconds(3)) } catch { return } + self?.citation = nil + } + } +} +private struct AssistantCitationHighlightKey: EnvironmentKey { + static let defaultValue: AssistantCitationHighlight? = nil +} +extension EnvironmentValues { + var assistantCitationHighlight: AssistantCitationHighlight? { + get { self[AssistantCitationHighlightKey.self] } + set { self[AssistantCitationHighlightKey.self] = newValue } + } +} + +@MainActor +enum MarkdownCitationHighlight { + static func mark(_ text: AttributedString, range: NSRange) -> AttributedString { + let plain = String(text.characters) + guard let stringRange = Range(range, in: plain), + let lower = AttributedString.Index(stringRange.lowerBound, within: text), + let upper = AttributedString.Index(stringRange.upperBound, within: text) else { return text } + var result = text + result[lower.. [MarkdownRenderedBlock] { + var offset = 0 + func localRange(_ text: String) -> NSRange? { + let count = text.utf16.count + defer { offset += count } + let start = max(offset, range.location), end = min(offset + count, NSMaxRange(range)) + return end > start ? NSRange(location: start - offset, length: end - start) : nil + } + func inline(_ value: MarkdownRenderedInline) -> MarkdownRenderedInline { + guard let local = localRange(String(value.attributedText.characters)) else { return value } + return MarkdownRenderedInline(attributedText: mark(value.attributedText, range: local), style: value.style) + } + func items(_ values: [MarkdownRenderedListItem]) -> [MarkdownRenderedListItem] { + values.enumerated().map { index, value in + if index > 0 { offset += 1 } + return MarkdownRenderedListItem(task: value.task, blocks: walk(value.blocks)) + } + } + func cells(_ values: [MarkdownRenderedInline]) -> [MarkdownRenderedInline] { + values.enumerated().map { index, value in + if index > 0 { offset += 1 } + return inline(value) + } + } + func walk(_ values: [MarkdownRenderedBlock]) -> [MarkdownRenderedBlock] { + values.enumerated().map { index, block in + if index > 0 { offset += 1 } + switch block { + case .paragraph(let value): return .paragraph(inline(value)) + case .heading(let level, let value): return .heading(level: level, inline: inline(value)) + case .unorderedList(let values): return .unorderedList(items(values)) + case .orderedList(let start, let values): return .orderedList(start: start, items: items(values)) + case .blockquote(let values): return .blockquote(walk(values)) + case .githubAlert(let kind, let values): return .githubAlert(kind: kind, blocks: walk(values)) + case .table(let table): + let header = cells(table.header) + let rows = table.rows.map { row in offset += 1; return cells(row) } + return .table(MarkdownRenderedTable(header: header, alignments: table.alignments, rows: rows, columnWidths: table.columnWidths)) + case .codeBlock(let language, let code, _): + return .codeBlock(language: language, code: code, citationRange: localRange(code)) + case .image, .htmlEmbed, .artifactTemplate, .thematicBreak: return block + } + } + } + return walk(blocks) + } +} diff --git a/apps/swift-ios/Features/Chat/AssistantCitationTextRange.swift b/apps/swift-ios/Features/Chat/AssistantCitationTextRange.swift new file mode 100644 index 000000000..e68107daa --- /dev/null +++ b/apps/swift-ios/Features/Chat/AssistantCitationTextRange.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Resolves normalized cross-client citation offsets back to native UTF-16 text. +/// Ambiguous repeats are left unmarked, even when a stale offset happens to fit. +enum AssistantCitationTextRange { + static func resolve(in source: String, quote: String, start: Int, end: Int, prefix: String, suffix: String) -> NSRange? { + let stream = normalized(source) + let text = stream.text as NSString + let wanted = normalized(quote).text as NSString + guard wanted.length > 0, (wanted as String).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { return nil } + let before = normalized(prefix).text as NSString, after = normalized(suffix).text as NSString + func matchesContext(_ range: NSRange) -> Bool { + let prefixStart = max(0, range.location - before.length) + let suffixEnd = min(text.length, NSMaxRange(range) + after.length) + return text.substring(with: NSRange(location: prefixStart, length: range.location - prefixStart)) == before as String + && text.substring(with: NSRange(location: NSMaxRange(range), length: suffixEnd - NSMaxRange(range))) == after as String + } + var match: NSRange? + if start >= 0, end >= start, end <= text.length, end - start == wanted.length { + let range = NSRange(location: start, length: end - start) + if text.substring(with: range) == wanted as String, matchesContext(range) { match = range } + } + var only: NSRange? + var count = 0 + var offset = 0 + while offset <= text.length - wanted.length { + let range = text.range(of: wanted as String, options: .literal, range: NSRange(location: offset, length: text.length - offset)) + if range.location == NSNotFound { break } + count += 1; only = range + if matchesContext(range) { + if let match, match.location != range.location { return nil } + match = range + } + offset = range.location + 1 + } + guard let selected = match ?? (count == 1 ? only : nil), NSMaxRange(selected) < stream.boundaries.count else { return nil } + let raw = NSRange(location: stream.boundaries[selected.location], length: stream.boundaries[NSMaxRange(selected)] - stream.boundaries[selected.location]) + return Range(raw, in: source) == nil ? nil : raw + } + + private static func normalized(_ source: String) -> (text: String, boundaries: [Int]) { + let raw = source as NSString + let whitespace = try! NSRegularExpression(pattern: #"\s+"#) + let matches = whitespace.matches(in: source, range: NSRange(location: 0, length: raw.length)) + var parts: [String] = [] + var boundaries = [0] + var offset = 0 + for match in matches { + let gap = match.range.location - offset + if gap > 0 { + parts.append(raw.substring(with: NSRange(location: offset, length: gap))) + boundaries.append(contentsOf: (offset + 1)...match.range.location) + } + parts.append(" ") + offset = NSMaxRange(match.range) + boundaries.append(offset) + } + if offset < raw.length { + parts.append(raw.substring(from: offset)) + boundaries.append(contentsOf: (offset + 1)...raw.length) + } + return (parts.joined(), boundaries) + } +} diff --git a/apps/swift-ios/Features/Chat/AssistantCitationViews.swift b/apps/swift-ios/Features/Chat/AssistantCitationViews.swift new file mode 100644 index 000000000..5a753f7e4 --- /dev/null +++ b/apps/swift-ios/Features/Chat/AssistantCitationViews.swift @@ -0,0 +1,235 @@ +import SwiftUI +import UIKit + +struct AssistantCitationContext { + let environmentId: String + let threadId: String + let onCite: (AssistantCitation) -> Void +} +private struct AssistantCitationContextKey: EnvironmentKey { + static let defaultValue: AssistantCitationContext? = nil +} +extension EnvironmentValues { + var assistantCitationContext: AssistantCitationContext? { + get { self[AssistantCitationContextKey.self] } + set { self[AssistantCitationContextKey.self] = newValue } + } +} + +struct AssistantCitationChips: View { + @Binding var text: String + @State private var editing: AssistantCitation.Match? + @SwiftUI.Environment(\.openURL) private var openURL + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(AssistantCitation.matches(in: text)) { match in + HStack(spacing: 0) { + Button { if let url = URL(string: match.citation.href) { openURL(url) } } label: { + Label(match.citation.comment?.isEmpty == false ? match.citation.comment! : match.citation.text, systemImage: "quote.bubble") + .lineLimit(1).frame(maxWidth: 180).padding(.leading, 12).padding(.trailing, 4) + }.accessibilityLabel("View quoted response: \(match.citation.text)") + Button { editing = match } label: { Image(systemName: "pencil").frame(width: 44, height: 44) } + .accessibilityLabel("Edit quote comment") + Button { text = AssistantCitation.replacing(match, in: text, with: nil) } label: { + Image(systemName: "xmark").frame(width: 44, height: 44) + }.accessibilityLabel("Remove quote") + }.font(T3Typography.supporting).buttonStyle(.plain) + .foregroundStyle(T3Colors.accent).background(T3Colors.subtleStrong, in: RoundedRectangle(cornerRadius: 12)) + } + } + } + .sheet(item: $editing) { match in + NavigationStack { AssistantCitationCommentSheet(citation: match.citation) { citation in + text = AssistantCitation.replacing(match, in: text, with: citation) + } } + } + } +} + +struct AssistantCitationCommentSheet: View { + let citation: AssistantCitation + let onSave: (AssistantCitation) -> Void + @State private var comment: String + @SwiftUI.Environment(\.dismiss) private var dismiss + init(citation: AssistantCitation, onSave: @escaping (AssistantCitation) -> Void) { + self.citation = citation + self.onSave = onSave + _comment = State(initialValue: citation.comment ?? "") + } + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Label("Assistant quote", systemImage: "quote.bubble").font(T3Typography.supportingStrong) + Text(citation.text).textSelection(.enabled).font(T3Typography.threadBody) + TextField("Add an optional comment", text: $comment, axis: .vertical) + .lineLimit(3...8).textFieldStyle(.roundedBorder) + if comment.utf16.count > 8_000 { Text("Comments can contain up to 8,000 characters.").foregroundStyle(T3Colors.danger) } + }.padding(18) + }.background(T3Colors.background).navigationTitle("Quote comment").navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + var updated = citation + let trimmed = comment.trimmingCharacters(in: .whitespacesAndNewlines) + updated.comment = trimmed.isEmpty ? nil : trimmed + onSave(updated) + dismiss() + }.disabled(comment.utf16.count > 8_000) + } + } + } +} + +struct AssistantCitationSelectionSheet: View { + let text: String + let messageId: String + let context: AssistantCitationContext + @State private var range = NSRange(location: 0, length: 0) + @State private var quote: AssistantCitation? + @SwiftUI.Environment(\.dismiss) private var dismiss + private var selection: AssistantCitation? { + AssistantCitation.capture(text: text, range: range, environmentId: context.environmentId, threadId: context.threadId, messageId: messageId) + } + var body: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 12) { + Text("Select the text you want to quote, then tap Cite.") + .font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary).padding(.horizontal, 18) + CitationSelectableText(text: text, range: $range) + }.padding(.top, 12).background(T3Colors.background).navigationTitle("Cite response").navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { Button("Cite") { quote = selection }.disabled(selection == nil) } + } + .sheet(item: $quote) { citation in + NavigationStack { AssistantCitationCommentSheet(citation: citation) { updated in + context.onCite(updated) + dismiss() + } } + } + } + } +} + +private struct CitationSelectableText: UIViewRepresentable { + let text: String + @Binding var range: NSRange + func makeCoordinator() -> Coordinator { Coordinator(range: $range) } + func makeUIView(context: Context) -> UITextView { + let view = UITextView() + view.isEditable = false + view.isSelectable = true + view.backgroundColor = .clear + view.textColor = .label + view.font = .preferredFont(forTextStyle: .body) + view.adjustsFontForContentSizeCategory = true + view.textContainerInset = UIEdgeInsets(top: 6, left: 18, bottom: 18, right: 18) + view.delegate = context.coordinator + view.text = text + return view + } + func updateUIView(_ view: UITextView, context: Context) { + context.coordinator.range = $range + if view.text != text { view.text = text } + } + final class Coordinator: NSObject, UITextViewDelegate { + var range: Binding + init(range: Binding) { self.range = range } + func textViewDidChangeSelection(_ textView: UITextView) { range.wrappedValue = textView.selectedRange } + } +} + +extension MarkdownRenderedDocument { + var citationText: String { Self.citationText(blocks) } + private static func citationText(_ blocks: [MarkdownRenderedBlock]) -> String { + blocks.map { block in + switch block { + case .paragraph(let inline), .heading(_, let inline): String(inline.attributedText.characters) + case .unorderedList(let items), .orderedList(_, let items): items.map { citationText($0.blocks) }.joined(separator: "\n") + case .blockquote(let nested), .githubAlert(_, let nested): citationText(nested) + case .table(let table): ([table.header] + table.rows).map { $0.map { String($0.attributedText.characters) }.joined(separator: "\t") }.joined(separator: "\n") + case .codeBlock(_, let code, _): code + case .image, .htmlEmbed, .artifactTemplate, .thematicBreak: "" + } + }.joined(separator: "\n") + } +} + +struct AssistantCitationPreview: View { + let citation: AssistantCitation + let onOpenSource: () -> Void + @SwiftUI.Environment(\.dismiss) private var dismiss + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Label("Assistant quote", systemImage: "quote.bubble").font(T3Typography.supportingStrong) + Text(citation.text).font(T3Typography.threadBody).textSelection(.enabled) + if let comment = citation.comment, !comment.isEmpty { + Divider() + Text("Your comment").font(T3Typography.supportingStrong) + Text(comment).font(T3Typography.threadBody).textSelection(.enabled) + } + Button("View source response", systemImage: "arrow.up.forward") { dismiss(); onOpenSource() } + .frame(minHeight: 44) + }.padding(18) + }.background(T3Colors.background).navigationTitle("Quoted response").navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } + } + } +} + +struct AssistantCitationNavigationRequest: Equatable { + let id = UUID() + let citation: AssistantCitation +} + +struct CitationAwareMessageText: View { + let source: String + let isStreaming: Bool + @SwiftUI.Environment(\.openURL) private var openURL + private struct Segment: Identifiable { + let id: Int + let text: String + let citation: AssistantCitation? + } + private var segments: [Segment] { + let matches = AssistantCitation.matches(in: source) + guard !matches.isEmpty else { return [Segment(id: 0, text: source, citation: nil)] } + let raw = source as NSString + var cursor = 0 + var result: [Segment] = [] + for match in matches { + if match.range.location > cursor { + result.append(Segment(id: cursor, text: raw.substring(with: NSRange(location: cursor, length: match.range.location - cursor)), citation: nil)) + } + result.append(Segment(id: match.range.location, text: "", citation: match.citation)) + cursor = NSMaxRange(match.range) + } + if cursor < raw.length { result.append(Segment(id: cursor, text: raw.substring(from: cursor), citation: nil)) } + return result + } + var body: some View { + VStack(alignment: .leading, spacing: 10) { + ForEach(segments) { segment in + if let citation = segment.citation { + Button { if let url = URL(string: citation.href) { openURL(url) } } label: { + VStack(alignment: .leading, spacing: 8) { + Label("Assistant quote", systemImage: "quote.bubble").font(T3Typography.supportingStrong).foregroundStyle(T3Colors.accent) + Text(citation.text).font(T3Typography.threadBody).lineLimit(4).foregroundStyle(T3Colors.textPrimary) + if let comment = citation.comment, !comment.isEmpty { + Text(comment).font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + } + Label("View source", systemImage: "arrow.up.forward").font(T3Typography.supporting).foregroundStyle(T3Colors.accent) + }.frame(maxWidth: .infinity, alignment: .leading).padding(12) + .background(T3Colors.surface, in: RoundedRectangle(cornerRadius: 12)) + }.buttonStyle(.plain) + } else if !segment.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + MarkdownMessageView(segment.text, isStreaming: isStreaming) + } + } + } + } +} diff --git a/apps/swift-ios/Features/Chat/CheckpointRestoreSheet.swift b/apps/swift-ios/Features/Chat/CheckpointRestoreSheet.swift index 1dd195226..3ad61a680 100644 --- a/apps/swift-ios/Features/Chat/CheckpointRestoreSheet.swift +++ b/apps/swift-ios/Features/Chat/CheckpointRestoreSheet.swift @@ -193,12 +193,12 @@ struct CheckpointRestoreSheet: View { if file.additions > 0 { Text("+\(file.additions)") .font(ChatTimelineStyle.smallMono) - .foregroundStyle(T3Colors.success) + .foregroundStyle(T3Colors.diffAddition) } if file.deletions > 0 { Text("−\(file.deletions)") .font(ChatTimelineStyle.smallMono) - .foregroundStyle(T3Colors.danger) + .foregroundStyle(T3Colors.diffDeletion) } } .padding(.leading, 30) diff --git a/apps/swift-ios/Features/Chat/CodexMarkdownDirectives.swift b/apps/swift-ios/Features/Chat/CodexMarkdownDirectives.swift new file mode 100644 index 000000000..8a9734e1d --- /dev/null +++ b/apps/swift-ios/Features/Chat/CodexMarkdownDirectives.swift @@ -0,0 +1,125 @@ +import Foundation + +struct CodexArtifactTemplate: Equatable, Sendable { + let kind: String + let displayName: String + let skillName: String + let skillDirectory: String + + static let labels = ["document": "Document", "presentation": "Presentation", "spreadsheet": "Spreadsheet", "site": "Site", "google-docs": "Google Doc", "google-slides": "Google Slides", "google-sheets": "Google Sheet", "image": "Image", "email": "Email", "slack": "Slack"] + var label: String { "\(Self.labels[kind] ?? kind) template" } + var prompt: String { + let skill = "$\(skillName)" + switch kind { + case "presentation": return "Create a presentation using the \(skill) template about…" + case "image": return "Create an image using this \(skill) of…" + case "email": return "Draft an email using this \(skill) about…" + case "slack": return "Draft a Slack message using this \(skill) about…" + default: return "Create a \(Self.labels[kind] ?? kind) using this \(skill) about…" + } + } + + static func parse(_ line: String) -> Self? { + let value = line.trimmingCharacters(in: .whitespaces) + let prefix = "::artifact-template{" + guard value.hasPrefix(prefix), value.hasSuffix("}"), + let attrs = CodexMarkdownDirectives.attributes(String(value.dropFirst(prefix.count).dropLast())), + let kind = attrs["artifact_kind"], labels[kind] != nil, + let display = attrs["display_name"]?.trimmingCharacters(in: .whitespacesAndNewlines), !display.isEmpty, + let name = attrs["skill_name"], name.hasPrefix("artifact-template-"), + let directory = attrs["skill_directory"], + (directory.hasPrefix("/") || directory.hasPrefix("\\\\") || directory.range(of: #"^[A-Za-z]:[\\/]"#, options: .regularExpression) != nil), + attrs["gallery_kind"] == nil || ["imagegen", "product-design"].contains(attrs["gallery_kind"]!) else { return nil } + return Self(kind: kind, displayName: display, skillName: name, skillDirectory: directory) + } +} + +/// Only recognizes Codex's two directives. Fenced code stays outside this inline formatter. +enum CodexMarkdownDirectives { + static func attributes(_ source: String) -> [String: String]? { + let chars = Array(source) + var i = 0 + var values: [String: String] = [:] + while i < chars.count { + while i < chars.count && chars[i].isWhitespace { i += 1 } + if i == chars.count { break } + let start = i + while i < chars.count && (chars[i].isLetter || chars[i].isNumber || chars[i] == "_" || chars[i] == "-") { i += 1 } + guard i > start else { return nil } + let key = String(chars[start.. String { + guard source.contains(":codex-file-citation{") else { return source } + let chars = Array(source) + let prefix = Array(":codex-file-citation{") + var i = 0 + var codeTicks = 0 + var result = "" + while i < chars.count { + if chars[i] == "\\", i + 1 < chars.count { + result.append(chars[i]); result.append(chars[i + 1]); i += 2; continue + } + if chars[i] == "`" { + let start = i + while i < chars.count && chars[i] == "`" { i += 1 } + let count = i - start + if codeTicks == 0 { codeTicks = count } else if codeTicks == count { codeTicks = 0 } + result += String(chars[start.. 0 { + url.queryItems?.append(URLQueryItem(name: "line", value: String(line))) + } + let label = path.replacingOccurrences(of: "\\", with: "/").split(separator: "/").last.map(String.init) ?? path + let escaped = label.reduce(into: "") { output, char in + if "\\[]*_`<&".contains(char) { output += "\\" }; output.append(char) + } + if let href = url.string { result += "[\(escaped)](<\(href)>)"; i = end + 1; continue } + } + } + result.append(chars[i]); i += 1 + } + return result + } + + static func fileTarget(_ url: URL) -> (path: String, line: Int?)? { + guard url.scheme == "t3-file-citation", let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems, + let path = items.first(where: { $0.name == "path" })?.value, !path.isEmpty else { return nil } + let line = items.first(where: { $0.name == "line" })?.value.flatMap(Int.init) + return (path, line.flatMap { $0 > 0 ? $0 : nil }) + } +} diff --git a/apps/swift-ios/Features/Chat/ComposerAttachmentKinds.swift b/apps/swift-ios/Features/Chat/ComposerAttachmentKinds.swift index cad65ff21..8153e751a 100644 --- a/apps/swift-ios/Features/Chat/ComposerAttachmentKinds.swift +++ b/apps/swift-ios/Features/Chat/ComposerAttachmentKinds.swift @@ -39,7 +39,7 @@ public enum ComposerAttachments { public static let maximumImageBytes = 10 * 1024 * 1024 /// `PROVIDER_SEND_TURN_MAX_FILE_BYTES` — PDFs, video and generic files all /// share the looser cap. - public static let maximumFileBytes = 20 * 1024 * 1024 + public static let maximumFileBytes = 50 * 1024 * 1024 public static func maximumBytes(for kind: ComposerAttachmentKind) -> Int { kind == .image ? maximumImageBytes : maximumFileBytes diff --git a/apps/swift-ios/Features/Chat/ComposerStashSheet.swift b/apps/swift-ios/Features/Chat/ComposerStashSheet.swift new file mode 100644 index 000000000..b4647639d --- /dev/null +++ b/apps/swift-ios/Features/Chat/ComposerStashSheet.swift @@ -0,0 +1,48 @@ +import SwiftUI + +struct ComposerStashSheet: View { + let entries: [FeatureComposerStashEntry] + let busy: Bool + let error: String? + let restore: (FeatureComposerStashEntry) -> Void + let remove: (FeatureComposerStashEntry) -> Void + @State private var pendingRemoval: FeatureComposerStashEntry? + + var body: some View { + ScrollView { + LazyVStack(spacing: 16) { + if let error { Text(error).font(T3Typography.supporting).foregroundStyle(T3Colors.danger) } + if entries.isEmpty { + ContentUnavailableView("No stashed drafts", systemImage: "bookmark", + description: Text("Save a draft from the composer’s prompt history menu.")) + } + ForEach(entries.reversed()) { entry in + VStack(alignment: .leading, spacing: 12) { + Text(entry.draft.text.isEmpty ? "Attachments" : AssistantCitation.plainText(entry.draft.text)) + .font(T3Typography.threadBody).foregroundStyle(T3Colors.textPrimary) + .lineLimit(6).frame(maxWidth: .infinity, alignment: .leading) + if !entry.draft.attachments.isEmpty { + Label(entry.draft.attachments.map(\.filename).joined(separator: ", "), systemImage: "paperclip") + .font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary).lineLimit(2) + } + HStack { + Button("Restore") { restore(entry) } + .frame(minHeight: T3Metrics.minimumTapTarget) + Spacer() + Button("Remove", role: .destructive) { pendingRemoval = entry } + .frame(minHeight: T3Metrics.minimumTapTarget) + }.disabled(busy) + }.padding(16).background(T3Colors.surfaceRaised, in: RoundedRectangle(cornerRadius: 16)) + } + Text("Restoring a draft saves any unsent text and attachments in the stash first.") + .font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + }.padding(16) + } + .background(T3Colors.background) + .navigationTitle("Stashed drafts").navigationBarTitleDisplayMode(.inline) + .confirmationDialog("Remove this stashed draft?", isPresented: Binding(get: { pendingRemoval != nil }, set: { if !$0 { pendingRemoval = nil } }), titleVisibility: .visible) { + Button("Remove draft", role: .destructive) { if let entry = pendingRemoval { remove(entry) }; pendingRemoval = nil } + Button("Cancel", role: .cancel) { pendingRemoval = nil } + } + } +} diff --git a/apps/swift-ios/Features/Chat/ComposerTasksView.swift b/apps/swift-ios/Features/Chat/ComposerTasksView.swift new file mode 100644 index 000000000..267541fda --- /dev/null +++ b/apps/swift-ios/Features/Chat/ComposerTasksView.swift @@ -0,0 +1,60 @@ +import SwiftUI + +/// The current run's V2 task list, kept adjacent to the composer and out of its gesture tree. +struct ComposerTasksView: View { + let detail: FeatureThreadDetail + @State private var expanded = false + + private var activeRunID: String? { + ThreadWorkflows.resolveActiveRun(runs: detail.workflow.runs)?.id + } + + private var steps: [OrchestrationV2PlanStep] { + guard detail.approvals.isEmpty, detail.userInputs.isEmpty, let activeRunID, + detail.thread.state == .working, + let item = detail.timelineItems.last(where: { + $0.item.base.runId == activeRunID && $0.item.type == "todo_list" + }), case let .todoList(_, steps, _) = item.item.payload, + steps.contains(where: { $0.status == "running" }) else { return [] } + return steps + } + + var body: some View { + let steps = steps + if !steps.isEmpty { + VStack(alignment: .leading, spacing: 0) { + Button { withAnimation(.easeInOut(duration: 0.18)) { expanded.toggle() } } label: { + HStack(spacing: 8) { + Image(systemName: "list.bullet.clipboard") + Text(steps.first(where: { $0.status == "running" })?.text ?? "Tasks") + .lineLimit(1).frame(maxWidth: .infinity, alignment: .leading) + Text("\(steps.filter { $0.status == "completed" }.count)/\(steps.count)").monospacedDigit() + Image(systemName: "chevron.down").rotationEffect(.degrees(expanded ? 180 : 0)) + } + .font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + .padding(12).contentShape(Rectangle()) + }.buttonStyle(.plain) + .accessibilityLabel("Tasks, \(steps.filter { $0.status == "completed" }.count) of \(steps.count) complete") + .accessibilityHint(expanded ? "Collapse task list" : "Expand task list") + if expanded { + ScrollView { + VStack(alignment: .leading, spacing: 10) { + ForEach(Array(steps.enumerated()), id: \.offset) { _, step in + HStack(alignment: .top, spacing: 8) { + Image(systemName: step.status == "completed" ? "checkmark.circle.fill" : step.status == "running" ? "circle.inset.filled" : "circle") + Text(step.text).frame(maxWidth: .infinity, alignment: .leading) + Text(step.status == "running" ? "Running" : step.status == "completed" ? "Completed" : "Pending") + .foregroundStyle(T3Colors.textSecondary) + }.font(T3Typography.supporting) + .foregroundStyle(step.status == "completed" ? T3Colors.textSecondary : T3Colors.textPrimary) + } + }.padding(.horizontal, 12).padding(.bottom, 12) + }.frame(maxHeight: 180) + } + } + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) + .padding(.horizontal, 24) + .onChange(of: activeRunID) { _, _ in expanded = false } + } + } +} diff --git a/apps/swift-ios/Features/Chat/FeatureComposerRequestViews.swift b/apps/swift-ios/Features/Chat/FeatureComposerRequestViews.swift index a75e4643f..fdc996bfe 100644 --- a/apps/swift-ios/Features/Chat/FeatureComposerRequestViews.swift +++ b/apps/swift-ios/Features/Chat/FeatureComposerRequestViews.swift @@ -66,6 +66,15 @@ struct FeatureComposerApprovalPanel: View { Divider().overlay(T3Colors.separator) VStack(spacing: 9) { + if let options = approval.options { + ForEach(options, id: \.decision) { option in + approvalButton(option.label, + background: option.decision == .allowOnce ? T3Colors.accent : Color.clear, + border: T3Colors.border, + foreground: option.decision == .deny ? T3Colors.danger : T3Colors.textPrimary, + action: { onDecision(option.decision) }) + } + } else { HStack(spacing: 7) { approvalButton( "Approve once", @@ -74,7 +83,7 @@ struct FeatureComposerApprovalPanel: View { ) approvalButton( - "Always allow", + "Allow this session", background: Color.clear, border: T3Colors.border, foreground: T3Colors.textPrimary, @@ -94,6 +103,7 @@ struct FeatureComposerApprovalPanel: View { .font(T3Typography.supportingStrong) .buttonStyle(.plain) .frame(maxWidth: .infinity) + } } .padding(.horizontal, 10) .padding(.top, 10) diff --git a/apps/swift-ios/Features/Chat/FeatureComposerView.swift b/apps/swift-ios/Features/Chat/FeatureComposerView.swift index 9f0fae00c..720a4341d 100644 --- a/apps/swift-ios/Features/Chat/FeatureComposerView.swift +++ b/apps/swift-ios/Features/Chat/FeatureComposerView.swift @@ -1,5 +1,6 @@ import SwiftUI import UIKit +import UniformTypeIdentifiers struct FeatureComposerView: View { /// True while the attachment picker has a camera, photo, or file source on @@ -7,6 +8,8 @@ struct FeatureComposerView: View { /// resulting focus loss would collapse the footer, and the composer needs /// to know a presentation it just opened is the reason focus went away. @State private var isPickingAttachment = false + @State private var isFileDropTargeted = false + @State private var fileDropError: String? /// The in-pill attachment menu the plus morphs the composer into. @State private var isAttachMenuOpen = false /// The in-pill camera / photo-library window. Files stay on the native @@ -30,7 +33,15 @@ struct FeatureComposerView: View { private let voice = VoiceComposerCoordinator.shared @State private var caret = VoiceComposerCaret() @SwiftUI.Environment(\.accessibilityReduceMotion) private var reduceMotion - @Binding private var text: String + @Binding private var storedText: String + private var text: String { + get { ReviewCommentContext.removingBlocks(from: AssistantCitation.removingMarkers(from: storedText)) } + nonmutating set { + storedText = ReviewCommentContext.replacingPlainText(in: storedText, + with: AssistantCitation.replacingPlainText(in: storedText, with: newValue)) + } + } + private var textBinding: Binding { Binding(get: { text }, set: { text = $0 }) } @Binding private var selection: FeatureSelection? @Binding private var attachments: [FeatureDraftAttachment] /// The thread's Plan/Build mode, or nil on a surface that has no mode to @@ -40,6 +51,7 @@ struct FeatureComposerView: View { private let interactionMode: Binding? private let providers: [FeatureProvider] + private let providerSetup: ProviderSetupContext? private let threadSelection: FeatureSelection? private let materializesDefaultSelection: Bool private let isSending: Bool @@ -59,9 +71,12 @@ struct FeatureComposerView: View { private let historyDraftStore: FeatureComposerDraftStore private let onWillStash: () async -> Void private let onDidStash: () -> Void + private let externalFileDrop: ThreadFileDropBatch? + private let onExternalFileDropConsumed: (UUID) -> Void @State private var historyGeneration = UUID() @State private var promptHistory = ComposerPromptHistory() - @State private var stashedDraft: FeatureComposerDraft? + @State private var stashedDrafts: [FeatureComposerStashEntry] = [] + @State private var showsStash = false @State private var isStashing = false @State private var historyError: String? private let onSend: () -> Void @@ -75,6 +90,7 @@ struct FeatureComposerView: View { attachments: Binding<[FeatureDraftAttachment]>, interactionMode: Binding? = nil, providers: [FeatureProvider], + providerSetup: ProviderSetupContext? = nil, threadSelection: FeatureSelection?, materializesDefaultSelection: Bool = true, isSending: Bool, @@ -94,14 +110,17 @@ struct FeatureComposerView: View { historyDraftStore: FeatureComposerDraftStore = .shared, onWillStash: @escaping () async -> Void = {}, onDidStash: @escaping () -> Void = {}, + externalFileDrop: ThreadFileDropBatch? = nil, + onExternalFileDropConsumed: @escaping (UUID) -> Void = { _ in }, onApprovalDecision: ((String, FeatureApprovalDecision) -> Void)? = nil, onUserInputSubmit: ((String, [String: FeatureInputAnswer], [String: [FeatureUploadAttachment]], Bool) -> Void)? = nil ) { - _text = text + _storedText = text _selection = selection _attachments = attachments self.interactionMode = interactionMode self.providers = providers + self.providerSetup = providerSetup self.threadSelection = threadSelection self.materializesDefaultSelection = materializesDefaultSelection self.isSending = isSending @@ -121,24 +140,70 @@ struct FeatureComposerView: View { self.historyDraftStore = historyDraftStore self.onWillStash = onWillStash self.onDidStash = onDidStash + self.externalFileDrop = externalFileDrop + self.onExternalFileDropConsumed = onExternalFileDropConsumed self.onApprovalDecision = onApprovalDecision self.onUserInputSubmit = onUserInputSubmit } var body: some View { - composerSurface + VStack(spacing: 8) { + if !stashedDrafts.isEmpty { + HStack { + Spacer() + Button { showsStash = true } label: { + Label("Stash \(stashedDrafts.count)", systemImage: "bookmark") + .font(T3Typography.supportingStrong) + .padding(.horizontal, 14) + .frame(minHeight: T3Metrics.minimumTapTarget) + .background(T3Colors.surfaceRaised, in: Capsule()) + }.buttonStyle(.plain).disabled(!historyAvailable) + .accessibilityIdentifier("composer-stash") + } + } + if !AssistantCitation.matches(in: storedText).isEmpty { + AssistantCitationChips(text: $storedText).disabled(isSending || isStashing) + } + if !ReviewCommentContext.matches(in: storedText).isEmpty { + ReviewCommentContextChips(text: $storedText).disabled(isSending || isStashing) + } + composerSurface + } + .onDrop(of: [UTType.data], isTargeted: $isFileDropTargeted, perform: receiveDroppedFiles) + .overlay { + if isFileDropTargeted { + RoundedRectangle(cornerRadius: 24) + .strokeBorder(T3Colors.accent, style: StrokeStyle(lineWidth: 2, dash: [6])) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + .alert("Attach files", isPresented: Binding(get: { fileDropError != nil }, set: { if !$0 { fileDropError = nil } })) { + Button("OK") { fileDropError = nil } + } message: { Text(fileDropError ?? "") } + .task(id: readyExternalFileDropID) { await receiveExternalFileDrop() } .task(id: historyDraftKey) { historyGeneration = UUID() promptHistory = ComposerPromptHistory() - guard let historyDraftKey else { stashedDraft = nil; return } + stashedDrafts = [] + showsStash = false + guard let historyDraftKey else { stashedDrafts = []; return } do { - let saved = try await historyDraftStore.stashedDraft(for: historyDraftKey) + let saved = try await historyDraftStore.stashEntries(for: historyDraftKey) guard !Task.isCancelled, self.historyDraftKey == historyDraftKey else { return } - stashedDraft = saved + stashedDrafts = saved } catch { historyError = error.localizedDescription } } - .alert("Prompt history", isPresented: Binding(get: { historyError != nil }, set: { if !$0 { historyError = nil } })) { + .sheet(isPresented: $showsStash) { + NavigationStack { + ComposerStashSheet(entries: stashedDrafts, busy: isStashing, error: historyError, + restore: { entry in mutateStash(restoring: entry.id) }, + remove: { entry in removeStash(entry.id) }) + .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { showsStash = false } } } + } + } + .alert("Prompt history", isPresented: Binding(get: { historyError != nil && !showsStash }, set: { if !$0 { historyError = nil } })) { Button("OK") { historyError = nil } } message: { Text(historyError ?? "") } .overlay(alignment: .top) { @@ -219,6 +284,7 @@ struct FeatureComposerView: View { attachVoice() } .onDisappear { + historyGeneration = UUID() caret.stopTracking() voice.detach(identity: powerFeatures.voiceComposerIdentity) } @@ -387,6 +453,86 @@ struct FeatureComposerView: View { } } + private var readyExternalFileDropID: UUID? { + guard !isSending, !isStashing, !voice.state.isBusy, + externalFileDrop?.draftKey == historyDraftKey else { return nil } + return externalFileDrop?.id + } + + private func receiveExternalFileDrop() async { + guard readyExternalFileDropID != nil, let batch = externalFileDrop else { return } + let remaining = max(0, 8 - attachments.count - attachmentPreparation.pendingItemCount) + let accepted = min(remaining, batch.providers.count - batch.nextIndex) + let endIndex = batch.nextIndex + accepted + let operation = attachmentPreparation.begin(itemCount: accepted) + defer { attachmentPreparation.finish(operation) } + var failures: [String] = [] + while batch.nextIndex < endIndex { + let index = batch.nextIndex + let provider = batch.providers[index] + do { + guard let type = ThreadFileDropBatch.supportedType(provider) else { throw CocoaError(.fileReadUnsupportedScheme) } + let attachment = try await FeatureDroppedAttachment.load(provider, typeIdentifier: type) + guard !Task.isCancelled, historyDraftKey == batch.draftKey else { return } + guard batch.nextIndex == index else { continue } + guard attachments.count < 8 else { break } + attachments.append(attachment) + batch.advance(expectedIndex: index) + } catch { + guard !Task.isCancelled, historyDraftKey == batch.draftKey else { return } + failures.append(error.localizedDescription) + batch.advance(expectedIndex: index) + } + } + guard !Task.isCancelled else { return } + if !batch.isComplete || batch.omittedCount > 0 { + failures.append("A message can contain up to 8 attachments. Extra files were not added.") + } + batch.finish() + onExternalFileDropConsumed(batch.id) + if !failures.isEmpty { fileDropError = failures.joined(separator: "\n") } + focused.wrappedValue = true + } + + /// Resolve each provider while its temporary file is valid, then append only + /// to the composer that accepted the drop. The existing upload queue owns sending. + private func receiveDroppedFiles(_ providers: [NSItemProvider]) -> Bool { + guard !isSending, !isStashing, !voice.state.isBusy else { return false } + let remaining = max(0, 8 - attachments.count - attachmentPreparation.pendingItemCount) + let accepted = providers.compactMap { provider -> (NSItemProvider, String)? in + guard let type = provider.registeredTypeIdentifiers.first(where: { + UTType($0)?.conforms(to: .data) == true + }) else { return nil } + return (provider, type) + } + guard !accepted.isEmpty else { return false } + guard remaining > 0 else { + fileDropError = "A message can contain up to 8 attachments." + return false + } + let destination = historyDraftKey + let generation = historyGeneration + let operation = attachmentPreparation.begin(itemCount: min(remaining, accepted.count)) + Task { @MainActor in + defer { attachmentPreparation.finish(operation) } + for (provider, type) in accepted.prefix(remaining) { + do { + let attachment = try await FeatureDroppedAttachment.load(provider, typeIdentifier: type) + guard historyDraftKey == destination, historyGeneration == generation else { return } + guard attachments.count < 8 else { break } + attachments.append(attachment) + } catch { + guard historyDraftKey == destination, historyGeneration == generation else { return } + fileDropError = error.localizedDescription + } + } + if accepted.count > remaining { + fileDropError = "Only the first \(remaining) files were added. A message can contain up to 8 attachments." + } + } + return true + } + private var editorContent: some View { VStack(spacing: 0) { if !attachments.isEmpty { @@ -509,7 +655,7 @@ struct FeatureComposerView: View { private var inputRow: some View { TextField( isWorking ? "Message to queue…" : "Ask anything…", - text: $text, + text: textBinding, axis: .vertical ) .disabled(isStashing) @@ -517,9 +663,9 @@ struct FeatureComposerView: View { guard press.modifiers.isEmpty, historyAvailable, caret.canRecallHistory(backward: press.key == .upArrow), let recalled = promptHistory.step(backward: press.key == .upArrow, - entries: ComposerPromptHistory.entries(historyMessages()), current: text) else { return .ignored } - text = recalled - caret.moveCaret(to: recalled.utf16.count) + entries: ComposerPromptHistory.entries(historyMessages()), current: storedText) else { return .ignored } + storedText = recalled + caret.moveCaret(to: text.utf16.count) return .handled } .font(T3Typography.composer) @@ -635,36 +781,65 @@ struct FeatureComposerView: View { && pendingApprovals.isEmpty && pendingUserInputs.isEmpty && !isAttachMenuOpen } + private func mutateStash(restoring id: String? = nil) { + guard let historyDraftKey, historyAvailable else { return } + let current = FeatureComposerDraft(text: storedText, attachments: attachments) + let generation = historyGeneration + isStashing = true + Task { + defer { isStashing = false; onDidStash() } + await onWillStash() + do { + let restored: FeatureComposerDraft + if let id { restored = try await historyDraftStore.restoreStash(id: id, replacing: current, for: historyDraftKey) } + else { + _ = try await historyDraftStore.stashDraft(current, for: historyDraftKey) + restored = FeatureComposerDraft() + } + let entries = try await historyDraftStore.stashEntries(for: historyDraftKey) + guard generation == historyGeneration else { return } + stashedDrafts = entries + storedText = restored.text + attachments = restored.attachments + promptHistory = ComposerPromptHistory() + showsStash = false + } catch { historyError = error.localizedDescription } + } + } + + private func removeStash(_ id: String) { + guard let historyDraftKey, !isStashing else { return } + let generation = historyGeneration + isStashing = true + Task { + defer { isStashing = false } + do { + let entries = try await historyDraftStore.removeStash(id: id, for: historyDraftKey) + guard generation == historyGeneration else { return } + stashedDrafts = entries + } catch { historyError = error.localizedDescription } + } + } + private var historyMenu: some View { Menu { - if let historyDraftKey { - Button(stashedDraft == nil ? "Stash draft" : (text.isEmpty && attachments.isEmpty ? "Restore stashed draft" : "Swap with stashed draft")) { - let current = FeatureComposerDraft(text: text, attachments: attachments) - let generation = historyGeneration - isStashing = true - Task { - defer { isStashing = false; onDidStash() } - await onWillStash() - do { - let restored = try await historyDraftStore.swapStash(current, for: historyDraftKey) - guard generation == historyGeneration else { return } - stashedDraft = current.text.isEmpty && current.attachments.isEmpty ? nil : current - text = restored.text - attachments = restored.attachments - promptHistory = ComposerPromptHistory() - } catch { historyError = error.localizedDescription } - } - }.disabled(stashedDraft == nil && text.isEmpty && attachments.isEmpty) + if historyDraftKey != nil { + Button("Stash draft", systemImage: "bookmark") { mutateStash() } + .disabled(storedText.isEmpty && attachments.isEmpty) + .keyboardShortcut("s", modifiers: .command) + if !stashedDrafts.isEmpty { + Button("Browse stash (\(stashedDrafts.count))", systemImage: "tray.full") { showsStash = true } + } } let entries = ComposerPromptHistory.entries(historyMessages()) if !entries.isEmpty { Section("Recent prompts") { ForEach(entries.suffix(20).reversed()) { entry in - Button(String(entry.prompt.prefix(100))) { - text = promptHistory.select(entry) + Button(String(AssistantCitation.plainText(entry.prompt).prefix(100))) { + storedText = promptHistory.select(entry) focused.wrappedValue = true caret.moveCaret(to: text.utf16.count) - }.disabled(!text.isEmpty && text != promptHistory.position?.prompt) + }.disabled(!storedText.isEmpty && storedText != promptHistory.position?.prompt) } } } else { Text("No sent prompts in this thread") } @@ -757,7 +932,8 @@ struct FeatureComposerView: View { selection: $selection, providers: providers, threadSelection: threadSelection, - materializesDefaultSelection: materializesDefaultSelection + materializesDefaultSelection: materializesDefaultSelection, + setupContext: providerSetup ) } // The picker used to own this: mounting it was what materialized a @@ -875,13 +1051,13 @@ struct FeatureComposerView: View { } private var textIsEmpty: Bool { - text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + storedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } private var canSend: Bool { guard composerTrigger?.kind != .model else { return false } return FeatureComposerSubmissionEligibility.canSend( - text: text, + text: storedText, attachmentCount: attachments.count, imagesAllowed: imagesAllowed, isSending: isSending, diff --git a/apps/swift-ios/Features/Chat/FeatureInlineVideoView.swift b/apps/swift-ios/Features/Chat/FeatureInlineVideoView.swift index 7773d6ff3..0463bf4ec 100644 --- a/apps/swift-ios/Features/Chat/FeatureInlineVideoView.swift +++ b/apps/swift-ios/Features/Chat/FeatureInlineVideoView.swift @@ -11,6 +11,8 @@ struct FeatureInlineVideoView: View { @State private var player: AVPlayer? @State private var failed = false + @State private var retryCount = 0 + @SwiftUI.Environment(\.scenePhase) private var scenePhase var body: some View { Group { @@ -27,6 +29,11 @@ struct FeatureInlineVideoView: View { .font(T3Typography.supporting) .lineLimit(2) .multilineTextAlignment(.center) + if failed { + Button("Retry video", systemImage: "arrow.clockwise") { retryCount += 1 } + .buttonStyle(.bordered) + Link("Open in browser", destination: url).font(T3Typography.supporting) + } } .foregroundStyle(T3Colors.textSecondary) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -35,7 +42,16 @@ struct FeatureInlineVideoView: View { } } .aspectRatio(16 / 9, contentMode: .fit) - .task(id: url) { await preparePlayer() } + .task(id: "\(url.absoluteString):\(retryCount)") { await preparePlayer() } + .onChange(of: scenePhase) { _, phase in + if phase != .active { player?.pause() } + } + .onReceive(NotificationCenter.default.publisher(for: .AVPlayerItemFailedToPlayToEndTime)) { notice in + guard let item = notice.object as? AVPlayerItem, item === player?.currentItem else { return } + player?.pause() + player = nil + failed = true + } .onDisappear { player?.pause() player = nil @@ -59,7 +75,14 @@ struct FeatureInlineVideoView: View { return } try Task.checkCancellation() - player = AVPlayer(playerItem: AVPlayerItem(asset: asset)) + let prepared = AVPlayer(playerItem: AVPlayerItem(asset: asset)) + let duration = try await asset.load(.duration).seconds + try Task.checkCancellation() + if duration.isFinite && duration > 0 && url.fragment == nil { + await prepared.seek(to: CMTime(seconds: min(0.1, duration / 2), preferredTimescale: 600)) + } + try Task.checkCancellation() + player = prepared } catch is CancellationError { return } catch { diff --git a/apps/swift-ios/Features/Chat/FeatureLinkedPullRequestSettlement.swift b/apps/swift-ios/Features/Chat/FeatureLinkedPullRequestSettlement.swift new file mode 100644 index 000000000..330eda4bf --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeatureLinkedPullRequestSettlement.swift @@ -0,0 +1,19 @@ +import Foundation + +/// A primary badge may only become terminal after every linked request has answered as terminal. +enum FeatureLinkedPullRequestSettlement { + static func aggregate(_ reads: [FeaturePullRequest?]) -> FeaturePullRequest? { + let known = reads.compactMap { $0 } + guard var result = known.first(where: { $0.state == "open" }) ?? known.first else { return nil } + if result.state == "open" { return result } + guard known.count == reads.count, + known.allSatisfy({ $0.state == "closed" || $0.state == "merged" }) else { + result.state = "unknown" + return result + } + // If merge settlement is disabled, a mixed closed/merged collection must stay active. + result.state = known.contains(where: { $0.state == "merged" }) ? "merged" : "closed" + result.updatedAt = known.compactMap(\.updatedAt).max() + return result + } +} diff --git a/apps/swift-ios/Features/Chat/FeaturePullRequestAccess.swift b/apps/swift-ios/Features/Chat/FeaturePullRequestAccess.swift new file mode 100644 index 000000000..9b46a3e05 --- /dev/null +++ b/apps/swift-ios/Features/Chat/FeaturePullRequestAccess.swift @@ -0,0 +1,163 @@ +import Foundation + +struct FeaturePullRequestProjectScope: Equatable, Sendable { + let projectID: String + let host: String + let repository: String + var canonicalKey: String { "\(host)/\(repository)".lowercased() } +} + +@MainActor +protocol FeatureProjectPullRequestManaging: AnyObject, Sendable { + func listPullRequests(environmentID: String, input: PullRequestListInput) async throws -> PullRequestListResult + func pullRequestStats(environmentID: String, entries: [PullRequestListEntry]) async throws -> PullRequestListStatsResult + func projectPullRequestOverview(scope: FeaturePullRequestProjectScope, number: Int) async throws -> FeaturePullRequestOverview + func projectPullRequestLabels(scope: FeaturePullRequestProjectScope, number: Int) async throws -> PullRequestLabelCandidateList + func setProjectPullRequestLabels(scope: FeaturePullRequestProjectScope, number: Int, labels: [String], applied: Bool) async throws + func projectPullRequestStack(scope: FeaturePullRequestProjectScope, number: Int) async throws -> PullRequestStack? + func runProjectPullRequestStackAction(scope: FeaturePullRequestProjectScope, number: Int, stack: PullRequestStack, action: String, mergeMethod: String?) async throws +} + +/// The same detail, label and reviewed-stack screens work from a thread or a +/// project. A workspace browse never creates a dummy thread just to read a PR. +@MainActor +struct FeaturePullRequestAccess { + let scope: FeaturePullRequestScope + let reviewers: ((Int, String) -> FeaturePullRequestReviewerAccess)? + let react: ((Int, String, PullRequestReactionRequest) async throws -> Void)? + let editing: ((Int, String) -> FeaturePullRequestEditingAccess)? + let invalidate: ((Int) async throws -> Void)? + let runAction: ((Int, String, PullRequestActionRequest) async throws -> Void)? + let threads: ((Int, String) -> FeaturePullRequestThreadAccess)? + let draftKey: String + let submitReview: ((Int, String, PullRequestReviewSubmission) async throws -> Void)? + let fileContents: ((Int, String, PullRequestDiffFileInput) async throws -> PullRequestDiffFileContents)? + let diff: ((Int, String?, String?) async throws -> PullRequestDiffResult)? + let overview: (Int) async throws -> FeaturePullRequestOverview + let labels: (Int) async throws -> PullRequestLabelCandidateList + let setLabels: (Int, [String], Bool) async throws -> Void + let stack: (Int) async throws -> PullRequestStack? + let runStackAction: (Int, PullRequestStack, String, String?) async throws -> Void + + init(client: any FeatureClient, threadID: String) { + scope = .thread(threadID) + if let cache = client as? any FeaturePullRequestCacheInvalidating { + invalidate = { try await cache.invalidatePullRequest(scope: .thread(threadID), number: $0) } + } else { invalidate = nil } + draftKey = "thread:\(threadID)" + if let reviewer = client as? any FeaturePullRequestReviewWriting { + reviewers = { FeaturePullRequestReviewerAccess(writer: reviewer, scope: .thread(threadID), number: $0, expectedURL: $1) } + react = { try await reviewer.setPullRequestReaction(scope: .thread(threadID), number: $0, expectedURL: $1, request: $2) } + editing = { FeaturePullRequestEditingAccess(writer: reviewer, scope: .thread(threadID), number: $0, expectedURL: $1) } + runAction = { try await reviewer.runPullRequestAction(scope: .thread(threadID), number: $0, expectedURL: $1, request: $2) } + threads = { FeaturePullRequestThreadAccess(writer: reviewer, scope: .thread(threadID), number: $0, expectedURL: $1) } + submitReview = { try await reviewer.submitPullRequestReview(scope: .thread(threadID), number: $0, expectedURL: $1, submission: $2) } + } else { submitReview = nil; threads = nil; runAction = nil; editing = nil; react = nil; reviewers = nil } + if let reader = client as? any FeaturePullRequestCodeReading { + fileContents = { try await reader.pullRequestFileContents(scope: .thread(threadID), number: $0, expectedURL: $1, input: $2) } + diff = { try await reader.pullRequestDiff(scope: .thread(threadID), number: $0, cursor: $1, commit: $2) } + } else { diff = nil; fileContents = nil } + overview = { try await client.pullRequestOverview(threadID: threadID, number: $0) } + labels = { try await client.pullRequestLabelCandidates(threadID: threadID, number: $0) } + setLabels = { try await client.setPullRequestLabels(threadID: threadID, number: $0, labels: $1, applied: $2) } + stack = { try await client.pullRequestStack(threadID: threadID, number: $0) } + runStackAction = { try await client.runPullRequestStackAction(threadID: threadID, number: $0, stack: $1, action: $2, mergeMethod: $3) } + } + + init(manager: any FeatureProjectPullRequestManaging, scope: FeaturePullRequestProjectScope) { + self.scope = .project(scope) + if let cache = manager as? any FeaturePullRequestCacheInvalidating { + invalidate = { try await cache.invalidatePullRequest(scope: .project(scope), number: $0) } + } else { invalidate = nil } + draftKey = "project:\(scope.projectID):\(scope.canonicalKey)" + if let reviewer = manager as? any FeaturePullRequestReviewWriting { + reviewers = { FeaturePullRequestReviewerAccess(writer: reviewer, scope: .project(scope), number: $0, expectedURL: $1) } + react = { try await reviewer.setPullRequestReaction(scope: .project(scope), number: $0, expectedURL: $1, request: $2) } + editing = { FeaturePullRequestEditingAccess(writer: reviewer, scope: .project(scope), number: $0, expectedURL: $1) } + runAction = { try await reviewer.runPullRequestAction(scope: .project(scope), number: $0, expectedURL: $1, request: $2) } + threads = { FeaturePullRequestThreadAccess(writer: reviewer, scope: .project(scope), number: $0, expectedURL: $1) } + submitReview = { try await reviewer.submitPullRequestReview(scope: .project(scope), number: $0, expectedURL: $1, submission: $2) } + } else { submitReview = nil; threads = nil; runAction = nil; editing = nil; react = nil; reviewers = nil } + if let reader = manager as? any FeaturePullRequestCodeReading { + fileContents = { try await reader.pullRequestFileContents(scope: .project(scope), number: $0, expectedURL: $1, input: $2) } + diff = { try await reader.pullRequestDiff(scope: .project(scope), number: $0, cursor: $1, commit: $2) } + } else { diff = nil; fileContents = nil } + overview = { try await manager.projectPullRequestOverview(scope: scope, number: $0) } + labels = { try await manager.projectPullRequestLabels(scope: scope, number: $0) } + setLabels = { try await manager.setProjectPullRequestLabels(scope: scope, number: $0, labels: $1, applied: $2) } + stack = { try await manager.projectPullRequestStack(scope: scope, number: $0) } + runStackAction = { try await manager.runProjectPullRequestStackAction(scope: scope, number: $0, stack: $1, action: $2, mergeMethod: $3) } + } +} + +/// A host-backed diff is addressed through its project, never the local working tree. +enum FeaturePullRequestScope: Sendable { + case thread(String) + case project(FeaturePullRequestProjectScope) +} + +@MainActor +protocol FeaturePullRequestCodeReading: AnyObject, Sendable { + func pullRequestFileContents(scope: FeaturePullRequestScope, number: Int, expectedURL: String, input: PullRequestDiffFileInput) async throws -> PullRequestDiffFileContents + func pullRequestDiff(scope: FeaturePullRequestScope, number: Int, cursor: String?, commit: String?) async throws -> PullRequestDiffResult +} + +@MainActor +protocol FeaturePullRequestReviewWriting: AnyObject, Sendable { + func pullRequestReviewerCandidates(scope: FeaturePullRequestScope, number: Int, expectedURL: String) async throws -> PullRequestReviewerCandidateList + func requestPullRequestReviewers(scope: FeaturePullRequestScope, number: Int, expectedURL: String, request: PullRequestReviewerRequest) async throws + func setPullRequestReaction(scope: FeaturePullRequestScope, number: Int, expectedURL: String, request: PullRequestReactionRequest) async throws + func updatePullRequestText(scope: FeaturePullRequestScope, number: Int, expectedURL: String, update: PullRequestTextUpdate) async throws + func updatePullRequestComment(scope: FeaturePullRequestScope, number: Int, expectedURL: String, commentID: String, kind: String, body: String) async throws + func commentOnPullRequest(scope: FeaturePullRequestScope, number: Int, expectedURL: String, body: String) async throws + func runPullRequestAction(scope: FeaturePullRequestScope, number: Int, expectedURL: String, request: PullRequestActionRequest) async throws + func pullRequestThreadComments(scope: FeaturePullRequestScope, number: Int, threadID: String, cursor: String) async throws -> PullRequestThreadCommentsResult + func replyToPullRequestThread(scope: FeaturePullRequestScope, number: Int, expectedURL: String, threadID: String, body: String) async throws + func setPullRequestThreadResolution(scope: FeaturePullRequestScope, number: Int, expectedURL: String, threadID: String, resolved: Bool) async throws + func submitPullRequestReview(scope: FeaturePullRequestScope, number: Int, expectedURL: String, submission: PullRequestReviewSubmission) async throws +} + +@MainActor +struct FeaturePullRequestThreadAccess { + let loadMore: (String, String) async throws -> PullRequestThreadCommentsResult + let reply: (String, String) async throws -> Void + let resolve: (String, Bool) async throws -> Void + init(writer: any FeaturePullRequestReviewWriting, scope: FeaturePullRequestScope, number: Int, expectedURL: String) { + loadMore = { try await writer.pullRequestThreadComments(scope: scope, number: number, threadID: $0, cursor: $1) } + reply = { try await writer.replyToPullRequestThread(scope: scope, number: number, expectedURL: expectedURL, threadID: $0, body: $1) } + resolve = { try await writer.setPullRequestThreadResolution(scope: scope, number: number, expectedURL: expectedURL, threadID: $0, resolved: $1) } + } +} + +@MainActor +protocol FeaturePullRequestCacheInvalidating: AnyObject, Sendable { + func invalidatePullRequest(scope: FeaturePullRequestScope, number: Int) async throws + func invalidatePullRequestListings(environmentID: String) async throws +} + +@MainActor +struct FeaturePullRequestEditingAccess { + let update: (PullRequestTextUpdate) async throws -> Void + let updateComment: (String, String, String) async throws -> Void + let comment: (String) async throws -> Void + init(update: @escaping (PullRequestTextUpdate) async throws -> Void, + updateComment: @escaping (String, String, String) async throws -> Void, + comment: @escaping (String) async throws -> Void) { + self.update = update; self.updateComment = updateComment; self.comment = comment + } + init(writer: any FeaturePullRequestReviewWriting, scope: FeaturePullRequestScope, number: Int, expectedURL: String) { + update = { try await writer.updatePullRequestText(scope: scope, number: number, expectedURL: expectedURL, update: $0) } + updateComment = { try await writer.updatePullRequestComment(scope: scope, number: number, expectedURL: expectedURL, commentID: $0, kind: $1, body: $2) } + comment = { try await writer.commentOnPullRequest(scope: scope, number: number, expectedURL: expectedURL, body: $0) } + } +} + +@MainActor +struct FeaturePullRequestReviewerAccess { + let load: () async throws -> PullRequestReviewerCandidateList + let request: (PullRequestReviewerRequest) async throws -> Void + init(writer: any FeaturePullRequestReviewWriting, scope: FeaturePullRequestScope, number: Int, expectedURL: String) { + load = { try await writer.pullRequestReviewerCandidates(scope: scope, number: number, expectedURL: expectedURL) } + request = { try await writer.requestPullRequestReviewers(scope: scope, number: number, expectedURL: expectedURL, request: $0) } + } +} diff --git a/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift b/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift index c92078069..9db442e16 100644 --- a/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift +++ b/apps/swift-ios/Features/Chat/ImageAttachmentViews.swift @@ -658,7 +658,7 @@ enum FeatureImageAttachmentError: LocalizedError, Equatable { /// Turns a picked file into a draft attachment without re-encoding it. /// /// The size cap comes from the classified kind rather than a single constant: -/// the contract gives PDFs, video and generic files 20 MB while images keep the +/// the contract gives PDFs, video and generic files 50 MB while images keep the /// tighter 10 MB limit, and validating here means a rejection is a picker error /// instead of a failed turn. enum FeatureDocumentProcessor { @@ -701,3 +701,45 @@ enum FeatureDocumentAttachmentError: LocalizedError, Equatable { } } } + +/// NSItemProvider deletes its temporary file as soon as the callback returns. +/// Read and process inside that callback, never pass the temporary URL to a Task. +enum FeatureDroppedAttachment { + static func presentationURL(temporaryURL: URL, suggestedName: String?, typeIdentifier: String) -> URL { + let suggested = suggestedName?.trimmingCharacters(in: .whitespacesAndNewlines) + let name = suggested.flatMap { $0.isEmpty ? nil : URL(fileURLWithPath: $0).lastPathComponent } ?? temporaryURL.lastPathComponent + var result = URL(fileURLWithPath: name.isEmpty ? "Attachment" : name) + if result.pathExtension.isEmpty, let suffix = UTType(typeIdentifier)?.preferredFilenameExtension { + result.appendPathExtension(suffix) + } + return result + } + + static func load(_ provider: NSItemProvider, typeIdentifier: String) async throws -> FeatureDraftAttachment { + let suggestedName = provider.suggestedName + return try await withCheckedThrowingContinuation { continuation in + provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) { url, error in + do { + if let error { throw error } + guard let url else { throw CocoaError(.fileReadUnknown) } + let maximum = 50 * 1_024 * 1_024 + let values = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) + guard values.isRegularFile == true else { throw CocoaError(.fileReadUnsupportedScheme) } + guard (values.fileSize ?? 0) <= maximum else { + throw FeatureDocumentAttachmentError.tooLarge(name: url.lastPathComponent, maximumBytes: maximum) + } + let data = try Data(contentsOf: url, options: .mappedIfSafe) + let namedURL = presentationURL(temporaryURL: url, suggestedName: suggestedName, typeIdentifier: typeIdentifier) + let document = try FeatureDocumentProcessor.attachment(from: data, url: namedURL) + if ComposerAttachments.classify(mimeType: document.mimeType, name: document.filename) == .image { + var image = try FeatureImageProcessor.attachment(from: data, ordinal: 1, sourceMIMEType: document.mimeType) + image.filename = namedURL.deletingPathExtension().lastPathComponent + ".jpg" + continuation.resume(returning: image) + } else { + continuation.resume(returning: document) + } + } catch { continuation.resume(throwing: error) } + } + } + } +} diff --git a/apps/swift-ios/Features/Chat/InlineUnifiedDiff.swift b/apps/swift-ios/Features/Chat/InlineUnifiedDiff.swift index f135ac877..f63614d78 100644 --- a/apps/swift-ios/Features/Chat/InlineUnifiedDiff.swift +++ b/apps/swift-ios/Features/Chat/InlineUnifiedDiff.swift @@ -195,16 +195,16 @@ struct InlineUnifiedDiff: View { private func markerColor(_ kind: UnifiedDiffRow.Kind) -> Color { switch kind { - case .addition: T3Colors.success - case .deletion: T3Colors.danger + case .addition: T3Colors.diffAddition + case .deletion: T3Colors.diffDeletion case .context, .hunk: T3Colors.textTertiary } } private func background(_ kind: UnifiedDiffRow.Kind) -> Color { switch kind { - case .addition: T3Colors.success.opacity(0.12) - case .deletion: T3Colors.danger.opacity(0.12) + case .addition: T3Colors.diffAddition.opacity(0.12) + case .deletion: T3Colors.diffDeletion.opacity(0.12) case .context, .hunk: .clear } } diff --git a/apps/swift-ios/Features/Chat/MarkdownDocument.swift b/apps/swift-ios/Features/Chat/MarkdownDocument.swift index 991446dee..c0b2211e1 100644 --- a/apps/swift-ios/Features/Chat/MarkdownDocument.swift +++ b/apps/swift-ios/Features/Chat/MarkdownDocument.swift @@ -38,6 +38,7 @@ indirect enum MarkdownBlock: Equatable, Sendable { /// as source. The block carries the fence body verbatim; assembling the /// document around it belongs to `HtmlEmbed`. case htmlEmbed(String) + case artifactTemplate(CodexArtifactTemplate) case thematicBreak } @@ -103,6 +104,12 @@ private struct MarkdownBlockParser { continue } + if let template = CodexArtifactTemplate.parse(lines[index]) { + blocks.append(.artifactTemplate(template)) + index += 1 + continue + } + if let heading = atxHeading(in: lines[index]) { blocks.append(.heading(level: heading.level, text: heading.text)) index += 1 @@ -432,7 +439,8 @@ private struct MarkdownBlockParser { } private func isBlockStarter(_ line: String) -> Bool { - fenceMarker(in: line) != nil + CodexArtifactTemplate.parse(line) != nil + || fenceMarker(in: line) != nil || atxHeading(in: line) != nil || blockquoteContent(in: line) != nil || listMarker(in: line) != nil diff --git a/apps/swift-ios/Features/Chat/MarkdownMediaView.swift b/apps/swift-ios/Features/Chat/MarkdownMediaView.swift index ff0eb79ca..5f2b29163 100644 --- a/apps/swift-ios/Features/Chat/MarkdownMediaView.swift +++ b/apps/swift-ios/Features/Chat/MarkdownMediaView.swift @@ -7,6 +7,7 @@ import UIKit /// nothing to resolve against and stays a named placeholder. struct MarkdownMediaContext { let threadID: String + var resolveDocumentURL: (@MainActor (FeatureMessageAttachment) async throws -> URL)? = nil /// Mints a signed asset URL for media that lives on the environment rather /// than on the open web. Throwing is the normal failure path: an /// environment without the capability, a purged worktree, a missing file. @@ -23,7 +24,7 @@ struct MarkdownMediaContext { /// The transcript's context: workspace files and browser artifacts both go /// through the client's signed asset route, which is also what resolves /// them for the file viewer and for message attachments. - init(threadID: String, client: any FeatureClient) { + init(threadID: String, client: any FeatureClient, baseDirectory: String? = nil) { let resolver = client as? any FeatureWorkspaceAssetResolving self.init(threadID: threadID) { resource in guard let resolver else { @@ -31,7 +32,7 @@ struct MarkdownMediaContext { } switch resource { case let .workspaceFile(threadID, path): - return try await resolver.workspaceAssetURL(threadID: threadID, path: path) + return try await resolver.workspaceAssetURL(threadID: threadID, path: FeatureFilePreviewPath.resolve(path, relativeTo: baseDirectory)) case let .browserArtifact(fileName): return try await resolver.browserArtifactAssetURL( threadID: threadID, @@ -39,6 +40,9 @@ struct MarkdownMediaContext { ) } } + if let documents = client as? any FeatureDocumentAttachmentResolving { + resolveDocumentURL = { attachment in try await documents.documentAttachmentURL(threadID: threadID, attachment: attachment) } + } } } @@ -340,9 +344,7 @@ struct FeatureImagePreviewSheet: View { AsyncImage(url: url) { phase in switch phase { case let .success(image): - image - .resizable() - .scaledToFit() + ZoomableMessageImage(image: image) case .failure: ContentUnavailableView( "Image unavailable", @@ -403,7 +405,7 @@ struct MarkdownGallerySheet: View { } else if let url = urls[page] { AsyncImage(url: url) { phase in switch phase { - case let .success(image): image.resizable().scaledToFit() + case let .success(image): ZoomableMessageImage(image: image, isCurrentPage: page == index) case .failure: ContentUnavailableView("Image unavailable", systemImage: "photo") default: ProgressView() } diff --git a/apps/swift-ios/Features/Chat/MarkdownMessageView.swift b/apps/swift-ios/Features/Chat/MarkdownMessageView.swift index 684e68bb6..514fcdaa3 100644 --- a/apps/swift-ios/Features/Chat/MarkdownMessageView.swift +++ b/apps/swift-ios/Features/Chat/MarkdownMessageView.swift @@ -9,6 +9,10 @@ struct MarkdownMessageView: View { } private let source: String + private let citationMessageID: String? + @State private var isCiting = false + @SwiftUI.Environment(\.assistantCitationContext) private var citationContext + @SwiftUI.Environment(\.assistantCitationHighlight) private var citationHighlight private let revision: MarkdownContentRevision private let isStreaming: Bool @State private var renderedDocument: MarkdownRenderedDocument? @@ -17,8 +21,9 @@ struct MarkdownMessageView: View { @State private var previewTarget: PullRequestLinkTarget? @SwiftUI.Environment(\.markdownPullRequestContext) private var pullRequestContext - init(_ source: String, isStreaming: Bool = false) { + init(_ source: String, isStreaming: Bool = false, citationMessageID: String? = nil) { self.source = source + self.citationMessageID = citationMessageID self.isStreaming = isStreaming let revision = MarkdownContentRevision(source) self.revision = revision @@ -35,12 +40,12 @@ struct MarkdownMessageView: View { var body: some View { Group { if let displayDocument { - MarkdownBlocksView(blocks: displayDocument.blocks) + MarkdownBlocksView(blocks: highlightedBlocks(displayDocument)) .environment(\.markdownGallery, MarkdownGallery.images(in: displayDocument.blocks)) } else { // Parsing waits briefly so token-by-token streaming cancels stale revisions // instead of scheduling work for content the user will never see. - Text(verbatim: source) + highlightedSourceText .font(T3Typography.threadBody) .lineSpacing(4) .fixedSize(horizontal: false, vertical: true) @@ -48,6 +53,9 @@ struct MarkdownMessageView: View { } .modifier(MarkdownTextSelectionModifier(isEnabled: isSelectingText)) .contextMenu { + if citationMessageID != nil, citationContext != nil, !isStreaming { + Button("Cite text", systemImage: "quote.bubble") { isCiting = true } + } if pullRequestContext != nil { ForEach(PullRequestLinkTarget.links(in: source)) { target in Button("Preview pull request #\(String(target.number))", systemImage: "arrow.triangle.pull") { @@ -69,6 +77,11 @@ struct MarkdownMessageView: View { Label("Copy message", systemImage: "doc.on.doc") } } + .sheet(isPresented: $isCiting) { + if let citationContext, let citationMessageID { + AssistantCitationSelectionSheet(text: displayDocument?.citationText ?? source, messageId: citationMessageID, context: citationContext) + } + } .sheet(item: $previewTarget) { target in if let pullRequestContext { PullRequestLinkPreview(target: target, context: pullRequestContext) } } @@ -105,6 +118,20 @@ struct MarkdownMessageView: View { } } + private var highlightedSourceText: Text { + guard let citation = citationHighlight?.citation, citation.messageId == citationMessageID, + let range = AssistantCitationTextRange.resolve(in: source, quote: citation.text, + start: citation.start, end: citation.end, prefix: citation.prefix, suffix: citation.suffix) else { return Text(verbatim: source) } + return Text(MarkdownCitationHighlight.mark(AttributedString(source), range: range)) + } + + private func highlightedBlocks(_ document: MarkdownRenderedDocument) -> [MarkdownRenderedBlock] { + guard let citation = citationHighlight?.citation, citation.messageId == citationMessageID, + let range = AssistantCitationTextRange.resolve(in: document.citationText, quote: citation.text, + start: citation.start, end: citation.end, prefix: citation.prefix, suffix: citation.suffix) else { return document.blocks } + return MarkdownCitationHighlight.blocks(document.blocks, range: range) + } + private var displayDocument: MarkdownRenderedDocument? { if let renderedDocument, renderedDocument.revision == revision { return renderedDocument @@ -260,12 +287,15 @@ private struct MarkdownBlockView: View, Equatable { case let .image(image): MarkdownMediaView(image: image) - case let .codeBlock(language, code): - MarkdownCodeBlockView(language: language, code: code) + case let .codeBlock(language, code, citationRange): + MarkdownCodeBlockView(language: language, code: code, citationRange: citationRange) case let .htmlEmbed(html): HtmlEmbedView(html: html) + case let .artifactTemplate(template): + NativeArtifactTemplateCard(template: template) + case .thematicBreak: Rectangle() .fill(T3Colors.separator) @@ -454,6 +484,11 @@ private struct MarkdownListView: View { private struct MarkdownCodeBlockView: View { let language: String? let code: String + let citationRange: NSRange? + private var codeText: Text { + if let citationRange { Text(MarkdownCitationHighlight.mark(AttributedString(code), range: citationRange)) } + else { Text(verbatim: code) } + } @State private var wrapOverride: Bool? private var wrapsLines: Bool { @@ -503,7 +538,7 @@ private struct MarkdownCodeBlockView: View { .frame(height: 1) if wrapsLines { - Text(verbatim: code) + codeText .font(T3Typography.code) .foregroundStyle(T3Colors.textPrimary.opacity(0.94)) .lineSpacing(3) @@ -512,7 +547,7 @@ private struct MarkdownCodeBlockView: View { .padding(13) } else { ScrollView(.horizontal) { - Text(verbatim: code) + codeText .font(T3Typography.code) .foregroundStyle(T3Colors.textPrimary.opacity(0.94)) .lineSpacing(3) @@ -606,3 +641,33 @@ enum MarkdownGallery { } } } + +private struct MarkdownTemplateActionKey: EnvironmentKey { + static let defaultValue: ((CodexArtifactTemplate) -> Void)? = nil +} +extension EnvironmentValues { + var markdownTemplateAction: ((CodexArtifactTemplate) -> Void)? { + get { self[MarkdownTemplateActionKey.self] } + set { self[MarkdownTemplateActionKey.self] = newValue } + } +} + +private struct NativeArtifactTemplateCard: View { + let template: CodexArtifactTemplate + @SwiftUI.Environment(\.markdownTemplateAction) private var useTemplate + var body: some View { + HStack(spacing: 12) { + Image(systemName: "doc.badge.gearshape").font(.title2).foregroundStyle(T3Colors.accent) + VStack(alignment: .leading, spacing: 3) { + Text(template.displayName).font(T3Typography.supportingStrong) + Text(template.label).font(T3Typography.supporting).foregroundStyle(T3Colors.textSecondary) + }.frame(maxWidth: .infinity, alignment: .leading) + if let useTemplate { + Button("Use template") { useTemplate(template) }.buttonStyle(.bordered).font(T3Typography.supporting) + } + } + .padding(12).background(T3Colors.surface, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(T3Colors.border, lineWidth: 1)) + .accessibilityElement(children: .contain) + } +} diff --git a/apps/swift-ios/Features/Chat/MarkdownRenderCache.swift b/apps/swift-ios/Features/Chat/MarkdownRenderCache.swift index 299dbd065..d5e5225a5 100644 --- a/apps/swift-ios/Features/Chat/MarkdownRenderCache.swift +++ b/apps/swift-ios/Features/Chat/MarkdownRenderCache.swift @@ -134,11 +134,12 @@ indirect enum MarkdownRenderedBlock: Equatable, @unchecked Sendable { /// posted in, which this render task does not know, and loading happens on /// the main actor once the block is on screen. case image(MarkdownInlineImage) - case codeBlock(language: String?, code: String) + case codeBlock(language: String?, code: String, citationRange: NSRange? = nil) /// Carried through unrendered: the embed's document is assembled on the /// main actor from the current colour scheme, which this render task does /// not know and must not capture. case htmlEmbed(String) + case artifactTemplate(CodexArtifactTemplate) case thematicBreak } @@ -384,6 +385,9 @@ final class MarkdownRenderCache: @unchecked Sendable { case let .htmlEmbed(html): rendered = .htmlEmbed(html) + case let .artifactTemplate(template): + rendered = .artifactTemplate(template) + case .thematicBreak: rendered = .thematicBreak } @@ -445,7 +449,7 @@ final class MarkdownRenderCache: @unchecked Sendable { } let inline = MarkdownRenderedInline( - attributedText: MarkdownInlineFormatter.format(source, baseFont: style.font), + attributedText: MarkdownInlineFormatter.format(CodexMarkdownDirectives.renderFileCitations(source), baseFont: style.font), style: style ) guard !Task.isCancelled else { return nil } diff --git a/apps/swift-ios/Features/Chat/NativeAppToolIcon.swift b/apps/swift-ios/Features/Chat/NativeAppToolIcon.swift new file mode 100644 index 000000000..a896db6df --- /dev/null +++ b/apps/swift-ios/Features/Chat/NativeAppToolIcon.swift @@ -0,0 +1,70 @@ +import SwiftUI + +@MainActor +public protocol FeatureNativeAppIconResolving: AnyObject { + func nativeAppIconURL(environmentID: String, app: ToolActivityNativeAppReference) async throws -> URL? +} + +@MainActor +final class NativeAppToolIconStore { + private struct Entry { let url: URL?; let expires: Date } + private var entries: [String: Entry] = [:] + private var pending: [String: Task] = [:] + + func resolve(environmentID: String, app: ToolActivityNativeAppReference, client: any FeatureNativeAppIconResolving) async -> URL? { + let key = environmentID + "|" + app._tag + "|" + (app.appId ?? app.displayName ?? "") + if let cached = entries[key], cached.expires > Date() { return cached.url } + if let task = pending[key] { return await task.value } + let task = Task { @MainActor in try? await client.nativeAppIconURL(environmentID: environmentID, app: app) } + pending[key] = task + let url = await task.value + pending.removeValue(forKey: key) + if entries.count >= 128, let oldest = entries.min(by: { $0.value.expires < $1.value.expires })?.key { entries.removeValue(forKey: oldest) } + entries[key] = Entry(url: url, expires: Date().addingTimeInterval(url == nil ? 30 : 300)) + return url + } +} + +struct NativeAppToolIconContext { + let environmentID: String + let store: NativeAppToolIconStore + let client: any FeatureNativeAppIconResolving +} + +private struct NativeAppToolIconContextKey: EnvironmentKey { + static let defaultValue: NativeAppToolIconContext? = nil +} + +extension EnvironmentValues { + var nativeAppToolIconContext: NativeAppToolIconContext? { + get { self[NativeAppToolIconContextKey.self] } + set { self[NativeAppToolIconContextKey.self] = newValue } + } +} + +struct NativeAppToolIcon: View { + let app: ToolActivityNativeAppReference + let fallback: String + @SwiftUI.Environment(\.nativeAppToolIconContext) private var context + @State private var resolved: (key: String, url: URL?)? + private var requestKey: String { (context?.environmentID ?? "") + "|" + app._tag + "|" + (app.appId ?? app.displayName ?? "") } + var body: some View { + Group { + if resolved?.key == requestKey, let url = resolved?.url { + AsyncImage(url: url) { phase in + if let image = phase.image { image.resizable().scaledToFit() } + else { Image(systemName: fallback) } + } + } else { Image(systemName: fallback) } + } + .frame(width: 16, height: 16) + .accessibilityHidden(true) + .task(id: requestKey) { + guard let context else { return } + let key = requestKey + let url = await context.store.resolve(environmentID: context.environmentID, app: app, client: context.client) + guard !Task.isCancelled else { return } + resolved = (key, url) + } + } +} diff --git a/apps/swift-ios/Features/Chat/NativeToolLogo.swift b/apps/swift-ios/Features/Chat/NativeToolLogo.swift new file mode 100644 index 000000000..f0af66a92 --- /dev/null +++ b/apps/swift-ios/Features/Chat/NativeToolLogo.swift @@ -0,0 +1,90 @@ +import SwiftUI +import ImageIO +import SVGView + +/// Transcript rows display cached bitmaps. SVG parsing and image decoding happen once per URL, +/// not during body evaluation or scrolling; dark logos have their own cache identity. +@MainActor +final class NativeToolLogoStore { + static let shared = NativeToolLogoStore() + private struct Entry { let image: UIImage?; let expires: Date } + private var entries: [URL: Entry] = [:] + private var pending: [URL: Task] = [:] + private let session: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 10 + configuration.httpMaximumConnectionsPerHost = 4 + return URLSession(configuration: configuration) + }() + + func image(for url: URL) async -> UIImage? { + if let entry = entries[url], entry.expires > .now { return entry.image } + if let task = pending[url] { return await task.value } + // A noisy transcript must not create an unbounded set of concurrent downloads. + guard pending.count < 16 else { return nil } + let task = Task { @MainActor [session] in + do { + let data: Data + if url.scheme?.lowercased() == "data" { + guard let inline = ToolIconImageData.inline(url.absoluteString) else { return nil as UIImage? } + data = inline + } else { + guard ["http", "https"].contains(url.scheme?.lowercased() ?? "") else { return nil } + let (bytes, response) = try await session.bytes(from: url) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode), + response.expectedContentLength <= ToolIconImageData.maximumBytes else { return nil } + var received = Data() + for try await byte in bytes { + guard received.count < ToolIconImageData.maximumBytes else { return nil } + received.append(byte) + } + data = received + } + return Self.decode(data) + } catch { return nil } + } + pending[url] = task + let image = await task.value + pending.removeValue(forKey: url) + if entries.count >= 128, let oldest = entries.min(by: { $0.value.expires < $1.value.expires })?.key { + entries.removeValue(forKey: oldest) + } + entries[url] = Entry(image: image, expires: .now.addingTimeInterval(image == nil ? 30 : 600)) + return image + } + + static func decode(_ data: Data) -> UIImage? { + if let source = CGImageSourceCreateWithData(data as CFData, nil), + let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: 48, + kCGImageSourceCreateThumbnailWithTransform: true, + ] as CFDictionary) { + return UIImage(cgImage: thumbnail, scale: 3, orientation: .up) + } + guard ToolIconSVGValidation.accepts(data), let svg = SVGParser.parse(data: data, settings: SVGSettings(linker: .none)) else { return nil } + let renderer = ImageRenderer(content: SVGView(svg: svg).frame(width: 16, height: 16)) + renderer.scale = 3 + return renderer.uiImage + } +} + +struct NativeToolLogo: View { + let url: URL + let fallback: String + @State private var loaded: (url: URL, image: UIImage?)? + var body: some View { + Group { + if loaded?.url == url, let image = loaded?.image { + Image(uiImage: image).resizable().scaledToFit() + } else { Image(systemName: fallback) } + } + .frame(width: 16, height: 16) + .accessibilityHidden(true) + .task(id: url) { + let image = await NativeToolLogoStore.shared.image(for: url) + guard !Task.isCancelled else { return } + loaded = (url, image) + } + } +} diff --git a/apps/swift-ios/Features/Chat/PullRequestActionLogic.swift b/apps/swift-ios/Features/Chat/PullRequestActionLogic.swift new file mode 100644 index 000000000..e4b361af5 --- /dev/null +++ b/apps/swift-ios/Features/Chat/PullRequestActionLogic.swift @@ -0,0 +1,58 @@ +import Foundation + +enum NativePullRequestAction: String, CaseIterable, Identifiable { + case merge, ready, draft, close, reopen + case updateBranch = "update-branch" + case enableAutoMerge = "enable-auto-merge" + case disableAutoMerge = "disable-auto-merge" + var id: String { rawValue } + var label: String { + switch self { + case .merge: "Merge pull request" + case .ready: "Mark ready for review" + case .draft: "Convert to draft" + case .close: "Close pull request" + case .reopen: "Reopen pull request" + case .updateBranch: "Update branch" + case .enableAutoMerge: "Enable auto-merge" + case .disableAutoMerge: "Disable auto-merge" + } + } + var needsReview: Bool { [.merge, .close, .updateBranch, .enableAutoMerge].contains(self) } + var explanation: String { + switch self { + case .merge: "Merge this pull request on the host. Your local checkout is unchanged." + case .close: "Close without merging. You can reopen it if the host still permits it." + case .updateBranch: "Bring the base branch into this PR’s branch on the host. Rebase rewrites the branch’s commits." + case .enableAutoMerge: "The host merges as soon as its requirements are met. It may merge immediately if they are already met." + default: label + } + } +} + +enum PullRequestActionLogic { + static func mergeMethods(_ detail: PullRequestDetail) -> [String] { + (detail.capabilities?.mergeMethods ?? []).filter { ["merge", "squash", "rebase"].contains($0) && detail.mergeCapabilities?[$0] == true } + } + static func updateMethods(_ detail: PullRequestDetail) -> [String] { + (detail.capabilities?.updateMethods ?? []).filter { ["merge", "rebase"].contains($0) && detail.viewerPermissions?.updateMethods?.contains($0) == true } + } + static func offered(_ detail: PullRequestDetail) -> [NativePullRequestAction] { + NativePullRequestAction.allCases.filter { action in + guard detail.capabilities?.actions.contains(action.rawValue) == true, + detail.viewerPermissions?.actions.contains(action.rawValue) == true else { return false } + if detail.state == .closed { return action == .reopen } + guard detail.state == .open else { return false } + switch action { + case .merge: return !detail.isDraft && detail.mergeability != .conflicting && !mergeMethods(detail).isEmpty + case .ready: return detail.isDraft + case .draft: return !detail.isDraft + case .close: return true + case .reopen: return false + case .updateBranch: return detail.baseComparison == "behind" && detail.mergeability == .mergeable && !updateMethods(detail).isEmpty + case .enableAutoMerge: return detail.autoMergeEnabled == false && !detail.isDraft && detail.mergeability != .conflicting && !mergeMethods(detail).isEmpty + case .disableAutoMerge: return detail.autoMergeEnabled == true + } + } + } +} diff --git a/apps/swift-ios/Features/Chat/PullRequestActionSheet.swift b/apps/swift-ios/Features/Chat/PullRequestActionSheet.swift new file mode 100644 index 000000000..e41e1507f --- /dev/null +++ b/apps/swift-ios/Features/Chat/PullRequestActionSheet.swift @@ -0,0 +1,54 @@ +import SwiftUI + +struct PullRequestActionSheet: View { + let action: NativePullRequestAction + let detail: PullRequestDetail + let perform: (PullRequestActionRequest) async throws -> Void + let completed: () -> Void + @State private var method = "" + @State private var pending = false + @State private var error: String? + @SwiftUI.Environment(\.dismiss) private var dismiss + private var methods: [String] { + switch action { + case .merge, .enableAutoMerge: PullRequestActionLogic.mergeMethods(detail) + case .updateBranch: PullRequestActionLogic.updateMethods(detail) + default: [] + } + } + var body: some View { + NavigationStack { + Form { + Section { + Text("#\(detail.number) · \(detail.title)").font(T3Typography.supportingStrong) + Text("\(detail.headBranch) → \(detail.baseBranch)").font(T3Typography.supporting.monospaced()) + Text(action.explanation).foregroundStyle(T3Colors.textSecondary) + if !methods.isEmpty { + Picker("Method", selection: $method) { + ForEach(methods, id: \.self) { Text($0.capitalized).tag($0) } + }.disabled(pending) + } + if let error { Text(error).foregroundStyle(T3Colors.warning) } + Button(role: action == .close ? .destructive : nil) { + pending = true; error = nil + Task { + defer { pending = false } + do { + try await perform(.init(action: action.rawValue, + mergeMethod: action == .merge || action == .enableAutoMerge ? method : nil, + updateMethod: action == .updateBranch ? method : nil)) + dismiss(); completed() + } catch { self.error = error.localizedDescription } + } + } label: { HStack { Text(action.label); if pending { ProgressView() } } } + .disabled(pending || (!methods.isEmpty && !methods.contains(method))) + } + } + .scrollContentBackground(.hidden).background(T3Colors.background) + .navigationTitle(action.label).navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() }.disabled(pending) } } + .interactiveDismissDisabled(pending) + .onAppear { if !methods.contains(method) { method = methods.first ?? "" } } + } + } +} diff --git a/apps/swift-ios/Features/Chat/PullRequestCodeModel.swift b/apps/swift-ios/Features/Chat/PullRequestCodeModel.swift new file mode 100644 index 000000000..64fdd931f --- /dev/null +++ b/apps/swift-ios/Features/Chat/PullRequestCodeModel.swift @@ -0,0 +1,87 @@ +import Foundation +import Observation + +/// Each page is scoped to the selected PR/commit. Stale requests cannot append to a newer diff. +@MainActor @Observable +final class PullRequestCodeModel { + private(set) var files: [FeatureReviewFile] = [] + private(set) var loading = false + private(set) var error: String? + private(set) var nextCursor: String? + private(set) var truncated = false + private var generation = UUID() + private var loadedCursors = Set() + + func refresh(commit: String?, load: (String?, String?) async throws -> PullRequestDiffResult) async { + generation = UUID() + files = []; nextCursor = nil; truncated = false; loadedCursors = [] + await fetch(cursor: nil, commit: commit, load: load) + } + + func loadMore(commit: String?, load: (String?, String?) async throws -> PullRequestDiffResult) async { + guard !loading, let nextCursor else { return } + await fetch(cursor: nextCursor, commit: commit, load: load) + } + + private func fetch(cursor: String?, commit: String?, load: (String?, String?) async throws -> PullRequestDiffResult) async { + let request = generation + loading = true; error = nil + defer { if generation == request { loading = false } } + do { + let result = try await load(cursor, commit) + guard !Task.isCancelled, generation == request else { return } + let source = ReviewDiffSource(id: cursor ?? "first", kind: "pull-request", title: "Pull request", baseRef: nil, headRef: nil, diff: result.patch, diffHash: "", truncated: result.truncated) + var incoming = NativeUnifiedDiffMapper.parseDiff(source) + for stat in result.omittedFileStats ?? [] { + if let index = incoming.firstIndex(where: { $0.path == stat.path }) { + incoming[index].additions = stat.additions; incoming[index].deletions = stat.deletions + } else { + incoming.append(FeatureReviewFile(path: stat.path, change: .modified, additions: stat.additions, deletions: stat.deletions)) + } + } + // Some hosts repeat a boundary file when a page is refreshed. Its authoritative + // entry replaces the old one without duplicating paths or adding its counts twice. + for file in incoming { + if let index = files.firstIndex(where: { $0.path == file.path }) { files[index] = file } + else { files.append(file) } + } + if let cursor { loadedCursors.insert(cursor) } + truncated = truncated || result.truncated + if let next = result.nextCursor, loadedCursors.contains(next) { + nextCursor = nil + error = "The host repeated a diff page. Refresh to try again." + } else { nextCursor = result.nextCursor } + } catch { + guard !Task.isCancelled, generation == request else { return } + self.error = error.localizedDescription + } + } +} + +struct PullRequestCodeTreeRow: Identifiable { + let id: String + let name: String + let depth: Int + let file: FeatureReviewFile? + let expanded: Bool + + static func rows(files: [FeatureReviewFile], collapsed: Set, search: String) -> [Self] { + let matching = files.filter { search.isEmpty || $0.path.localizedCaseInsensitiveContains(search) } + var result: [Self] = [] + var directories = Set() + for file in matching.sorted(by: { $0.path.localizedStandardCompare($1.path) == .orderedAscending }) { + let components = file.path.split(separator: "/").map(String.init) + var hidden = false + for index in 0..
+
{showEnvironmentIndicator && availableEnvironments && ( <> )} + {composerControlsHostRef ? ( +
+ ) : null} + {showGitControls ? ( { + (branch: string | null, worktreePath: string | null, automatic = false) => { if (!activeThreadId || !activeProject) return; if (serverSession && worktreePath !== activeWorktreePath) { void stopThreadSession({ @@ -192,6 +193,7 @@ export function BranchToolbarBranchSelector({ branch, worktreePath, envMode: nextDraftEnvMode, + environmentSelection: automatic ? (draftThread?.environmentSelection ?? "auto") : "manual", projectRef: scopeProjectRef(environmentId, activeProject.id), }); }, @@ -207,6 +209,7 @@ export function BranchToolbarBranchSelector({ threadRef, environmentId, effectiveEnvMode, + draftThread?.environmentSelection, stopThreadSession, updateThreadMetadata, ], @@ -513,7 +516,7 @@ export function BranchToolbarBranchSelector({ ) { return; } - setThreadBranch(worktreeBaseBranchCandidate, null); + setThreadBranch(worktreeBaseBranchCandidate, null, true); }, [ activeThreadBranch, activeWorktreePath, @@ -759,7 +762,11 @@ export function BranchToolbarBranchSelector({ /> } > - + #{branchPr.number} {branchPrTooltip} @@ -820,6 +827,8 @@ export function BranchToolbarBranchSelector({ } > {panelPrLabel} @@ -901,7 +910,7 @@ export function BranchToolbarBranchSelector({ className="flex cursor-pointer items-center justify-between gap-3 border-t border-border/60 px-3 py-2 text-xs" > - void) | undefined; envLocked: boolean; environmentId: EnvironmentId; availableEnvironments: readonly EnvironmentOption[]; @@ -29,6 +32,8 @@ interface BranchToolbarEnvironmentSelectorProps { } export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ + autoEnvironmentLabel, + onAutoEnvironment, envLocked, environmentId, availableEnvironments, @@ -40,12 +45,13 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir }, [availableEnvironments, environmentId]); const environmentItems = useMemo( - () => - availableEnvironments.map((env) => ({ - value: env.environmentId, - label: env.label, - })), - [availableEnvironments], + () => [ + ...(onAutoEnvironment + ? [{ value: "auto", label: autoEnvironmentLabel ?? "Auto balance" }] + : []), + ...availableEnvironments.map((env) => ({ value: env.environmentId, label: env.label })), + ], + [availableEnvironments, autoEnvironmentLabel, onAutoEnvironment], ); // The static label carries the xs control's height (h-7 sm:h-6) as well as @@ -62,19 +68,10 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir )} data-composer-context-control > - {activeEnvironment?.isPrimary ? ( - - ) : ( - - )} + onEnvironmentChange(value as EnvironmentId)} + value={autoEnvironmentLabel ? "auto" : environmentId} + onValueChange={(value) => + value === "auto" ? onAutoEnvironment?.() : onEnvironmentChange(value as EnvironmentId) + } items={environmentItems} > - {activeEnvironment?.isPrimary ? ( - + {autoEnvironmentLabel ? ( +
- +
+
+ +
); }); @@ -1330,6 +1379,9 @@ function ChatViewContent(props: ChatViewProps) { ); const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); + const updateActionSettings = useAtomCommand(serverEnvironment.updateSettings, { + reportFailure: false, + }); const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { reportFailure: false, }); @@ -1420,6 +1472,31 @@ function ChatViewContent(props: ChatViewProps) { const alwaysExpandActivity = settings.alwaysExpandActivity; const autoOpenPlanSidebar = settings.autoOpenPlanSidebar; const navigate = useNavigate(); + const citationLocation = useLocation({ + select: (location) => ({ + href: location.href, + key: location.state.assistantCitationActivation ?? location.state.__TSR_key, + }), + }); + const citationRequest = useMemo(() => { + const citation = assistantCitationFromLocation(citationLocation.href); + return citation && + citation.environmentId === routeThreadRef.environmentId && + citation.threadId === routeThreadRef.threadId + ? { citation, key: citationLocation.key ?? citationLocation.href } + : null; + }, [ + citationLocation.href, + citationLocation.key, + routeThreadRef.environmentId, + routeThreadRef.threadId, + ]); + const citeAssistantText = useCallback( + (citation: AssistantCitation, anchor: AssistantCitationSourceAnchor) => + composerRef.current?.citeAssistantText(citation, anchor) ?? false, + [], + ); + const { resolvedTheme } = useTheme(); // Granular store selectors — avoid subscribing to prompt changes. const composerRuntimeMode = useComposerDraftStore( @@ -1528,6 +1605,7 @@ function ChatViewContent(props: ChatViewProps) { const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); const shouldUsePlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); + const isMobileViewport = useMediaQuery("max-sm"); const [workspaceLayoutRef, workspaceLayoutWidth] = useElementWidth(); const previewPanelInlineSize = usePreviewPanelInlineSize(); const threadPanelPopoverAnchorRef = useRef(null); @@ -1559,6 +1637,8 @@ function ChatViewContent(props: ChatViewProps) { const legendListRef = useRef(null); const [composerOverlayElement, setComposerOverlayElement] = useState(null); const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); + const [composerResting, setComposerResting] = useState(false); + const [restingControlsHost, setRestingControlsHost] = useState(null); const isAtEndRef = useRef(true); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); @@ -1573,7 +1653,7 @@ function ChatViewContent(props: ChatViewProps) { const nextHeight = Math.ceil(composerOverlayElement.getBoundingClientRect().height); if (nextHeight <= 0) return; setComposerOverlayHeight((currentHeight) => - currentHeight === nextHeight ? currentHeight : nextHeight, + resolveRestingComposerInset(currentHeight, nextHeight, composerResting), ); }; @@ -1583,7 +1663,7 @@ function ChatViewContent(props: ChatViewProps) { const observer = new ResizeObserver(updateHeight); observer.observe(composerOverlayElement); return () => observer.disconnect(); - }, [composerOverlayElement]); + }, [composerOverlayElement, composerResting]); const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef), @@ -1672,10 +1752,17 @@ function ChatViewContent(props: ChatViewProps) { ? buildLocalDraftThread( threadId, draftThread, - fallbackDraftProject?.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION, + fallbackDraftProject?.defaultModelSelection ?? + settings.defaultModelSelection ?? + NO_PROVIDER_MODEL_SELECTION, ) : undefined, - [draftThread, fallbackDraftProject?.defaultModelSelection, threadId], + [ + draftThread, + fallbackDraftProject?.defaultModelSelection, + settings.defaultModelSelection, + threadId, + ], ); const isServerThread = serverThread !== null; const activeThread = isServerThread ? serverThread : localDraftThread; @@ -1862,8 +1949,6 @@ function ChatViewContent(props: ChatViewProps) { }, [activePullRequestSurfaceId], ); - const activeFileSurface = - activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; const activePreviewState = useThreadPreviewState(activeThreadRef); const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) => selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef), @@ -1883,6 +1968,35 @@ function ChatViewContent(props: ChatViewProps) { ); const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); const rightPanelOpen = rightPanelState.isOpen; + const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = + usePanelAnimationSettings(); + const activeTerminalDrawerPresence = usePanelPresence( + Boolean(activeThreadKey && terminalUiState.terminalOpen), + true, + panelAnimationsActive, + activeThreadKey, + panelAnimationDurationMs, + ); + const rightPanelPresenceValue = useMemo( + () => ({ + activeSurface: activeRightPanelSurface, + surfaces: rightPanelState.surfaces, + }), + [activeRightPanelSurface, rightPanelState.surfaces], + ); + const rightPanelPresence = usePanelPresence( + rightPanelOpen && activeThreadRef !== null, + rightPanelPresenceValue, + panelAnimationsActive, + activeThreadKey, + panelAnimationDurationMs, + ); + const rightPanelPresent = rightPanelPresence.present; + const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; + const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; + const activeFileSurface = + renderedRightPanelSurface?.kind === "file" ? renderedRightPanelSurface : null; + const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; const rightPanelMaximized = canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; @@ -1961,7 +2075,7 @@ function ChatViewContent(props: ChatViewProps) { currentThreadIds, openThreadIds: existingOpenTerminalThreadKeys, activeThreadId: activeThreadKey, - activeThreadTerminalOpen: Boolean(activeThreadKey && terminalUiState.terminalOpen), + activeThreadTerminalOpen: activeTerminalDrawerPresence.present, maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, }); return currentThreadIds.length === nextThreadIds.length && @@ -1969,15 +2083,24 @@ function ChatViewContent(props: ChatViewProps) { ? currentThreadIds : nextThreadIds; }); - }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); + }, [activeThreadKey, existingOpenTerminalThreadKeys, activeTerminalDrawerPresence.present]); const latestRunSettled = isLatestRunSettled(activeLatestRun, activeRuntime); const activeProjectRef = useMemo( () => activeThread ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) : null, [activeThread?.environmentId, activeThread?.projectId], ); - const activeProject = useProject(activeProjectRef); + const rawActiveProject = useProject(activeProjectRef); const serverConfigs = useServerConfigs(); + const supportsActionDefaults = + serverConfigs.get(environmentId)?.environment.capabilities.projectActionDefaults === true; + const activeProject = useMemo( + () => + rawActiveProject && supportsActionDefaults + ? { ...rawActiveProject, scripts: resolveProjectScripts(settings, rawActiveProject) } + : rawActiveProject, + [rawActiveProject, settings, supportsActionDefaults], + ); const handleNewThreadInActiveProject = useCallback(() => { startNewThreadForProject(activeProjectRef, handleNewThread); }, [activeProjectRef, handleNewThread]); @@ -2120,6 +2243,7 @@ function ChatViewContent(props: ChatViewProps) { projectId: ProjectId; label: string; isPrimary: boolean; + machineKind: EnvironmentMachineKind; }> = []; for (const p of memberProjects) { if (seen.has(p.environmentId)) continue; @@ -2131,6 +2255,9 @@ function ChatViewContent(props: ChatViewProps) { projectId: p.id, label, isPrimary, + machineKind: resolveEnvironmentMachineKind( + environmentById.get(p.environmentId)?.serverConfig ?? null, + ), }); } // Sort: primary first, then alphabetical @@ -2314,6 +2441,7 @@ function ChatViewContent(props: ChatViewProps) { const threadProvider = activeThread?.modelSelection.instanceId ?? activeProject?.defaultModelSelection?.instanceId ?? + settings.defaultModelSelection?.instanceId ?? null; // Once a thread selects an environment, never substitute the primary // environment's config while the selected environment is still loading. @@ -2334,6 +2462,9 @@ function ChatViewContent(props: ChatViewProps) { const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null; const supportsAttachmentUploads = attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true; + const supportsFileAttachmentUploads = + supportsAttachmentUploads && + attachmentEnvironmentConfig?.environment.capabilities.fileAttachments !== undefined; const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -2609,6 +2740,35 @@ function ChatViewContent(props: ChatViewProps) { () => deriveActivePlanState(serverProjection, activeLatestRun?.runId ?? undefined), [activeLatestRun?.runId, serverProjection], ); + const [tasksDrawerExpanded, setTasksDrawerExpanded] = useState(false); + useEffect(() => setTasksDrawerExpanded(false), [activeLatestRun?.runId, activeThread?.id]); + const composerTasks = useMemo(() => { + if ( + !activePlan || + !activeLatestRun || + latestRunSettled || + activePlan.runId !== activeLatestRun.runId || + pendingApprovals.length > 0 || + pendingUserInputs.length > 0 + ) + return null; + const current = activePlan.steps.find((step) => step.status === "inProgress"); + if (!current) return null; + return { + steps: activePlan.steps, + progress: { + step: current.step, + completedSteps: activePlan.steps.filter((step) => step.status === "completed").length, + totalSteps: activePlan.steps.length, + }, + }; + }, [ + activePlan, + activeLatestRun, + latestRunSettled, + pendingApprovals.length, + pendingUserInputs.length, + ]); const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks"; const showPlanFollowUpPrompt = pendingUserInputs.length === 0 && @@ -2722,6 +2882,10 @@ function ChatViewContent(props: ChatViewProps) { return next; }); }, []); + const workspaceMutationId = useMemo( + () => latestWorkspaceMutationId(serverVisibleTurnItems), + [serverVisibleTurnItems], + ); const committedServerAttachmentIds = useMemo(() => { const attachmentIds = new Set(); for (const row of serverVisibleTurnItems) { @@ -3036,6 +3200,7 @@ function ChatViewContent(props: ChatViewProps) { activeRuntime?.providerInstanceId ?? activeThread?.modelSelection.instanceId ?? activeProject?.defaultModelSelection?.instanceId ?? + settings.defaultModelSelection?.instanceId ?? null; const activeProviderStatus = useMemo(() => { if (activeProviderInstanceId) { @@ -3069,8 +3234,13 @@ function ChatViewContent(props: ChatViewProps) { : undefined; const activeTerminalLaunchContext = terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; - // Default true while loading to avoid toolbar flicker. - const isGitRepo = gitStatusQuery.data?.isRepo ?? true; + const reportedIsRepo = gitStatusQuery.data?.isRepo; + useEffect(() => { + if (gitStatusCwd !== null && reportedIsRepo !== undefined) { + rememberCheckoutIsRepo(environmentId, gitStatusCwd, reportedIsRepo); + } + }, [environmentId, gitStatusCwd, reportedIsRepo]); + const isGitRepo = reportedIsRepo ?? recallCheckoutIsRepo(environmentId, gitStatusCwd) ?? true; const showComposerContextStrip = shouldShowComposerContextStrip({ isDraftHeroState, isGitRepo, @@ -3122,6 +3292,192 @@ function ChatViewContent(props: ChatViewProps) { }, [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen]); const envLocked = Boolean(activeThread && (activeMessageCount > 0 || activeRuntime !== null)); + const loadBalancingSettings = useClientSettings(); + const routingModelInput = useMemo( + () => ({ + providers: providerStatuses, + selectedProvider, + selectedInstanceId: activeProviderInstanceId, + threadModelSelection: activeThread?.modelSelection, + projectModelSelection: activeProject?.defaultModelSelection, + settings, + }), + [ + providerStatuses, + selectedProvider, + activeProviderInstanceId, + activeThread?.modelSelection, + activeProject?.defaultModelSelection, + settings, + ], + ); + const routingModel = useEffectiveComposerModelState({ + ...routingModelInput, + threadRef: composerDraftTarget, + }); + const composerHasAttachments = useComposerDraftStore((store) => { + const draft = store.getComposerDraft(composerDraftTarget); + return Boolean( + draft && + (draft.images.length || + draft.persistedAttachments.length || + draft.terminalContexts.length || + draft.elementContexts.length || + draft.previewAnnotations.length || + draft.reviewComments.length), + ); + }); + const automaticEnvironment = Boolean( + clientSettingsHydrated && + draftId && + !envLocked && + hasMultipleEnvironments && + loadBalancingSettings.loadBalancingEnabled && + draftThread?.environmentSelection !== "manual" && + (!composerHasAttachments || Boolean(draftThread?.loadBalancedEnvironmentId)) && + (!draftThread?.branch || draftThread.environmentSelection === "auto") && + !draftThread?.worktreePath, + ); + + const needsLoadBalancing = automaticEnvironment && !draftThread?.loadBalancedEnvironmentId; + const loadBalancingCandidates = useMemo( + () => + needsLoadBalancing + ? logicalProjectEnvironments + .filter((candidate) => { + const environment = environmentById.get(candidate.environmentId); + return ( + environment?.connection.phase === "connected" && + (loadBalancingSettings.loadBalancingWeights[candidate.environmentId] ?? 50) > 0 && + environment.serverConfig?.providers.some( + (provider) => + (activeProviderInstanceId === null || + provider.instanceId === activeProviderInstanceId) && + provider.driver === selectedProvider && + provider.enabled && + provider.installed && + provider.status !== "error" && + provider.auth.status !== "unauthenticated" && + provider.availability !== "unavailable" && + provider.models.some( + (model) => + model.slug === routingModel.selectedModel || + model.aliases?.includes(routingModel.selectedModel), + ), + ) + ); + }) + .map((candidate) => candidate.environmentId) + : [], + [ + needsLoadBalancing, + logicalProjectEnvironments, + environmentById, + loadBalancingSettings.loadBalancingWeights, + activeProviderInstanceId, + selectedProvider, + routingModel.selectedModel, + ], + ); + const loadBalancing = useLoadBalancedEnvironment( + loadBalancingCandidates, + loadBalancingSettings.loadBalancingWeights, + ); + useEffect(() => { + if (!needsLoadBalancing || loadBalancing.pending || !draftId || sendInFlightRef.current) return; + const target = logicalProjectEnvironments.find( + (environment) => environment.environmentId === loadBalancing.environmentId, + ); + if (!target) return; + const current = useComposerDraftStore.getState().getDraftSession(draftId); + const content = useComposerDraftStore.getState().getComposerDraft(draftId); + const currentModel = deriveEffectiveComposerModelState({ + ...routingModelInput, + draft: content, + }); + if ( + !current || + current.environmentSelection === "manual" || + current.loadBalancedEnvironmentId || + current.worktreePath || + (current.branch && current.environmentSelection !== "auto") || + current.environmentId !== draftThread?.environmentId || + current.projectId !== draftThread.projectId || + content?.activeProvider !== composerActiveProvider || + currentModel.selectedModel !== routingModel.selectedModel || + (content && + (content.images.length || + content.persistedAttachments.length || + content.terminalContexts.length || + content.elementContexts.length || + content.previewAnnotations.length || + content.reviewComments.length)) + ) + return; + if (!activeProviderInstanceId) return; + useComposerDraftStore.getState().setModelSelection(draftId, { + instanceId: activeProviderInstanceId, + model: currentModel.selectedModel, + ...(currentModel.modelOptions?.[activeProviderInstanceId] + ? { options: currentModel.modelOptions[activeProviderInstanceId] } + : {}), + }); + setDraftThreadContext(draftId, { + projectRef: scopeProjectRef(target.environmentId, target.projectId), + environmentSelection: "auto", + loadBalancedEnvironmentId: target.environmentId, + }); + }, [ + needsLoadBalancing, + composerActiveProvider, + activeProviderInstanceId, + routingModelInput, + routingModel.selectedModel, + draftThread, + loadBalancing.pending, + loadBalancing.environmentId, + draftId, + logicalProjectEnvironments, + setDraftThreadContext, + ]); + const onAutoEnvironment = useCallback(() => { + if (envLocked || !draftId) return; + if (composerHasAttachments) { + toastManager.add({ + type: "warning", + id: "load-balancing-attachments", + title: "Keep attachments on this machine", + description: + "Remove attachments and machine-specific context before choosing automatic routing, then attach them on the selected machine.", + }); + return; + } + loadBalancing.refresh( + logicalProjectEnvironments.map((environment) => environment.environmentId), + ); + setDraftThreadContext(draftId, { + environmentSelection: "auto", + loadBalancedEnvironmentId: null, + branch: null, + worktreePath: null, + }); + }, [ + envLocked, + draftId, + setDraftThreadContext, + loadBalancing.refresh, + logicalProjectEnvironments, + composerHasAttachments, + ]); + const autoEnvironmentLabel = automaticEnvironment + ? draftThread?.loadBalancedEnvironmentId + ? "Auto balance" + : loadBalancing.pending + ? "Checking machines…" + : loadBalancing.failed + ? "Auto balance unavailable" + : "Auto balance" + : undefined; // Handle environment change for draft threads. When the user picks a // different environment we update the draft context to point at the physical @@ -3135,6 +3491,8 @@ function ChatViewContent(props: ChatViewProps) { if (!target) return; setDraftThreadContext(draftId, { projectRef: scopeProjectRef(target.environmentId, target.projectId), + environmentSelection: "manual", + loadBalancedEnvironmentId: null, }); }, [draftId, envLocked, logicalProjectEnvironments, setDraftThreadContext], @@ -3212,6 +3570,25 @@ function ChatViewContent(props: ChatViewProps) { focusComposer(); }); }, [focusComposer]); + const useArtifactTemplate = useCallback( + (template: CodexArtifactTemplate) => { + const composer = composerRef.current; + if (!composer) return; + const current = composer.getSendContext().prompt; + const next = appendCodexArtifactTemplateUsePrompt(current, template); + if (next !== current && !composer.insertTextAtEnd(next.slice(current.length))) { + toastManager.add({ + type: "error", + title: "Unable to add template", + description: "The composer is busy. Try again once it is ready.", + }); + return; + } + scheduleComposerFocus(); + }, + [composerRef, scheduleComposerFocus], + ); + const addTerminalContextToDraft = useCallback( (selection: TerminalContextSelection) => { composerRef.current?.addTerminalContext(selection); @@ -3579,14 +3956,21 @@ function ChatViewContent(props: ChatViewProps) { keybinding?: string | null; keybindingCommand: KeybindingCommand | null; }): Promise> => { - const updateResult = mapAtomCommandResult( - await updateProject({ - environmentId, - input: { - projectId: input.projectId, - scripts: input.nextScripts, - }, - }), + const updateResult = mapAtomCommandResult( + supportsActionDefaults + ? await updateActionSettings({ + environmentId, + input: { + patch: { projectScriptOverrides: { [input.projectId]: input.nextScripts } }, + }, + }) + : await updateProject({ + environmentId, + input: { + projectId: input.projectId, + scripts: input.nextScripts, + }, + }), () => undefined, ); if (updateResult._tag === "Failure") { @@ -3609,7 +3993,7 @@ function ChatViewContent(props: ChatViewProps) { } return updateResult; }, - [environmentId, updateProject, upsertKeybinding], + [environmentId, supportsActionDefaults, updateActionSettings, updateProject, upsertKeybinding], ); const saveProjectScript = useCallback( async (input: NewProjectScriptInput): Promise> => { @@ -3875,6 +4259,127 @@ function ChatViewContent(props: ChatViewProps) { supportsPullRequests, ], ); + const proactivePanelObservationRef = useRef | null>(null); + const observedThreadPullRequestRef = useRef<{ + threadKey: string; + reference: NonNullable["linkedPullRequest"]; + } | null>(null); + const activeRunningRunId = latestRunSettled ? null : (activeLatestRun?.runId ?? null); + const linkedThreadPullRequestKey = + linkedThreadPullRequest === null + ? null + : JSON.stringify([ + linkedThreadPullRequest.projectId, + linkedThreadPullRequest.repository.toLowerCase(), + linkedThreadPullRequest.number, + linkedThreadPullRequest.url, + ]); + useEffect(() => { + if (!isServerThread || !activeThreadRef || !activeThreadKey) { + proactivePanelObservationRef.current = null; + observedThreadPullRequestRef.current = null; + return; + } + const panels = useRightPanelStore.getState(); + const observation = observeProactivePanelUserChoice(proactivePanelObservationRef.current, { + threadKey: activeThreadKey, + runningRunId: activeRunningRunId, + userActionRevision: panels.getUserActionRevision(activeThreadRef), + }); + proactivePanelObservationRef.current = observation; + const previous = observedThreadPullRequestRef.current; + observedThreadPullRequestRef.current = { + threadKey: activeThreadKey, + reference: linkedThreadPullRequest, + }; + const followSelected = + previous?.threadKey === activeThreadKey && + shouldRetargetThreadPullRequestPanel( + previous.reference, + linkedThreadPullRequest, + selectActiveRightPanelSurface(panels.byThreadKey, activeThreadRef), + ); + if (followSelected && linkedThreadPullRequest && supportsPullRequests) { + panels.openProactive( + activeThreadRef, + pullRequestSurface(linkedThreadPullRequest), + observation.userActionRevision, + ); + } + if (!clientSettingsHydrated || serverProjection === null) return; + const settledRunId = latestRunSettled ? (activeLatestRun?.runId ?? null) : null; + const completedRunId = shouldOpenProactiveRunDiff({ + previousRunningRunId: observation.runningRunId, + runningRunId: activeRunningRunId, + settledRunId, + runCompleted: activeLatestRun?.status === "completed", + }) + ? settledRunId + : null; + const enabled = + settings.proactivePanelsEnabled && !shouldUsePlanSidebarSheet && exposeWorkspaceArtifacts; + const diffAction = + enabled && completedRunId !== null + ? resolveProactiveRunDiffAction({ + checkpoint: visibleTurnDiffSummaries.find( + (checkpoint) => checkpoint.runId === completedRunId, + ), + isGitRepo: gitStatusQuery.data?.isRepo, + }) + : "ignore"; + const eligibleLink = + enabled && shouldOpenProactivePullRequest(observation.targetKey, linkedThreadPullRequestKey); + proactivePanelObservationRef.current = { + ...observation, + runningRunId: diffAction === "defer" ? observation.runningRunId : activeRunningRunId, + targetKey: + eligibleLink && !pullRequestsCapabilityKnown + ? observation.targetKey + : linkedThreadPullRequestKey, + }; + if (!followSelected && eligibleLink && supportsPullRequests && linkedThreadPullRequest) { + panels.openProactive( + activeThreadRef, + pullRequestSurface(linkedThreadPullRequest), + observation.userActionRevision, + ); + } + if ( + diffAction !== "open" || + completedRunId === null || + !panels.openProactive( + activeThreadRef, + { id: "diff", kind: "diff" }, + observation.userActionRevision, + ) + ) + return; + useDiffPanelStore.getState().selectTurn(activeThreadRef, completedRunId); + onDiffPanelOpen?.(); + }, [ + activeThreadKey, + activeThreadRef, + activeRunningRunId, + activeLatestRun?.runId, + activeLatestRun?.status, + latestRunSettled, + linkedThreadPullRequest, + linkedThreadPullRequestKey, + isServerThread, + serverProjection, + clientSettingsHydrated, + settings.proactivePanelsEnabled, + shouldUsePlanSidebarSheet, + exposeWorkspaceArtifacts, + visibleTurnDiffSummaries, + gitStatusQuery.data?.isRepo, + supportsPullRequests, + pullRequestsCapabilityKnown, + onDiffPanelOpen, + ]); + const togglePreviewPanel = useCallback(() => { if (!activeThreadRef || !isPreviewSupportedInRuntime()) return; if (previewPanelOpen) { @@ -4520,6 +5025,16 @@ function ChatViewContent(props: ChatViewProps) { [composerOverlayHeight], ); + const composerReadingTimeline = useMemo( + () => ({ + getElement: () => legendListRef.current?.getScrollableNode() ?? null, + overflows: () => timelineRealContentOverflowsViewport(), + atEnd: () => isAtEndRef.current, + onManualNavigation: cancelTimelineLiveFollowForUserNavigation, + }), + [timelineRealContentOverflowsViewport, cancelTimelineLiveFollowForUserNavigation], + ); + // Live-follow stays active after send/thread-open until an actual list scroll // gesture opts out. const scrollToEnd = useCallback((animated = false) => { @@ -4726,28 +5241,32 @@ function ChatViewContent(props: ChatViewProps) { }); }, []); - const onIsAtEndChange = useCallback((isAtEnd: boolean) => { - if ( - !isAtEnd && - liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current - ) { - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - return; - } - if (isAtEndRef.current === isAtEnd) return; - isAtEndRef.current = isAtEnd; - if (isAtEnd) { - timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - } else { - timelineScrollModeRef.current = "free-scrolling"; - liveFollowUserScrollGenerationRef.current = null; - showScrollDebouncer.current.maybeExecute(); - } - }, []); + const onIsAtEndChange = useCallback( + (isAtEnd: boolean) => { + if ( + !isAtEnd && + liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current + ) { + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + return; + } + if (isAtEndRef.current === isAtEnd) return; + isAtEndRef.current = isAtEnd; + if (isAtEnd) { + composerRef.current?.restoreAfterTimelineReachedEnd(); + timelineScrollModeRef.current = "following-end"; + liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + } else { + timelineScrollModeRef.current = "free-scrolling"; + liveFollowUserScrollGenerationRef.current = null; + showScrollDebouncer.current.maybeExecute(); + } + }, + [composerRef], + ); useEffect(() => { if (!activeThread?.id) { @@ -4840,6 +5359,22 @@ function ChatViewContent(props: ChatViewProps) { // activeThreadRef resets transitively with the active thread. }, [activeThread?.id, markProgrammaticTimelineScroll]); + const planPanelChoiceRef = useRef<{ + threadKey: string; + runId: string | null; + revision: number; + } | null>(null); + useEffect(() => { + planPanelChoiceRef.current = + activeThreadRef && activeThreadKey + ? { + threadKey: activeThreadKey, + runId: activeLatestRun?.runId ?? null, + revision: useRightPanelStore.getState().getUserActionRevision(activeThreadRef), + } + : null; + }, [activeThreadKey, activeThreadRef, activeLatestRun?.runId]); + // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. // Don't auto-open for plans carried over from a previous turn (the user can open manually). useEffect(() => { @@ -4851,12 +5386,18 @@ function ChatViewContent(props: ChatViewProps) { const turnKey = activePlan.runId ?? sidebarProposedPlan?.runId ?? "__dismissed__"; if (planSidebarDismissedForTurnRef.current === turnKey) return; if (activeThreadRef) { - useRightPanelStore.getState().open(activeThreadRef, "plan"); + const observed = planPanelChoiceRef.current; + if (!observed || observed.threadKey !== activeThreadKey || observed.runId !== latestRunId) + return; + useRightPanelStore + .getState() + .openProactive(activeThreadRef, { id: "plan", kind: "plan" }, observed.revision); } }, [ activePlan, activeLatestRun?.runId, activeThreadRef, + activeThreadKey, autoOpenPlanSidebar, planSidebarOpen, sidebarProposedPlan?.runId, @@ -4876,6 +5417,33 @@ function ChatViewContent(props: ChatViewProps) { }; }, [activeThread?.id, focusComposer, terminalUiState.terminalOpen]); + // Tabbing back into the app lands focus wherever it last was, often the right panel or the + // body. Put it in the composer unless something that takes typing already holds it. The + // drawer terminal owns keyboard input while it is open, so it opts out here; a right panel + // terminal is a surface and is recognized by the predicate instead. Mobile is left alone so + // returning to the app does not raise the keyboard. + useEffect(() => { + if (!activeThreadKey || terminalUiState.terminalOpen || isMobileViewport) return; + let frame: number | null = null; + const onWindowFocus = () => { + if (frame !== null) window.cancelAnimationFrame(frame); + // The element that held focus receives it again after the window's own event, and the + // composer ignores that same frame so a restored focus does not lift a scroll-collapsed + // composer. Wait one more frame so this focus counts as a request to expand it. + frame = window.requestAnimationFrame(() => { + frame = window.requestAnimationFrame(() => { + frame = null; + if (shouldRefocusComposerOnWindowFocus(document.activeElement)) focusComposer(); + }); + }); + }; + window.addEventListener("focus", onWindowFocus); + return () => { + window.removeEventListener("focus", onWindowFocus); + if (frame !== null) window.cancelAnimationFrame(frame); + }; + }, [activeThreadKey, focusComposer, isMobileViewport, terminalUiState.terminalOpen]); + useEffect(() => { if (!activeThread?.id) return; if (activeMessageCount === 0) { @@ -4978,23 +5546,29 @@ function ChatViewContent(props: ChatViewProps) { const linkedPullRequestStatus = useLinkedThreadPullRequest( activeThreadRef?.environmentId ?? null, linkedThreadPullRequest, + activeThreadShell?.pullRequests, + activeThreadShell?.branchPullRequest, ); const activeThreadPr = resolveDisplayedThreadPr({ threadBranch: activeThread?.branch ?? null, gitStatus: gitStatusQuery.data ?? null, snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined, retainTerminalOnBranchMismatch: activeThread?.worktreePath === null, - linkedPullRequest: linkedThreadPullRequest, + linkedPullRequest: linkedThreadPullRequest ?? activeThreadShell?.branchPullRequest, linkedPullRequestStatus, }); - // The right panel offers the thread's own change request, so it can only offer it once the - // branch has one; until then the picker says so rather than opening an empty panel. + const supportsThreadPullRequests = + isServerThread && serverConfig?.environment.capabilities.threadPullRequestsV2 === true; const addPullRequestSurface = useCallback(() => { - if (activeThreadPr === null) return; - openThreadPullRequest(activeThreadPr.number); - }, [activeThreadPr, openThreadPullRequest]); + if (supportsThreadPullRequests && activeThreadRef) { + useRightPanelStore.getState().open(activeThreadRef, "thread-pull-requests"); + return; + } + if (activeThreadPr !== null) openThreadPullRequest(activeThreadPr.number); + }, [supportsThreadPullRequests, activeThreadRef, activeThreadPr, openThreadPullRequest]); const pullRequestSurfaceAvailable = - supportsPullRequests && activeThreadPr !== null && threadRepository !== null; + supportsPullRequests && + (supportsThreadPullRequests || (activeThreadPr !== null && threadRepository !== null)); // Primitive slice of the displayed PR for the settle-rule memos below: // resolveDisplayedThreadPr returns a fresh object every render, so memoize // on the fields the rules read instead of the object identity. @@ -5855,6 +6429,17 @@ function ChatViewContent(props: ChatViewProps) { image: ComposerImageAttachment | null; }, ) => { + if (needsLoadBalancing) { + e?.preventDefault(); + toastManager.add({ + type: "warning", + title: loadBalancing.pending ? "Checking machines…" : "Choose a machine", + description: loadBalancing.pending + ? "Wait for automatic selection before sending." + : "No available machine was selected. Choose a machine manually or retry Auto balance.", + }); + return; + } const dispatchMode = submitOptions.dispatchMode ?? "auto"; const submissionIntent = submitOptions.submissionIntent ?? "foreground"; e?.preventDefault(); @@ -6184,11 +6769,27 @@ function ChatViewContent(props: ChatViewProps) { return; } + const fileLimit = supportsFileAttachmentUploads + ? Math.min( + 50 * 1024 * 1024, + attachmentEnvironmentConfig?.environment.capabilities.fileAttachments?.maxUploadBytes ?? + 20 * 1024 * 1024, + ) + : 20 * 1024 * 1024; + const oversizedFile = composerImagesSnapshot.find( + (attachment) => attachment.type !== "image" && attachment.sizeBytes > fileLimit, + ); + if (oversizedFile) { + setThreadError( + threadIdForSend, + `'${oversizedFile.name}' exceeds this server's ${Math.floor(fileLimit / 1024 / 1024)} MB file limit.`, + ); + return; + } + sendInFlightRef.current = true; - // Only images have a signed-upload path; the composer's other attachment - // kinds are still encoded inline below. const uploadableImagesSnapshot = composerImagesSnapshot.filter( - (image): image is ComposerImageAttachment => image.type === "image", + (image) => image.type === "image" || supportsFileAttachmentUploads, ); if (supportsAttachmentUploads && uploadableImagesSnapshot.length > 0) { for (const image of uploadableImagesSnapshot) { @@ -6197,7 +6798,10 @@ function ChatViewContent(props: ChatViewProps) { await awaitAttachmentUploads(uploadableImagesSnapshot.map((image) => image.id)); if (getUploadedAttachments({ environmentId, images: uploadableImagesSnapshot }) === null) { sendInFlightRef.current = false; - setThreadError(threadIdForSend, "Retry or remove failed image uploads before sending."); + setThreadError( + threadIdForSend, + "Retry or remove failed attachment uploads before sending.", + ); return; } } @@ -6235,10 +6839,13 @@ function ChatViewContent(props: ChatViewProps) { const messageCreatedAt = new Date().toISOString(); const turnAttachmentsPromise = Promise.all( composerImagesSnapshot.map(async (image) => { - if (supportsAttachmentUploads && image.type === "image") { + if ( + supportsAttachmentUploads && + (image.type === "image" || supportsFileAttachmentUploads) + ) { const uploaded = getUploadedAttachments({ environmentId, images: [image] })?.[0]; if (!uploaded) { - throw new Error(`Image '${image.name}' did not finish uploading.`); + throw new Error(`Attachment '${image.name}' did not finish uploading.`); } return uploaded; } @@ -6350,7 +6957,7 @@ function ChatViewContent(props: ChatViewProps) { firstComposerAttachmentName = firstComposerAttachment.name; } } - let titleSeed = trimmed; + let titleSeed = assistantCitationsToPlainText(trimmed); if (!titleSeed) { if (firstComposerAttachmentName) { titleSeed = `Attachment: ${firstComposerAttachmentName}`; @@ -6437,7 +7044,6 @@ function ChatViewContent(props: ChatViewProps) { : {}), } : undefined; - beginLocalDispatch({ preparingWorktree: false }); const backgroundThreadRef = resolvedSubmissionIntent === "background" ? scopeThreadRef(activeThread.environmentId, threadIdForSend) @@ -7045,6 +7651,12 @@ function ChatViewContent(props: ChatViewProps) { [activeRuntime, activeThread, providerStatuses, supportsProviderSwitchingViaHandoff], ); + const openProviderSetup = useCallback( + (instanceId: ProviderInstanceId) => { + void navigate({ to: "/settings/providers", search: { environmentId, instanceId } }); + }, + [environmentId, navigate], + ); const onProviderModelSelect = useCallback( (instanceId: ProviderInstanceId, model: string) => { if (!activeThread) return; @@ -7127,6 +7739,7 @@ function ChatViewContent(props: ChatViewProps) { setComposerDraftModelSelection( scopeThreadRef(activeThread.environmentId, activeThread.id), nextModelSelection, + { explicit: true }, ); } scheduleComposerFocus(); @@ -7154,6 +7767,7 @@ function ChatViewContent(props: ChatViewProps) { } if (isLocalDraftThread) { setDraftThreadContext(composerDraftTarget, { + environmentSelection: "manual", envMode: mode, startFromOrigin: resolveNewDraftStartFromOrigin({ envMode: mode, @@ -7187,6 +7801,7 @@ function ChatViewContent(props: ChatViewProps) { } if (isLocalDraftThread) { setDraftThreadContext(composerDraftTarget, { + environmentSelection: "manual", startFromOrigin: nextStartFromOrigin, }); } @@ -7218,18 +7833,65 @@ function ChatViewContent(props: ChatViewProps) { void onRevertToTurnCountRef.current(targetTurnCount); }, []); + // Files dropped on a sidebar row land here once the dropped-on thread is + // actually open, then take the exact same path as a workspace drop: + // validate, compress, focus the composer, never send. Kept above the + // no-active-thread early return so hook order never changes. + const pendingSidebarFileDrops = useSidebarPendingFileDropStore((state) => state.pending); + const consumePendingFileDrop = useSidebarPendingFileDropStore( + (state) => state.consumePendingFileDrop, + ); + useEffect(() => { + if (pendingSidebarFileDrops.length === 0) return; + // A promoting draft can mount this view with the server thread id while + // its composer is still draft-keyed; finalization would discard what we + // attach there. Only the canonical thread target may consume a drop. + if ( + typeof composerDraftTarget === "string" || + !pendingSidebarFileDrops.some((drop) => + isSameSidebarThreadRef(composerDraftTarget, drop.threadRef), + ) + ) { + return; + } + if (!activeThread) return; + if (!composerRef.current) { + const raf = window.requestAnimationFrame(() => { + if (!composerRef.current) return; + if (typeof composerDraftTarget === "string") return; + // Consume matches by target, so a newer drop that arrived meanwhile + // is collected too rather than orphaned. + const files = consumePendingFileDrop(composerDraftTarget); + if (files !== null) { + composerRef.current?.addDroppedFiles(files); + } + }); + return () => window.cancelAnimationFrame(raf); + } + const files = consumePendingFileDrop(composerDraftTarget); + if (files !== null) { + composerRef.current.addDroppedFiles(files); + } + }, [ + activeThread, + composerDraftTarget, + composerRef, + consumePendingFileDrop, + pendingSidebarFileDrops, + ]); + // Empty state: no active thread if (!activeThread) { return ; } const rightPanelContent = activeThreadRef ? ( - activeRightPanelSurface?.kind === "preview" ? ( + renderedRightPanelSurface?.kind === "preview" ? ( { @@ -7237,10 +7899,10 @@ function ChatViewContent(props: ChatViewProps) { }} /> - ) : activeRightPanelSurface?.kind === "terminal" ? ( + ) : renderedRightPanelSurface?.kind === "terminal" ? ( - ) : activeRightPanelSurface?.kind === "diff" ? ( + ) : renderedRightPanelSurface?.kind === "diff" ? ( - ) : activeRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( + ) : renderedRightPanelSurface?.kind === "thread-pull-requests" ? ( + + ) : renderedRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( - ) : activeRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? ( + ) : renderedRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? ( - ) : activeRightPanelSurface?.kind === "pull-request" ? ( + ) : renderedRightPanelSurface?.kind === "pull-request" ? ( // No onClose: the surface tab's own X owns closing here, and a second X in the header // would be the same action twice. The thread context also drops the checkout button, so it // is only right for the thread's own pull request, whose branch is already under the // reader's feet. A link the agent wrote can open any other one here, and that one has to be // checkable out like it is anywhere else. - ) : activeRightPanelSurface?.kind === "agents" ? ( + ) : renderedRightPanelSurface?.kind === "agents" ? ( - ) : activeRightPanelSurface?.kind === "plan" ? ( + ) : renderedRightPanelSurface?.kind === "plan" ? ( - ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") && - activeProject && - activeWorkspaceRoot ? ( + ) : (renderedRightPanelSurface?.kind === "files" || + renderedRightPanelSurface?.kind === "file") && + ((renderedRightPanelSurface.kind === "file" && + renderedRightPanelSurface.attachment !== undefined) || + (activeProject && activeWorkspaceRoot)) ? ( ) : null @@ -7362,6 +8037,8 @@ function ChatViewContent(props: ChatViewProps) { gitCwd, isGitRepo, envLocked, + autoEnvironmentLabel, + onAutoEnvironment: loadBalancingSettings.loadBalancingEnabled ? onAutoEnvironment : undefined, availableEnvironments: logicalProjectEnvironments, onEnvironmentChange, onEnvModeChange, @@ -7450,20 +8127,27 @@ function ChatViewContent(props: ChatViewProps) { ); const panelLayoutControls = (
- {rightPanelOpen && !shouldUsePlanSidebarSheet ? ( - + {!shouldUsePlanSidebarSheet ? ( + + + ) : null} - {panelToggleControls} +
{panelToggleControls}
); @@ -7476,7 +8160,7 @@ function ChatViewContent(props: ChatViewProps) { ref={workspaceLayoutRef} className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background" > - {rightPanelOpen && !shouldUsePlanSidebarSheet ? panelLayoutControls : null} + {rightPanelPresent && !shouldUsePlanSidebarSheet ? panelLayoutControls : null}
+ {rightPanelPresent && !shouldUsePlanSidebarSheet ? ( + + ) : null} {inlineRightPanelOwnsTitleBar ? threadPanelHeaderControl - : !rightPanelOpen + : !rightPanelPresent ? panelLayoutControls : null} { + const grouping = buildPhysicalToLogicalProjectKeyMap({ + projects: allProjects, + settings: projectGroupingSettings, + primaryEnvironmentId, + }); + const projectKey = + grouping.get(derivePhysicalProjectKey(activeProject)) ?? + deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); + void navigate({ to: "/projects/$projectKey", params: { projectKey } }); + } + : undefined + } /> + { @@ -7548,6 +8266,9 @@ function ChatViewContent(props: ChatViewProps) {
setDismissedProviderStatusBannerKey(providerStatusBannerKey)} />
@@ -7555,6 +8276,13 @@ function ChatViewContent(props: ChatViewProps) {
{/* Messages — LegendList handles virtualization and scrolling internally */} -
+
{isDraftHeroState ? (
-
- ) : ( - - )} + ) : null} {isServerThread ? ( ) : null} +
-
-
+ +
+ + {composerTasks && + (tasksDrawerExpanded ? ( + setTasksDrawerExpanded(false)} + progress={composerTasks.progress} + steps={composerTasks.steps} + /> + ) : ( + + setTasksDrawerExpanded(true)} + progress={composerTasks.progress} + steps={composerTasks.steps} + /> + + ))} + + } + promptHistoryMessages={serverProjection?.messages ?? []} composerRef={composerRef} composerDraftTarget={composerDraftTarget} environmentId={environmentId} attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown} supportsAttachmentUploads={supportsAttachmentUploads} + supportsFileAttachmentUploads={supportsFileAttachmentUploads} routeKind={routeKind} draftId={draftId} activeThreadId={activeThreadId} @@ -7736,6 +8485,7 @@ function ChatViewContent(props: ChatViewProps) { } activeThreadModelSelection={activeThread?.modelSelection} onThreadModelOptionsChange={onThreadModelOptionsChange} + threadDetailLoading={isServerThread && serverProjection === null} activeThreadVisibleTurnItems={serverVisibleTurnItems} resolvedTheme={resolvedTheme} settings={settings} @@ -7785,6 +8535,10 @@ function ChatViewContent(props: ChatViewProps) { onChangeActivePendingUserInputCustomAnswer={ onChangeActivePendingUserInputCustomAnswer } + {...(serverConfig?.environment.capabilities + .providerTerminalEnvironment === true + ? { onOpenProviderSetup: openProviderSetup } + : {})} onProviderModelSelect={onProviderModelSelect} getModelDisabledReason={getModelDisabledReason} toggleInteractionMode={toggleInteractionMode} @@ -7797,7 +8551,7 @@ function ChatViewContent(props: ChatViewProps) { onExpandImage={onExpandTimelineImage} />
-
+
-
+
- {!shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( + {!shouldUsePlanSidebarSheet && rightPanelPresent && activeThreadRef ? ( ) : null} - {shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( - + {shouldUsePlanSidebarSheet && rightPanelPresent && activeThreadRef ? ( + {panelToggleControls}
} - surfaces={rightPanelState.surfaces} - activeSurfaceId={activeRightPanelSurface?.id ?? null} + surfaces={renderedRightPanelSurfaces} + activeSurfaceId={renderedRightPanelSurface?.id ?? null} pendingSurfaceIds={pendingFileSurfaceIds} previewSessions={activePreviewState.sessions} desktopByTabId={activePreviewState.desktopByTabId} @@ -8025,9 +8794,5 @@ function ChatViewContent(props: ChatViewProps) { } export default function ChatView(props: ChatViewProps) { - return ( - - - - ); + return ; } diff --git a/apps/web/src/components/CodexArtifactTemplateCard.tsx b/apps/web/src/components/CodexArtifactTemplateCard.tsx new file mode 100644 index 000000000..cec966698 --- /dev/null +++ b/apps/web/src/components/CodexArtifactTemplateCard.tsx @@ -0,0 +1,74 @@ +import { + FileTextIcon, + PresentationIcon, + FileSpreadsheetIcon, + GlobeIcon, + ImageIcon, + MailIcon, + MessageSquareIcon, + SparklesIcon, + type LucideIcon, +} from "lucide-react"; +import { + codexArtifactTemplatePresentationLabel, + type CodexArtifactTemplate, + type CodexArtifactTemplateKind, +} from "@t3tools/client-runtime/codex-artifact-templates"; +import { Button } from "./ui/button"; +const ARTIFACT_TEMPLATE_ICON_BY_KIND = { + document: FileTextIcon, + presentation: PresentationIcon, + spreadsheet: FileSpreadsheetIcon, + site: GlobeIcon, + "google-docs": FileTextIcon, + "google-slides": PresentationIcon, + "google-sheets": FileSpreadsheetIcon, + image: ImageIcon, + email: MailIcon, + slack: MessageSquareIcon, +} satisfies Record; + +export function CodexArtifactTemplateCard(props: { + readonly template: CodexArtifactTemplate; + readonly onUse?: ((template: CodexArtifactTemplate) => void) | undefined; +}) { + const Icon = ARTIFACT_TEMPLATE_ICON_BY_KIND[props.template.artifactKind]; + const presentationLabel = codexArtifactTemplatePresentationLabel(props.template.artifactKind); + + return ( +
+
+ + + + + + + + + {props.template.displayName} + + {presentationLabel} + +
+ {props.onUse ? ( + + ) : null} +
+ ); +} diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 4f6644ca8..9ebc44d7b 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -169,6 +169,32 @@ function makeThread(overrides: Partial = {}): Thread { } describe("buildThreadActionItems", () => { + it("includes the V2 linked PR in thread search", () => { + const [item] = buildThreadActionItems({ + threads: [ + makeThread({ + linkedPullRequest: { + projectId: PROJECT_ID, + repository: "Bl4ckBl1zZ/t3code", + number: 287, + url: "https://github.com/Bl4ckBl1zZ/t3code/pull/287", + }, + }), + ], + projectTitleById: new Map([[PROJECT_ID, "T3 Code"]]), + sortOrder: "updated_at", + icon: null, + runThread: async () => undefined, + }); + expect(item?.searchTerms).toEqual( + expect.arrayContaining([ + "#287", + "Bl4ckBl1zZ/t3code#287", + "https://github.com/Bl4ckBl1zZ/t3code/pull/287", + ]), + ); + }); + it("orders threads by most recent activity and formats timestamps from updatedAt", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-25T12:00:00.000Z")); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index db1709fac..d75785002 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,3 +1,4 @@ +import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import { type FilesystemBrowseEntry, type KeybindingCommand, @@ -180,6 +181,8 @@ export type BuildThreadActionItemsThread = Pick< > & { updatedAt: string; latestUserMessageAt?: string | null; + linkedPullRequest?: SidebarThreadSummary["linkedPullRequest"]; + linkedPullRequests?: SidebarThreadSummary["linkedPullRequests"]; }; export function buildThreadActionItems(input: { @@ -232,6 +235,7 @@ export function buildThreadActionItems {location.kind === "remote" ? ( - + ) : null} {location.label} @@ -1529,6 +1536,22 @@ function OpenCommandPaletteDialog(props: { const actionItems: Array = []; + if ( + activeThread && + serverConfigs.get(activeThread.environmentId)?.environment.capabilities.threadPullRequestsV2 === + true + ) { + actionItems.push({ + kind: "action", + value: "action:link-pull-request", + title: "Link pull request", + searchTerms: ["link", "pull request", "merge request", "pr", "thread"], + icon: , + run: async () => + openLinkPullRequestDialog(scopeThreadRef(activeThread.environmentId, activeThread.id)), + }); + } + if (projects.length > 0) { const activeProjectTitle = projectPickerEntries.find((entry) => entry.isPreferred)?.group.displayName ?? diff --git a/apps/web/src/components/ComposerCitationNode.tsx b/apps/web/src/components/ComposerCitationNode.tsx new file mode 100644 index 000000000..2c8f02586 --- /dev/null +++ b/apps/web/src/components/ComposerCitationNode.tsx @@ -0,0 +1,223 @@ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import type { AssistantCitation } from "@t3tools/contracts"; +import { + serializeAssistantCitation, + withAssistantCitationComment, +} from "@t3tools/shared/assistantCitations"; +import { + $applyNodeReplacement, + $getNodeByKey, + $getRoot, + $isElementNode, + DecoratorNode, + HISTORY_PUSH_TAG, + SKIP_DOM_SELECTION_TAG, + type NodeKey, + type SerializedLexicalNode, + type Spread, +} from "lexical"; +import { createContext, use, type ReactElement } from "react"; +import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; + +import { AssistantCitationChip } from "./chat/AssistantCitationChip"; +import { COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME } from "./composerInlineChip"; + +type SerializedComposerCitationNode = Spread< + { + citation: AssistantCitation; + source: string; + type: "composer-citation"; + version: 1; + }, + SerializedLexicalNode +>; + +export type ComposerCitationCommentRequest = { + previousValue: string; + value: string; + citationStart: number; + sourceAnchor: AssistantCitationSourceAnchor; +}; + +export type ComposerCitationCommentTarget = { + nodeKey: NodeKey; + sourceAnchor?: AssistantCitationSourceAnchor; + removeOnCancel?: boolean; +}; + +export const ComposerCitationCommentContext = createContext<{ + openComment: ComposerCitationCommentTarget | null; + onOpenChange: (nodeKey: NodeKey, open: boolean) => void; + onSubmitAndSend: () => void; +}>({ openComment: null, onOpenChange: () => {}, onSubmitAndSend: () => {} }); + +/** Consume a cite action once its controlled prompt has been committed to the editor. */ +export function $consumeComposerCitationCommentRequest(requestRef: { + current: ComposerCitationCommentRequest | null; +}): ComposerCitationCommentTarget | null { + const request = requestRef.current; + if (!request) return null; + const root = $getRoot(); + const value = root.getTextContent(); + if (value === request.previousValue) return null; + requestRef.current = null; + if (value !== request.value) return null; + + // Controlled prompts use one paragraph with inline nodes and explicit line breaks. + const paragraph = root.getFirstChild(); + if (!$isElementNode(paragraph)) return null; + let offset = 0; + for (const node of paragraph.getChildren()) { + if (offset === request.citationStart && node instanceof ComposerCitationNode) { + return { + nodeKey: node.getKey(), + sourceAnchor: request.sourceAnchor, + removeOnCancel: true, + }; + } + offset += node.getTextContentSize(); + } + return null; +} + +function ComposerCitationDecorator(props: { citation: AssistantCitation; nodeKey: NodeKey }) { + const [editor] = useLexicalComposerContext(); + const commentContext = use(ComposerCitationCommentContext); + const commentTarget = + commentContext.openComment?.nodeKey === props.nodeKey ? commentContext.openComment : null; + const onSaveComment = (comment: string): boolean => { + if (!editor.isEditable()) return false; + let accepted = false; + editor.update( + () => { + const node = $getNodeByKey(props.nodeKey); + if (node instanceof ComposerCitationNode && node.isAttached()) { + node.setComment(comment); + accepted = true; + } + }, + { discrete: true, tag: [HISTORY_PUSH_TAG, SKIP_DOM_SELECTION_TAG] }, + ); + return accepted; + }; + const onRemove = () => { + if (!editor.isEditable()) return; + editor.update( + () => { + const node = $getNodeByKey(props.nodeKey); + if (node instanceof ComposerCitationNode) { + node.selectPrevious(); + node.remove(); + } + }, + { tag: HISTORY_PUSH_TAG }, + ); + editor.getRootElement()?.focus({ preventScroll: true }); + }; + + return ( + + { + if (open && !editor.isEditable()) return; + commentContext.onOpenChange(props.nodeKey, open); + }, + ...(commentTarget?.removeOnCancel ? { onCancel: onRemove } : {}), + onSave: onSaveComment, + onSaveAndSend: (comment) => { + if (!onSaveComment(comment)) return false; + commentContext.onSubmitAndSend(); + return true; + }, + }} + onRemove={onRemove} + /> + + ); +} + +export class ComposerCitationNode extends DecoratorNode { + __citation: AssistantCitation; + __source: string; + + static override getType(): string { + return "composer-citation"; + } + + static override clone(node: ComposerCitationNode): ComposerCitationNode { + return new ComposerCitationNode(node.__citation, node.__source, node.__key); + } + + static override importJSON(serializedNode: SerializedComposerCitationNode): ComposerCitationNode { + return $createComposerCitationNode( + serializedNode.citation, + serializedNode.source, + ).updateFromJSON(serializedNode); + } + + constructor(citation: AssistantCitation, source: string, key?: NodeKey) { + super(key); + this.__citation = citation; + this.__source = source; + } + + override exportJSON(): SerializedComposerCitationNode { + const node = this.getLatest(); + return { + ...super.exportJSON(), + citation: node.__citation, + source: node.__source, + type: "composer-citation", + version: 1, + }; + } + + override createDOM(): HTMLElement { + const dom = document.createElement("span"); + dom.className = `${COMPOSER_INLINE_CHIP_DECORATOR_CLASS_NAME} max-w-full`; + return dom; + } + + override updateDOM(): false { + return false; + } + + override getTextContent(): string { + return this.getLatest().__source; + } + + setComment(comment: string): this { + const latest = this.getLatest(); + const citation = withAssistantCitationComment(latest.__citation, comment); + if (citation.comment === latest.__citation.comment) return latest; + const source = serializeAssistantCitation(citation); + const writable = this.getWritable(); + writable.__citation = citation; + writable.__source = source; + return writable; + } + + override isInline(): true { + return true; + } + + override decorate(): ReactElement { + return ; + } +} + +export function $createComposerCitationNode( + citation: AssistantCitation, + source: string, +): ComposerCitationNode { + return $applyNodeReplacement(new ComposerCitationNode(citation, source)); +} diff --git a/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx b/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx index 980521084..578c5c6d4 100644 --- a/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx +++ b/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx @@ -1,3 +1,5 @@ +import { ComposerCitationNode } from "./ComposerCitationNode"; +import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { $copyNode, $getRoot, $isElementNode, PASTE_COMMAND, type LexicalEditor } from "lexical"; import { act, createRef } from "react"; @@ -182,3 +184,30 @@ describe("composer mention serialization", () => { expect(lexicalEditor.getEditorState().read(() => $firstMention().isInline())).toBe(true); }); }); + +describe("composer citation serialization", () => { + it("keeps the quote through comment edits, export and reload", async () => { + const href = "t3-citation://v1/e/t/m?text=Selected+answer&start=0&end=15&prefix=&suffix="; + await renderPrompt(`[Assistant quote](${href}) explain`); + const firstCitation = () => { + const paragraph = $getRoot().getFirstChild(); + if (!$isElementNode(paragraph)) throw new Error("Missing paragraph"); + const citation = paragraph.getFirstChild(); + if (!(citation instanceof ComposerCitationNode)) throw new Error("Missing citation"); + return citation; + }; + await act(() => { + lexicalEditor.update(() => firstCitation().setComment("Why this?"), { discrete: true }); + }); + const text = editorRef.current!.readSnapshot().value; + const source = lexicalEditor.getEditorState().read(() => firstCitation().getTextContent()); + expect(parseAssistantCitationHref(source.slice("[Assistant quote](".length, -1))).toMatchObject( + { text: "Selected answer", comment: "Why this?" }, + ); + expect(text).toBe(`${source} explain`); + const exported = lexicalEditor.getEditorState().toJSON(); + await renderPrompt(""); + await act(() => lexicalEditor.setEditorState(lexicalEditor.parseEditorState(exported))); + expect(editorRef.current?.readSnapshot().value).toBe(text); + }); +}); diff --git a/apps/web/src/components/ComposerPromptEditor.test.ts b/apps/web/src/components/ComposerPromptEditor.test.ts index 0aab8fb0c..97b285f04 100644 --- a/apps/web/src/components/ComposerPromptEditor.test.ts +++ b/apps/web/src/components/ComposerPromptEditor.test.ts @@ -49,6 +49,7 @@ describe("registerComposerInlineTokenPaste", () => { { discrete: true }, ); registerComposerInlineTokenPaste(editor, { + createCitationNode: (_citation, source) => $createTextNode(source), createMentionNode: (path) => $createTextNode(``), getExpandedAbsoluteOffsetForPoint: () => 0, }); @@ -94,6 +95,7 @@ describe("registerComposerInlineTokenPaste", () => { { discrete: true }, ); registerComposerInlineTokenPaste(editor, { + createCitationNode: (_citation, source) => $createTextNode(source), createMentionNode: (path) => $createTextNode(``), getExpandedAbsoluteOffsetForPoint: () => 0, }); @@ -128,6 +130,7 @@ describe("registerComposerInlineTokenPaste", () => { { discrete: true }, ); registerComposerInlineTokenPaste(editor, { + createCitationNode: (_citation, source) => $createTextNode(source), createMentionNode: (path) => $createTextNode(``), getExpandedAbsoluteOffsetForPoint: () => 0, }); diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 4637ab8f9..28298363e 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1,3 +1,11 @@ +import { + $consumeComposerCitationCommentRequest, + $createComposerCitationNode, + ComposerCitationCommentContext, + ComposerCitationNode, + type ComposerCitationCommentRequest, + type ComposerCitationCommentTarget, +} from "./ComposerCitationNode"; import { LexicalComposer, type InitialConfigType } from "@lexical/react/LexicalComposer"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; @@ -11,6 +19,9 @@ import { $applyNodeReplacement, $createRangeSelectionFromDom, $createRangeSelection, + $getNodeByKey, + HISTORY_PUSH_TAG, + SKIP_DOM_SELECTION_TAG, $getSelection, $setSelection, $isElementNode, @@ -52,6 +63,7 @@ import { useLayoutEffect, useMemo, useRef, + useState, } from "react"; import { @@ -432,12 +444,14 @@ function $createComposerTerminalContextNode( } type ComposerInlineTokenNode = + | ComposerCitationNode | ComposerMentionNode | ComposerSkillNode | ComposerTerminalContextNode; function isComposerInlineTokenNode(candidate: unknown): candidate is ComposerInlineTokenNode { return ( + candidate instanceof ComposerCitationNode || candidate instanceof ComposerMentionNode || candidate instanceof ComposerSkillNode || candidate instanceof ComposerTerminalContextNode @@ -836,6 +850,10 @@ function $setComposerEditorPrompt( const segments = splitPromptIntoComposerSegments(prompt, terminalContexts); for (const segment of segments) { + if (segment.type === "citation") { + paragraph.append($createComposerCitationNode(segment.citation, segment.source)); + continue; + } if (segment.type === "mention") { paragraph.append($createComposerMentionNode(segment.path, segment.source)); continue; @@ -872,6 +890,8 @@ function collectTerminalContextIds(node: LexicalNode): string[] { } export interface ComposerPromptEditorHandle { + requestCitationComment: (request: ComposerCitationCommentRequest) => void; + isCaretOnVisualEdge: (edge: "start" | "end") => boolean; focus: () => void; focusAt: (cursor: number) => void; focusAtEnd: () => void; @@ -884,6 +904,7 @@ export interface ComposerPromptEditorHandle { } interface ComposerPromptEditorProps { + onCitationSubmitAndSend?: () => void; value: string; cursor: number; terminalContexts: ReadonlyArray; @@ -891,6 +912,8 @@ interface ComposerPromptEditorProps { disabled: boolean; placeholder: string; className?: string; + containerClassName?: string | undefined; + placeholderClassName?: string | undefined; onRemoveTerminalContext: (contextId: string) => void; onChange: ( nextValue: string, @@ -907,6 +930,55 @@ interface ComposerPromptEditorProps { editorRef: React.RefObject; } +function caretLineRect(range: Range, edge: "start" | "end"): DOMRect | null { + const collapsedRects = Array.from(range.getClientRects()).filter((rect) => rect.height > 0); + const collapsedRect = edge === "start" ? collapsedRects.at(-1) : collapsedRects[0]; + if (collapsedRect) return collapsedRect; + + const container = range.startContainer; + if (container.nodeType === Node.TEXT_NODE) { + const textNode = container as Text; + if (textNode.data.length === 0) return null; + const probeStart = Math.max( + 0, + Math.min( + edge === "start" ? range.startOffset : range.startOffset - 1, + textNode.data.length - 1, + ), + ); + const probeRange = document.createRange(); + probeRange.setStart(textNode, probeStart); + probeRange.setEnd(textNode, probeStart + 1); + const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0); + if (probeRect) return probeRect; + const boundingRect = probeRange.getBoundingClientRect(); + return boundingRect.height > 0 ? boundingRect : null; + } + + if (!(container instanceof HTMLElement)) return null; + // The caret sits between the paragraph's children, which is where Lexical + // puts it next to an inline chip. Measure the neighbouring child. + const neighbour = + container.childNodes[Math.max(0, range.startOffset - 1)] ?? + container.childNodes[range.startOffset]; + if (neighbour instanceof HTMLElement) { + const neighbourRect = neighbour.getBoundingClientRect(); + if (neighbourRect.height > 0) return neighbourRect; + } else if (neighbour instanceof Text && neighbour.data.length > 0) { + // Probe the character on the caret's side. A soft-wrapped text node's + // first rect is its first visual line, which may not be the caret's. + const isBeforeCaret = neighbour === container.childNodes[range.startOffset - 1]; + const probeStart = isBeforeCaret ? neighbour.data.length - 1 : 0; + const probeRange = document.createRange(); + probeRange.setStart(neighbour, probeStart); + probeRange.setEnd(neighbour, probeStart + 1); + const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0); + if (probeRect) return probeRect; + } + const containerRect = container.getBoundingClientRect(); + return containerRect.height > 0 ? containerRect : null; +} + function ComposerCommandKeyPlugin(props: { onCommandKeyDown?: ( key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", @@ -1257,6 +1329,7 @@ function ComposerInlineTokenPastePlugin() { useEffect( () => registerComposerInlineTokenPaste(editor, { + createCitationNode: $createComposerCitationNode, createMentionNode: $createComposerMentionNode, getExpandedAbsoluteOffsetForPoint, }), @@ -1540,7 +1613,10 @@ function ComposerPromptEditorInner({ disabled, placeholder, className, + containerClassName, + placeholderClassName, onRemoveTerminalContext, + onCitationSubmitAndSend, onChange, onCommandKeyDown, onPaste, @@ -1561,6 +1637,21 @@ function ComposerPromptEditorInner({ terminalContextIds: terminalContexts.map((context) => context.id), }); const isApplyingControlledUpdateRef = useRef(false); + const citationCommentRequestRef = useRef(null); + const [openCitationComment, setOpenCitationComment] = + useState(null); + const citationCommentActions = useMemo( + () => ({ + openComment: openCitationComment, + onOpenChange: (nodeKey: NodeKey, open: boolean) => { + setOpenCitationComment((current) => + open ? { nodeKey } : current?.nodeKey === nodeKey ? null : current, + ); + }, + onSubmitAndSend: onCitationSubmitAndSend ?? (() => {}), + }), + [onCitationSubmitAndSend, openCitationComment], + ); const terminalContextActions = useMemo( () => ({ onRemoveTerminalContext }), [onRemoveTerminalContext], @@ -1578,6 +1669,22 @@ function ComposerPromptEditorInner({ editor.setEditable(!disabled); }, [disabled, editor]); + useEffect(() => { + const openCitationNodeKey = openCitationComment?.nodeKey; + if (!openCitationNodeKey) return; + return editor.registerUpdateListener(({ editorState }) => { + const isAttached = editorState.read(() => { + const node = $getNodeByKey(openCitationNodeKey); + return node instanceof ComposerCitationNode && node.isAttached(); + }); + if (!isAttached) { + setOpenCitationComment((current) => + current?.nodeKey === openCitationNodeKey ? null : current, + ); + } + }); + }, [editor, openCitationComment?.nodeKey]); + useLayoutEffect(() => { const normalizedCursor = clampCollapsedComposerCursor(value, cursor); const previousSnapshot = snapshotRef.current; @@ -1608,16 +1715,27 @@ function ComposerPromptEditorInner({ } isApplyingControlledUpdateRef.current = true; - editor.update(() => { - const shouldRewriteEditorState = - previousSnapshot.value !== value || contextsChanged || skillsChanged; - if (shouldRewriteEditorState) { - $setComposerEditorPrompt(value, terminalContexts, skillMetadataRef.current); - } - if (shouldRewriteEditorState || isFocused) { - $setSelectionAtComposerOffset(normalizedCursor); - } - }); + const isCiteInsertion = citationCommentRequestRef.current?.value === value; + let citationToOpen: ComposerCitationCommentTarget | null = null; + editor.update( + () => { + const shouldRewriteEditorState = + previousSnapshot.value !== value || contextsChanged || skillsChanged; + if (shouldRewriteEditorState) { + $setComposerEditorPrompt(value, terminalContexts, skillMetadataRef.current); + } + if (shouldRewriteEditorState || isFocused) { + $setSelectionAtComposerOffset(normalizedCursor); + } + citationToOpen = $consumeComposerCitationCommentRequest(citationCommentRequestRef); + }, + { + ...(isCiteInsertion ? { tag: [HISTORY_PUSH_TAG, SKIP_DOM_SELECTION_TAG] } : {}), + onUpdate: () => { + if (citationToOpen) setOpenCitationComment(citationToOpen); + }, + }, + ); queueMicrotask(() => { isApplyingControlledUpdateRef.current = false; }); @@ -1698,7 +1816,44 @@ function ComposerPromptEditorInner({ ), ); }, + requestCitationComment: (request) => { + citationCommentRequestRef.current = request; + const target = editor + .getEditorState() + .read(() => $consumeComposerCitationCommentRequest(citationCommentRequestRef)); + if (target) setOpenCitationComment(target); + }, readSnapshot, + isCaretOnVisualEdge: (edge) => { + const snapshot = readSnapshot(); + if (snapshot.value.length === 0) return true; + const beforeCaret = snapshot.value.slice(0, snapshot.expandedCursor); + const afterCaret = snapshot.value.slice(snapshot.expandedCursor); + if (edge === "start" ? beforeCaret.includes("\n") : afterCaret.includes("\n")) { + return false; + } + const rootElement = editor.getRootElement(); + const selection = window.getSelection(); + if ( + !rootElement || + !selection || + !selection.isCollapsed || + selection.rangeCount === 0 || + !selection.anchorNode || + !rootElement.contains(selection.anchorNode) + ) { + return false; + } + const caretRect = caretLineRect(selection.getRangeAt(0), edge); + if (!caretRect) return false; + const edgeElement = + edge === "start" ? rootElement.firstElementChild : rootElement.lastElementChild; + const edgeRect = (edgeElement ?? rootElement).getBoundingClientRect(); + const threshold = caretRect.height / 2; + return edge === "start" + ? caretRect.top - edgeRect.top < threshold + : edgeRect.bottom - caretRect.bottom < threshold; + }, }), [focusAt, readSnapshot], ); @@ -1753,43 +1908,55 @@ function ComposerPromptEditorInner({ }, []); return ( - -
- } - onPaste={onPaste} - /> - } - placeholder={ - terminalContexts.length > 0 ? null : ( -
- {placeholder} -
- ) - } - ErrorBoundary={LexicalErrorBoundary} - /> - - - - - - - - - - -
-
+ + +
+ } + onPaste={onPaste} + /> + } + placeholder={ + terminalContexts.length > 0 ? null : ( +
+ {placeholder} +
+ ) + } + ErrorBoundary={LexicalErrorBoundary} + /> + + + + + + + + + + +
+
+
); } @@ -1801,7 +1968,10 @@ export function ComposerPromptEditor({ disabled, placeholder, className, + containerClassName, + placeholderClassName, onRemoveTerminalContext, + onCitationSubmitAndSend, onChange, onCommandKeyDown, onPaste, @@ -1814,7 +1984,12 @@ export function ComposerPromptEditor({ () => ({ namespace: "t3tools-composer-editor", editable: true, - nodes: [ComposerMentionNode, ComposerSkillNode, ComposerTerminalContextNode], + nodes: [ + ComposerCitationNode, + ComposerMentionNode, + ComposerSkillNode, + ComposerTerminalContextNode, + ], editorState: () => { $setComposerEditorPrompt( initialValueRef.current, @@ -1838,12 +2013,15 @@ export function ComposerPromptEditor({ skills={skills} disabled={disabled} placeholder={placeholder} + {...(onCitationSubmitAndSend ? { onCitationSubmitAndSend } : {})} onRemoveTerminalContext={onRemoveTerminalContext} onChange={onChange} onPaste={onPaste} editorRef={editorRef} {...(onCommandKeyDown ? { onCommandKeyDown } : {})} {...(className ? { className } : {})} + containerClassName={containerClassName} + placeholderClassName={placeholderClassName} /> ); diff --git a/apps/web/src/components/DiffFilePathCopyButton.tsx b/apps/web/src/components/DiffFilePathCopyButton.tsx new file mode 100644 index 000000000..49b00ed89 --- /dev/null +++ b/apps/web/src/components/DiffFilePathCopyButton.tsx @@ -0,0 +1,41 @@ +import { CheckIcon, CopyIcon } from "lucide-react"; +import { useRef } from "react"; +import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; +import { + ANCHORED_COPY_TOAST_TIMEOUT_MS, + showAnchoredCopyErrorToast, + showAnchoredCopySuccessToast, +} from "./ui/anchoredCopyToast"; +import { Button } from "./ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +export function DiffFilePathCopyButton({ filePath }: { filePath: string }) { + const ref = useRef(null); + const { copyToClipboard, isCopied } = useCopyToClipboard({ + onCopy: () => showAnchoredCopySuccessToast(ref), + onError: (error) => showAnchoredCopyErrorToast(ref, error), + timeout: ANCHORED_COPY_TOAST_TIMEOUT_MS, + }); + + return ( + + copyToClipboard(filePath, undefined)} + /> + } + > + {isCopied ? : } + + +

{isCopied ? "Copied" : "Copy path"}

+
+
+ ); +} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index a23632cfd..c2f8d41b1 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,3 +1,8 @@ +import { DiffFileTree } from "./diffs/DiffFileTree"; +import { diffFileTreeEntries } from "./diffs/diffFileTree.logic"; +import { useCodeViewFileReveal } from "./diffs/useCodeViewFileReveal"; +import { ListTreeIcon } from "lucide-react"; +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { useAtomValue } from "@effect/atom-react"; import type { FileDiffContentsLoader } from "@pierre/diffs"; import { useParams } from "@tanstack/react-router"; @@ -16,7 +21,6 @@ import { ChevronsUpDownIcon, Columns2Icon, PilcrowIcon, - RefreshCwIcon, Rows3Icon, SearchIcon, TextWrapIcon, @@ -112,7 +116,8 @@ export default function DiffPanel({ fileKeys: EMPTY_COLLAPSED_DIFF_FILE_KEYS, })); const [codeViewRevision, setCodeViewRevision] = useState(0); - const codeViewRef = useRef(null); + const [codeView, setCodeView] = useState(null); + const [fileTreeOpen, setFileTreeOpen] = useState(false); const lastCompletedTurnRefreshRef = useRef<{ readonly threadKey: string | null; readonly runId: RunId | null; @@ -432,8 +437,8 @@ export default function DiffPanel({ useEffect(() => { if (!selectedDiffFileKey) return; - codeViewRef.current?.scrollTo({ type: "item", id: selectedDiffFileKey, align: "start" }); - }, [codeViewMountKey, selectedDiffFileKey, selectedFileRevealRequestId]); + codeView?.scrollTo({ type: "item", id: selectedDiffFileKey, align: "start" }); + }, [codeView, codeViewMountKey, selectedDiffFileKey, selectedFileRevealRequestId]); const openDiffFile = useCallback( (filePath: string) => { @@ -478,6 +483,22 @@ export default function DiffPanel({ [collapseScopeKey], ); + const fileTreeEntries = useMemo(() => diffFileTreeEntries(renderableFiles), [renderableFiles]); + const requestFileReveal = useCodeViewFileReveal(codeView, collapseScopeKey); + const revealDiffFile = useCallback( + (path: string) => { + const file = codeViewFiles.find((candidate) => candidate.filePath === path); + if (!file) return; + setCollapsedDiffFiles((current) => { + const keys = new Set(current.scopeKey === collapseScopeKey ? current.fileKeys : []); + keys.delete(file.fileKey); + return { scopeKey: collapseScopeKey, fileKeys: keys }; + }); + requestFileReveal(file.fileKey); + }, + [codeViewFiles, collapseScopeKey, requestFileReveal], + ); + const toggleDiffFileCollapse = useCallback(() => { setCodeViewRevision((current) => current + 1); setCollapsedDiffFiles((current) => { @@ -716,6 +737,24 @@ export default function DiffPanel({ )}
+ {codeViewFiles.length > 0 && ( + + + } + > + + + {fileTreeOpen ? "Hide file tree" : "Show file tree"} + + )} {codeViewFiles.length > 0 && ( } > - + {branchDiffPreview.isPending ? "Refreshing diff…" : "Refresh diff"} @@ -883,93 +920,110 @@ export default function DiffPanel({
) ) : renderablePatch.kind === "files" ? ( -
{ - const composedPath = event.nativeEvent.composedPath?.() ?? []; - for (const node of composedPath) { - if (!(node instanceof HTMLElement)) continue; - // Header controls keep their own actions. In particular, the chevron must - // not also trigger the row handler or the two toggles cancel each other. - if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) { +
+
{ + const composedPath = event.nativeEvent.composedPath?.() ?? []; + for (const node of composedPath) { + if (!(node instanceof HTMLElement)) continue; + // Header controls keep their own actions. In particular, the chevron must + // not also trigger the row handler or the two toggles cancel each other. + if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) { + return; + } + } + const title = composedPath.find( + (node): node is HTMLElement => + node instanceof HTMLElement && node.hasAttribute("data-title"), + ); + const filePath = title?.textContent?.trim(); + // The filename remains the explicit "open in editor" affordance. + if (filePath) { + openDiffFile(filePath); return; } - } - const title = composedPath.find( - (node): node is HTMLElement => - node instanceof HTMLElement && node.hasAttribute("data-title"), - ); - const filePath = title?.textContent?.trim(); - // The filename remains the explicit "open in editor" affordance. - if (filePath) { - openDiffFile(filePath); - return; - } - const header = composedPath.find( - (node): node is HTMLElement => - node instanceof HTMLElement && node.hasAttribute("data-diffs-header"), - ); - const headerFilePath = header?.querySelector("[data-title]")?.textContent?.trim(); - if (!headerFilePath) return; - const file = codeViewFiles.find( - (candidate) => candidate.filePath === headerFilePath, - ); - if (file) toggleDiffFileCollapsed(file.fileKey); - }} - > - { - const filePath = resolveFileDiffPath(fileDiff); - return ( - - { - event.stopPropagation(); - toggleDiffFileCollapsed(fileKey); - }} - /> - } - > - {collapsed ? ( - - ) : ( - - )} - - - {collapsed ? "Expand diff" : "Collapse diff"} - - + const header = composedPath.find( + (node): node is HTMLElement => + node instanceof HTMLElement && node.hasAttribute("data-diffs-header"), ); + const headerFilePath = header + ?.querySelector("[data-title]") + ?.textContent?.trim(); + if (!headerFilePath) return; + const file = codeViewFiles.find( + (candidate) => candidate.filePath === headerFilePath, + ); + if (file) toggleDiffFileCollapsed(file.fileKey); }} - options={{ - diffStyle: diffRenderMode === "split" ? "split" : "unified", - lineDiffType: "none", - overflow: wordWrap ? "wrap" : "scroll", - theme: resolveDiffThemeName(resolvedTheme), - themeType: resolvedTheme as DiffThemeType, - stickyHeaders: true, - ...(loadDiffFiles ? { loadDiffFiles } : {}), - }} - /> + > + { + const filePath = resolveFileDiffPath(fileDiff); + return ( + + { + event.stopPropagation(); + toggleDiffFileCollapsed(fileKey); + }} + /> + } + > + {collapsed ? ( + + ) : ( + + )} + + + {collapsed ? "Expand diff" : "Collapse diff"} + + + ); + }} + options={{ + diffStyle: diffRenderMode === "split" ? "split" : "unified", + lineDiffType: "none", + overflow: wordWrap ? "wrap" : "scroll", + theme: resolveDiffThemeName(resolvedTheme), + themeType: resolvedTheme as DiffThemeType, + stickyHeaders: true, + ...(loadDiffFiles ? { loadDiffFiles } : {}), + }} + /> +
+ {fileTreeOpen && ( + + )}
) : (
diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx index 3ec748c6b..1cd2393e8 100644 --- a/apps/web/src/components/DiffWorkerPoolProvider.tsx +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -1,9 +1,18 @@ -import { WorkerPoolContextProvider, useWorkerPool } from "@pierre/diffs/react"; +import { WorkerPoolContext, useWorkerPool } from "@pierre/diffs/react"; +import { WorkerPoolManager } from "@pierre/diffs/worker"; import DiffsWorker from "@pierre/diffs/worker/worker.js?worker"; import * as Schema from "effect/Schema"; -import { useEffect, useMemo, type ReactNode } from "react"; +import { + useCallback, + useEffect, + useMemo, + useState, + useSyncExternalStore, + type ReactNode, +} from "react"; import { useTheme } from "../hooks/useTheme"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; +import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting"; export class DiffWorkerError extends Schema.TaggedErrorClass()("DiffWorkerError", { operation: Schema.Literals(["create-worker", "get-render-options", "set-render-options"]), @@ -15,6 +24,46 @@ export class DiffWorkerError extends Schema.TaggedErrorClass()( } } +const DIFF_WORKER_IDLE_TTL_MS = 30_000; +let sharedWorkerPool: + | { + readonly pool: WorkerPoolManager; + consumers: number; + idleTimer: ReturnType | undefined; + } + | undefined; + +/** Create workers after a viewer commits, then reuse them across short panel closures. */ +function acquireDiffWorkerPool(themeName: DiffThemeName, poolSize: number) { + const entry = (sharedWorkerPool ??= { + pool: new WorkerPoolManager( + { + workerFactory: () => { + try { + return new DiffsWorker(); + } catch (cause) { + throw new DiffWorkerError({ operation: "create-worker", themeName, cause }); + } + }, + poolSize, + totalASTLRUCacheSize: 240, + }, + { + theme: themeName, + preferredHighlighter: PREFERRED_HIGHLIGHTER, + tokenizeMaxLineLength: 1_000, + useTokenTransformer: true, + }, + ), + consumers: 0, + idleTimer: undefined, + }); + clearTimeout(entry.idleTimer); + entry.idleTimer = undefined; + entry.consumers += 1; + return entry; +} + function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { const workerPool = useWorkerPool(); @@ -45,6 +94,40 @@ function DiffWorkerThemeSync({ themeName }: { themeName: DiffThemeName }) { return null; } +// Plain-text views do not queue a highlight task that could retry a blank first render. +function DiffWorkerReady({ children }: { children?: ReactNode }) { + const workerPool = useWorkerPool(); + const [readyPool, setReadyPool] = useState(); + const ready = workerPool + ? readyPool === workerPool || workerPool.isInitialized() || !workerPool.isWorkingPool() + : typeof window === "undefined"; + + useEffect(() => { + if (ready || !workerPool) return; + + let mounted = true; + const finish = () => { + if (mounted) setReadyPool(workerPool); + }; + // Failed pools use Pierre's existing main-thread highlighter. + void workerPool.initialize().then(finish, finish); + return () => { + mounted = false; + }; + }, [ready, workerPool]); + + return ready ? ( + children + ) : ( +
+ Loading code... +
+ ); +} + export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { const { resolvedTheme } = useTheme(); const diffThemeName = resolveDiffThemeName(resolvedTheme); @@ -53,32 +136,32 @@ export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4); return Math.max(2, Math.min(6, Math.floor(cores / 2))); }, []); + const workerPool = useSyncExternalStore( + useCallback( + (onStoreChange) => { + if (typeof window === "undefined") return () => {}; + const entry = acquireDiffWorkerPool(diffThemeName, workerPoolSize); + onStoreChange(); + return () => { + entry.consumers -= 1; + if (entry.consumers !== 0) return; + entry.idleTimer = setTimeout(() => { + entry.idleTimer = undefined; + entry.pool.terminate(); + if (sharedWorkerPool === entry) sharedWorkerPool = undefined; + }, DIFF_WORKER_IDLE_TTL_MS); + }; + }, + [diffThemeName, workerPoolSize], + ), + () => sharedWorkerPool?.pool, + () => undefined, + ); return ( - { - try { - return new DiffsWorker(); - } catch (cause) { - throw new DiffWorkerError({ - operation: "create-worker", - themeName: diffThemeName, - cause, - }); - } - }, - poolSize: workerPoolSize, - totalASTLRUCacheSize: 240, - }} - highlighterOptions={{ - theme: diffThemeName, - tokenizeMaxLineLength: 1_000, - useTokenTransformer: true, - }} - > + - {children} - + {children} + ); } diff --git a/apps/web/src/components/EnvironmentMachineIcon.tsx b/apps/web/src/components/EnvironmentMachineIcon.tsx new file mode 100644 index 000000000..a7cb95849 --- /dev/null +++ b/apps/web/src/components/EnvironmentMachineIcon.tsx @@ -0,0 +1,102 @@ +import { useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/unstable/reactivity"; +import { serverEnvironment } from "../state/server"; +import { + resolveEnvironmentMachineKind, + type EnvironmentId, + type EnvironmentMachineKind, +} from "@t3tools/contracts"; +import { CloudIcon, LaptopIcon, MonitorIcon, ServerIcon, type LucideProps } from "lucide-react"; +import type { FunctionComponent, SVGProps } from "react"; +import { LinuxIcon } from "./Icons"; + +// Lucide has no Apple desktops, so these two are drawn to its grammar (24 +// unit grid, 2 unit stroke, round joins) and share its prop surface so callers +// can swap freely. +function LucideLike(props: SVGProps) { + return ( + + ); +} + +/** A Mac mini: squat rounded slab with a front-edge LED. */ +function MacMiniIcon(props: SVGProps) { + return ( + + + + + ); +} + +/** A Mac Studio: the same slab twice as tall, ports along the front foot. */ +function MacStudioIcon(props: SVGProps) { + return ( + + + + + ); +} + +const ICON_BY_KIND: Record> = { + server: ServerIcon, + cloud: CloudIcon, + linux: LinuxIcon, + desktop: MonitorIcon, + laptop: LaptopIcon, + "mac-mini": MacMiniIcon, + "mac-studio": MacStudioIcon, +}; + +export const ENVIRONMENT_MACHINE_KIND_LABELS: Record = { + server: "Server", + cloud: "Cloud VM", + linux: "Linux/WSL", + desktop: "Desktop", + laptop: "Laptop", + "mac-mini": "Mini PC", + "mac-studio": "Workstation", +}; + +export function environmentMachineIcon( + kind: EnvironmentMachineKind, +): FunctionComponent { + return ICON_BY_KIND[kind]; +} + +export function EnvironmentMachineIcon({ + kind, + ...props +}: LucideProps & { readonly kind: EnvironmentMachineKind }) { + const Icon = ICON_BY_KIND[kind]; + return ; +} + +const environmentMachineAtom = Atom.family((environmentId: EnvironmentId | null) => + Atom.make((get) => + resolveEnvironmentMachineKind( + environmentId === null ? null : get(serverEnvironment.configValueAtom(environmentId)), + ), + ), +); + +/** Subscribe only to the resolved glyph, so quota/config updates do not repaint thread rows. */ +export function ConnectedEnvironmentMachineIcon({ + environmentId, + ...props +}: LucideProps & { environmentId: EnvironmentId | null }) { + const kind = useAtomValue(environmentMachineAtom(environmentId)); + return ; +} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index abd8e9dad..3064cc5ae 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1080,7 +1080,7 @@ export default function GitActionsControl({ let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise; const persistThreadBranchSync = useCallback( - (branch: string | null) => { + (branch: string | null, manualSelection = false) => { if (!activeThreadRef) { return; } @@ -1108,6 +1108,10 @@ export default function GitActionsControl({ setDraftThreadContext(draftId ?? activeThreadRef, { branch, worktreePath: activeDraftThread.worktreePath, + environmentSelection: manualSelection + ? "manual" + : (activeDraftThread.environmentSelection ?? + (activeDraftThread.branch ? "manual" : "auto")), }); }, [ @@ -1127,7 +1131,7 @@ export default function GitActionsControl({ return; } - persistThreadBranchSync(branchUpdate.branch); + persistThreadBranchSync(branchUpdate.branch, true); }, [persistThreadBranchSync], ); @@ -1347,6 +1351,7 @@ export default function GitActionsControl({ const actionId = randomUUID(); const result = await runImmediateGitAction.run({ + ...(activeServerThread ? { threadId: activeServerThread.id } : {}), actionId, action, ...(commitMessage ? { commitMessage } : {}), @@ -1864,8 +1869,8 @@ export default function GitActionsControl({ Changes - +{changeStat?.insertions ?? 0} - -{changeStat?.deletions ?? 0} + +{changeStat?.insertions ?? 0} + -{changeStat?.deletions ?? 0} ) : null} @@ -1973,9 +1978,9 @@ export default function GitActionsControl({ Excluded ) : ( <> - +{file.insertions} + +{file.insertions} / - -{file.deletions} + -{file.deletions} )} @@ -1986,11 +1991,11 @@ export default function GitActionsControl({
- + +{selectedFiles.reduce((sum, f) => sum + f.insertions, 0)} / - + -{selectedFiles.reduce((sum, f) => sum + f.deletions, 0)}
diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index d3866a82b..e99f31b0b 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -759,3 +759,9 @@ export const PiAgentIcon: Icon = ({ className, ...props }) => ( ); + +export const LinuxIcon: Icon = ({ className, ...props }) => ( + + + +); diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 9b247d2cf..973edad88 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -1,3 +1,8 @@ +import { + useSidebarFileDropNavigation, + useSidebarFileDropTarget, +} from "../hooks/useSidebarFileDrop"; +import { Spinner } from "~/components/ui/spinner"; import { ArchiveIcon, ArrowUpDownIcon, @@ -7,7 +12,6 @@ import { FolderPlusIcon, GitForkIcon, Globe2Icon, - LoaderIcon, SearchIcon, SquarePenIcon, TerminalIcon, @@ -335,6 +339,7 @@ interface SidebarThreadRowProps { orderedProjectThreadKeys: readonly string[], ) => void; navigateToThread: (threadRef: ScopedThreadRef) => void; + onFileDropThreads?: ((threadRef: ScopedThreadRef, files: File[]) => void) | undefined; handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; handleThreadContextMenu: ( threadRef: ScopedThreadRef, @@ -383,6 +388,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr thread, } = props; const threadRef = scopeThreadRef(thread.environmentId, thread.id); + const fileDrop = useSidebarFileDropTarget(threadRef, props.onFileDropThreads); const threadKey = scopedThreadKey(threadRef); const forkParentThreadId = getSidebarForkParentThreadId(thread); const localLastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); @@ -471,9 +477,11 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr const linkedPullRequestStatus = useLinkedThreadPullRequest( thread.environmentId, thread.linkedPullRequest, + thread.pullRequests, + thread.branchPullRequest, ); const pr = - thread.linkedPullRequest == null + thread.linkedPullRequest == null && thread.branchPullRequest == null ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data }) : (linkedPullRequestStatus?.pr ?? null); const prStatus = prStatusIndicator( @@ -702,6 +710,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr @@ -713,7 +722,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr className={`${resolveThreadRowClassName({ isActive, isSelected, - })} relative isolate`} + })} relative isolate${fileDrop.active ? " ring-1 ring-inset ring-primary/70" : ""}`} onClick={handleRowClick} onDoubleClick={handleRowDoubleClick} onKeyDown={handleRowKeyDown} @@ -739,7 +748,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr Open parent thread ) : null} - {prStatus && ( + {prStatus && pr && ( event.stopPropagation()} onClick={handlePrClick} > - + } /> @@ -969,6 +982,7 @@ interface SidebarProjectThreadListProps { orderedProjectThreadKeys: readonly string[], ) => void; navigateToThread: (threadRef: ScopedThreadRef) => void; + onFileDropThreads?: ((threadRef: ScopedThreadRef, files: File[]) => void) | undefined; handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; handleThreadContextMenu: ( threadRef: ScopedThreadRef, @@ -1073,6 +1087,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( confirmArchiveButtonRefs={confirmArchiveButtonRefs} handleThreadClick={handleThreadClick} navigateToThread={navigateToThread} + onFileDropThreads={props.onFileDropThreads} handleMultiSelectContextMenu={handleMultiSelectContextMenu} handleThreadContextMenu={handleThreadContextMenu} clearSelection={clearSelection} @@ -1788,13 +1803,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (isMobile) { setOpenMobile(false); } - void router.navigate({ + return router.navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), }); }, [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], ); + const handleThreadFileDrop = useSidebarFileDropNavigation(navigateToThread); const handleThreadClick = useCallback( ( @@ -2356,6 +2372,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec /> )} - + Connecting {connecting.join(", ")} diff --git a/apps/web/src/components/NoProjectsHero.tsx b/apps/web/src/components/NoProjectsHero.tsx new file mode 100644 index 000000000..09bd92c82 --- /dev/null +++ b/apps/web/src/components/NoProjectsHero.tsx @@ -0,0 +1,36 @@ +import { PlusIcon } from "lucide-react"; +import { useCallback } from "react"; + +import { openCommandPalette } from "../commandPaletteBus"; +import { Button } from "./ui/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; +import { SidebarInset } from "./ui/sidebar"; + +export function NoProjectsHero() { + const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); + + return ( + +
+ +
+ + + What should we work on? + + + Add a project to start your first thread. + +
+ +
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index bbeeda4bc..d6de5d552 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -51,6 +51,8 @@ vi.mock("react", async (importOriginal) => { }; }); +vi.mock("../state/entities", () => ({ useProjects: () => [] })); + vi.mock("react/compiler-runtime", () => ({ c: hooks.useMemoCache })); vi.mock("../assets/assetUrls", () => ({ useAssetUrlState: (_environmentId: unknown, resource: unknown) => { diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 9b07633b5..ca8169a6b 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,32 +1,183 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId, ProjectIconColor } from "@t3tools/contracts"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { getProjectFaviconCacheKey, isProjectFaviconFallbackUrl, } from "@t3tools/shared/projectFavicon"; -import { FolderIcon } from "lucide-react"; +import { + BotIcon, + BookOpenIcon, + BracesIcon, + CircuitBoardIcon, + CloudCogIcon, + Code2Icon, + DatabaseIcon, + FlaskConicalIcon, + FolderCodeIcon, + Gamepad2Icon, + Globe2Icon, + ImageIcon, + Layers3Icon, + MonitorIcon, + MusicIcon, + PackageIcon, + ServerIcon, + ShieldCheckIcon, + ShoppingBagIcon, + SmartphoneIcon, + TerminalIcon, + VideoIcon, +} from "lucide-react"; +import type { IconName } from "lucide-react/dynamic"; import type { ComponentType } from "react"; -import { useState } from "react"; +import { lazy, Suspense, useState } from "react"; import { useAssetUrlState } from "../assets/assetUrls"; +import { useProjects } from "../state/entities"; +import { selectProjectIcon, type ProjectIconName } from "../projectIconModel"; +import { projectIconColorClassName } from "../projectIconColors"; import { cn } from "~/lib/utils"; -const loadedProjectFaviconSrcs = new Map(); +const DynamicIcon = lazy(() => + import("lucide-react/dynamic").then((module) => ({ default: module.DynamicIcon })), +); + +function DynamicProjectIconFallback() { + return ; +} + +const PROJECT_ICONS: Record> = { + ai: BotIcon, + book: BookOpenIcon, + braces: BracesIcon, + circuit: CircuitBoardIcon, + cloud: CloudCogIcon, + code: Code2Icon, + database: DatabaseIcon, + desktop: MonitorIcon, + "folder-code": FolderCodeIcon, + game: Gamepad2Icon, + image: ImageIcon, + layers: Layers3Icon, + mobile: SmartphoneIcon, + music: MusicIcon, + package: PackageIcon, + security: ShieldCheckIcon, + server: ServerIcon, + shopping: ShoppingBagIcon, + terminal: TerminalIcon, + test: FlaskConicalIcon, + video: VideoIcon, + web: Globe2Icon, +}; + +const PROJECT_ICON_COLOR_BY_NAME: Record = { + ai: "violet", + book: "amber", + braces: "purple", + circuit: "teal", + cloud: "sky", + code: "blue", + database: "cyan", + desktop: "indigo", + "folder-code": "orange", + game: "emerald", + image: "pink", + layers: "fuchsia", + mobile: "lime", + music: "fuchsia", + package: "orange", + security: "teal", + server: "blue", + shopping: "rose", + terminal: "green", + test: "yellow", + video: "red", + web: "sky", +}; + +// Explicit project records win; legacy row surfaces resolve the canonical scoped +// record from project state, never their shortened display label. +export type ProjectFaviconProject = Pick< + EnvironmentProject, + "environmentId" | "workspaceRoot" | "title" | "faviconPath" | "projectIcon" +>; export function ProjectFavicon(input: { environmentId: EnvironmentId; cwd: string; faviconPath?: string | null | undefined; + projectId?: ProjectId | undefined; + project?: ProjectFaviconProject | undefined; className?: string | undefined; fallbackIcon?: ComponentType<{ className?: string }>; }) { - const state = useProjectFaviconAsset(input); - const src = state._tag === "Success" ? state.url : null; - const FallbackIcon = input.fallbackIcon ?? FolderIcon; + const projects = useProjects(); + const project = input.project ?? + projects.find( + (candidate) => + candidate.environmentId === input.environmentId && + (input.projectId + ? candidate.id === input.projectId + : candidate.workspaceRoot === input.cwd), + ) ?? { + environmentId: input.environmentId, + workspaceRoot: input.cwd, + title: input.cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? "Project", + faviconPath: input.faviconPath, + projectIcon: null, + }; + const asset = useProjectFaviconAsset({ + environmentId: project.environmentId, + cwd: project.workspaceRoot, + faviconPath: project.faviconPath, + }); + const src = asset._tag === "Success" ? asset.url : null; + if (project.projectIcon?.kind === "emoji") { + return ; + } + if (project.projectIcon?.kind === "lucide") { + const colorClassName = projectIconColorClassName(project.projectIcon.color); + const iconClassName = cn( + "inline-flex size-3.5 shrink-0 items-center justify-center", + colorClassName, + input.className, + ); + return ( + + ); + } + const automaticIconName = input.fallbackIcon + ? null + : selectProjectIcon(project.title, project.workspaceRoot); + const FallbackIcon = + input.fallbackIcon ?? + (automaticIconName?.kind === "lucide" ? PROJECT_ICONS[automaticIconName.icon] : undefined); + const fallbackEmoji = automaticIconName?.kind === "emoji" ? automaticIconName.emoji : undefined; + const fallbackColorClassName = + automaticIconName?.kind === "lucide" + ? projectIconColorClassName(PROJECT_ICON_COLOR_BY_NAME[automaticIconName.icon]) + : undefined; if (!src || isProjectFaviconFallbackUrl(src)) { - return ; + return ( + + ); } - const cacheKey = getProjectFaviconCacheKey(input.environmentId, input.cwd, src); + const cacheKey = getProjectFaviconCacheKey(project.environmentId, project.workspaceRoot, src); return ( ); } @@ -53,40 +206,69 @@ export function useProjectFaviconAsset(input: { function ProjectFaviconFallback({ className, + colorClassName, icon: Icon, + emoji, }: { readonly className?: string | undefined; - readonly icon: ComponentType<{ className?: string }>; + readonly colorClassName?: string | undefined; + readonly icon?: ComponentType<{ className?: string }> | undefined; + readonly emoji?: string | undefined; }) { - return ; + if (emoji) { + return ( + + ); + } + + if (!Icon) return null; + return ; } +const loadedProjectFaviconSrcs = new Map(); + function ProjectFaviconImage({ cacheKey, src, className, fallbackIcon: FallbackIcon, + fallbackEmoji, + fallbackColorClassName, }: { readonly cacheKey: string; readonly src: string; readonly className?: string | undefined; - readonly fallbackIcon: ComponentType<{ className?: string }>; + readonly fallbackIcon?: ComponentType<{ className?: string }> | undefined; + readonly fallbackEmoji?: string | undefined; + readonly fallbackColorClassName?: string | undefined; }) { const [displayedSrc, setDisplayedSrc] = useState( - () => loadedProjectFaviconSrcs.get(cacheKey) ?? null, + () => loadedProjectFaviconSrcs.get(cacheKey) ?? (src.startsWith("data:image/") ? src : null), ); const isLoading = displayedSrc !== src; const handleLoadError = (failedSrc: string) => { - if (loadedProjectFaviconSrcs.get(cacheKey) === failedSrc) { + if (loadedProjectFaviconSrcs.get(cacheKey) === failedSrc) loadedProjectFaviconSrcs.delete(cacheKey); - } setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc)); }; return ( <> {displayedSrc === null ? ( - + ) : null} {displayedSrc ? ( void; }) { @@ -18,6 +19,9 @@ export function RightPanelSheet(props: { }} > ; case "plan": return ; + case "thread-pull-requests": + return ; case "pull-request": { const status = pullRequestStatuses?.[surface.id] ?? null; const toneClassName = @@ -603,11 +610,53 @@ function SurfaceIcon({ } } +const TAB_SCROLL_EDGE_TOLERANCE = 1; + +function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null { + return root?.querySelector('[data-slot="scroll-area-viewport"]') ?? null; +} + export function RightPanelTabs(props: RightPanelTabsProps) { const ownsDesktopTitleBar = isElectron && props.mode === "inline"; const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); + const [tabScrollState, setTabScrollState] = useState({ + hasOverflow: false, + canScrollLeft: false, + canScrollRight: false, + }); + + const updateTabScrollState = useCallback(() => { + const viewport = tabScrollViewport(tabListRef.current); + if (!viewport) return; + + const hasOverflow = viewport.scrollWidth - viewport.clientWidth > TAB_SCROLL_EDGE_TOLERANCE; + const canScrollLeft = hasOverflow && viewport.scrollLeft > TAB_SCROLL_EDGE_TOLERANCE; + const canScrollRight = + hasOverflow && + viewport.scrollLeft + viewport.clientWidth < viewport.scrollWidth - TAB_SCROLL_EDGE_TOLERANCE; + setTabScrollState((current) => { + if ( + current.hasOverflow === hasOverflow && + current.canScrollLeft === canScrollLeft && + current.canScrollRight === canScrollRight + ) { + return current; + } + return { hasOverflow, canScrollLeft, canScrollRight }; + }); + }, []); + + const scrollTabs = useCallback((direction: -1 | 1) => { + const viewport = tabScrollViewport(tabListRef.current); + if (!viewport) return; + const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + viewport.scrollBy({ + left: direction * Math.max(120, viewport.clientWidth * 0.75), + behavior: reduceMotion ? "auto" : "smooth", + }); + }, []); const addSurfaceActions = [ { @@ -772,13 +821,54 @@ export function RightPanelTabs(props: RightPanelTabsProps) { ); useEffect(() => { + if (!props.activeSurfaceId || !tabScrollState.hasOverflow) return; const activeTab = tabListRef.current?.querySelector("[data-active-tab='true']"); activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" }); - }, [props.activeSurfaceId]); + }, [props.activeSurfaceId, tabScrollState.hasOverflow]); + + useEffect(() => { + const viewport = tabScrollViewport(tabListRef.current); + if (!viewport) return; + + const content = viewport.firstElementChild; + const resizeObserver = new ResizeObserver(updateTabScrollState); + resizeObserver.observe(viewport); + if (content) resizeObserver.observe(content); + viewport.addEventListener("scroll", updateTabScrollState, { passive: true }); + updateTabScrollState(); + + return () => { + resizeObserver.disconnect(); + viewport.removeEventListener("scroll", updateTabScrollState); + }; + }, [updateTabScrollState]); + + useEffect(() => { + const viewport = tabScrollViewport(tabListRef.current); + if (!viewport) return; + + const handleWheel = (event: WheelEvent) => { + if (event.ctrlKey) return; + let delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; + if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) delta *= 16; + if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) delta *= viewport.clientWidth; + if (delta === 0) return; + + const previousScrollLeft = viewport.scrollLeft; + viewport.scrollLeft += delta; + if (viewport.scrollLeft === previousScrollLeft) return; + event.preventDefault(); + updateTabScrollState(); + }; + + viewport.addEventListener("wheel", handleWheel, { passive: false }); + return () => viewport.removeEventListener("wheel", handleWheel); + }, [updateTabScrollState]); return ( + {tabScrollState.hasOverflow ? ( +
+ + + + + } + /> + Scroll tabs left + + + + + + } + /> + Scroll tabs right + +
+ ) : null} {props.layoutControls}
diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 34a6f70c7..3ad95366a 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,3 +1,7 @@ +import { + filterSidebarProjectScopeItems, + reduceSidebarProjectScopeMenuState, +} from "./Sidebar.logic"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; import { @@ -31,7 +35,7 @@ import { resolveThreadStatusPill, resolveWorkingStartedAt, resolveWorkInboxBadge, - searchSidebarThreadsByTitle, + searchSidebarThreads, resolveWorkspaceSwitchNavigation, sidebarProjectKey, sidebarProviderInstanceKey, @@ -1364,7 +1368,26 @@ describe("formatBackgroundWorkTooltip", () => { }); }); -describe("searchSidebarThreadsByTitle", () => { +describe("searchSidebarThreads", () => { + it("finds the V2 linked PR by number, repository or URL without changing order", () => { + const linkedPullRequest = { + projectId: ProjectId.make("project"), + repository: "Bl4ckBl1zZ/t3code", + number: 287, + url: "https://github.com/Bl4ckBl1zZ/t3code/pull/287", + }; + const threads = [ + { title: "First", linkedPullRequest }, + { title: "Unlinked", linkedPullRequest: null }, + { title: "Last", linkedPullRequest }, + ]; + for (const query of [" #287 ", "bl4ckbl1zz/t3code#287", linkedPullRequest.url]) { + expect(searchSidebarThreads(threads, query)).toEqual([threads[0], threads[2]]); + } + expect(searchSidebarThreads(threads, "#999")).toEqual([]); + expect(searchSidebarThreads([{ title: "Older server" }], "#287")).toEqual([]); + }); + const threads = [ { id: "thread-1", title: "Fix workspace search", project: "Alpha" }, { id: "thread-2", title: "Review providers", project: "Workspace" }, @@ -1372,15 +1395,15 @@ describe("searchSidebarThreadsByTitle", () => { ]; it("matches thread titles case-insensitively and preserves their order", () => { - expect(searchSidebarThreadsByTitle(threads, "work")).toEqual([threads[0], threads[2]]); + expect(searchSidebarThreads(threads, "work")).toEqual([threads[0], threads[2]]); }); it("does not match project metadata", () => { - expect(searchSidebarThreadsByTitle(threads, "workspace")).toEqual([threads[0]]); + expect(searchSidebarThreads(threads, "workspace")).toEqual([threads[0]]); }); it("returns no results for an empty query", () => { - expect(searchSidebarThreadsByTitle(threads, " ")).toEqual([]); + expect(searchSidebarThreads(threads, " ")).toEqual([]); }); }); @@ -2462,3 +2485,66 @@ describe("resolveWorkInboxBadge", () => { ); }); }); + +describe("filterSidebarProjectScopeItems", () => { + const items = [ + { value: "all", label: "All projects" }, + { value: "alpha", label: "Alpha workspace" }, + { value: "beta", label: "Beta tools" }, + ] as const; + const filter = (activeScopeKey: string | null, query: string) => + filterSidebarProjectScopeItems({ + items, + activeScopeKey, + query, + matches: (item, candidate) => + item.label.toLocaleLowerCase().includes(candidate.toLocaleLowerCase()), + }); + + it("omits the reset row when the sidebar is already unscoped", () => { + expect(filter(null, "")).toEqual(items.slice(1)); + }); + + it("shows the reset row first while a project scope is active", () => { + expect(filter("alpha", "")).toEqual(items); + }); + + it("hides the reset row while filtering an active scope", () => { + expect(filter("alpha", "all")).toEqual([]); + }); + + it("returns matching projects in source order and supports no-match results", () => { + expect(filter(null, "WORK")).toEqual([items[1]]); + expect(filter(null, "missing")).toEqual([]); + }); +}); + +describe("reduceSidebarProjectScopeMenuState", () => { + const queriedOpenState = { open: true, query: "alpha" }; + + it("clears the query when the combobox closes through onOpenChange", () => { + expect( + reduceSidebarProjectScopeMenuState(queriedOpenState, { + type: "open-changed", + open: false, + }), + ).toEqual({ open: false, query: "" }); + }); + + it("clears the query when project settings closes the combobox", () => { + expect( + reduceSidebarProjectScopeMenuState(queriedOpenState, { + type: "project-settings-opened", + }), + ).toEqual({ open: false, query: "" }); + }); + + it("keeps the popup open while the query changes", () => { + expect( + reduceSidebarProjectScopeMenuState( + { open: true, query: "" }, + { type: "query-changed", query: "beta" }, + ), + ).toEqual({ open: true, query: "beta" }); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 108737392..54d989829 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,3 +1,4 @@ +import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; import { isAtomCommandInterrupted, type AtomCommandResult, @@ -1021,17 +1022,20 @@ export { export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort"; /** - * Search the already-ordered sidebar thread collection by title only. + * Search the already-ordered sidebar thread collection by title or linked PR. * Keeping the input order means lifecycle ordering (active, snoozed, settled) * remains stable while the user narrows the list. */ -export function searchSidebarThreadsByTitle( - threads: readonly T[], - query: string, -): T[] { +export function searchSidebarThreads< + T extends { readonly title: string } & Parameters[0], +>(threads: readonly T[], query: string): T[] { const normalizedQuery = query.trim().toLowerCase(); if (normalizedQuery.length === 0) return []; - return threads.filter((thread) => thread.title.toLowerCase().includes(normalizedQuery)); + return threads.filter((thread) => + [thread.title, ...threadPullRequestSearchTerms(thread)].some((term) => + term.toLowerCase().includes(normalizedQuery), + ), + ); } type SettledTimestampInput = Pick< @@ -1431,3 +1435,41 @@ export function sortScopedProjectsForSidebar< left.id.localeCompare(right.id), ); } + +export function filterSidebarProjectScopeItems(input: { + items: readonly TItem[]; + activeScopeKey: string | null; + query: string; + matches: (item: TItem, query: string) => boolean; +}): readonly TItem[] { + const projectItems = input.items.filter((item) => item.value !== "all"); + const query = input.query.trim(); + if (query.length > 0) { + return projectItems.filter((item) => input.matches(item, query)); + } + return input.activeScopeKey === null ? projectItems : input.items; +} + +export interface SidebarProjectScopeMenuState { + readonly open: boolean; + readonly query: string; +} + +export type SidebarProjectScopeMenuAction = + | { readonly type: "query-changed"; readonly query: string } + | { readonly type: "open-changed"; readonly open: boolean } + | { readonly type: "project-settings-opened" }; + +export function reduceSidebarProjectScopeMenuState( + state: SidebarProjectScopeMenuState, + action: SidebarProjectScopeMenuAction, +): SidebarProjectScopeMenuState { + switch (action.type) { + case "query-changed": + return { ...state, query: action.query }; + case "open-changed": + return { open: action.open, query: "" }; + case "project-settings-opened": + return { open: false, query: "" }; + } +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 01543ec6a..e8aa7bae8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,3 +1,28 @@ +import { ThreadPullRequestsControl } from "./pullRequest/ThreadPullRequestsControl"; +import { + applyDurableThreadOrder, + planDurableThreadReorder, +} from "@t3tools/client-runtime/state/thread-sort"; +import { + useSidebarFileDropNavigation, + useSidebarFileDropTarget, +} from "../hooks/useSidebarFileDrop"; +import { ConnectedEnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { useReducer, type SyntheticEvent } from "react"; +import { + filterSidebarProjectScopeItems, + reduceSidebarProjectScopeMenuState, +} from "./Sidebar.logic"; +import { + Combobox, + ComboboxTrigger, + ComboboxPopup, + ComboboxSearchInput, + ComboboxEmpty, + ComboboxList, + ComboboxItem, + useComboboxFilter, +} from "./ui/combobox"; import { autoAnimate } from "@formkit/auto-animate"; import { DndContext, @@ -54,7 +79,6 @@ import { PinOffIcon, PlusIcon, SearchIcon, - ServerIcon, SettingsIcon, SquarePenIcon, TerminalIcon, @@ -159,13 +183,12 @@ import { isThreadVisibleInSidebarWorkspace, isTrailingDoubleClick, orderItemsByPreferredIds, - planPinnedReorder, resolveAdjacentThreadId, resolveWorkspaceSwitchNavigation, resolveSettledTimestamp, resolveSidebarThreadStatus, resolveThreadLastVisitedAt, - searchSidebarThreadsByTitle, + searchSidebarThreads, shouldCreateNewThreadInCurrentProject, resolveWorkingStartedAt, resolveWorkInboxBadge, @@ -424,6 +447,7 @@ function SidebarThreadTooltip({ {showProjectContext && projectTitle ? (
- +
{environmentLabel}
) : null} @@ -870,6 +897,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { providerEntryByInstanceId: ReadonlyMap; onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; onThreadActivate: (threadRef: ScopedThreadRef) => void; + onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; onStartRename: (threadRef: ScopedThreadRef, title: string) => void; onRenameTitleChange: (title: string) => void; onCommitRename: (threadRef: ScopedThreadRef, title: string, originalTitle: string) => void; @@ -914,6 +942,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { () => scopeThreadRef(thread.environmentId, thread.id), [thread.environmentId, thread.id], ); + const fileDrop = useSidebarFileDropTarget(threadRef, props.onFileDropThreads); const threadKey = scopedThreadKey(threadRef); const isRegeneratingTitle = thread.titleRegeneration != null; const localLastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); @@ -1080,13 +1109,15 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const linkedPullRequestStatus = useLinkedThreadPullRequest( thread.environmentId, thread.linkedPullRequest, + thread.pullRequests, + thread.branchPullRequest, ); const pr = resolveDisplayedThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data, snapshot: changeRequestSnapshot, retainTerminalOnBranchMismatch, - linkedPullRequest: thread.linkedPullRequest, + linkedPullRequest: thread.linkedPullRequest ?? thread.branchPullRequest, linkedPullRequestStatus, }); const prProvider = resolveDisplayedThreadPrProvider({ @@ -1094,7 +1125,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { gitStatus: gitStatus.data, snapshot: changeRequestSnapshot, retainTerminalOnBranchMismatch, - linkedPullRequest: thread.linkedPullRequest, + linkedPullRequest: thread.linkedPullRequest ?? thread.branchPullRequest, linkedPullRequestStatus, }); const prStatus = prStatusIndicator(pr, prProvider); @@ -1105,7 +1136,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { gitStatus: gitStatus.data, snapshot: changeRequestSnapshot, retainTerminalOnBranchMismatch, - linkedPullRequest: thread.linkedPullRequest, + linkedPullRequest: thread.linkedPullRequest ?? thread.branchPullRequest, linkedPullRequestStatus, }); if (nextSnapshot === undefined) return; @@ -1118,6 +1149,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { retainTerminalOnBranchMismatch, thread.branch, thread.linkedPullRequest, + thread.branchPullRequest, threadKey, ]); @@ -1279,6 +1311,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // a useful hierarchy nor a reliable hover cue. Status now lives in the row // content; surface is reserved for interaction (hover, multi-select, route). const rowSurfaceClassName = cn( + fileDrop.active && "ring-1 ring-inset ring-primary/70 bg-sidebar-row-hover", "group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none", props.isActive ? "bg-sidebar-row-active text-sidebar-foreground" @@ -1344,7 +1377,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // A real link so cmd/ctrl+click and middle-click open the host in the // browser. A plain click still opens T3's pull request view. const prBadge = - prStatus && pr ? ( + (thread.linkedPullRequests?.length ?? 0) > 1 ? ( + + ) : prStatus && pr ? (
- + {(thread.linkedPullRequests?.length ?? 0) > 1 ? ( + + ) : ( + + )} {pr.title} ) : null; @@ -1429,6 +1468,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { return (
  • @@ -1464,6 +1504,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { /> ) : ( {isRemote ? ( - + ) : null} {props.isPinned ? ( @@ -1762,6 +1807,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ref={props.sortable?.setNodeRef} style={props.sortable?.style} {...props.sortable?.listeners} + {...fileDrop.handlers} data-thread-item className={cn( "list-none py-0.5 [content-visibility:auto]", @@ -1860,6 +1906,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : ( <> - - +{diff.insertions} - {" "} - −{diff.deletions} + +{diff.insertions}{" "} + −{diff.deletions} ) : null} {rowIconCluster} @@ -1966,8 +2011,14 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { resultId: string; onHighlight: () => void; onSelect: () => void; + onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; }) { const { thread } = props; + const threadRef = useMemo( + () => scopeThreadRef(thread.environmentId, thread.id), + [thread.environmentId, thread.id], + ); + const fileDrop = useSidebarFileDropTarget(threadRef, props.onFileDropThreads); // Same details tooltip as the regular rows: a search hit is still a thread, // and the hover card is how you disambiguate identically-titled results. const gitCwd = thread.worktreePath ?? props.projectCwd; @@ -2003,7 +2054,7 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { }); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); return ( -
  • +
  • store.threadOrder); const reorderThreads = useUiStateStore((store) => store.reorderThreads); const threads = useThreadShells(); + const reorderInFlight = useRef(false); + const [pendingOrder, setPendingOrder] = useState<{ + readonly pinned: boolean; + readonly keys: ReadonlyMap; + readonly original: ReadonlyMap; + } | null>(null); + useEffect(() => { + if (pendingOrder === null) return; + const canonical = new Map( + threads.map((thread) => [ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + thread, + ]), + ); + let complete = true; + for (const [id, expected] of pendingOrder.keys) { + const thread = canonical.get(id); + const actual = pendingOrder.pinned ? thread?.pinOrderKey : thread?.activeOrderKey; + if ( + !thread || + thread.archivedAt != null || + thread.deletedAt != null || + (thread.pinnedAt != null) !== pendingOrder.pinned || + (actual !== expected && (actual ?? null) !== (pendingOrder.original.get(id) ?? null)) + ) { + setPendingOrder(null); + return; + } + if (actual !== expected) complete = false; + } + if (complete) setPendingOrder(null); + }, [pendingOrder, threads]); + const pinnedThreadKeySet = useMemo( () => new Set( @@ -2101,6 +2187,8 @@ export default function Sidebar() { const timestampFormat = useClientSettings((s) => s.timestampFormat); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const { + pinThread, + unpinThread, settleThread, unsettleThread, snoozeThread, @@ -2134,13 +2222,7 @@ export default function Sidebar() { return; } } - const result = await updateThreadMetadata({ - environmentId: threadRef.environmentId, - input: { - threadId: threadRef.threadId, - pinned: nextPinned, - }, - }); + const result = await (nextPinned ? pinThread(threadRef) : unpinThread(threadRef)); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( @@ -2153,7 +2235,7 @@ export default function Sidebar() { } })(); }, - [confirmThreadUnpin, pinnedThreadKeySet, updateThreadMetadata], + [confirmThreadUnpin, pinnedThreadKeySet, pinThread, unpinThread], ); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false, @@ -2232,7 +2314,12 @@ export default function Sidebar() { ); }, }); - const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false); + const [projectScopeMenuState, dispatchProjectScopeMenu] = useReducer( + reduceSidebarProjectScopeMenuState, + { open: false, query: "" }, + ); + const suppressNextScopeChangeRef = useRef(false); + const highlightedProjectScopeKeyRef = useRef(null); const newThreadContext = useHandleNewThread(); const openAddProjectCommandPalette = useCallback( () => openCommandPalette({ open: "add-project" }), @@ -2481,6 +2568,45 @@ export default function Sidebar() { // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. const [projectScopeKey, setProjectScopeKey] = useState(null); + const projectScopeItems = useMemo( + () => [ + { value: "all", label: "All projects" }, + ...projectGroups.map((project) => ({ + value: project.projectKey, + label: project.displayName, + })), + ], + [projectGroups], + ); + const projectGroupByScopeKey = useMemo( + () => new Map(projectGroups.map((project) => [project.projectKey, project] as const)), + [projectGroups], + ); + const selectedProjectScopeItem = useMemo( + () => + projectScopeItems.find((item) => item.value === (projectScopeKey ?? "all")) ?? + projectScopeItems[0]!, + [projectScopeItems, projectScopeKey], + ); + const projectScopeFilter = useComboboxFilter(); + // Filtering derives from the same React state that controls the input, so + // the visible query and the visible list can never desync — the peer wiring + // in DiffPanel and BranchToolbarBranchSelector. "All projects" is a scope + // reset, not a searchable entry: it only shows while a project scope is + // active (there is something to reset) and the query is empty, so it can't + // outrank a project match under autoHighlight and no-hit queries reach the + // empty state. + const filteredProjectScopeItems = useMemo( + () => + filterSidebarProjectScopeItems({ + items: projectScopeItems, + activeScopeKey: projectScopeKey, + query: projectScopeMenuState.query, + matches: (item, query) => + projectScopeFilter.contains(item, query, (candidate) => candidate.label), + }), + [projectScopeFilter, projectScopeItems, projectScopeKey, projectScopeMenuState.query], + ); const scopedProjectGroup = useMemo( () => projectScopeKey === null @@ -2537,10 +2663,11 @@ export default function Sidebar() { }, [clearSelection, projectScopeKey, workEnvironmentScopeId]); const handleProjectSettings = useCallback( - (event: ReactMouseEvent, projectGroup: SidebarProjectSnapshot) => { + (event: SyntheticEvent, projectGroup: SidebarProjectSnapshot) => { event.preventDefault(); event.stopPropagation(); - setProjectScopeMenuOpen(false); + suppressNextScopeChangeRef.current = true; + dispatchProjectScopeMenu({ type: "project-settings-opened" }); if (isMobile) { setOpenMobile(false); } @@ -2622,11 +2749,23 @@ export default function Sidebar() { const isPinnedThread = (thread: EnvironmentThreadShell) => pinnedThreadKeySet.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))); return { - activeThreads: applyManualThreadOrderForSidebarV2( - sortThreadsForSidebar(active, isPinnedThread), - threadOrder, - (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + activeThreads: applyDurableThreadOrder( + applyManualThreadOrderForSidebarV2( + sortThreadsForSidebar(active, isPinnedThread), + threadOrder, + (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + isPinnedThread, + ), + (thread) => { + const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + const pinned = isPinnedThread(thread); + return ( + (pendingOrder?.pinned === pinned ? pendingOrder.keys.get(key) : undefined) ?? + (pinned ? thread.pinOrderKey : thread.activeOrderKey) + ); + }, isPinnedThread, + (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), ), // Soonest wake first: "what comes back next" is the shelf's question. snoozedThreads: snoozed.toSorted( @@ -2638,6 +2777,7 @@ export default function Sidebar() { snoozeNow: preciseNow, }; }, [ + pendingOrder, autoSettleAfterDays, autoSettleOnMerge, changeRequestSnapshotByKey, @@ -2751,9 +2891,130 @@ export default function Sidebar() { if (activeSection === undefined || activeSection !== activeSectionByKey.get(overKey)) { return; } - reorderThreads(sortableThreadKeys, [activeKey], [overKey]); + if (reorderInFlight.current || pendingOrder !== null || activeSection === "main") return; + if ( + activeThreads.some( + (thread) => + thread.workInboxRole === "main" && + [activeKey, overKey].includes( + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ) + ) + return; + const pinned = pinnedThreadKeySet.has(activeKey); + if (pinned !== pinnedThreadKeySet.has(overKey)) return; + const fixedKeys = new Set( + activeThreads + .filter((thread) => thread.workInboxRole === "main") + .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + ); + const orderedIds = sortableThreadKeys.filter( + (key) => + !fixedKeys.has(key) && + activeSectionByKey.get(key) === activeSection && + pinnedThreadKeySet.has(key) === pinned, + ); + const refs = orderedIds.map(parseScopedThreadKey); + if (refs.some((ref) => ref === null)) return; + if ( + refs.some((ref) => { + const caps = ref && serverConfigs.get(ref.environmentId)?.environment.capabilities; + return !caps || (pinned ? caps.threadPinReorder : caps.threadActiveOrderV2) !== true; + }) + ) { + // Older environments retain their local-only ordering. A mixed section + // with durable keys cannot safely mix two incompatible order sources. + if ( + activeThreads.some( + (thread) => + orderedIds.includes( + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ) && (pinned ? thread.pinOrderKey : thread.activeOrderKey) != null, + ) + ) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Update connected servers to reorder this section", + }), + ); + return; + } + reorderThreads(sortableThreadKeys, [activeKey], [overKey]); + return; + } + const from = orderedIds.indexOf(activeKey), + to = orderedIds.indexOf(overKey); + if (from < 0 || to < 0) return; + orderedIds.splice(from, 1); + orderedIds.splice(to, 0, activeKey); + const original = new Map( + threads + .filter((thread) => (thread.pinnedAt != null) === pinned) + .map((thread) => [ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + pinned ? thread.pinOrderKey : thread.activeOrderKey, + ]), + ); + const keys = planDurableThreadReorder(orderedIds, activeKey, original); + if (keys.size === 0) return; + reorderInFlight.current = true; + setPendingOrder({ pinned, keys, original }); + void (async () => { + try { + for (const [id, key] of keys) { + const ref = parseScopedThreadKey(id); + if (!ref) throw new Error("Thread is no longer available."); + const latest = readThreadShell(ref); + const currentKey = pinned ? latest?.pinOrderKey : latest?.activeOrderKey; + if ( + !latest || + latest.archivedAt != null || + latest.deletedAt != null || + (latest.pinnedAt != null) !== pinned || + ((currentKey ?? null) !== (original.get(id) ?? null) && currentKey !== key) + ) { + throw new Error("The thread order changed on another device. Try the move again."); + } + const result = await updateThreadMetadata({ + environmentId: ref.environmentId, + input: { + threadId: ref.threadId, + ...(pinned ? { pinOrderKey: key } : { activeOrderKey: key }), + }, + }); + if (result._tag === "Failure") throw squashAtomCommandFailure(result); + } + } catch (error) { + setPendingOrder(null); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not save thread order", + description: + error instanceof Error + ? error.message + : "Some positions may have saved. Try the move again.", + }), + ); + } finally { + reorderInFlight.current = false; + } + })(); }, - [activeSectionByKey, finishThreadDrag, reorderThreads, sortableThreadKeys], + [ + activeSectionByKey, + activeThreads, + finishThreadDrag, + pendingOrder, + pinnedThreadKeySet, + reorderThreads, + serverConfigs, + sortableThreadKeys, + threads, + updateThreadMetadata, + ], ); const threadSearchInputRef = useRef(null); @@ -2765,7 +3026,7 @@ export default function Sidebar() { [activeThreads, settledThreads, snoozedThreads], ); const threadSearchResults = useMemo( - () => searchSidebarThreadsByTitle(searchableThreads, threadSearchQuery), + () => searchSidebarThreads(searchableThreads, threadSearchQuery), [searchableThreads, threadSearchQuery], ); const threadSearchResultOrderKey = threadSearchResults @@ -2965,13 +3226,14 @@ export default function Sidebar() { if (isMobile) { setOpenMobile(false); } - void router.navigate({ + return router.navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), }); }, [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], ); + const handleThreadFileDrop = useSidebarFileDropNavigation(navigateToThread); // The work-mode composer target: a fresh draft on the Hermes backing // project. Returns false when Hermes is not ready so callers can fall back. const openWorkComposer = useCallback((): boolean => { @@ -3644,6 +3906,18 @@ export default function Sidebar() { }, ] : []), + ...(!isPinned && + thread.activeOrderKey != null && + serverConfigs.get(thread.environmentId)?.environment.capabilities + .threadActiveOrderV2 === true + ? [ + { + id: "reset-active-order", + label: "Reset thread position", + icon: "list-restart", + }, + ] + : []), { id: "rename", label: "Rename thread", icon: "pencil", separatorBefore: true }, ...(supportsTitleRegeneration ? [ @@ -3735,6 +4009,23 @@ export default function Sidebar() { case "rename": startThreadRename(threadRef, thread.title); return; + case "reset-active-order": { + const result = await updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, activeOrderKey: null }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not reset thread position", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } case "regenerate-title": { if (isRegeneratingTitle) return; const result = await updateThreadMetadata({ @@ -4053,8 +4344,8 @@ export default function Sidebar() { setActiveSearchResultIndex(0); }} onKeyDown={handleThreadSearchKeyDown} - placeholder="Search" - aria-label="Search threads" + placeholder="Search threads or PRs" + aria-label="Search threads or PRs" role="combobox" aria-autocomplete="list" aria-expanded={isSearchingThreads && threadSearchResults.length > 0} @@ -4161,8 +4452,31 @@ export default function Sidebar() {
  • {workspace === "code" && projectGroups.length > 0 ? (
    - - item.label} + isItemEqualToValue={(a, b) => a.value === b.value} + open={projectScopeMenuState.open} + onOpenChange={(open) => { + if (open) suppressNextScopeChangeRef.current = false; + dispatchProjectScopeMenu({ type: "open-changed", open }); + }} + onItemHighlighted={(item) => { + highlightedProjectScopeKeyRef.current = item?.value ?? null; + }} + value={selectedProjectScopeItem} + onValueChange={(item) => { + if (suppressNextScopeChangeRef.current) { + suppressNextScopeChangeRef.current = false; + return; + } + if (!item) return; + setProjectScopeKey(item.value === "all" ? null : item.value); + }} + > + {scopedProjectGroup ? ( - + + + ) : ( )} @@ -4184,57 +4501,88 @@ export default function Sidebar() { {scopedProjectGroup?.displayName ?? "All projects"} - - - - setProjectScopeKey(value === "all" ? null : (value as string)) + + + { + if ( + event.defaultPrevented || + event.nativeEvent.isComposing || + event.ctrlKey || + event.altKey || + event.metaKey || + (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) + ) { + return; + } + // Combobox items use virtual focus: keyboard events + // stay on this input, not on the highlighted option. + const scopeKey = highlightedProjectScopeKeyRef.current; + const project = scopeKey ? projectGroupByScopeKey.get(scopeKey) : null; + if (project) handleProjectSettings(event, project); + }} + onChange={(event) => + dispatchProjectScopeMenu({ + type: "query-changed", + query: event.target.value, + }) } - > - - - All projects - - {projectGroups.map((project) => { - const scopeKey = project.projectKey; + /> + No matching projects. + + {(item: (typeof projectScopeItems)[number]) => { + const project = projectGroupByScopeKey.get(item.value) ?? null; return ( - { + if (project) handleProjectSettings(event, project); + }} > - - {project.displayName} - - + {project ? ( + + ) : ( + + )} + {item.label} + {project ? ( + + ) : null} + ); - })} - - - + }} + + + } > - + {(workEnvironmentScopeId !== null ? environmentLabelById.get(workEnvironmentScopeId) @@ -4290,7 +4641,10 @@ export default function Sidebar() { closeOnClick className="h-8 min-h-8 px-1 py-0 text-sm font-medium [&>span:last-child]:flex [&>span:last-child]:min-w-0 [&>span:last-child]:items-center [&>span:last-child]:gap-2" > - + {environment.label} ))} @@ -4348,6 +4702,7 @@ export default function Sidebar() { resultId={`sidebar-thread-search-result-${index}`} onHighlight={() => setActiveSearchResultIndex(index)} onSelect={() => selectThreadSearchResult(thread)} + onFileDropThreads={handleThreadFileDrop} /> ); })} @@ -4463,6 +4818,7 @@ export default function Sidebar() { EMPTY_PROVIDER_ENTRIES, onThreadClick: handleThreadClick, onThreadActivate: navigateToThread, + onFileDropThreads: handleThreadFileDrop, onStartRename: startThreadRename, onRenameTitleChange: setRenamingTitle, onCommitRename: commitThreadRename, diff --git a/apps/web/src/components/T3Wordmark.tsx b/apps/web/src/components/T3Wordmark.tsx new file mode 100644 index 000000000..d0882a5dd --- /dev/null +++ b/apps/web/src/components/T3Wordmark.tsx @@ -0,0 +1,12 @@ +import type { SVGProps } from "react"; + +export function T3Wordmark(props: SVGProps) { + return ( + + + + ); +} diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 58171d8a5..03e8ad46c 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -12,6 +12,7 @@ import { resolveThreadPr, settledPrHoverColorClass, threadChangeRequestSnapshotsAtom, + threadChangeRequestSnapshotsEqual, type ThreadChangeRequestSnapshot, } from "./ThreadStatusIndicators"; @@ -581,6 +582,28 @@ describe("prStatusIndicator", () => { }); }); + it("keeps draft status distinct and lets terminal states outrank it", () => { + const pr = status().pr!; + expect(prStatusIndicator({ ...pr, isDraft: true }, undefined)).toMatchObject({ + label: "PR draft", + tooltipLead: "PR #42 - Draft", + colorClass: "text-zinc-500 dark:text-zinc-400/80", + }); + expect(prStatusIndicator({ ...pr, state: "merged", isDraft: true }, undefined)?.label).toBe( + "PR merged", + ); + expect(prStatusIndicator({ ...pr, state: "closed", isDraft: true }, undefined)?.label).toBe( + "PR closed", + ); + const original = snapshotFor("feature/current", pr); + expect( + threadChangeRequestSnapshotsEqual(original, { ...original, pr: { ...pr, isDraft: true } }), + ).toBe(false); + expect( + threadChangeRequestSnapshotsEqual(original, { ...original, pr: { ...pr, isDraft: false } }), + ).toBe(true); + }); + it("uses red for closed pull requests", () => { const closedPr = status().pr; if (!closedPr) throw new Error("Expected pull request fixture"); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 9f71325c0..8f65a1f92 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -1,12 +1,20 @@ +import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; +import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequestChains"; +import { ConnectedEnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { scopeProjectRef, scopedThreadKey, scopeThreadRef, } from "@t3tools/client-runtime/environment"; import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; -import type { EnvironmentId, ThreadLinkedPullRequest, VcsStatusResult } from "@t3tools/contracts"; +import type { + EnvironmentId, + ThreadLinkedPullRequest, + ThreadPullRequestLink, + VcsStatusResult, +} from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; -import { CloudIcon, FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; +import { FolderGit2Icon, TerminalIcon } from "lucide-react"; import { useMemo } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; @@ -51,36 +59,72 @@ export interface LinkedThreadPullRequestStatus { export function useLinkedThreadPullRequest( environmentId: EnvironmentId | null, linkedPullRequest: ThreadLinkedPullRequest | null | undefined, + pullRequests?: readonly ThreadPullRequestLink[], + branchPullRequest?: ThreadLinkedPullRequest | null, ): LinkedThreadPullRequestStatus | null { + const current = useMemo( + () => resolveThreadCurrentPullRequestLink(pullRequests ?? []), + [pullRequests], + ); + const fallback = linkedPullRequest ?? branchPullRequest; const detail = useEnvironmentQuery( - environmentId === null || linkedPullRequest == null + current !== null || environmentId === null || fallback == null ? null : linkedPullRequestDetailAtom({ environmentId, input: { - projectId: linkedPullRequest.projectId, - repository: linkedPullRequest.repository, - number: linkedPullRequest.number, + projectId: fallback.projectId, + repository: fallback.repository, + number: fallback.number, }, }), ).data; return useMemo( () => - detail === null - ? null - : { - pr: pullRequestDetailToVcsStatus(detail), - sourceControlProvider: { - kind: detail.provider, - name: detail.provider, - baseUrl: "", + current !== null + ? linkedPullRequestSnapshotStatus(current) + : detail === null + ? null + : { + pr: pullRequestDetailToVcsStatus(detail), + sourceControlProvider: { + kind: detail.provider, + name: detail.provider, + baseUrl: "", + }, }, - }, - [detail], + [current, detail], ); } +export function linkedPullRequestSnapshotStatus( + link: ThreadPullRequestLink, +): LinkedThreadPullRequestStatus | null { + const snapshot = link.snapshot; + if (snapshot === null) return null; + const kind = link.url.includes("/-/merge_requests/") + ? "gitlab" + : link.url.includes("/pullrequest/") + ? "azure-devops" + : link.url.includes("/pull-requests/") + ? "bitbucket" + : "github"; + return { + pr: { + number: link.number, + url: link.url, + title: snapshot.title, + state: snapshot.state, + isDraft: snapshot.isDraft, + headRef: snapshot.headBranch, + baseRef: snapshot.baseBranch, + ...(snapshot.updatedAt === null ? {} : { updatedAt: snapshot.updatedAt }), + }, + sourceControlProvider: { kind, name: kind, baseUrl: "" }, + }; +} + export function settledPrHoverColorClass(state: NonNullable["state"]): string { switch (state) { case "open": @@ -96,54 +140,31 @@ export function prStatusIndicator( pr: ThreadPr, provider: VcsStatusResult["sourceControlProvider"] | null | undefined, ): PrStatusIndicator | null { - function formatPrState(state: NonNullable["state"]): string { - return state.charAt(0).toUpperCase() + state.slice(1); - } - - function formatPrStatusLead(pr: NonNullable, changeRequestShortName: string): string { - return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr.state)}`; - } if (!pr) return null; const presentation = resolveChangeRequestPresentation(provider); - - const tooltipLead = formatPrStatusLead(pr, presentation.shortName); - const tooltip = `${tooltipLead}: ${pr.title}`; - - if (pr.state === "open") { - return { - label: `${presentation.shortName} open`, - colorClass: "text-emerald-600 dark:text-emerald-300/90", - tooltip, - tooltipLead, - tooltipTitle: pr.title, - url: pr.url, - }; - } - if (pr.state === "closed") { - return { - label: `${presentation.shortName} closed`, - colorClass: "text-red-600 dark:text-red-300/90", - tooltip, - tooltipLead, - tooltipTitle: pr.title, - url: pr.url, - }; - } - if (pr.state === "merged") { - return { - label: `${presentation.shortName} merged`, - colorClass: "text-violet-600 dark:text-violet-300/90", - tooltip, - tooltipLead, - tooltipTitle: pr.title, - url: pr.url, - }; - } - return null; + const state = resolvePullRequestState({ state: pr.state, isDraft: pr.isDraft ?? false }); + const tooltipLead = `${presentation.shortName} #${pr.number} - ${state.label}`; + return { + label: `${presentation.shortName} ${state.label.toLowerCase()}`, + colorClass: state.toneClassName, + tooltip: `${tooltipLead}: ${pr.title}`, + tooltipLead, + tooltipTitle: pr.title, + url: pr.url, + }; } -export function ChangeRequestStatusIcon({ className }: { className?: string }) { - return ; +export function ChangeRequestStatusIcon({ + state, + isDraft = false, + className, +}: { + state: NonNullable["state"]; + isDraft?: boolean | undefined; + className?: string | undefined; +}) { + const presentation = resolvePullRequestState({ state, isDraft }); + return ; } export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }) { @@ -227,6 +248,7 @@ export function threadChangeRequestSnapshotsEqual( left.pr.baseRef === right.pr.baseRef && left.pr.headRef === right.pr.headRef && left.pr.state === right.pr.state && + Boolean(left.pr.isDraft) === Boolean(right.pr.isDraft) && (left.pr.updatedAt ?? null) === (right.pr.updatedAt ?? null) && sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) && linkedPullRequestsEqual(left.linkedPullRequest, right.linkedPullRequest) @@ -540,9 +562,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar const linkedPullRequest = useLinkedThreadPullRequest( thread.environmentId, thread.linkedPullRequest, + thread.pullRequests, + thread.branchPullRequest, ); const gitStatus = useEnvironmentQuery( thread.linkedPullRequest == null && + thread.branchPullRequest == null && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.status({ @@ -552,7 +577,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar : null, ); const pr = - thread.linkedPullRequest == null + thread.linkedPullRequest == null && thread.branchPullRequest == null ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data }) : (linkedPullRequest?.pr ?? null); const prStatus = prStatusIndicator( @@ -572,7 +597,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar return ( - {prStatus ? ( + {prStatus && pr ? ( } > - + @@ -646,7 +671,10 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma /> } > - + {threadEnvironmentLabel} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index abd9bf9ed..349f471cc 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -3,7 +3,13 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { type TerminalSessionState } from "@t3tools/client-runtime/state/terminal"; +import { + INITIAL_TERMINAL_OUTPUT_CURSOR, + readTerminalOutputUpdate, + type TerminalOutputCursor, + type TerminalOutputUpdate, + type TerminalSessionState, +} from "@t3tools/client-runtime/state/terminal"; import { Plus, Square, @@ -27,6 +33,7 @@ import { useCallback, useEffect, useEffectEvent, + useLayoutEffect, useMemo, useRef, useState, @@ -95,8 +102,12 @@ function writeSystemMessage(terminal: GhosttyTerminalSurface, message: string): terminal.write(`\r\n[terminal] ${message}\r\n`); } -function writeTerminalBuffer(terminal: GhosttyTerminalSurface, buffer: string): void { - terminal.resetAndWrite(buffer); +export function writeTerminalOutputUpdate( + terminal: Pick, + update: TerminalOutputUpdate, +): void { + if (update.type === "reset") terminal.resetAndWrite(update.data); + else if (update.type === "append") terminal.write(update.data); } function parseTerminalColor(value: string, fallback: GhosttyColor): GhosttyColor { @@ -154,16 +165,23 @@ function terminalFontOptions(family: string, size: number): { family?: string; s } export function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { - const isDark = document.documentElement.classList.contains("dark"); - const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; - const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; const drawerSurface = mountElement?.closest(".thread-terminal-drawer") ?? document.querySelector(".thread-terminal-drawer") ?? document.body; const drawerStyles = getComputedStyle(drawerSurface); + const themeStyles = mountElement ? getComputedStyle(mountElement) : drawerStyles; + const colorScheme = themeStyles.colorScheme; + const isDark = + colorScheme === "dark" + ? true + : colorScheme === "light" + ? false + : document.documentElement.classList.contains("dark"); + const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; + const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; const bodyStyles = getComputedStyle(document.body); - const themeStyles = getComputedStyle(document.documentElement); + const rootThemeStyles = getComputedStyle(document.documentElement); const background = normalizeComputedColor( drawerStyles.backgroundColor, normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground), @@ -172,8 +190,16 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty drawerStyles.color, normalizeComputedColor(bodyStyles.color, fallbackForeground), ); - const terminalBackground = readThemeColor(themeStyles, "--terminal-background", background); - const terminalForeground = readThemeColor(themeStyles, "--terminal-foreground", foreground); + const terminalBackground = readThemeColor( + themeStyles, + "--terminal-background", + readThemeColor(rootThemeStyles, "--terminal-background", background), + ); + const terminalForeground = readThemeColor( + themeStyles, + "--terminal-foreground", + readThemeColor(rootThemeStyles, "--terminal-foreground", foreground), + ); const terminalCursor = readThemeColor( themeStyles, "--terminal-cursor", @@ -312,6 +338,7 @@ export function shouldHandleTerminalExit( } interface TerminalViewportProps { + providerInstanceId?: import("@t3tools/contracts").ProviderInstanceId; advancedTypography: boolean; threadRef: ScopedThreadRef; threadId: ThreadId; @@ -324,6 +351,7 @@ interface TerminalViewportProps { onAddTerminalContext: (selection: TerminalContextSelection) => void; focusRequestId: number; autoFocus: boolean; + visible: boolean; resizeEpoch: number; drawerHeight: number; keybindings: ResolvedKeybindingsConfig; @@ -336,6 +364,7 @@ interface TerminalLaunchLocation { } export function TerminalViewport({ + providerInstanceId, advancedTypography, threadRef, threadId, @@ -348,12 +377,18 @@ export function TerminalViewport({ onAddTerminalContext, focusRequestId, autoFocus, + visible, resizeEpoch, drawerHeight, keybindings, }: TerminalViewportProps) { const containerRef = useRef(null); const terminalRef = useRef(null); + const visibleRef = useRef(visible); + useLayoutEffect(() => { + visibleRef.current = visible; + terminalRef.current?.setVisible(visible); + }, [visible]); const environmentId = threadRef.environmentId; const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const openInPreferredEditor = useOpenInPreferredEditor( @@ -408,6 +443,7 @@ export function TerminalViewport({ terminal: { threadId, terminalId, + ...(providerInstanceId === undefined ? {} : { providerInstanceId }), cwd, ...(worktreePath !== undefined ? { worktreePath } : {}), ...(runtimeEnv ? { env: runtimeEnv } : {}), @@ -425,7 +461,8 @@ export function TerminalViewport({ input: { threadId, terminalId, cols, rows }, }), ); - const terminalBuffer = terminalSession.buffer; + const terminalOutput = terminalSession.output; + const outputCursorRef = useRef(INITIAL_TERMINAL_OUTPUT_CURSOR); const terminalError = terminalSession.error; const terminalStatus = terminalSession.status; const synchronizedStatusRef = useRef("closed"); @@ -448,14 +485,14 @@ export function TerminalViewport({ ); const terminalVersion = terminalSession.version; const previousSessionRef = useRef({ - buffer: terminalBuffer, + output: terminalOutput, error: terminalError, status: terminalStatus, version: terminalVersion, }); const latestSessionRef = useRef(previousSessionRef.current); latestSessionRef.current = { - buffer: terminalBuffer, + output: terminalOutput, error: terminalError, status: terminalStatus, version: terminalVersion, @@ -485,6 +522,9 @@ export function TerminalViewport({ const setup = async (): Promise<(() => void) | null> => { const setupFont = terminalFontRef.current; const terminalOptions: GhosttyTerminalSurfaceOptions = { + get visible() { + return visibleRef.current; + }, theme: terminalThemeFromApp(mount), font: terminalFontOptions(setupFont.family, setupFont.size), onData: (data) => handleData(data), @@ -506,6 +546,7 @@ export function TerminalViewport({ } // The theme observer is not installed yet, so re-read the theme in case // the app toggled light/dark while the WASM surface was loading. + terminal.setVisible(visibleRef.current); terminal.setTheme(terminalThemeFromApp(mount)); setupTerminal = terminal; terminalRef.current = terminal; @@ -518,7 +559,13 @@ export function TerminalViewport({ } const latestSession = latestSessionRef.current; previousSessionRef.current = latestSession; - if (latestSession.buffer.length > 0) terminal.resetAndWrite(latestSession.buffer); + const initialOutput = readTerminalOutputUpdate( + latestSession.output, + INITIAL_TERMINAL_OUTPUT_CURSOR, + ); + if (initialOutput.type === "reset" && initialOutput.data.length > 0) + writeTerminalOutputUpdate(terminal, initialOutput); + outputCursorRef.current = initialOutput.cursor; if (latestSession.error !== null) writeSystemMessage(terminal, latestSession.error); // Attaching to a session that already exited must still run exit handling // once, so mount synchronization starts from the empty "closed" state. @@ -526,7 +573,8 @@ export function TerminalViewport({ // never started, so only "exited" triggers the message — as with xterm.) synchronizedStatusRef.current = "closed"; synchronizeTerminalStatus(terminal, latestSession.status); - if (autoFocus) window.requestAnimationFrame(() => terminal.focus()); + // WASM may finish after the user has returned to the composer. + if (visibleRef.current && mount.contains(document.activeElement)) terminal.focus(); const clearSelectionAction = () => { selectionActionRequestIdRef.current += 1; @@ -901,7 +949,9 @@ export function TerminalViewport({ return () => { cancelled = true; + const hadFocus = mount.contains(document.activeElement); teardown?.(); + if (hadFocus && mount.isConnected) mount.focus({ preventScroll: true }); }; // autoFocus is intentionally omitted; // it is only read at mount time and must not trigger terminal teardown/recreation. @@ -910,7 +960,7 @@ export function TerminalViewport({ useEffect(() => { const terminal = terminalRef.current; const current = { - buffer: terminalBuffer, + output: terminalOutput, error: terminalError, status: terminalStatus, version: terminalVersion, @@ -922,43 +972,26 @@ export function TerminalViewport({ const previous = previousSessionRef.current; synchronizeTerminalStatus(terminal, current.status); - if (current.version === previous.version) { + if (current.version === previous.version && current.output === previous.output) { return; } - if ( - current.buffer.length >= previous.buffer.length && - current.buffer.startsWith(previous.buffer) - ) { - terminal.write(current.buffer.slice(previous.buffer.length)); - } else { - writeTerminalBuffer(terminal, current.buffer); - } + const outputUpdate = readTerminalOutputUpdate(current.output, outputCursorRef.current); + writeTerminalOutputUpdate(terminal, outputUpdate); + outputCursorRef.current = outputUpdate.cursor; terminal.clearSelection(); if (current.error !== null && current.error !== previous.error) { writeSystemMessage(terminal, current.error); } - if (previous.version === 0 && autoFocus) { - window.requestAnimationFrame(() => { - terminal.focus(); - }); - } previousSessionRef.current = current; - }, [autoFocus, terminalBuffer, terminalError, terminalStatus, terminalVersion]); + }, [terminalOutput, terminalError, terminalStatus, terminalVersion]); useEffect(() => { - if (!autoFocus) return; - const terminal = terminalRef.current; - if (!terminal) return; - const frame = window.requestAnimationFrame(() => { - terminal.focus(); - }); - return () => { - window.cancelAnimationFrame(frame); - }; - }, [autoFocus, focusRequestId]); + if (!autoFocus || !visible) return; + (terminalRef.current ?? containerRef.current)?.focus(); + }, [autoFocus, focusRequestId, visible]); useEffect(() => { const terminal = terminalRef.current; @@ -979,6 +1012,7 @@ export function TerminalViewport({ return (
    ); @@ -1541,6 +1575,7 @@ export default function ThreadTerminalDrawer({ onSessionExited={() => onCloseTerminal(terminalId)} onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} + visible={visible} autoFocus={terminalId === resolvedActiveTerminalId} resizeEpoch={resizeEpoch} drawerHeight={drawerHeight} @@ -1570,6 +1605,7 @@ export default function ThreadTerminalDrawer({ onSessionExited={() => onCloseTerminal(resolvedActiveTerminalId)} onAddTerminalContext={onAddTerminalContext} focusRequestId={focusRequestId} + visible={visible} autoFocus resizeEpoch={resizeEpoch} drawerHeight={drawerHeight} diff --git a/apps/web/src/components/WorkspacePageHeader.tsx b/apps/web/src/components/WorkspacePageHeader.tsx index 8f54f7ba7..01839aede 100644 --- a/apps/web/src/components/WorkspacePageHeader.tsx +++ b/apps/web/src/components/WorkspacePageHeader.tsx @@ -16,7 +16,7 @@ export function WorkspacePageHeader({ return (
    + {text} + {active && ( + + + + {text} + + + + )} + + ); +} diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx new file mode 100644 index 000000000..ccfd74666 --- /dev/null +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -0,0 +1,199 @@ +import type { AssistantCitation } from "@t3tools/contracts"; +import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { PencilIcon, QuoteIcon, XIcon } from "lucide-react"; +import { useEffect, useEffectEvent, useRef, type MouseEvent as ReactMouseEvent } from "react"; +import { + findAssistantCitationSourceAnchor, + type AssistantCitationSourceAnchor, +} from "~/lib/assistantTextSelection"; +import { cn } from "~/lib/utils"; +import { + assistantCitationHash, + assistantCitationNavigation, +} from "../../lib/assistantCitationNavigation"; +import { + CHAT_INLINE_CHIP_CLASS_NAME, + COMPOSER_INLINE_CHIP_CLASS_NAME, + COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME, + COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, + COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, +} from "../composerInlineChip"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor"; +import { observeAssistantCitationCommentSource } from "./AssistantCitationSource"; +import { composerFloatingLayerProps } from "./composerEventScope"; + +const CITATION_ACTION_BUTTON_CLASS_NAME = cn( + COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME, + "text-primary/80 hover:bg-primary/10 hover:text-primary", +); + +export function AssistantCitationChip({ + citation, + onRemove, + commentEditor, +}: { + citation: AssistantCitation; + onRemove?: () => void; + commentEditor?: { + open: boolean; + sourceAnchor?: AssistantCitationSourceAnchor | undefined; + onOpenChange: (open: boolean) => void; + onCancel?: () => void; + onSave: (comment: string) => boolean; + onSaveAndSend?: (comment: string) => boolean; + }; +}) { + const navigate = useNavigate(); + const commentInputRef = useRef(null); + const commentOpen = commentEditor?.open ?? false; + const sourceAnchor = commentEditor?.sourceAnchor; + const onSourceUnavailable = useEffectEvent(() => { + if (sourceAnchor) commentEditor?.onOpenChange(false); + }); + useEffect(() => { + if (!commentOpen) return; + const anchor = sourceAnchor ?? findAssistantCitationSourceAnchor(document, citation); + if (!anchor) return; + return observeAssistantCitationCommentSource({ + anchor, + citation, + onUnavailable: onSourceUnavailable, + }); + }, [citation, commentOpen, sourceAnchor]); + // A multi-line selection's bounding box spans the full message width; anchor + // the bubble to the selection's last line, where the pointer released. + const popupAnchor = sourceAnchor + ? { + contextElement: sourceAnchor.source, + getBoundingClientRect: () => { + const rects = sourceAnchor.range.getClientRects(); + return rects.item(rects.length - 1) ?? sourceAnchor.range.getBoundingClientRect(); + }, + } + : undefined; + const preview = (citation.comment?.trim() || citation.text).replace(/\s+/g, " "); + const label = preview.length > 64 ? `${preview.slice(0, 64)}…` : preview; + const sourceLinkProps = { + to: "/$environmentId/$threadId" as const, + params: { environmentId: citation.environmentId, threadId: citation.threadId }, + hash: assistantCitationHash(citation), + "data-markdown-copy": serializeAssistantCitation(citation), + resetScroll: false, + onClick: (event: ReactMouseEvent) => { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + event.preventDefault(); + void navigate(assistantCitationNavigation(citation)); + }, + }; + const composerSourceLink = ( + +