diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index ac1894ec4b..6a49f4c5ad 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -15,7 +15,7 @@ The maintainer authorized compatible catch-up and routine adaptations that prese ## Active cycle -None currently active. Upstream integration cycle #689 is completed. +[Upstream catch-up cycle #736](https://github.com/pylon-code/pylon/issues/736) is active. Its frozen review range is `bbedad0278bbf753503184c00e0c09a0eab6679c..e67abcf798f8c4d8458755e3b4dde02c2c1f628b` (481 sources). The issue holds the source-by-source disposition and actionable work queue, including open PRs, deliberate Pylon divergences, dependencies, and product decisions. This review does not advance the completed integration cursor `fa1e1715538f6f8ac076761f7821f68479f616ab` or claim the open implementations are adopted. The Oh My Pi integration was explicitly rejected and is excluded from this cycle. Cycle #689 remains the latest completed integration. ## Latest cycle @@ -33,23 +33,26 @@ New records use the compact group format in [the decision framework](skills/revi Historical groups are indexed in the linked archive. This file migration changes no source disposition, review cursor, deferred trigger, watch state, product code, or Git ancestry. -| Group / bounded head | Sources | Outcome and remaining scope | Pylon PR / verification | -| ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| OTLP log export, desktop main process telemetry, and per-signal observability / `fa1e1715538f6f8ac076761f7821f68479f616ab` | `3bb06ad910389fcfc886bd5156f41dc73f307f80` (#12493), `82059df1523d070b00ab6f45159115f626a4977e` (#12520), `a8693eb4ed3a66d72e6b814117e7beeaf0fb5f8b` (#12540), `fa1e1715538f6f8ac076761f7821f68479f616ab` (#12657) | Adopted all four in Cycle #689 (PR #690). Server exports structured log records over OTLP with trace/span ID correlation, replaces `tracerLogger` to eliminate duplicate in-span span-event exports, and applies dedicated flusher/serialization layers. Desktop exports main process traces and logs over OTLP while withholding metric emissions until metrics are instrumented. OTLP settings and HTTP header sets are isolated per signal (traces, metrics, logs) across server and desktop configurations. Adversarial review findings resolved: preserved `pylon-server` runtime and service identity defaults, verified `DEFAULT_SIGNAL_EXPORT` fallback, tested backward compatibility of `ServerObservability` schema decoding, and validated log deduplication. Cursor unchanged. | Cycle [#689](https://github.com/pylon-code/pylon/issues/689), [PR #690](https://github.com/pylon-code/pylon/pull/690); independent adversarial review, new ServerLogger test suite, 9 desktop observability tests, all CI checks green. | -| Web chat/diff polish, editor discovery, diff cache eviction, ACP elicitation, and preview recovery / `c14f6015bfe479d313355cb234af1a5c16dbb15f` | `c14f6015bfe479d313355cb234af1a5c16dbb15f` (#12453), `0ff87f251dafb32703d5f531e7b248ad0a581ee2` (#10831), `4a560b4e4ebb37efb7f57805ba79e37f5500bdca` (#12438), `9accc567670da5096e9a694c1adb0d35adb51d9d` (#12571), `dfbb11bdd7c3f1a5575cb55d3e3abb12be025727` (#12377), `408ff8ae9bd7eb2e7e90cbfd8b3fcfe63641bf23` (#12439), `cb3d95c17487f3d834a5537f7c3aa0106bc9b5ec` (#12523), `de6a230db0bb71d3fa91d8de0ae7067b68253f82` (#11294), `efb96939fbb135b9f4de3bff19447200b178b23a` (#7861), `5378f87f99175bded2b9241876319567875aca03` (#12535) | Adopted all ten across Cycle #685 Group A (PR #686) and Group B (PR #687). Retained whitespace when copying diff contents, kept linebreaks in citation content, opened chat popovers with relative placement, discovered installed external editors on desktop outside PATH, wrapped long titles in confirmation dialogs, showed plain text in collapsed thought previews, released oversized pull request diff cache entries, accepted ACP SDK elicitation requests (`session/elicitation` & `elicitation/create`), surfaced Codex app permission requests as approvable, and recovered preview host registration after request timeouts via connection queue eviction. Hardening and adversarial review findings resolved: normalized `acceptAlways` approval decision in Codex session runtime to grant permissions, enforced retirement epoch and quarantine boundaries on approval requests, scoped event emission with `CodexNotificationEpoch`, wrapped diff cache invalidation in `Effect.uninterruptible`, cleaned up mobile icon maps, and rebranded pre-existing PR tool descriptions to Pylon. Preserved Pylon Effect services, session lifecycles, and UI layout authority. Cursor unchanged. | Cycle [#685](https://github.com/pylon-code/pylon/issues/685), [PR #686](https://github.com/pylon-code/pylon/pull/686) and [PR #687](https://github.com/pylon-code/pylon/pull/687); independent adversarial reviews, 260+ focused unit tests across web, server, and effect-acp, full workspace typecheck, scoped format/lint. | -| Web interaction, ACP session startup stderr, Claude continuation, and GTK4 snapshot / `52d08a14b9e475371658fabbb12c056c3d3dad67` | `8dd02470b1e7603b8d36a21ae27c510480351f18` (#12552), `7445aa733ada33e45289e5aa5055f79142556513` (#11263), `599c9776eb887d4fff42da7d2daf8aa07ee9a9e2` (#11268), `dcf8942304cfef382af55c0068cb2f04c86757fc` (#12636), `e36725682bd2935f1bcfdf81660cb569b5f3df29` (#12577), `63ff33756c47f127391962458d8388a647e8ed44` (#12624), `d6f291303ddc0c9a14f570266a4d9eff6d431593` (#12625), `52d08a14b9e475371658fabbb12c056c3d3dad67` (#12635) | Adopted all eight across Cycle #682 Group A (PR #683) and Group B (PR #684). Preserved draft question input when clicking options, desktop CSP screenshot annotations, restored providers settings heading, aligned PR detail menu glyphs. Empty Claude homePath correctly resolves and shares continuation with `~/.claude` or `CLAUDE_CONFIG_DIR`, surfaced ACP stderr excerpt in session startup failures instead of generic closed session errors, and extracted desktop accessible text for Flatpak and GTK4 apps in SnapShot. Adversarial review findings resolved: ACP stderr excerpt sanitized against tokens/credentials and pending buffer bounded; defensive empty `homePath` trimming and `CLAUDE_CONFIG_DIR` resolution in OAuth usage; desktop accessibility timeout preserved without premature concurrency overlap. Preserved Pylon provider rate limits and error reporting. Cursor unchanged. | Cycle [#682](https://github.com/pylon-code/pylon/issues/682), [PR #683](https://github.com/pylon-code/pylon/pull/683) and [PR #684](https://github.com/pylon-code/pylon/pull/684); independent adversarial reviews, 225+ focused tests across desktop and server, full workspace typecheck, scoped format/lint. | -| Chat message heading accessibility and compact sidebar density / `b44c1ce5d25ee0d5a5be82e380618a886c19ea96` | `6cdbf76fa4b0509913046abe6105037ded9e52bb` (#11199), `ca2cc1339bbd1904b932c3a92d23a2c0ffe1d267` (#11525), `77bca8b2d76a1f42552e5eee7d277fcb1160347a` (#9417), `df7ccc8fd01f5d2a1d8ec21bef8f9c59398fe913` (#11644), `7b61099886f3aac139ad43fb73873c73ba7baf4f` (#11652), `0118b52295264a73ef7cd045437181f7b3e5a99f` (#11595) | Adopted all six across Cycle #669 Group A and Group B. Screen readers navigate chat messages as headings with hierarchical levels (`h3` at offset 3); proposed plans expose semantic headings. Compact sidebar rail (`compactSidebarEnabled`) and compact thread row density (`sidebarCompactThreadRows`) added as opt-in settings defaulting to `false`, preserving Pylon's pull request badges and stack integration, custom settings, and avoiding dead delegation UI. Sparse shelves stay pinned to bottom. Adversarial review findings resolved: accessible fallback for `plan-ready` dot in compact rows, drag layout projection fix for compact rail mode, explicit test fixtures. Cursor unchanged. | Cycle [#669](https://github.com/pylon-code/pylon/issues/669), [PR #679](https://github.com/pylon-code/pylon/pull/679) and [PR #680](https://github.com/pylon-code/pylon/pull/680); independent adversarial reviews, 490+ focused tests, `@t3tools/web`, `@t3tools/desktop`, and `@t3tools/contracts` typechecks, scoped lint/format, green CI. | -| Navigation, search and diff safety / `b44c1ce5d25ee0d5a5be82e380618a886c19ea96` | `9ea9c3d5d2c444133e3ddff40eecf38737951589` (#11075), `886c83450797c46ee84ae28fc16c2208cafa965e` (#10909), `c557bb10aa7cbb78a50df230d1e8e24af79cb662` (#11761), `3fd5d6439d8fd49d173503ecda96500463a39bd2` (#12315) | Adopted all four in Cycle #667 Group B. Symlink file type changes no longer crash diff view; chat folder links reveal directory in file tree; sidebar search matches thread message contents; numbered jump shortcuts do not steal browser tab navigation. Preserved Pylon's keybinding identity, routing and panel-choice semantics. Cursor unchanged. | Cycle [#667](https://github.com/pylon-code/pylon/issues/667), [PR #671](https://github.com/pylon-code/pylon/pull/671); 389 focused tests, web/shared typechecks, scoped lint, green CI. | -| Composer banner density and scroll-pill stability / `b44c1ce5d25ee0d5a5be82e380618a886c19ea96` | `4749035bda13b4b6260499caedbc0d69a2f60e6f` (#12166), `fcfd9f911a27ee3b813d64582c53d82dfa2e892b` (#12317) | Adopted both. Banner stack items gain an optional `compact` flag: compact rows keep the narrow layout and the description that collapses into an info popover below 400px, while other banners get room for the description beside the title, with a labelled, bounded, scrollable popover. The scroll-to-end clearance is split out of the overlay-height publisher and recomputed from a stored height when the pill mounts, so a fling during the composer's resting tween no longer publishes a stale height and yanks the scroll position. **Excluded:** upstream's `approval` banner layout classes — Pylon's `ComposerBanner.Row` union is `inline | wrap-actions | wrap-actions-narrow`and the approval layout arrives in an unadopted commit; only this source's`justify-end`change was applied. Pylon adaptations:`ComposerTasksBadge`keeps its delegates label, expanded guard and`TaskProgressSegments fit`, with the delegates label moved to the same container query as the segment bar so the pair no longer flips at different widths; `ChatView`'s pull-request resolution block is preserved; and five Pylon banners with short static descriptions (environment update, thread woke, snoozed/settled, resume-compaction, auto-balance) are marked `compact` so they do not grow a permanently redundant details popover. Cursor unchanged. | [Chat rendering #668](https://github.com/pylon-code/pylon/pull/668); independent adversarial review, 184 focused tests including new `compact` coverage, `@t3tools/web` typecheck, scoped lint. Two upstream-inherited defects recorded in the PR rather than fixed here. | -| Checkpoint capture reliability and shared-workspace rewind / `93e04160a0c0dece8a258384d2118a660415a53d` | `869347bc26051bba7c3c0274a7dfcae7770d3e3a` (#11665), `1455cb5c35e50211145073be6ca155d56684f0f4` (#12181), `67993623afb14c017bb0b959478dc892f9c427da` (#12307), `b4620d595554654c259ec7c0afb3d3d5532959a8` (#12306), `d17f46d7631b178b6834596264f0f44f210d584b` (#12308) | Adopted all five. Transient git exits retry during capture behind a new optional `retryable` hint on `VcsProcessExitError`; capture recovers nested repositories without commits under index-lock recovery and bounded probe timeouts; a failed baseline lookup logs and treats the ref as absent instead of losing the checkpoint; the `@`-mention entry refresh moves to a coalescing drainable worker chained into `CheckpointReactor.drain`. File rewind is refused unless the thread's cwd is its own worktree with no other thread or live provider session nested inside or above it, and web hides the destructive control for shared directories. **Capability removal, maintainer-approved 2026-09-18**: threads running in the project directory rewind the conversation only, with no override path. Pylon adaptation: the isolation check is scoped with `restoreFiles !== false` so conversation-only rewind still reaches Pylon's existing provider-capability explanation rather than the isolation message or silence, since Pylon's rollback saga services are optional. Pylon git identity, rollback revert action and wire compatibility preserved. No excluded source behavior; cursor unchanged. | [Checkpoint reliability #665](https://github.com/pylon-code/pylon/pull/665); 122 focused tests including three new `restoreFiles` regressions where none existed, `t3`/`contracts`/`web` typechecks, scoped lint (0 errors), rebased onto `origin/pylon` after #653. | -| Provider event log bounds and Codex image attachments / `93e04160a0c0dece8a258384d2118a660415a53d` | `b17cc2ab5d5029f898121798ca19db3288cba616` (#12305, partial), `3fd21df62da3191f1abf12a0cd74df6de9ed30a2` (#11050) | `3fd21df62d` adopted: Codex image attachments pass as `{ type: "localImage", path }` instead of base64 data URLs, so `turn/start` no longer scales with file size. `localImage` is a real content type in the generated Codex app-server schema; Pylon's attachment-id validation against `attachmentsDir` and its `FileSystem` wiring are retained. `b17cc2ab5d` **partially adopted**: record traversal is bounded before serialization with character, field and depth budgets, cycle detection, a `summarizeProviderEvent` fallback that keeps routing and failure fields, and a post-serialization byte-length fallback; new transient suppressions for `turn/diff/updated` and `stream_event`/`content_block_delta`. Pylon's `commitGuard` and existing transient filters preserved. **Excluded: the `stage === "raw"` suppression and `stage === "decoded"` unwrap** (see EXC-1). Cursor unchanged. | [Provider payload bounds #653](https://github.com/pylon-code/pylon/pull/653), merged `4be539f4d48af6ffbe545e015ec246fbb271c92e`; 133 focused tests, `t3` typecheck, scoped format/lint, all final-head CI jobs green, no bot findings. | -| PR comments, controls and avatars / `6d1d549441be84f19696ab59ed7e2fbf305280d4` | `f0a0ead946ca3f3f310460a91b7dabb0e3fe4834` (#11962), `f45c6b4a755905485431872f4a4188de3ed05974` (#11994), `c12ba7581ac00ba2a623ad23283da5d03d34c2d8` (#12150), `962bf62921c0ecbc9320f9b071e16b1d41484aad` (#12125), `df466c79810046d20c0f3ccc4e51709c5703814e` (#11728) | Adopted all five. Narrow controls, keyboard submission, paged bot/finished groups, long-comment previews, detail avatars and image-failure fallback. Pylon corrections align bot app links, lazily page finished groups, and scroll after collapsing. Optional actor metadata preserves wire compatibility; existing bounded GitHub enrichment/fallback limits remain. No excluded source behavior; cursor unchanged. | [PR review #621](https://github.com/pylon-code/pylon/pull/621); focused regressions, scoped types/lint, independent Opus adversarial reviews, browser before/after evidence and comment interaction recording. | -| Large diffs and review panel defaults / `6d1d549441be84f19696ab59ed7e2fbf305280d4` | `aae361c4ef6478e4dcf970d574f3fd38d908e6b9` (#10822), `36caf200c2b24c12838570966f5c85786a1fc1f8` (#11484), `3efdcc5296f1754e0f3bf7fee5fc2ada510e0438` (#11931), `6d1d549441be84f19696ab59ed7e2fbf305280d4` (#12190), `52ad70ec43b5dcfcdc72ac79c0964da8e4d163db` (#12142), `394af73e02238e551db14f44d57f1469dd2942aa` (#12139) | Adopted all six. Complete Git metadata and progressive per-file patches on web/mobile; preserve old-server preview fallback, literal paths and bounded output. Tree order/folder state survive refresh. Files default collapsed while explicit saved choices persist. Generic diff openings reset to working tree; linked PRs outrank automatic diffs. Preserve Pylon multi-PR authority, environment identity, responsive layout and existing settings. No excluded source behavior; cursor unchanged. | [Review diffs #616](https://github.com/pylon-code/pylon/pull/616); 509 integrated focused tests plus 79 unchanged backend regressions, six scoped package typechecks, lint, independent Antigravity adversarial reviews, before/after browser evidence and refresh video. Native verification details in PR. | -| Usage pools, hub quotas, reset credits and local composer action / `062987b2fb0ef48cc7776d654931bd9f3bcf1f9f` | Full 16-source list in the usage pilot PR; parent `19d8ab2ae9fc562ee7b216a0d72903fbfafa9572`, final hub-routing fix `6abdf37a50ce6c1c9fabc499f4d0e159a6182d90` | Pylon port of the final behavior; parent partially adopted with native Claude scoped push mapping retained in DEF-16. `00d6109cbb1a13712d7347701969870aee83252d` is covered: demo fixtures were never introduced. Native Claude quota reads reuse Pylon's cached OAuth parser; no duplicate SDK probe or ingestion worker. | [Usage pilot #400](https://github.com/pylon-code/pylon/pull/400); focused quota, retry, RPC and compatibility checks, web/mobile evidence in PR. | -| Pull-request browsing, cached reads and opt-in panel behavior / `062987b2fb0ef48cc7776d654931bd9f3bcf1f9f` | Full 16-source disposition list in #401; 15 adopted, #9877 deferred | Final PR state icons, authored/readiness sorting, seeded detail, keyboard pickers, link previews, profile links, revision-aware caches, project-filter deduplication and manual panel priority. Preserve Pylon server-projected PR tracking and quota reservation/refund semantics. Copy priority remains DEF-17. | [PR workflow #401](https://github.com/pylon-code/pylon/pull/401); 768 focused tests, affected package types, scoped lint and integrated web evidence. | -| Composer interaction, layout and notice follow-ups / `062987b2fb0ef48cc7776d654931bd9f3bcf1f9f` | Full 27-source disposition list in #402 | Final scroll-only resting behavior, readable multiline drafts, stable control measurements, last-message reservation, narrow-width controls and opt-in context meter. Preserve Pylon provider locks, quick questions, session/harness/goal actions, quota placement, upload handling and diagnostic errors. Temporary blur-selection holding in #9499 is superseded by #10437; no unresolved scope in this group. | [Composer #402](https://github.com/pylon-code/pylon/pull/402); 724 focused tests, six affected package typechecks, scoped lint, integrated web screenshots and motion evidence. | -| Changed-file trees and stable diff navigation / `062987b2fb0ef48cc7776d654931bd9f3bcf1f9f` | Full eight-source list in #403; seven new sources and DEF-11 | Adopted: persistent folder trees, diff/PR trees, repeated selection, saved layout, wrapped edit heights, search focus and folder sorting. Completes DEF-11 after removing all helper callers. Pylon lazy-viewer scope guards remain; narrow panels place the tree below code. | [File navigation #403](https://github.com/pylon-code/pylon/pull/403); 271 focused tests, six package typechecks, scoped lint/export checks and integrated web evidence. | -| Thread link targets / `062987b2fb0ef48cc7776d654931bd9f3bcf1f9f` | Three full source SHAs in [#404](https://github.com/pylon-code/pylon/pull/404). | Adopted link target preference across chat, terminal and PR surfaces; completed DEF-12 and DEF-15 after replacing their last callers. Preserve Pylon context/routing, browser defaults, safe errors and external fallback. Browser profiles remain an independent source group. | [#404](https://github.com/pylon-code/pylon/pull/404); 233 focused tests, seven package typechecks, targeted lint/export checks; web evidence in PR. | +| Group / bounded head | Sources | Outcome and remaining scope | Pylon PR / verification | +| ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Explicit provider refresh cache bypass / `f193a6863c494fdfa36a1ef3132e42a7d7b42926` | `f193a6863c494fdfa36a1ef3132e42a7d7b42926` (#13109) | Implemented in PR #743, pending merge: explicit refresh bypasses the model manifest freshness/retry timer and provider-owned capability, model and package-version caches before status probes. Background probes retain their timers; Pylon's installed-provider `refreshModels` path remains. No behavior excluded. Cursor unchanged. | Cycle [#736](https://github.com/pylon-code/pylon/issues/736), [PR #743](https://github.com/pylon-code/pylon/pull/743); independent adversarial review, 246 focused tests, server typecheck and scoped lint/format. | +| Background GitHub PR quota / `eafb4a9340bd2c2271b7c8fac7eddd55beea3972` | `5975ec78b7eaff654e6183b99f8de7a05591e99d` (#13189), `18de6bb328059703b5d1b0661610076e3c0fa2d4` (#13198), `eafb4a9340bd2c2271b7c8fac7eddd55beea3972` (#13200) | Adapted all three: skip uncached recheck when a terminal link cannot settle, batch background PR summaries, and avoid owner-qualified head probes. Preserve Pylon credential scopes, GraphQL budget and per-environment routing; exclude upstream-only test fixture APIs. No cursor advance. | [GitHub quota #749](https://github.com/pylon-code/pylon/pull/749); focused server regressions, scoped lint and typecheck. | +| OTLP export kill switch / `e67abcf798f8c4d8458755e3b4dde02c2c1f628b` | `e67abcf798f8c4d8458755e3b4dde02c2c1f628b` (#13355) | Implemented in PR #746, pending merge: `OTEL_SDK_DISABLED` and the inherited `T3CODE_OTEL_SDK_DISABLED` override suppress server and desktop main-process OTLP traces, metrics, and logs from environment, bootstrap, or Settings endpoints; WSL inherits the flags. Local trace files and local logs remain active, and Pylon service identity is retained. No behavior excluded. Cursor unchanged. | Cycle [#736](https://github.com/pylon-code/pylon/issues/736), [PR #746](https://github.com/pylon-code/pylon/pull/746); independent adversarial review, 80 focused tests, server/desktop/shared typechecks and scoped lint/format. | +| OTLP log export, desktop main process telemetry, and per-signal observability / `fa1e1715538f6f8ac076761f7821f68479f616ab` | `3bb06ad910389fcfc886bd5156f41dc73f307f80` (#12493), `82059df1523d070b00ab6f45159115f626a4977e` (#12520), `a8693eb4ed3a66d72e6b814117e7beeaf0fb5f8b` (#12540), `fa1e1715538f6f8ac076761f7821f68479f616ab` (#12657) | Adopted all four in Cycle #689 (PR #690). Server exports structured log records over OTLP with trace/span ID correlation, replaces `tracerLogger` to eliminate duplicate in-span span-event exports, and applies dedicated flusher/serialization layers. Desktop exports main process traces and logs over OTLP while withholding metric emissions until metrics are instrumented. OTLP settings and HTTP header sets are isolated per signal (traces, metrics, logs) across server and desktop configurations. Adversarial review findings resolved: preserved `pylon-server` runtime and service identity defaults, verified `DEFAULT_SIGNAL_EXPORT` fallback, tested backward compatibility of `ServerObservability` schema decoding, and validated log deduplication. Cursor unchanged. | Cycle [#689](https://github.com/pylon-code/pylon/issues/689), [PR #690](https://github.com/pylon-code/pylon/pull/690); independent adversarial review, new ServerLogger test suite, 9 desktop observability tests, all CI checks green. | +| Web chat/diff polish, editor discovery, diff cache eviction, ACP elicitation, and preview recovery / `c14f6015bfe479d313355cb234af1a5c16dbb15f` | `c14f6015bfe479d313355cb234af1a5c16dbb15f` (#12453), `0ff87f251dafb32703d5f531e7b248ad0a581ee2` (#10831), `4a560b4e4ebb37efb7f57805ba79e37f5500bdca` (#12438), `9accc567670da5096e9a694c1adb0d35adb51d9d` (#12571), `408ff8ae9bd7eb2e7e90cbfd8b3fcfe63641bf23` (#12439), `cb3d95c17487f3d834a5537f7c3aa0106bc9b5ec` (#12523), `de6a230db0bb71d3fa91d8de0ae7067b68253f82` (#11294), `efb96939fbb135b9f4de3bff19447200b178b23a` (#7861), `5378f87f99175bded2b9241876319567875aca03` (#12535) | Adopted nine across Cycle #685 Group A (PR #686) and Group B (PR #687). Retained whitespace when copying diff contents, kept linebreaks in citation content, opened chat popovers with relative placement, discovered installed external editors on desktop outside PATH, wrapped long titles in confirmation dialogs, released oversized pull request diff cache entries, accepted ACP SDK elicitation requests (`session/elicitation` & `elicitation/create`), surfaced Codex app permission requests as approvable, and recovered preview host registration after request timeouts via connection queue eviction. Hardening and adversarial review findings resolved: normalized `acceptAlways` approval decision in Codex session runtime to grant permissions, enforced retirement epoch and quarantine boundaries on approval requests, scoped event emission with `CodexNotificationEpoch`, wrapped diff cache invalidation in `Effect.uninterruptible`, cleaned up mobile icon maps, and rebranded pre-existing PR tool descriptions to Pylon. Preserved Pylon Effect services, session lifecycles, and UI layout authority. Cursor unchanged. | Cycle [#685](https://github.com/pylon-code/pylon/issues/685), [PR #686](https://github.com/pylon-code/pylon/pull/686) and [PR #687](https://github.com/pylon-code/pylon/pull/687); independent adversarial reviews, 260+ focused unit tests across web, server, and effect-acp, full workspace typecheck, scoped format/lint. | +| Web interaction, ACP session startup stderr, Claude continuation, and GTK4 snapshot / `52d08a14b9e475371658fabbb12c056c3d3dad67` | `8dd02470b1e7603b8d36a21ae27c510480351f18` (#12552), `7445aa733ada33e45289e5aa5055f79142556513` (#11263), `599c9776eb887d4fff42da7d2daf8aa07ee9a9e2` (#11268), `dcf8942304cfef382af55c0068cb2f04c86757fc` (#12636), `e36725682bd2935f1bcfdf81660cb569b5f3df29` (#12577), `63ff33756c47f127391962458d8388a647e8ed44` (#12624), `d6f291303ddc0c9a14f570266a4d9eff6d431593` (#12625), `52d08a14b9e475371658fabbb12c056c3d3dad67` (#12635) | Adopted all eight across Cycle #682 Group A (PR #683) and Group B (PR #684). Preserved draft question input when clicking options, desktop CSP screenshot annotations, restored providers settings heading, aligned PR detail menu glyphs. Empty Claude homePath correctly resolves and shares continuation with `~/.claude` or `CLAUDE_CONFIG_DIR`, surfaced ACP stderr excerpt in session startup failures instead of generic closed session errors, and extracted desktop accessible text for Flatpak and GTK4 apps in SnapShot. Adversarial review findings resolved: ACP stderr excerpt sanitized against tokens/credentials and pending buffer bounded; defensive empty `homePath` trimming and `CLAUDE_CONFIG_DIR` resolution in OAuth usage; desktop accessibility timeout preserved without premature concurrency overlap. Preserved Pylon provider rate limits and error reporting. Cursor unchanged. | Cycle [#682](https://github.com/pylon-code/pylon/issues/682), [PR #683](https://github.com/pylon-code/pylon/pull/683) and [PR #684](https://github.com/pylon-code/pylon/pull/684); independent adversarial reviews, 225+ focused tests across desktop and server, full workspace typecheck, scoped format/lint. | +| Chat message heading accessibility and compact sidebar density / `b44c1ce5d25ee0d5a5be82e380618a886c19ea96` | `6cdbf76fa4b0509913046abe6105037ded9e52bb` (#11199), `ca2cc1339bbd1904b932c3a92d23a2c0ffe1d267` (#11525), `77bca8b2d76a1f42552e5eee7d277fcb1160347a` (#9417), `df7ccc8fd01f5d2a1d8ec21bef8f9c59398fe913` (#11644), `7b61099886f3aac139ad43fb73873c73ba7baf4f` (#11652), `0118b52295264a73ef7cd045437181f7b3e5a99f` (#11595) | Adopted all six across Cycle #669 Group A and Group B. Screen readers navigate chat messages as headings with hierarchical levels (`h3` at offset 3); proposed plans expose semantic headings. Compact sidebar rail (`compactSidebarEnabled`) and compact thread row density (`sidebarCompactThreadRows`) added as opt-in settings defaulting to `false`, preserving Pylon's pull request badges and stack integration, custom settings, and avoiding dead delegation UI. Sparse shelves stay pinned to bottom. Adversarial review findings resolved: accessible fallback for `plan-ready` dot in compact rows, drag layout projection fix for compact rail mode, explicit test fixtures. Cursor unchanged. | Cycle [#669](https://github.com/pylon-code/pylon/issues/669), [PR #679](https://github.com/pylon-code/pylon/pull/679) and [PR #680](https://github.com/pylon-code/pylon/pull/680); independent adversarial reviews, 490+ focused tests, `@t3tools/web`, `@t3tools/desktop`, and `@t3tools/contracts` typechecks, scoped lint/format, green CI. | +| Navigation, search and diff safety / `b44c1ce5d25ee0d5a5be82e380618a886c19ea96` | `9ea9c3d5d2c444133e3ddff40eecf38737951589` (#11075), `886c83450797c46ee84ae28fc16c2208cafa965e` (#10909), `c557bb10aa7cbb78a50df230d1e8e24af79cb662` (#11761), `3fd5d6439d8fd49d173503ecda96500463a39bd2` (#12315) | Adopted all four in Cycle #667 Group B. Symlink file type changes no longer crash diff view; chat folder links reveal directory in file tree; sidebar search matches thread message contents; numbered jump shortcuts do not steal browser tab navigation. Preserved Pylon's keybinding identity, routing and panel-choice semantics. Cursor unchanged. | Cycle [#667](https://github.com/pylon-code/pylon/issues/667), [PR #671](https://github.com/pylon-code/pylon/pull/671); 389 focused tests, web/shared typechecks, scoped lint, green CI. | +| Composer banner density and scroll-pill stability / `b44c1ce5d25ee0d5a5be82e380618a886c19ea96` | `4749035bda13b4b6260499caedbc0d69a2f60e6f` (#12166), `fcfd9f911a27ee3b813d64582c53d82dfa2e892b` (#12317) | Adopted both. Banner stack items gain an optional `compact` flag: compact rows keep the narrow layout and the description that collapses into an info popover below 400px, while other banners get room for the description beside the title, with a labelled, bounded, scrollable popover. The scroll-to-end clearance is split out of the overlay-height publisher and recomputed from a stored height when the pill mounts, so a fling during the composer's resting tween no longer publishes a stale height and yanks the scroll position. **Excluded:** upstream's `approval` banner layout classes — Pylon's `ComposerBanner.Row` union is `inline | wrap-actions | wrap-actions-narrow`and the approval layout arrives in an unadopted commit; only this source's`justify-end`change was applied. Pylon adaptations:`ComposerTasksBadge`keeps its delegates label, expanded guard and`TaskProgressSegments fit`, with the delegates label moved to the same container query as the segment bar so the pair no longer flips at different widths; `ChatView`'s pull-request resolution block is preserved; and five Pylon banners with short static descriptions (environment update, thread woke, snoozed/settled, resume-compaction, auto-balance) are marked `compact` so they do not grow a permanently redundant details popover. Cursor unchanged. | [Chat rendering #668](https://github.com/pylon-code/pylon/pull/668); independent adversarial review, 184 focused tests including new `compact` coverage, `@t3tools/web` typecheck, scoped lint. Two upstream-inherited defects recorded in the PR rather than fixed here. | +| Checkpoint capture reliability and shared-workspace rewind / `93e04160a0c0dece8a258384d2118a660415a53d` | `869347bc26051bba7c3c0274a7dfcae7770d3e3a` (#11665), `1455cb5c35e50211145073be6ca155d56684f0f4` (#12181), `67993623afb14c017bb0b959478dc892f9c427da` (#12307), `b4620d595554654c259ec7c0afb3d3d5532959a8` (#12306), `d17f46d7631b178b6834596264f0f44f210d584b` (#12308) | Adopted all five. Transient git exits retry during capture behind a new optional `retryable` hint on `VcsProcessExitError`; capture recovers nested repositories without commits under index-lock recovery and bounded probe timeouts; a failed baseline lookup logs and treats the ref as absent instead of losing the checkpoint; the `@`-mention entry refresh moves to a coalescing drainable worker chained into `CheckpointReactor.drain`. File rewind is refused unless the thread's cwd is its own worktree with no other thread or live provider session nested inside or above it, and web hides the destructive control for shared directories. **Capability removal, maintainer-approved 2026-09-18**: threads running in the project directory rewind the conversation only, with no override path. Pylon adaptation: the isolation check is scoped with `restoreFiles !== false` so conversation-only rewind still reaches Pylon's existing provider-capability explanation rather than the isolation message or silence, since Pylon's rollback saga services are optional. Pylon git identity, rollback revert action and wire compatibility preserved. No excluded source behavior; cursor unchanged. | [Checkpoint reliability #665](https://github.com/pylon-code/pylon/pull/665); 122 focused tests including three new `restoreFiles` regressions where none existed, `t3`/`contracts`/`web` typechecks, scoped lint (0 errors), rebased onto `origin/pylon` after #653. | +| Provider event log bounds and Codex image attachments / `93e04160a0c0dece8a258384d2118a660415a53d` | `b17cc2ab5d5029f898121798ca19db3288cba616` (#12305, partial), `3fd21df62da3191f1abf12a0cd74df6de9ed30a2` (#11050) | `3fd21df62d` adopted: Codex image attachments pass as `{ type: "localImage", path }` instead of base64 data URLs, so `turn/start` no longer scales with file size. `localImage` is a real content type in the generated Codex app-server schema; Pylon's attachment-id validation against `attachmentsDir` and its `FileSystem` wiring are retained. `b17cc2ab5d` **partially adopted**: record traversal is bounded before serialization with character, field and depth budgets, cycle detection, a `summarizeProviderEvent` fallback that keeps routing and failure fields, and a post-serialization byte-length fallback; new transient suppressions for `turn/diff/updated` and `stream_event`/`content_block_delta`. Pylon's `commitGuard` and existing transient filters preserved. **Excluded: the `stage === "raw"` suppression and `stage === "decoded"` unwrap** (see EXC-1). Cursor unchanged. | [Provider payload bounds #653](https://github.com/pylon-code/pylon/pull/653), merged `4be539f4d48af6ffbe545e015ec246fbb271c92e`; 133 focused tests, `t3` typecheck, scoped format/lint, all final-head CI jobs green, no bot findings. | +| PR comments, controls and avatars / `6d1d549441be84f19696ab59ed7e2fbf305280d4` | `f0a0ead946ca3f3f310460a91b7dabb0e3fe4834` (#11962), `f45c6b4a755905485431872f4a4188de3ed05974` (#11994), `c12ba7581ac00ba2a623ad23283da5d03d34c2d8` (#12150), `962bf62921c0ecbc9320f9b071e16b1d41484aad` (#12125), `df466c79810046d20c0f3ccc4e51709c5703814e` (#11728) | Adopted all five. Narrow controls, keyboard submission, paged bot/finished groups, long-comment previews, detail avatars and image-failure fallback. Pylon corrections align bot app links, lazily page finished groups, and scroll after collapsing. Optional actor metadata preserves wire compatibility; existing bounded GitHub enrichment/fallback limits remain. No excluded source behavior; cursor unchanged. | [PR review #621](https://github.com/pylon-code/pylon/pull/621); focused regressions, scoped types/lint, independent Opus adversarial reviews, browser before/after evidence and comment interaction recording. | +| Large diffs and review panel defaults / `6d1d549441be84f19696ab59ed7e2fbf305280d4` | `aae361c4ef6478e4dcf970d574f3fd38d908e6b9` (#10822), `36caf200c2b24c12838570966f5c85786a1fc1f8` (#11484), `3efdcc5296f1754e0f3bf7fee5fc2ada510e0438` (#11931), `6d1d549441be84f19696ab59ed7e2fbf305280d4` (#12190), `52ad70ec43b5dcfcdc72ac79c0964da8e4d163db` (#12142), `394af73e02238e551db14f44d57f1469dd2942aa` (#12139) | Adopted all six. Complete Git metadata and progressive per-file patches on web/mobile; preserve old-server preview fallback, literal paths and bounded output. Tree order/folder state survive refresh. Files default collapsed while explicit saved choices persist. Generic diff openings reset to working tree; linked PRs outrank automatic diffs. Preserve Pylon multi-PR authority, environment identity, responsive layout and existing settings. No excluded source behavior; cursor unchanged. | [Review diffs #616](https://github.com/pylon-code/pylon/pull/616); 509 integrated focused tests plus 79 unchanged backend regressions, six scoped package typechecks, lint, independent Antigravity adversarial reviews, before/after browser evidence and refresh video. Native verification details in PR. | +| Usage pools, hub quotas, reset credits and local composer action / `062987b2fb0ef48cc7776d654931bd9f3bcf1f9f` | Full 16-source list in the usage pilot PR; parent `19d8ab2ae9fc562ee7b216a0d72903fbfafa9572`, final hub-routing fix `6abdf37a50ce6c1c9fabc499f4d0e159a6182d90` | Pylon port of the final behavior; parent partially adopted with native Claude scoped push mapping retained in DEF-16. `00d6109cbb1a13712d7347701969870aee83252d` is covered: demo fixtures were never introduced. Native Claude quota reads reuse Pylon's cached OAuth parser; no duplicate SDK probe or ingestion worker. | [Usage pilot #400](https://github.com/pylon-code/pylon/pull/400); focused quota, retry, RPC and compatibility checks, web/mobile evidence in PR. | +| Pull-request browsing, cached reads and opt-in panel behavior / `062987b2fb0ef48cc7776d654931bd9f3bcf1f9f` | Full 16-source disposition list in #401; 15 adopted, #9877 deferred | Final PR state icons, authored/readiness sorting, seeded detail, keyboard pickers, link previews, profile links, revision-aware caches, project-filter deduplication and manual panel priority. Preserve Pylon server-projected PR tracking and quota reservation/refund semantics. Copy priority remains DEF-17. | [PR workflow #401](https://github.com/pylon-code/pylon/pull/401); 768 focused tests, affected package types, scoped lint and integrated web evidence. | +| Composer interaction, layout and notice follow-ups / `062987b2fb0ef48cc7776d654931bd9f3bcf1f9f` | Full 27-source disposition list in #402 | Final scroll-only resting behavior, readable multiline drafts, stable control measurements, last-message reservation, narrow-width controls and opt-in context meter. Preserve Pylon provider locks, quick questions, session/harness/goal actions, quota placement, upload handling and diagnostic errors. Temporary blur-selection holding in #9499 is superseded by #10437; no unresolved scope in this group. | [Composer #402](https://github.com/pylon-code/pylon/pull/402); 724 focused tests, six affected package typechecks, scoped lint, integrated web screenshots and motion evidence. | +| Changed-file trees and stable diff navigation / `062987b2fb0ef48cc7776d654931bd9f3bcf1f9f` | Full eight-source list in #403; seven new sources and DEF-11 | Adopted: persistent folder trees, diff/PR trees, repeated selection, saved layout, wrapped edit heights, search focus and folder sorting. Completes DEF-11 after removing all helper callers. Pylon lazy-viewer scope guards remain; narrow panels place the tree below code. | [File navigation #403](https://github.com/pylon-code/pylon/pull/403); 271 focused tests, six package typechecks, scoped lint/export checks and integrated web evidence. | +| Thread link targets / `062987b2fb0ef48cc7776d654931bd9f3bcf1f9f` | Three full source SHAs in [#404](https://github.com/pylon-code/pylon/pull/404). | Adopted link target preference across chat, terminal and PR surfaces; completed DEF-12 and DEF-15 after replacing their last callers. Preserve Pylon context/routing, browser defaults, safe errors and external fallback. Browser profiles remain an independent source group. | [#404](https://github.com/pylon-code/pylon/pull/404); 233 focused tests, seven package typechecks, targeted lint/export checks; web evidence in PR. | | Document and media preview integration / `062987b2fb0ef48cc7776d654931bd9f3bcf1f9f` | Full 13-source list in #405, including DEF-14 | Twelve adopted sources; #9460 is already covered by Pylon’s expanded-only viewed-image rows. Shared parsing, document attachment tabs, preview retry states, draft file readers, native SVG and short-source scrolling. Preserve image dimensions, POSIX case, explicit panel choices, video expansion and local-file leases. Completes DEF-14 after removing all predicate callers. | [Media previews #405](https://github.com/pylon-code/pylon/pull/405); focused cross-client tests, seven package typechecks, native iOS build and client evidence in PR. | @@ -164,9 +167,9 @@ All three sources in the range `d1d15c67f4a5fb82fd8d5e01e5e3b288296789c3..a43f9b One open functional deferral, DEF-18, and one open exclusion, EXC-1, are recorded below with their revisit triggers. DEF-7 and DEF-16 are completed above; remaining exact native rollback compatibility limits are documented above. Revisit a limit when the native provider exposes the missing proof or fork behavior; do not substitute relative counts. Historical reasons and revisit conditions remain in the archive and earlier cycle records. -| Deferral | Sources | Remaining behavior and reason | Opened | Revisit trigger | -| ------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| DEF-18: activity-group timeline refinements | `592021f008010ee947c326df0452d093ead8aeb1` (#12302), `fbc8b960026e408c163ea55db8075bbf1a42d628` (#12270) | Both refine upstream's activity-group timeline: thoughts collapse inside a tool group behind their own disclosure, and a failed tool call stays in the same activity row instead of ending the live run. Neither is portable — Pylon has no `ActivityGroupTimelineRow`, `ReasoningTraceBlock`, `expandedReasoningMessageIds`, `onToggleReasoning`, `isActivityEntry` or `reasoningMessages`, and `packages/contracts/src/orchestration.ts` has no reasoning fields at all. Pylon's `ThinkingTimelineRow` is a transient indicator, not persisted provider reasoning. Adopting them first requires `052c7ae53e46c705d5fd5dfa995275bc3100744e` (#11784, 32 files / 1,923 insertions, including contracts, client-runtime and a settings toggle) and `22539f2a428947ab218589c984b2af44e434012e` (#12147, web + mobile) — a new full-stack capability, not a catch-up adaptation. **Parked behind the orchestration upgrade at the maintainer's decision on 2026-09-19**: orchestration v2 is unmerged on `t3code-upstream/t3code/codex-turn-mapping` (442 commits off `main`, last touched 2026-09-12) and will rewrite orchestration contracts, so main-line reasoning fields adopted now risk rework. There is no orchestration v3 upstream. | 2026-09-19 | Pylon resolves the orchestration v2 adoption question, or that line merges upstream: `git merge-base --is-ancestor t3code-upstream/t3code/codex-turn-mapping t3code-upstream/main`. Also triggered if Pylon adopts the activity-group timeline independently — check for `ActivityGroupTimelineRow` in `apps/web/src/components/chat/MessagesTimeline.tsx`. Dependency-driven, not time-driven; no earliest date applies. | +| Deferral | Sources | Remaining behavior and reason | Opened | Revisit trigger | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| DEF-18: activity-group timeline refinements | `592021f008010ee947c326df0452d093ead8aeb1` (#12302), `fbc8b960026e408c163ea55db8075bbf1a42d628` (#12270), `dfbb11bdd7c3f1a5575cb55d3e3abb12be025727` (#12377) | The first two refine upstream's activity-group timeline: thoughts collapse inside a tool group behind their own disclosure, and a failed tool call stays in the same activity row instead of ending the live run. The third renders plain text in collapsed thought previews. These depend on provider reasoning and activity grouping that Pylon has not adopted; none is portable independently — Pylon has no `ActivityGroupTimelineRow`, `ReasoningTraceBlock`, `expandedReasoningMessageIds`, `onToggleReasoning`, `isActivityEntry` or `reasoningMessages`, and `packages/contracts/src/orchestration.ts` has no reasoning fields at all. Pylon's `ThinkingTimelineRow` is a transient indicator, not persisted provider reasoning. Adopting them first requires `052c7ae53e46c705d5fd5dfa995275bc3100744e` (#11784, 32 files / 1,923 insertions, including contracts, client-runtime and a settings toggle) and `22539f2a428947ab218589c984b2af44e434012e` (#12147, web + mobile) — a new full-stack capability, not a catch-up adaptation. **Parked behind the orchestration upgrade at the maintainer's decision on 2026-09-19**: orchestration v2 is unmerged on `t3code-upstream/t3code/codex-turn-mapping` (442 commits off `main`, last touched 2026-09-12) and will rewrite orchestration contracts, so main-line reasoning fields adopted now risk rework. There is no orchestration v3 upstream. | 2026-09-19 | Pylon resolves the orchestration v2 adoption question, or that line merges upstream: `git merge-base --is-ancestor t3code-upstream/t3code/codex-turn-mapping t3code-upstream/main`. Also triggered if Pylon adopts the activity-group timeline independently — check for `ActivityGroupTimelineRow` in `apps/web/src/components/chat/MessagesTimeline.tsx`. Dependency-driven, not time-driven; no earliest date applies. | | Exclusion | Source | Excluded behavior and reason | Opened | Revisit trigger | | ------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -219,3 +222,5 @@ The review cursor stays unchanged; unrelated sources in this range remain unclas | PR cache reuse and quota efficiency / `6d1d549441be84f19696ab59ed7e2fbf305280d4` | `f4600d77dd7c2fa9f10e8f4500882e427fcc7e26` (#11888), `d612d12b8bb278af97b0029f8d48b2403a5f9ee6` (#12168) | Adopted applicable behavior: scoped cache revisions, canonical detail reuse, deduplicated refreshes and bounded quota probes. Preserve Pylon environment fences and observed GraphQL consumption. Exclude account-router-specific hooks and Forgejo tests: those dependencies are absent in Pylon; their independent source decisions remain open. No cross-environment credential routing introduced. | Cycle [#625](https://github.com/pylon-code/pylon/issues/625), [PR #626](https://github.com/pylon-code/pylon/pull/626); direct adversarial self-review, focused backend/client regressions and scoped types/lint. | | Authenticated GitHub PR media / `6d1d549441be84f19696ab59ed7e2fbf305280d4` | `32e8b2584556c0c55ce0d1d8f72b506cb771d60d` (#11706) | Adopted with Pylon path/context guards retained. Signed assets use the active server GitHub CLI account per request; bounded GitHub/CDN redirects strip credentials off credentialed hosts, stream ranges, constrain MIME and sandbox SVG. Older servers retain public fallback. GitHub Enterprise media unsupported. | Cycle [#625](https://github.com/pylon-code/pylon/issues/625), [PR #629](https://github.com/pylon-code/pylon/pull/629); direct adversarial self-review, credential/redirect/range regressions, scoped checks and browser evidence. | + +| Retained usage totals and newest shared scan / `7ed3f711caa36485001538bbe1cb0017b84aa7fe` | `d4d5d12e8ba086cfbf79ca3adeb4156b46ead665` (#10315), `675869d2b0f9e059d5950b80390c0cad1cb1590e` (#12304) | Adopted with Pylon adaptation: retain saved usage after transcript cleanup or moves and across restart; claim a shared fingerprint from the newest scan with a stable ID tie-break. Configured and standalone Antigravity homes retain their profile identity, partial status and per-directory SQLite limits. Stale source identities expire when no current or retained records use them. Existing provider-level wire buckets cannot split costs by individual home when two environments each own distinct and shared homes; that cross-environment overlap remains outside this port and needs source-attributed buckets. The 90-day retention bound remains. Cursor unchanged. | Cycle [#736](https://github.com/pylon-code/pylon/issues/736), [PR #762](https://github.com/pylon-code/pylon/pull/762); 46 focused tests and scoped server checks. | diff --git a/.github/scripts/thread-transfer-report.cjs b/.github/scripts/thread-transfer-report.cjs index 94a02b7806..264a92b1c8 100644 --- a/.github/scripts/thread-transfer-report.cjs +++ b/.github/scripts/thread-transfer-report.cjs @@ -61,7 +61,7 @@ function validateResult(value) { } assertExactKeys(value.scenario, SCENARIO_KEYS, "result.scenario"); - if (value.scenario.id !== "thread-transfer-v1") { + if (!["thread-transfer-v1", "thread-transfer-v2"].includes(value.scenario.id)) { throw new Error("result.scenario.id is not supported"); } for (const key of SCENARIO_KEYS.slice(1)) { @@ -157,7 +157,7 @@ function renderComment(input) { `| ${provider === "codex" ? "Codex" : "Claude"} | ${metric.label} | ${baselineObserved === undefined ? "—" : formatValue(baselineObserved, metric.kind)} | ${formatValue(observed, metric.kind)} | ${formatImpact(observed, baselineObserved, metric.kind)} | ${formatValue(ceiling, metric.kind)} | ${pass ? "✅" : "❌"} |`, ); - if (baseline && baseline.providers[provider].ceiling[metric.key] !== ceiling) { + if (comparable && baseline.providers[provider].ceiling[metric.key] !== ceiling) { ceilingChanges.push( `- ${provider === "codex" ? "Codex" : "Claude"} ${metric.label}: ${formatValue(baseline.providers[provider].ceiling[metric.key], metric.kind)} → ${formatValue(ceiling, metric.kind)}`, ); diff --git a/.github/scripts/thread-transfer-report.test.cjs b/.github/scripts/thread-transfer-report.test.cjs index 4935864e46..3c8ab2578a 100644 --- a/.github/scripts/thread-transfer-report.test.cjs +++ b/.github/scripts/thread-transfer-report.test.cjs @@ -290,3 +290,21 @@ test("preserves a successful result when a same-SHA rerun has no artifact", asyn assert.equal(published, true); assert.equal(updatedComment, false); }); + +test("accepts V2 without comparing it to the V1 scenario", () => { + const current = result(); + current.scenario.id = "thread-transfer-v2"; + current.providers.codex.ceiling = { ...current.providers.codex.ceiling, totalWireBytes: 3000000 }; + assert.equal(validateResult(current), current); + const comment = renderComment({ + current, + baseline: result(), + currentRun: { sha: "bbbbbbbb", conclusion: "success", url: "https://example.com/current" }, + baselineRun: { sha: "aaaaaaaa", matchesBase: true, url: "https://example.com/baseline" }, + }); + assert.match(comment, /fixture changed/); + assert.doesNotMatch(comment, /This PR changes transfer ceilings/); + assert.doesNotMatch(comment, /[+-]\d+\.\d+%/); + current.scenario.id = "unrecognized"; + assert.throws(() => validateResult(current), /not supported/); +}); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b9ed3e8e43..46df2062ba 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -24,9 +24,9 @@ "@t3tools/tailscale": "workspace:*", "dbus-next": "0.10.2", "effect": "catalog:", - "electron": "44.1.0", + "electron": "44.4.2", "electron-store": "^8.2.0", - "electron-updater": "^6.6.2", + "electron-updater": "^6.8.9", "ffi-rs": "1.3.2", "playwright-core": "1.60.0", "react-grab": "^0.1.32" diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 719502636b..b5a7bb2007 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -1,4 +1,5 @@ import * as Cause from "effect/Cause"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -142,6 +143,20 @@ const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupErr const fatalStartupCause = (stage: string, cause: Cause.Cause) => handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause))); +export const stopAllPoolInstances = Effect.fn("desktop.app.stopAllPoolInstances")( + function* (): Effect.fn.Return { + // Stop every backend in the pool with a timeout to guarantee the quit + // path makes progress even if a backend hangs during teardown. + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const instances = yield* pool.list; + yield* Effect.forEach( + instances, + (instance) => instance.stop({ timeout: Duration.seconds(5) }), + { concurrency: "unbounded" }, + ); + }, +); + const bootstrap = Effect.gen(function* () { const pool = yield* DesktopBackendPool.DesktopBackendPool; const primaryBackend = yield* pool.primary; @@ -313,18 +328,12 @@ const scopedProgram = Effect.scoped( const shutdown = yield* DesktopShutdown.DesktopShutdown; yield* Effect.addFinalizer(() => - Effect.gen(function* () { - const pool = yield* DesktopBackendPool.DesktopBackendPool; - // Stop every backend in the pool, not just the primary. The - // electronApp.quit() path can race ahead of the layer-scope - // cascade, so leaving the WSL instance for its parent scope - // finalizer means it gets hard-killed by the OS instead of - // receiving SIGTERM + grace. Stops run concurrently. - const instances = yield* pool.list; - yield* Effect.forEach(instances, (instance) => instance.stop(), { - concurrency: "unbounded", - }); - }).pipe(Effect.ensuring(shutdown.markComplete)), + // Stop every backend in the pool, not just the primary. The + // electronApp.quit() path can race ahead of the layer-scope + // cascade, so leaving the WSL instance for its parent scope + // finalizer means it gets hard-killed by the OS instead of + // receiving SIGTERM + grace. + stopAllPoolInstances().pipe(Effect.ensuring(shutdown.markComplete)), ); yield* startup; diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index d7ccfc43b1..215998631e 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -1,6 +1,7 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -427,6 +428,36 @@ describe("DesktopObservability", () => { ); }); + it.effect("exports kill switch warnings through the configured logger", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir, true, { + T3CODE_OTLP_LOGS_URL: "https://collector.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.void.pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + assert.include(requests[0]?.body ?? "", "OTEL_SDK_DISABLED=1 was read as false"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + collectorLayer(requests), + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { OTEL_SDK_DISABLED: "1" } })), + ), + ), + ); + }); + it.effect("reads every signal endpoint from Settings when the environment names none", () => { const requests: Array = []; return Effect.gen(function* () { @@ -491,4 +522,37 @@ describe("DesktopObservability", () => { Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), ); }); + + it.effect("stops every export when the OpenTelemetry SDK is disabled", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir); + yield* writeObservabilitySettings(environmentLayer, { + otlpTracesUrl: "https://settings.example.com/v1/traces", + otlpLogsUrl: "https://settings.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop log stays local when disabled").pipe( + Effect.withSpan("desktop-disabled-test"), + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + assert.lengthOf(requests, 0); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + collectorLayer(requests), + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { OTEL_SDK_DISABLED: "true" } })), + ), + ), + ); + }); }); diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 9665721574..f8fd73a636 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -4,6 +4,7 @@ import { makeTraceSink, otlpSerializationLayer, } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { parsePersistedServerObservabilitySettings, type PersistedServerObservabilitySettings, @@ -353,12 +354,18 @@ const readPersistedObservabilitySettings: Effect.Effect< * resolve traces against one revision of the file and logs against another. */ const resolveOtlpEndpoints = Effect.gen(function* () { + const otel = yield* OtelEnvironment.load; + if (otel.disabled) { + return { traces: undefined, metrics: undefined, logs: undefined, warnings: otel.warnings }; + } + const environment = yield* DesktopEnvironment.DesktopEnvironment; const persisted = yield* readPersistedObservabilitySettings; return { traces: Option.getOrUndefined(environment.otlpTracesUrl) ?? persisted.otlpTracesUrl, metrics: Option.getOrUndefined(environment.otlpMetricsUrl) ?? persisted.otlpMetricsUrl, logs: Option.getOrUndefined(environment.otlpLogsUrl) ?? persisted.otlpLogsUrl, + warnings: otel.warnings, }; }); @@ -671,7 +678,12 @@ const telemetryLayer = Layer.unwrap( // resource, // }).pipe(Layer.provide(serializationLayer)); - return Layer.mergeAll(loggerLayer, tracerLayer); + // Logged once the loggers above are installed, so the warnings use them. + const otelWarningsLayer = Layer.effectDiscard( + Effect.forEach(endpoints.warnings, (warning) => Effect.logWarning(warning)), + ); + + return otelWarningsLayer.pipe(Layer.provideMerge(Layer.mergeAll(loggerLayer, tracerLayer))); }), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 62a11381fe..de63e91b10 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -1055,6 +1055,49 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("resolveWsl carries the kill switch into the distro", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + const previousWslEnv = process.env.WSLENV; + const previousDisabled = process.env.OTEL_SDK_DISABLED; + try { + delete process.env.WSLENV; + process.env.OTEL_SDK_DISABLED = "true"; + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); + + assert.equal(config.env.OTEL_SDK_DISABLED, "true"); + assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_SDK_DISABLED"); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), + getDistroIp: () => Option.some("172.27.0.99"), + }), + ), + Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), + ), + ), + ); + } finally { + restoreEnv("WSLENV", previousWslEnv); + restoreEnv("OTEL_SDK_DISABLED", previousDisabled); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("resolveWsl preserves existing WSLENV entries when forwarding backend secrets", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 711405002f..8df5f799e1 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -96,6 +96,9 @@ const DESKTOP_BACKEND_ENV_NAMES = [ const WSL_FORWARDED_ENV_NAMES = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + // Otherwise the WSL server keeps exporting to endpoints from the bootstrap. + "T3CODE_OTEL_SDK_DISABLED", + "OTEL_SDK_DISABLED", "T3CODE_OTLP_HEADERS", "T3CODE_OTLP_PROTOCOL", ] as const; diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 53ccf5a756..4bbecd7d74 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -23,6 +23,8 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopApp from "../app/DesktopApp.ts"; +import * as DesktopBackendPool from "./DesktopBackendPool.ts"; import * as DesktopObservability from "../app/DesktopObservability.ts"; import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; @@ -1503,4 +1505,147 @@ describe("DesktopBackendManager", () => { }).pipe(Effect.provide(TestClock.layer())), ), ); + + it.effect("stopAllPoolInstances bounds the quit finalizer when backends hang", () => + Effect.scoped( + Effect.gen(function* () { + // Each backend's process-scope finalizer reports when it starts and + // when it finishes, keyed by instance name, so the test can prove + // both backends reached each milestone instead of inferring it from + // a shared flag or a clock advance. + const teardownStarted = yield* Queue.unbounded(); + const teardownFinished = yield* Queue.unbounded(); + const allowTeardown = yield* Deferred.make(); + + const makeInstance = (name: string) => + makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + yield* Scope.addFinalizer( + scope, + Queue.offer(teardownStarted, name).pipe( + Effect.andThen(Deferred.await(allowTeardown)), + Effect.andThen(Queue.offer(teardownFinished, name)), + Effect.asVoid, + ), + ); + return makeProcess({ exitCode: Effect.never }); + }), + ), + ), + httpClientLayer: httpClientLayer(() => Effect.never), + }); + + const instance1 = yield* makeInstance("instance1"); + const instance2 = yield* makeInstance("instance2"); + + yield* instance1.start; + yield* instance2.start; + + const mockPool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { + list: Effect.succeed([instance1, instance2]), + get: () => Effect.succeed(Option.none()), + primary: Effect.die(new Error("primary not implemented")), + register: () => Effect.die(new Error("register not implemented")), + unregister: () => Effect.die(new Error("unregister not implemented")), + }); + + // Mirror the quit path: register stopAllPoolInstances as a scope + // finalizer and let the scope close run it, rather than calling it + // as an ordinary interruptible effect. + const quitFiber = yield* Effect.scoped( + Effect.addFinalizer(() => DesktopApp.stopAllPoolInstances()), + ).pipe(Effect.provide(mockPool), Effect.forkChild); + + const started = yield* Queue.takeN(teardownStarted, 2); + assert.deepEqual(started.toSorted(), ["instance1", "instance2"]); + + // Both backends are now hung in teardown. Advancing past the 5s + // budget must let the quit finalizer return without them. + yield* TestClock.adjust(Duration.seconds(5)); + yield* Fiber.join(quitFiber); + assert.equal(yield* Queue.size(teardownFinished), 0); + + // The timed-out closes keep running in the background and finish + // once the backends unblock. + yield* Deferred.succeed(allowTeardown, undefined); + const finished = yield* Queue.takeN(teardownFinished, 2); + assert.deepEqual(finished.toSorted(), ["instance1", "instance2"]); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + + it.effect("stopAllPoolInstances bounds an early shutdown callback", () => + Effect.scoped( + Effect.gen(function* () { + const ready = yield* Queue.unbounded(); + const hookStarted = yield* Queue.unbounded(); + const hookFinished = yield* Queue.unbounded(); + const releaseHook = yield* Deferred.make(); + + const makeInstance = (name: string) => + makeTestInstance({ + spawnerLayer: Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Scope.Scope; + const closed = yield* Deferred.make(); + yield* Scope.addFinalizer( + scope, + Deferred.succeed(closed, undefined).pipe(Effect.asVoid), + ); + return makeProcess({ + exitCode: Deferred.await(closed).pipe( + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + kill: () => Deferred.succeed(closed, undefined).pipe(Effect.asVoid), + }); + }), + ), + ), + onReady: Queue.offer(ready, name).pipe(Effect.asVoid), + onShutdown: Queue.offer(hookStarted, name).pipe( + Effect.andThen(Deferred.await(releaseHook)), + Effect.andThen(Queue.offer(hookFinished, name)), + Effect.asVoid, + ), + }); + + const instance1 = yield* makeInstance("instance1"); + const instance2 = yield* makeInstance("instance2"); + yield* instance1.start; + yield* instance2.start; + assert.deepEqual((yield* Queue.takeN(ready, 2)).toSorted(), ["instance1", "instance2"]); + + const mockPool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { + list: Effect.succeed([instance1, instance2]), + get: () => Effect.succeed(Option.none()), + primary: Effect.die(new Error("primary not implemented")), + register: () => Effect.die(new Error("register not implemented")), + unregister: () => Effect.die(new Error("unregister not implemented")), + }); + const quitFiber = yield* Effect.scoped( + Effect.addFinalizer(() => DesktopApp.stopAllPoolInstances()), + ).pipe(Effect.provide(mockPool), Effect.forkChild); + + assert.deepEqual((yield* Queue.takeN(hookStarted, 2)).toSorted(), [ + "instance1", + "instance2", + ]); + yield* TestClock.adjust(Duration.seconds(5)); + yield* Fiber.join(quitFiber); + assert.equal(yield* Queue.size(hookFinished), 0); + + yield* Deferred.succeed(releaseHook, undefined); + assert.deepEqual((yield* Queue.takeN(hookFinished, 2)).toSorted(), [ + "instance1", + "instance2", + ]); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); }); diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index 6f1ea139bd..997f6df459 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -1045,7 +1045,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( }); }); - const stop = Effect.fn("desktop.backendInstance.stop")(function* (options?: { + const stopUnsafe = Effect.fn("desktop.backendInstance.stopUnsafe")(function* (options?: { readonly timeout?: Duration.Duration; }) { const { active, restartFiber, notifyShutdown } = yield* mutex.withPermits(1)( @@ -1137,6 +1137,15 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( }); }); + const stop: DesktopBackendInstance["stop"] = (options) => { + const timeout = options?.timeout; + if (timeout === undefined) return stopUnsafe(); + return Effect.forkIn(stopUnsafe(), parentScope).pipe( + Effect.flatMap((fiber) => Fiber.join(fiber).pipe(Effect.timeoutOption(timeout))), + Effect.asVoid, + ); + }; + const waitForReady = (timeout: Duration.Duration): Effect.Effect => Effect.gen(function* () { const current = yield* Ref.get(state); @@ -1154,7 +1163,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( Effect.map(Option.getOrElse(() => false)), ); - yield* Effect.addFinalizer(() => stop()); + yield* Effect.addFinalizer(() => stopUnsafe({ timeout: Duration.seconds(5) })); return { id: spec.id, diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index b99d4219f7..579dbfac12 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -40,6 +40,7 @@ import { openExternal, openSystemSettings, checkSystemPermission, + pasteAsText, probeRemoteEditors, pickFolder, pickProjectFavicon, @@ -138,6 +139,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(openExternal); yield* ipc.handle(openSystemSettings); yield* ipc.handle(checkSystemPermission); + yield* ipc.handle(pasteAsText); yield* ipc.handle(probeRemoteEditors); yield* ipc.handle(getUpdateState); yield* ipc.handle(downloadUpdate); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 99234d9f2a..2bc35d303c 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -8,6 +8,7 @@ export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings"; export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors"; export const MENU_ACTION_CHANNEL = "desktop:menu-action"; +export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text"; export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event"; export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut"; export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state"; diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 1b21480a77..4d4ca7b63e 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -10,6 +10,15 @@ import { vi } from "vite-plus/test"; import type * as Electron from "electron"; +const { focusedWebContents, ownerWindow } = vi.hoisted(() => ({ + focusedWebContents: vi.fn(), + ownerWindow: vi.fn(), +})); +vi.mock("electron", () => ({ + webContents: { getFocusedWebContents: focusedWebContents }, + BrowserWindow: { fromWebContents: ownerWindow }, +})); + import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; @@ -17,6 +26,7 @@ import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import { getLocalEnvironmentBootstraps, getWindowFullscreenState, + pasteAsText, pickProjectFavicon, probeRemoteEditors, } from "./window.ts"; @@ -158,6 +168,50 @@ describe("getWindowFullscreenState", () => { }); }); +describe("pasteAsText", () => { + it.effect( + "pastes into the focused guest only after the main renderer acknowledges the menu action", + () => { + const pasteAndMatchStyle = vi.fn(); + const mainPasteAndMatchStyle = vi.fn(); + const window = { + webContents: { id: 42, pasteAndMatchStyle: mainPasteAndMatchStyle }, + isDestroyed: () => false, + } as unknown as Electron.BrowserWindow; + focusedWebContents.mockReturnValue({ pasteAndMatchStyle, isDestroyed: () => false }); + ownerWindow.mockReturnValue(window); + + return Effect.gen(function* () { + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(pasteAndMatchStyle.mock.calls.length, 1); + assert.equal(mainPasteAndMatchStyle.mock.calls.length, 0); + + yield* pasteAsText.handler(undefined, { sender: { id: 99 } }); + assert.equal(pasteAndMatchStyle.mock.calls.length, 1); + ownerWindow.mockReturnValue({}); // A focused PiP/other BrowserWindow. + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(pasteAndMatchStyle.mock.calls.length, 1); + ownerWindow.mockReturnValue(null); // Detached contents. + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(pasteAndMatchStyle.mock.calls.length, 1); + ownerWindow.mockReturnValue(window); + focusedWebContents.mockReturnValue({ pasteAndMatchStyle, isDestroyed: () => true }); + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(pasteAndMatchStyle.mock.calls.length, 1); + focusedWebContents.mockReturnValue(null); + yield* pasteAsText.handler(undefined, { sender: { id: 42 } }); + assert.equal(pasteAndMatchStyle.mock.calls.length, 1); + }).pipe( + Effect.provide( + Layer.mock(ElectronWindow.ElectronWindow)({ + main: Effect.succeed(Option.some(window)), + }), + ), + ); + }, + ); +}); + describe("pickProjectFavicon", () => { it.effect("opens a single-image picker from the project directory", () => Effect.gen(function* () { diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 1b8a9bbf89..06305551b9 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -339,6 +339,34 @@ export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ }), }); +export const pasteAsText = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PASTE_AS_TEXT_CHANNEL, + payload: Schema.Undefined, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.window.pasteAsText")(function* (_input, event) { + const electronWindow = yield* ElectronWindow.ElectronWindow; + const window = yield* electronWindow.main; + if ( + event === undefined || + Option.isNone(window) || + window.value.isDestroyed() || + window.value.webContents.id !== event.sender.id + ) { + return; + } + const focused = Electron.webContents.getFocusedWebContents(); + if ( + focused && + !focused.isDestroyed() && + Electron.BrowserWindow.fromWebContents(focused) === window.value + ) { + // Paste only the text representation into whichever app editor owns + // focus. The composer also intercepts the event to bypass folding. + focused.pasteAndMatchStyle(); + } + }), +}); + /** Theme files are a few KB; anything larger returns empty text and lets the * renderer reject it by size without the contents ever crossing the bridge. */ const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index c99481ffef..0eb191bf46 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -8,7 +8,7 @@ import type { DesktopSnapShotEvent, } from "@t3tools/contracts"; import { exposeClerkBridge } from "@clerk/electron/preload"; -import { contextBridge, ipcRenderer } from "electron"; +import { contextBridge, ipcRenderer, webUtils } from "electron"; import * as IpcChannels from "./ipc/channels.ts"; @@ -72,6 +72,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + getPathForFile: (file: File) => webUtils.getPathForFile(file), // oxlint-disable-next-line t3code/no-global-process-runtime -- The sandboxed Electron preload exposes the client platform, not the server platform. getClientPlatform: () => process.platform, setNotificationBadge: (badge) => @@ -181,6 +182,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { openSystemSettings: (pane: string) => ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane), probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined), + pasteAsText: () => ipcRenderer.invoke(IpcChannels.PASTE_AS_TEXT_CHANNEL, undefined), onMenuAction: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { if (typeof action !== "string") return; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index ae57daf62b..ec0ae4432f 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -3713,7 +3713,10 @@ describe("PreviewManager", () => { listeners.set(event, listener); }), once: vi.fn((event: string, listener: (...args: unknown[]) => void) => { - listeners.set(event, listener); + listeners.set(event, (...args) => { + listeners.delete(event); + listener(...args); + }); }), off: vi.fn(), ipc: { on: vi.fn(), off: vi.fn(), removeListener: vi.fn() }, @@ -3735,11 +3738,23 @@ describe("PreviewManager", () => { const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild); yield* Effect.yieldNow; - listeners.get("did-start-navigation")?.({}, "about:blank", false, false); + listeners.get("did-start-navigation")?.({ + url: "about:blank", + isSameDocument: false, + isMainFrame: false, + frame: null, + }); yield* Effect.yieldNow; expect(pick.pollUnsafe()).toBeUndefined(); - listeners.get("did-start-navigation")?.({}, "https://example.com/next", false, true); + listeners.get("did-start-navigation")?.({ + url: "https://example.com/next", + isSameDocument: false, + isMainFrame: true, + frame: null, + }); + yield* Effect.yieldNow; + expect(pick.pollUnsafe()).toBeDefined(); expect(yield* Fiber.join(pick)).toBeNull(); }), ), diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 3162e878ee..5670f200f2 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -2681,12 +2681,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }; const onDestroyed = () => settle(null); const onNavigated = ( - _event: Electron.Event, - _url: string, - _isInPlace: boolean, - isMainFrame: boolean, + event: Electron.Event, ) => { - if (isMainFrame) settle(null); + if (event.isMainFrame) settle(null); }; const registerPickElement = Effect.fn("PreviewManager.registerPickElement")(function* () { // Two picks on one tab can overlap. Swap this session in and cancel @@ -2707,7 +2704,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* attempt({ operation: "pickElement.register", tabId, webContentsId: wc.id }, () => { wc.ipc.on(ELEMENT_PICKED_CHANNEL, onMessage); wc.once("destroyed", onDestroyed); - wc.once("did-start-navigation", onNavigated); + wc.on("did-start-navigation", onNavigated); if (!wc.isFocused()) wc.focus(); wc.send(START_PICK_CHANNEL, annotationTheme); }); diff --git a/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.test.ts b/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.test.ts index 81889f9f07..d0548c78c0 100644 --- a/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.test.ts +++ b/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.test.ts @@ -54,6 +54,11 @@ describe("macOS modifier pair poller", () => { const poller = spawnedPollers[0]!; expect(poller.command).toBe("/usr/bin/osascript"); expect(poller.args.slice(-2)).toEqual(["8", "16"]); + const script = poller.args[3]!; + expect(script.indexOf("NSApplicationActivationPolicyProhibited")).toBeGreaterThan(0); + expect(script.indexOf("NSApplicationActivationPolicyProhibited")).toBeLessThan( + script.indexOf('ObjC.import("CoreGraphics")'), + ); poller.emitStderr("ready\ntrig"); const stop = await started; diff --git a/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts b/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts index 1c638f2444..c869e92efa 100644 --- a/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts +++ b/apps/desktop/src/snapShot/MacModifierPairShortcutProcess.ts @@ -12,6 +12,9 @@ const MAC_MODIFIER_PAIR_DEVICE_MASKS: Record; + +interface UpdatesHarness { + readonly layer: UpdatesHarnessLayer; + readonly checkCount: () => number; + readonly quitAndInstalls: () => number; + readonly installSteps: string[]; + readonly downloadCount: () => number; + readonly feedUrls: () => ReadonlyArray; + readonly allowPrerelease: () => boolean; + readonly fullChangelog: () => boolean; + readonly listenerCount: () => number; + readonly sentStates: DesktopUpdateState[]; + readonly emit: (eventName: string, payload?: unknown) => void; +} + +export function makeHarness(options: UpdatesHarnessOptions = {}): UpdatesHarness { let checkCount = 0; let quitAndInstallCount = 0; let downloadCount = 0; @@ -172,7 +199,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { const settingsLayer = DesktopAppSettings.layer; - const layer = DesktopUpdates.layer.pipe( + const layer: UpdatesHarnessLayer = DesktopUpdates.layer.pipe( Layer.provideMerge(updaterLayer), Layer.provideMerge(windowLayer), Layer.provideMerge(backendLayer), diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 2fdff27515..2e5279f721 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -152,6 +152,36 @@ describe("DesktopApplicationMenu", () => { }), ); + it.effect("owns Paste as Text and routes it through the renderer", () => + Effect.gen(function* () { + const selectedAction = yield* Deferred.make(); + const applicationMenuTemplate = + yield* Deferred.make(); + + yield* configureMenu(selectedAction, applicationMenuTemplate); + + const template = yield* Deferred.await(applicationMenuTemplate); + const editMenu = template.find((item) => item.label === "Edit"); + assert.isDefined(editMenu); + if (!Array.isArray(editMenu.submenu)) { + throw new Error("Expected Edit menu submenu to be an array."); + } + const pasteAsTextItem = editMenu.submenu.find((item) => item.label === "Paste as Text"); + assert.isDefined(pasteAsTextItem); + assert.equal(pasteAsTextItem.accelerator, "CmdOrCtrl+Shift+V"); + if (typeof pasteAsTextItem.click !== "function") { + throw new Error("Expected Paste as Text menu item to have a click handler."); + } + + pasteAsTextItem.click( + {} as Electron.MenuItem, + {} as Electron.BrowserWindow, + {} as KeyboardEvent, + ); + assert.equal(yield* Deferred.await(selectedAction), "paste-as-text"); + }), + ); + // Zoom must route through DesktopWindow.zoomMain instead of the Electron // zoom roles: the roles zoom whichever webContents has focus, which breaks // app zoom while an embedded preview WebContentsView holds focus. diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index 2d5c9bd5e9..eb67af5dc5 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -46,7 +46,9 @@ const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function action: string, ): Effect.fn.Return { const desktopWindow = yield* DesktopWindow.DesktopWindow; - yield* desktopWindow.dispatchMenuAction(action); + yield* desktopWindow.dispatchMenuAction(action, { + reveal: action !== "paste-as-text", + }); }); const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* ( @@ -135,6 +137,9 @@ export const make = Effect.gen(function* () { const settingsClick = () => { runMenuEffect("open-settings", dispatchMenuAction("open-settings")); }; + const pasteAsTextClick = () => { + runMenuEffect("paste-as-text", dispatchMenuAction("paste-as-text")); + }; const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => { runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction)); }; @@ -184,7 +189,34 @@ export const make = Effect.gen(function* () { { role: environment.platform === "darwin" ? "close" : "quit" }, ], }, - { role: "editMenu" }, + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { + label: "Paste as Text", + accelerator: "CmdOrCtrl+Shift+V", + click: pasteAsTextClick, + }, + { role: "delete" }, + { type: "separator" }, + { role: "selectAll" }, + ...(environment.platform === "darwin" + ? [ + { type: "separator" as const }, + { + label: "Speech", + submenu: [{ role: "startSpeaking" as const }, { role: "stopSpeaking" as const }], + }, + ] + : []), + ], + }, { label: "View", submenu: [ diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 8301c9ea33..fb360e2f63 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -168,7 +168,7 @@ describe("WSL runtime cache", () => { expect(nightly).not.toContain('"$HOME/.pylon-code/wsl-runtime"'); expect(buildWslRuntimeInvalidateScript("sha256-bbb", "pylon-code-nightly")).toContain( - ".pylon-code-nightly/wsl-runtime/sha256-bbb", + ".pylon-code-nightly/wsl-runtime", ); }); @@ -190,6 +190,10 @@ describe("WSL runtime cache", () => { expect(script).toContain("trap 'exit 1' HUP INT TERM"); expect(script).toContain('exec 9> "$runtime_lock"'); expect(script).toContain("flock -x 9"); + expect(script).toContain('probe_failure_marker="$runtime_parent/.1.2.3-x64.probe-failed"'); + expect(script.indexOf('if [ -f "$probe_failure_marker" ]')).toBeLessThan( + script.indexOf("if runtime_is_ready; then"), + ); expect(script).not.toContain("runtime_lock_pid"); expect(script).not.toContain("sleep 0.1"); expect(script).not.toContain('rm -rf "$runtime_lock"'); @@ -386,7 +390,7 @@ describe("WSL runtime cache", () => { // Without visible processes the retention rules cannot tell a live cache // from an abandoned one, so the sweep is skipped rather than guessed at. - expect(script).toContain("[ -d /proc/1 ] || exit 0"); + expect(script).toContain("[ -d /proc/1 ] && have_process_info=1"); // The guard has to gate the delete, not just exist. const inUseChecked = script.indexOf('! runtime_in_use "$candidate"'); @@ -400,9 +404,9 @@ describe("WSL runtime cache", () => { // Dot-prefixed, so `"$runtime_parent"/*` never matches them, and they carry // no ready marker either; without this pass a killed install leaks forever. - expect(script).toContain( - 'for scratch in "$runtime_parent"/.*.tmp.* "$runtime_parent"/.*.stale.*; do', - ); + expect(script).toContain('for scratch in "$runtime_parent"/.*.tmp.*; do'); + expect(script).toContain('for scratch in "$runtime_parent"/.*.stale.*; do'); + expect(script).toContain('! runtime_in_use "$runtime_parent/$original_name" || continue'); // Age guard: a scratch directory younger than this belongs to a live install. expect(script).toContain('find "$scratch" -maxdepth 0 -mmin +120'); }); @@ -412,9 +416,8 @@ describe("WSL runtime cache", () => { // Readiness is a presence check, so a tree whose pty.node is present but // unloadable stays ready forever unless the probe can revoke the marker. - expect(script).toContain( - 'rm -f "$HOME/.pylon-code/wsl-runtime/1.2.3_x64/.t3code-wsl-runtime-ready"', - ); + expect(script).toContain('rm -f "$runtime_parent/1.2.3_x64/.t3code-wsl-runtime-ready"'); + expect(script).toContain('touch "$runtime_parent/.1.2.3_x64.probe-failed"'); // Deleting the tree here would pull it out from under any backend still // running from it; the next install moves an unready root aside instead. expect(script).not.toContain("rm -rf"); @@ -503,6 +506,34 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed expect(restored.stdout).toBe(SERVER_ENTRY_SOURCE); }); + it("backs off a proven native probe failure and retries after the cooldown", () => { + const fixture = createFixture(); + expect(fixture.install().status).toBe(0); + const invalidated = runShell( + [ + "set -eu", + `HOME=${sh(`${fixture.work}/home`)}`, + "export HOME", + buildWslRuntimeInvalidateScript(fixture.runtimeId, "pylon-code"), + ].join("\n"), + ); + expect(invalidated.status, invalidated.stderr).toBe(0); + + // No extraction or runtime path is offered during the cooling period. + const cooled = fixture.install(); + expect(cooled.status).toBe(2); + expect(parseWslRuntimeRoot(cooled.stdout)).toBeNull(); + expect(cooled.stderr).toContain("using the mounted tree"); + + const expired = runShell( + `set -eu\ntouch -d "11 minutes ago" ${sh(`${fixture.runtimeParent}/.${fixture.runtimeId}.probe-failed`)}`, + ); + expect(expired.status, expired.stderr).toBe(0); + const repaired = fixture.install(); + expect(repaired.status, repaired.stderr).toBe(0); + expect(parseWslRuntimeRoot(repaired.stdout)).toBe(fixture.runtimeRoot); + }); + it("falls back instead of launching a corrupted cache it cannot reinstall", () => { const fixture = createFixture(); expect(fixture.install().status).toBe(0); @@ -664,7 +695,7 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); }); - it("removes an aged stale tree after replacing an active unready cache", () => { + it("keeps an aged stale tree until its original runtime process exits", () => { const fixture = createFixture(); expect(fixture.install().status).toBe(0); const result = runShell( @@ -683,15 +714,51 @@ describe.skipIf(posixShellRunner === null)("WSL runtime install script (executed `HOME=${sh(`${fixture.work}/home`)}`, "export HOME", buildWslRuntimePruneScript(fixture.runtimeId, "pylon-code"), - 'test ! -e "$stale"', + 'test -d "$stale"', "kill $active_pid", "wait $active_pid 2>/dev/null || true", + buildWslRuntimePruneScript(fixture.runtimeId, "pylon-code"), + 'test ! -e "$stale"', ].join("\n"), ); expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); }); + it("does not sweep aged extraction scratch while its install lock is held", () => { + const fixture = createFixture(); + const result = runShell( + [ + "set -eu", + `runtime_parent=${sh(fixture.runtimeParent)}`, + 'mkdir -p "$runtime_parent"', + `held_scratch="$runtime_parent/.${fixture.runtimeId}.tmp.test"`, + 'mkdir -p "$held_scratch"', + 'touch -d "180 minutes ago" "$held_scratch"', + // The writer signals only after flock has acquired the lock. -F and + // exec keep $! as the exact lock-owning sleep process for cleanup. + 'lock_ready="$runtime_parent/lock-ready.pipe"', + 'mkfifo "$lock_ready"', + `flock -x -F "$runtime_parent/.${fixture.runtimeId}.install.lock" sh -c 'printf "ready\\n" > "$1"; exec sleep 30' sh "$lock_ready" >/dev/null 2>&1 &`, + "lock_pid=$!", + 'trap \'kill "$lock_pid" 2>/dev/null || true; wait "$lock_pid" 2>/dev/null || true\' EXIT', + 'locked=$(timeout 5 cat "$lock_ready")', + 'test "$locked" = ready', + 'rm "$lock_ready"', + `HOME=${sh(`${fixture.work}/home`)}`, + "export HOME", + buildWslRuntimePruneScript(fixture.runtimeId, "pylon-code"), + 'test -d "$held_scratch"', + "kill $lock_pid", + "wait $lock_pid 2>/dev/null || true", + "trap - EXIT", + buildWslRuntimePruneScript(fixture.runtimeId, "pylon-code"), + 'test ! -e "$held_scratch"', + ].join("\n"), + ); + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + }); + it("prunes old and markerless caches without touching retained, active, locked, or unrelated roots", () => { const result = runShell( [ diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 3adf7d2498..f9c18a01ec 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -22,7 +22,9 @@ const WSLPATH_TIMEOUT = Duration.seconds(10); const PROBE_TIMEOUT = Duration.seconds(10); const TOOLCHAIN_TIMEOUT = Duration.seconds(10); const BUILD_TIMEOUT = Duration.minutes(5); -const RUNTIME_INSTALL_TIMEOUT = Duration.minutes(2); +// A cold extraction reads the archive from /mnt/c while holding an install lock. +// Slow disks and antivirus scans can take longer than two minutes. +const RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10); const RUNTIME_PRUNE_TIMEOUT = Duration.seconds(30); const RUNTIME_INVALIDATE_TIMEOUT = Duration.seconds(15); const USER_HOME_TIMEOUT = Duration.seconds(5); @@ -261,6 +263,7 @@ const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''") // that install wrote. const WSL_RUNTIME_READY_MARKER = ".t3code-wsl-runtime-ready"; const WSL_RUNTIME_SELECTED_MARKER = ".t3code-wsl-runtime-selected"; +const WSL_RUNTIME_PROBE_FAILURE_COOLDOWN_MINUTES = 10; // Pylon's own runtime home inside the distro, per channel — not `~/.t3`, which // belongs to T3 Code. This is a correctness boundary rather than branding: // `pruneRuntimes` deletes every `sha256-*` sibling it does not recognise as @@ -330,9 +333,18 @@ export const buildWslRuntimeInstallScript = ( "}", 'mkdir -p "$runtime_parent"', `runtime_lock="$runtime_parent/.${safeRuntimeId}.install.lock"`, + `probe_failure_marker="$runtime_parent/.${safeRuntimeId}.probe-failed"`, "trap 'exit 1' HUP INT TERM", 'exec 9> "$runtime_lock"', "flock -x 9", + // A staged binary that failed a real node-pty load while the mounted tree + // worked is unlikely to be repaired by reinstalling identical archive + // bytes on every desktop launch. Retry after a bounded cooling period. + `if [ -f "$probe_failure_marker" ] && find "$probe_failure_marker" -maxdepth 0 -mmin -${WSL_RUNTIME_PROBE_FAILURE_COOLDOWN_MINUTES} -print -quit | grep -q .; then`, + " printf 'The staged WSL runtime failed its native probe recently; using the mounted tree until retry.\\n' >&2", + " exit 2", + "fi", + 'rm -f -- "$probe_failure_marker"', "if runtime_is_ready; then", ` touch "$runtime_root/${WSL_RUNTIME_SELECTED_MARKER}"`, ` printf 'runtimeRoot:%s\\n' "$runtime_root"`, @@ -436,13 +448,16 @@ export const buildWslRuntimePruneScript = ( 'prune_lock="$runtime_parent/.prune.lock"', 'exec 8> "$prune_lock"', "flock -x 8", - // Without a way to see the distro's processes we cannot tell which caches - // are load-bearing, and the retention rules below are not safe on their own. - "[ -d /proc/1 ] || exit 0", + // Old extraction scratch can be swept without process info only after + // checking its stable per-runtime install lock. A timed-out host call does + // not prove that WSL's tar process has exited. + "have_process_info=0", + "[ -d /proc/1 ] && have_process_info=1", "runtime_in_use() {", ' grep -qF -- "$1/" /proc/[0-9]*/cmdline 2>/dev/null', "}", 'previous_runtime=""', + 'if [ "$have_process_info" = 1 ]; then', 'for candidate in "$runtime_parent"/sha256-*; do', ' [ -d "$candidate" ] || continue', ' [ "$candidate" != "$current_runtime" ] || continue', @@ -470,14 +485,38 @@ export const buildWslRuntimePruneScript = ( " continue", " fi", ' rm -rf -- "$candidate"', + // Keep the per-runtime flock pathname. Removing it after unlocking can + // split concurrent installers across old and new inodes. These tiny files + // are stable coordination state, even after their cache tree is pruned. " flock -u 9", "done", + "fi", // Interrupted installs use dot-prefixed names under this dedicated parent. - 'for scratch in "$runtime_parent"/.*.tmp.* "$runtime_parent"/.*.stale.*; do', + 'for scratch in "$runtime_parent"/.*.tmp.*; do', ' [ -d "$scratch" ] || continue', ` find "$scratch" -maxdepth 0 -mmin +${String(ORPHANED_RUNTIME_SCRATCH_MAX_AGE_MINUTES)} -print -quit | grep -q . || continue`, + " scratch_name=${scratch##*/}", + " scratch_runtime=${scratch_name#.}", + " scratch_runtime=${scratch_runtime%%.tmp.*}", + ' exec 9> "$runtime_parent/.${scratch_runtime}.install.lock"', + " flock -n 9 || continue", ' rm -rf -- "$scratch"', + " flock -u 9", "done", + 'if [ "$have_process_info" = 1 ]; then', + ' for scratch in "$runtime_parent"/.*.stale.*; do', + ' [ -d "$scratch" ] || continue', + ` find "$scratch" -maxdepth 0 -mmin +${String(ORPHANED_RUNTIME_SCRATCH_MAX_AGE_MINUTES)} -print -quit | grep -q . || continue`, + " stale_name=${scratch##*/}", + " original_name=${stale_name#.}", + " original_name=${original_name%%.stale.*}", + ' ! runtime_in_use "$runtime_parent/$original_name" || continue', + ' exec 9> "$runtime_parent/.${original_name}.install.lock"', + " flock -n 9 || continue", + ' rm -rf -- "$scratch"', + " flock -u 9", + " done", + "fi", ].join("\n"); }; @@ -495,7 +534,13 @@ export const buildWslRuntimeInvalidateScript = ( const safeRuntimeId = sanitizeWslRuntimeId(runtimeId); return [ "set -eu", - `rm -f "$HOME/.${runtimeHomeDirName}/wsl-runtime/${safeRuntimeId}/${WSL_RUNTIME_READY_MARKER}"`, + `runtime_parent=${wslRuntimeParent(runtimeHomeDirName)}`, + `[ -d "$runtime_parent" ] || exit 0`, + `runtime_lock="$runtime_parent/.${safeRuntimeId}.install.lock"`, + 'exec 9> "$runtime_lock"', + "flock -x 9", + `rm -f "$runtime_parent/${safeRuntimeId}/${WSL_RUNTIME_READY_MARKER}"`, + `touch "$runtime_parent/.${safeRuntimeId}.probe-failed"`, ].join("\n"); }; diff --git a/apps/marketing/package.json b/apps/marketing/package.json index 499da7f592..a9a04b70b7 100644 --- a/apps/marketing/package.json +++ b/apps/marketing/package.json @@ -12,11 +12,11 @@ }, "dependencies": { "@t3tools/shared": "workspace:*", - "astro": "^7.0.3", - "sharp": "0.34.5" + "astro": "^7.2.8", + "sharp": "0.35.4" }, "devDependencies": { - "@astrojs/check": "^0.9.7", + "@astrojs/check": "^0.9.10", "@vercel/config": "^0.3.0", "typescript": "~6.0.3" } diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx index ed8c14a03d..531125fa18 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx @@ -98,7 +98,7 @@ function resolveHeadingFontSize(textStyle: NativeMarkdownTextStyle, headingLevel } const scale = textStyle.fontSize / DEFAULT_BODY_FONT_SIZE; - return Math.max(12, Math.round(DEFAULT_HEADING_FONT_SIZES[index] * scale)); + return Math.max(12, Math.round((DEFAULT_HEADING_FONT_SIZES[index] ?? 15) * scale)); } function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle { diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 262cc8a8a7..9ae30968b2 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -137,6 +137,7 @@ private enum TerminalInputSequence { private final class TerminalInputField: UITextField { var onDeleteBackward: (() -> Void)? var onInsert: ((String) -> Void)? + private let deletionRouter = TerminalDeletionRouter() private static let hardwareKeyCommands = TerminalHardwareKeyEncoder.makeKeyCommands( action: #selector(handleHardwareKeyCommand(_:)) @@ -147,8 +148,14 @@ private final class TerminalInputField: UITextField { } override func deleteBackward() { - onDeleteBackward?() - super.deleteBackward() + deletionRouter.deleteBackward( + emit: { [weak self] in self?.onDeleteBackward?() }, + performSystemDelete: { super.deleteBackward() } + ) + } + + func reportEmptyReplacement() { + deletionRouter.emptyReplacement { [weak self] in self?.onDeleteBackward?() } } @objc @@ -410,6 +417,11 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { return false } + // Some software keyboards report deletion here without calling deleteBackward. + // The field suppresses a duplicate if UIKit also calls this delegate from super.deleteBackward. + if textField === inputField { + inputField.reportEmptyReplacement() + } return false } diff --git a/apps/mobile/modules/t3-terminal/ios/TerminalDeletionRouter.swift b/apps/mobile/modules/t3-terminal/ios/TerminalDeletionRouter.swift new file mode 100644 index 0000000000..5b766faa4b --- /dev/null +++ b/apps/mobile/modules/t3-terminal/ios/TerminalDeletionRouter.swift @@ -0,0 +1,17 @@ +/// Sends one terminal DEL for either UITextField deletion callback path. +/// `super.deleteBackward()` may synchronously call the editing delegate. +final class TerminalDeletionRouter { + private var insideDeleteBackward = false + + func deleteBackward(emit: () -> Void, performSystemDelete: () -> Void) { + insideDeleteBackward = true + defer { insideDeleteBackward = false } + emit() + performSystemDelete() + } + + func emptyReplacement(emit: () -> Void) { + guard !insideDeleteBackward else { return } + emit() + } +} diff --git a/apps/mobile/modules/t3-terminal/scripts/test-terminal-deletion.swift b/apps/mobile/modules/t3-terminal/scripts/test-terminal-deletion.swift new file mode 100644 index 0000000000..d5769906c9 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/scripts/test-terminal-deletion.swift @@ -0,0 +1,28 @@ +import Foundation + +@main +struct TerminalDeletionTest { + static func main() { + let router = TerminalDeletionRouter() + var sent = 0 + let emit = { sent += 1 } + + // Software keyboards may only send an empty replacement to the delegate. + router.emptyReplacement(emit: emit) + precondition(sent == 1) + + // UIKit may also invoke that delegate from the subclass's super call. + router.deleteBackward(emit: emit, performSystemDelete: { + router.emptyReplacement(emit: emit) + }) + precondition(sent == 2) + + // A hardware-key callback can arrive without the editing delegate. + router.deleteBackward(emit: emit, performSystemDelete: {}) + precondition(sent == 3) + + // Ownership resets after the call, so later software-keyboard deletes work. + router.emptyReplacement(emit: emit) + precondition(sent == 4) + } +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index df96bfa182..77ef8acae7 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -93,6 +93,7 @@ "expo-image": "~57.0.3", "expo-image-manipulator": "~57.0.17", "expo-image-picker": "~57.0.14", + "expo-keep-awake": "57.0.1", "expo-linking": "~57.0.8", "expo-network": "~57.0.1", "expo-notifications": "~57.0.15", @@ -131,7 +132,9 @@ "@pierre/trees": "1.0.0-beta.4", "@types/react": "~19.2.0", "@types/react-dom": "~19.2.3", + "@types/react-test-renderer": "19.1.0", "babel-preset-expo": "~57.0.9", + "react-test-renderer": "19.2.3", "tailwindcss": "^4.0.0", "typescript": "catalog:" }, diff --git a/apps/mobile/scripts/generate-uniwind-themes.mts b/apps/mobile/scripts/generate-uniwind-themes.mts index ec6da30651..e1faae4bbe 100644 --- a/apps/mobile/scripts/generate-uniwind-themes.mts +++ b/apps/mobile/scripts/generate-uniwind-themes.mts @@ -53,7 +53,7 @@ const color = (family: TailwindColorFamily, shade?: TailwindColorShade, opacity // These replace the remaining dark:* utility pairs. A registered palette theme is // neither literally `light` nor `dark`, so appearance-sensitive values must also be // represented as semantic variables for custom themes. -const ADAPTIVE_COLORS = { +const ADAPTIVE_COLORS: Readonly> = { "--color-adaptive-amber-100-500-a18": [color("amber", 100), color("amber", 500, 0.18)], "--color-adaptive-amber-50-950-a40": [color("amber", 50), color("amber", 950, 0.4)], "--color-adaptive-amber-200-900-a60": [color("amber", 200), color("amber", 900, 0.6)], @@ -155,9 +155,9 @@ export const customThemeNames = BUILT_IN_THEME_IDS.flatMap((themeId) => const adaptiveVariablesFor = (appearance: MobileThemeAppearance) => Object.fromEntries( - Object.entries(ADAPTIVE_COLORS).map(([name, values]) => [ + Object.entries(ADAPTIVE_COLORS).map(([name, [light, dark]]) => [ name, - values[appearance === "light" ? 0 : 1], + appearance === "light" ? light : dark, ]), ); diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 8095205a55..c33e6ca89c 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -24,6 +24,7 @@ import { OverlayPortalHost } from "./components/OverlayPortal"; import { useUniwindTheme } from "./lib/useUniwindTheme"; import { appBlurTargetRef } from "./lib/appBlurTarget"; import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; +import { shouldHandleAppLink } from "./lib/appLinking"; import "../global.css"; @@ -41,14 +42,7 @@ const appLinking = { // schemes `app.config.ts` registers, because iOS never delivers an // unregistered scheme to the app. prefixes: [Linking.createURL("/"), "pylon-code://", "pylon-code-dev://", "pylon-code-preview://"], - // The Expo dev client launches the app via - // ://expo-development-client/?url= — that URL addresses - // the launcher, not app navigation. Without this filter it falls through - // to the NotFound wildcard route on every dev launch. - // expo-sharing uses a private lifecycle URL only to wake the app. The - // persisted share inbox below owns navigation once the payload is durable. - filter: (url: string) => - !url.includes("expo-development-client") && !url.includes("://expo-sharing"), + filter: shouldHandleAppLink, }; const Navigation = createStaticNavigation(RootStack); diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 22c02e9957..9f2b81b6b7 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -10,12 +10,17 @@ import { createNativeStackScreen, type NativeStackNavigationOptions, } from "@react-navigation/native-stack"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, type ReactNode } from "react"; import { Platform, Pressable, ScrollView, StyleSheet, View } from "react-native"; import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/AppText"; import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; +import { + RenderErrorBoundary, + RenderFailureView, + type RenderFailureProps, +} from "./components/RenderErrorBoundary"; import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen"; @@ -503,7 +508,7 @@ function NotFoundScreen() { ); } -export const RootStack = createNativeStackNavigator({ +const RootStackConfig = createNativeStackNavigator({ initialRouteName: "Home", layout: RootStackLayout, screenOptions: { @@ -686,10 +691,15 @@ export const RootStack = createNativeStackNavigator({ // The whole new-task flow (choose project → draft → add project) shares // draft state via NewTaskFlowProvider. The expo-router era mounted it in // app/new/_layout.tsx; this layout wrapper is the native-stack equivalent. - layout: ({ children }) => ( - - {children} - + // A screen's layout replaces the navigator's screenLayout. + layout: ({ children, route }) => ( + + + + {children} + + + ), options: { gestureEnabled: true, @@ -711,6 +721,38 @@ export const RootStack = createNativeStackNavigator({ }), }, }); + +function GuardedScreenLayout(props: { + readonly children: ReactNode; + readonly route: { readonly name: string; readonly params?: object | undefined }; +}) { + return ( + ( + + )} + > + {props.children} + + ); +} + +function ScreenRenderFallback(props: RenderFailureProps & { readonly routeName: string }) { + const navigation = useNavigation(); + const exit = navigation.canGoBack() + ? { label: "Go back", onPress: () => navigation.goBack() } + : props.routeName === "Home" + ? { label: "Open settings", onPress: () => navigation.navigate("SettingsSheet") } + : { label: "Return home", onPress: () => navigation.dispatch(StackActions.replace("Home")) }; + + return ; +} + +export const RootStack = RootStackConfig.with(function GuardedRootStack({ Navigator }) { + return ; +}); + type RootStackType = typeof RootStack; const navigationPathConfig = { diff --git a/apps/mobile/src/components/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/AndroidAnchoredMenu.tsx index 1a4f11b8c7..79ec95d0a0 100644 --- a/apps/mobile/src/components/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/AndroidAnchoredMenu.tsx @@ -125,7 +125,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { return () => subscription.remove(); }, [anchor, close, submenuDepth]); - const parent = path.length > 0 ? path[path.length - 1] : null; + const parent = path[path.length - 1] ?? null; const levelActions = (parent?.subactions ?? props.actions).filter( (action) => !(action.attributes?.hidden ?? false), ); diff --git a/apps/mobile/src/components/AppSymbol.ios.tsx b/apps/mobile/src/components/AppSymbol.ios.tsx index f1a28ed3f3..b5ac0e9b64 100644 --- a/apps/mobile/src/components/AppSymbol.ios.tsx +++ b/apps/mobile/src/components/AppSymbol.ios.tsx @@ -1,3 +1,4 @@ +import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest"; import { SymbolView as ExpoSymbolView, type SymbolViewProps } from "expo-symbols"; import { withUniwind } from "uniwind"; @@ -5,10 +6,24 @@ export type { SFSymbol } from "expo-symbols"; export type AppSymbolName = SymbolViewProps["name"]; /** - * Keep the iOS implementation isolated from the Android Tabler fallback so - * Metro does not initialize the icon package when iOS renders SF Symbols. + * Use SF Symbols on iOS except for pull requests, which have no matching + * native glyph. Import only that Tabler icon to keep the bundle small. */ function AppSymbolView(props: SymbolViewProps) { + const name = typeof props.name === "string" ? props.name : (props.name as { ios?: string })?.ios; + if (name === "arrow.triangle.pull") { + return ( + + ); + } + return ; } diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 2f65531fa6..b96c99bb72 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -7,6 +7,7 @@ import IconAlertTriangle from "@tabler/icons-react-native/IconAlertTriangle"; import IconApps from "@tabler/icons-react-native/IconApps"; import IconArchive from "@tabler/icons-react-native/IconArchive"; import IconArrowBackUp from "@tabler/icons-react-native/IconArrowBackUp"; +import IconArrowDown from "@tabler/icons-react-native/IconArrowDown"; import IconArrowDownCircle from "@tabler/icons-react-native/IconArrowDownCircle"; import IconArrowRightCircle from "@tabler/icons-react-native/IconArrowRightCircle"; import IconArrowUp from "@tabler/icons-react-native/IconArrowUp"; @@ -75,6 +76,8 @@ import IconServer from "@tabler/icons-react-native/IconServer"; import IconSettings from "@tabler/icons-react-native/IconSettings"; import IconSparkles from "@tabler/icons-react-native/IconSparkles"; import IconStack2 from "@tabler/icons-react-native/IconStack2"; +import IconStar from "@tabler/icons-react-native/IconStar"; +import IconStarFilled from "@tabler/icons-react-native/IconStarFilled"; import IconStethoscope from "@tabler/icons-react-native/IconStethoscope"; import IconSun from "@tabler/icons-react-native/IconSun"; import IconLayoutSidebarRight from "@tabler/icons-react-native/IconLayoutSidebarRight"; @@ -96,6 +99,7 @@ import { withUniwind } from "uniwind"; const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "arrow.branch": IconGitBranch, "arrow.clockwise": IconRefresh, + "arrow.down": IconArrowDown, "arrow.down.circle": IconArrowDownCircle, "arrow.right.circle": IconArrowRightCircle, "arrow.triangle.branch": IconGitBranch, @@ -170,6 +174,8 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "square.and.pencil": IconEdit, "square.grid.2x2": IconApps, "square.split.2x1": IconLayoutColumns, + star: IconStar, + "star.fill": IconStarFilled, "sun.max": IconSun, "stop.fill": IconPlayerStopFilled, terminal: IconTerminal2, diff --git a/apps/mobile/src/components/ConfirmDialogHost.tsx b/apps/mobile/src/components/ConfirmDialogHost.tsx index 521c5e36c3..d7db39d39a 100644 --- a/apps/mobile/src/components/ConfirmDialogHost.tsx +++ b/apps/mobile/src/components/ConfirmDialogHost.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import { Modal, Pressable, View } from "react-native"; +import { Modal, Pressable, TextInput, View } from "react-native"; import { cn } from "../lib/cn"; import { AppText } from "./AppText"; @@ -14,7 +14,20 @@ export type ConfirmDialogRequest = { readonly onCancel?: () => void; }; -let presentRequest: ((request: ConfirmDialogRequest) => void) | null = null; +export type TextInputDialogRequest = { + readonly title: string; + readonly initialValue: string; + readonly cancelText?: string; + readonly confirmText: string; + readonly onConfirm: (value: string) => void; + readonly onCancel?: () => void; +}; + +type DialogRequest = + | { readonly kind: "confirm"; readonly request: ConfirmDialogRequest } + | { readonly kind: "text-input"; readonly request: TextInputDialogRequest }; + +let presentRequest: ((request: DialogRequest) => void) | null = null; /** * Imperative confirm dialog, Alert.alert-shaped. Native iOS alerts already @@ -23,7 +36,11 @@ let presentRequest: ((request: ConfirmDialogRequest) => void) | null = null; * once. Requires ConfirmDialogHost to be mounted at the app root. */ export function showConfirmDialog(request: ConfirmDialogRequest): void { - presentRequest?.(request); + presentRequest?.({ kind: "confirm", request }); +} + +export function showTextInputDialog(request: TextInputDialogRequest): void { + presentRequest?.({ kind: "text-input", request }); } /** @@ -33,42 +50,64 @@ export function showConfirmDialog(request: ConfirmDialogRequest): void { * button color and a dimmer message than the title. */ export function ConfirmDialogHost() { - const [request, setRequest] = useState(null); + const [presented, setPresented] = useState(null); + const [inputValue, setInputValue] = useState(""); useEffect(() => { - presentRequest = setRequest; + presentRequest = (request) => { + setInputValue(request.kind === "text-input" ? request.request.initialValue : ""); + setPresented(request); + }; return () => { presentRequest = null; }; }, []); const handleCancel = useCallback(() => { - request?.onCancel?.(); - setRequest(null); - }, [request]); + presented?.request.onCancel?.(); + setPresented(null); + }, [presented]); const handleConfirm = useCallback(() => { - request?.onConfirm(); - setRequest(null); - }, [request]); + if (presented?.kind === "confirm") { + presented.request.onConfirm(); + } else if (presented?.kind === "text-input") { + presented.request.onConfirm(inputValue); + } + setPresented(null); + }, [inputValue, presented]); + + const confirmDisabled = presented?.kind === "text-input" && inputValue.trim().length === 0; return ( - {request === null ? null : ( + {presented === null ? null : ( - {request.title} - {request.message === undefined ? null : ( + {presented.request.title} + {presented.kind === "confirm" && presented.request.message !== undefined ? ( - {request.message} + {presented.request.message} - )} + ) : null} + {presented.kind === "text-input" ? ( + + ) : null} - {request.cancelText ?? "Cancel"} + {presented.request.cancelText ?? "Cancel"} - {request.confirmText} + {presented.request.confirmText} diff --git a/apps/mobile/src/components/RenderErrorBoundary.test.tsx b/apps/mobile/src/components/RenderErrorBoundary.test.tsx new file mode 100644 index 0000000000..a2e17e4655 --- /dev/null +++ b/apps/mobile/src/components/RenderErrorBoundary.test.tsx @@ -0,0 +1,94 @@ +import { useEffect } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { Pressable, View } from "react-native"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { RenderErrorBoundary } from "./RenderErrorBoundary"; + +vi.mock("react-native", () => ({ + Pressable: "Pressable", + ScrollView: "ScrollView", + View: "View", +})); +vi.mock("./AppText", () => ({ AppText: "Text" })); +vi.mock("../lib/copyTextWithHaptic", () => ({ copyTextWithHaptic: vi.fn() })); + +// React 19's test renderer requires an explicit act environment for updates. +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +describe("RenderErrorBoundary", () => { + it("contains a failed child and retries without remounting its sibling", async () => { + let throwOnRender = true; + let siblingMounts = 0; + function Feed() { + if (throwOnRender) throw new Error("feed failed"); + return feed ready; + } + function Composer() { + useEffect(() => { + siblingMounts += 1; + }, []); + return draft retained; + } + let renderer: ReactTestRenderer | undefined; + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + await act(async () => { + renderer = create( + <> + Try again} + > + + + + , + ); + }); + expect(renderer?.root.findAllByType(Pressable)).toHaveLength(1); + expect(renderer?.root.findAllByType(View)).toHaveLength(1); + throwOnRender = false; + await act(async () => { + renderer?.root.findByType(Pressable).props.onPress(); + }); + expect(renderer?.root.findAllByType(Pressable)).toHaveLength(0); + expect(renderer?.root.findAllByType(View)).toHaveLength(2); + expect(siblingMounts).toBe(1); + } finally { + errorLog.mockRestore(); + await act(async () => renderer?.unmount()); + } + }); + + it("clears the failure when route identity changes", async () => { + let throwOnRender = true; + function Screen() { + if (throwOnRender) throw new Error("route failed"); + return route ready; + } + const render = (route: string) => ( + Unavailable} + > + + + ); + let renderer: ReactTestRenderer | undefined; + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + await act(async () => { + renderer = create(render("thread-a")); + }); + expect(renderer?.root.findAllByType(Pressable)).toHaveLength(1); + throwOnRender = false; + await act(async () => renderer?.update(render("thread-a"))); + expect(renderer?.root.findAllByType(Pressable)).toHaveLength(1); + await act(async () => renderer?.update(render("thread-b"))); + expect(renderer?.root.findAllByType(View)).toHaveLength(1); + } finally { + errorLog.mockRestore(); + await act(async () => renderer?.unmount()); + } + }); +}); diff --git a/apps/mobile/src/components/RenderErrorBoundary.tsx b/apps/mobile/src/components/RenderErrorBoundary.tsx new file mode 100644 index 0000000000..e670f3b5d7 --- /dev/null +++ b/apps/mobile/src/components/RenderErrorBoundary.tsx @@ -0,0 +1,148 @@ +import { Component, type ReactNode } from "react"; +import { Pressable, ScrollView, View } from "react-native"; + +import { AppText as Text } from "./AppText"; +import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; + +interface RenderErrorBoundaryProps { + readonly children: ReactNode; + readonly resetKeys?: ReadonlyArray; + readonly renderFallback?: (props: RenderFailureProps) => ReactNode; +} + +interface RenderErrorBoundaryState { + readonly failed: boolean; + readonly error: unknown; + readonly componentStack?: string; + readonly resetKeys?: ReadonlyArray; +} + +export interface RenderFailureProps { + readonly details: string; + readonly retry: () => void; +} + +function errorDetails(error: unknown, componentStack?: string): string { + let description: string; + try { + description = error instanceof Error ? (error.stack ?? error.message) : String(error); + } catch { + description = "Unknown render error"; + } + return componentStack ? `${description}\nComponent stack:\n${componentStack}` : description; +} + +/** A failed subtree remounts on retry or when its identifying inputs change. */ +export class RenderErrorBoundary extends Component< + RenderErrorBoundaryProps, + RenderErrorBoundaryState +> { + override state: RenderErrorBoundaryState = { + failed: false, + error: null, + resetKeys: this.props.resetKeys, + }; + + static getDerivedStateFromProps( + { resetKeys }: RenderErrorBoundaryProps, + state: RenderErrorBoundaryState, + ): Partial | null { + if ( + resetKeys?.length !== state.resetKeys?.length || + resetKeys?.some((key, index) => !Object.is(key, state.resetKeys?.[index])) + ) { + return { failed: false, error: null, componentStack: undefined, resetKeys }; + } + return null; + } + + static getDerivedStateFromError(error: unknown): Partial { + return { failed: true, error }; + } + + override componentDidCatch(_error: unknown, info: { componentStack?: string }) { + this.setState({ componentStack: info.componentStack }); + } + + private readonly retry = () => { + this.setState({ failed: false, error: null, componentStack: undefined }); + }; + + override render() { + if (!this.state.failed) return this.props.children; + const fallback = + this.props.renderFallback ?? + ((props: RenderFailureProps) => ); + return fallback({ + details: errorDetails(this.state.error, this.state.componentStack), + retry: this.retry, + }); + } +} + +export function RenderFailureView( + props: RenderFailureProps & { + readonly title?: string; + readonly bottomInset?: number; + readonly exit?: { readonly label: string; readonly onPress: () => void }; + }, +) { + const title = props.title ?? "This screen couldn't be displayed"; + return ( + + + {title} + + + Try again. If it keeps happening, copy the details for a bug report. + + + {props.details.split("\n", 1)[0]?.slice(0, 300)} + + + + copyTextWithHaptic(props.details, { target: "error details" })} + /> + {props.exit ? ( + + ) : null} + + + ); +} + +function RenderFailureAction(props: { + readonly label: string; + readonly onPress: () => void; + readonly primary?: boolean; +}) { + return ( + + + {props.label} + + + ); +} diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 97f9c5b69d..6845600b6d 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -634,7 +634,8 @@ export function ArchivedThreadsScreen(props: { }, [isFiltered, isInitialLoad]); return ( - + // Preserve this list container during iOS form-sheet search-bar resizing. + {statusText} {errorTraceId ? ( - <> - {" Trace ID: "} - { - event.stopPropagation(); - copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" }); - }} - onPress={(event) => { - event.stopPropagation(); - }} - > - {errorTraceId} - - + ) : null} {errorCanExpand ? ( diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index f1791096f1..fbb462b46f 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -1,3 +1,4 @@ +import { ConnectionTraceId, connectionTraceAccessibilityAction } from "./ConnectionTraceId"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { SymbolView } from "../../components/AppSymbol"; @@ -19,7 +20,6 @@ import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanim import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { ThemedSwitch } from "../../components/ThemedSwitch"; import { cn } from "../../lib/cn"; -import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types"; import { serverEnvironment } from "../../state/server"; import { ConnectionStatusDot } from "./ConnectionStatusDot"; @@ -201,6 +201,8 @@ export function ConnectionEnvironmentRow(props: { {statusLabel} {statusTraceId ? ( - <> - {" Trace ID: "} - { - event.stopPropagation(); - copyTextWithHaptic(statusTraceId, { target: "connection-trace-id" }); - }} - onPress={(event) => { - event.stopPropagation(); - }} - > - {statusTraceId} - - + ) : null} ) : null} diff --git a/apps/mobile/src/features/connection/ConnectionTraceId.test.tsx b/apps/mobile/src/features/connection/ConnectionTraceId.test.tsx new file mode 100644 index 0000000000..031b39e031 --- /dev/null +++ b/apps/mobile/src/features/connection/ConnectionTraceId.test.tsx @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("../../components/AppText", () => ({ AppText: "Text" })); +vi.mock("../../lib/copyTextWithHaptic", () => ({ copyTextWithHaptic: vi.fn() })); + +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { ConnectionTraceId, connectionTraceAccessibilityAction } from "./ConnectionTraceId"; + +describe("ConnectionTraceId", () => { + it("keeps row taps for disclosure while exposing a named accessibility copy action", () => { + const control = ConnectionTraceId({ traceId: "trace-123", activation: "longPress" }); + const stopPropagation = vi.fn(); + + expect(control.props.accessibilityLabel).toBe("Copy trace ID trace-123"); + expect(control.props.accessibilityHint).toBe("Long press to copy the trace ID"); + expect(control.props.accessibilityActions).toEqual([ + { name: "activate", label: "Copy trace ID" }, + ]); + control.props.onPress({ stopPropagation }); + expect(copyTextWithHaptic).not.toHaveBeenCalled(); + control.props.onAccessibilityAction({ + stopPropagation, + nativeEvent: { actionName: "activate" }, + }); + expect(copyTextWithHaptic).toHaveBeenCalledWith("trace-123", { + target: "connection-trace-id", + }); + expect(stopPropagation).toHaveBeenCalledTimes(2); + }); + + it("copies on press in a notice and ignores unsupported accessibility actions", () => { + vi.mocked(copyTextWithHaptic).mockClear(); + const control = ConnectionTraceId({ traceId: "trace-456" }); + const stopPropagation = vi.fn(); + control.props.onAccessibilityAction({ + stopPropagation, + nativeEvent: { actionName: "dismiss" }, + }); + expect(copyTextWithHaptic).not.toHaveBeenCalled(); + control.props.onPress({ stopPropagation }); + expect(copyTextWithHaptic).toHaveBeenCalledWith("trace-456", { + target: "connection-trace-id", + }); + }); + + it("exposes a named copy action on a disclosure parent without taking over activate", () => { + vi.mocked(copyTextWithHaptic).mockClear(); + const stopPropagation = vi.fn(); + const action = connectionTraceAccessibilityAction("trace-789"); + expect(action.accessibilityActions).toEqual([ + { name: "copy-trace-id", label: "Copy trace ID" }, + ]); + action.onAccessibilityAction?.({ + stopPropagation, + nativeEvent: { actionName: "activate" }, + }); + expect(copyTextWithHaptic).not.toHaveBeenCalled(); + action.onAccessibilityAction?.({ + stopPropagation, + nativeEvent: { actionName: "copy-trace-id" }, + }); + expect(copyTextWithHaptic).toHaveBeenCalledWith("trace-789", { + target: "connection-trace-id", + }); + expect(stopPropagation).toHaveBeenCalledOnce(); + + const nested = ConnectionTraceId({ + traceId: "trace-789", + activation: "longPress", + parentOwnsAccessibility: true, + }); + expect(nested.props.accessibilityRole).toBeUndefined(); + expect(nested.props.accessibilityActions).toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/features/connection/ConnectionTraceId.tsx b/apps/mobile/src/features/connection/ConnectionTraceId.tsx new file mode 100644 index 0000000000..725c9205f0 --- /dev/null +++ b/apps/mobile/src/features/connection/ConnectionTraceId.tsx @@ -0,0 +1,76 @@ +import { AppText as Text } from "../../components/AppText"; +import { cn } from "../../lib/cn"; +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; + +export function connectionTraceAccessibilityAction(traceId: string | null) { + return traceId + ? { + accessibilityActions: [{ name: "copy-trace-id", label: "Copy trace ID" }], + onAccessibilityAction: (event: { + readonly nativeEvent: { readonly actionName: string }; + stopPropagation(): void; + }) => { + if (event.nativeEvent.actionName !== "copy-trace-id") return; + event.stopPropagation(); + copyTextWithHaptic(traceId, { target: "connection-trace-id" }); + }, + } + : {}; +} + +/** Inline trace control; disclosure rows reserve ordinary taps for their own navigation. */ +export function ConnectionTraceId({ + traceId, + tone = "muted", + activation = "press", + parentOwnsAccessibility = false, +}: { + readonly traceId: string; + readonly tone?: "muted" | "danger"; + readonly activation?: "press" | "longPress"; + readonly parentOwnsAccessibility?: boolean; +}) { + const copy = () => copyTextWithHaptic(traceId, { target: "connection-trace-id" }); + return ( + { + event.stopPropagation(); + if (event.nativeEvent.actionName === "activate") copy(); + } + } + className={cn( + "underline decoration-dotted", + tone === "danger" ? "text-danger-foreground" : "text-foreground-muted", + )} + onLongPress={ + activation === "longPress" + ? (event) => { + event.stopPropagation(); + copy(); + } + : undefined + } + onPress={(event) => { + event.stopPropagation(); + if (activation === "press") copy(); + }} + > + {` Trace ID: ${traceId}`} + + ); +} diff --git a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx index ca799a0c81..f26d6d8721 100644 --- a/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx +++ b/apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx @@ -1,3 +1,4 @@ +import { ConnectionTraceId } from "./ConnectionTraceId"; import { type EnvironmentConnectionPhase, type EnvironmentConnectionPresentation, @@ -6,7 +7,6 @@ import { SymbolView } from "../../components/AppSymbol"; import { ActivityIndicator, Pressable, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; -import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; function noticeTitle(phase: EnvironmentConnectionPhase, environmentLabel: string): string { switch (phase) { @@ -80,24 +80,8 @@ export function EnvironmentConnectionNotice(props: { {noticeDetail(props.connection.phase, props.resourceName, props.connection.error)} - {props.connection.traceId ? ( - <> - {" Trace ID: "} - - copyTextWithHaptic(props.connection.traceId!, { - target: "connection-trace-id", - }) - } - > - {props.connection.traceId} - - - ) : null} + {props.connection.traceId ? : null} {props.connection.phase !== "offline" && props.connection.phase !== "unsupported" ? ( Promise; + readonly onRenameThread: (thread: EnvironmentThreadShell) => void; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; @@ -550,6 +551,10 @@ export function HomeScreen(props: HomeScreenProps) { }, [props.onRegenerateThreadTitle], ); + const handleRenameThread = useCallback( + (thread: EnvironmentThreadShell) => props.onRenameThread(thread), + [props.onRenameThread], + ); const handleDeleteThread = props.onDeleteThread; const handleUnsettleThread = props.onUnsettleThread; // The settled tail renders in pages; expansion resets when the filter @@ -896,6 +901,7 @@ export function HomeScreen(props: HomeScreenProps) { onSelectThread={props.onSelectThread} onDeleteThread={handleDeleteThread} onArchiveThread={props.onArchiveThread} + onRenameThread={handleRenameThread} onRegenerateThreadTitle={handleRegenerateThreadTitle} titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} @@ -932,6 +938,7 @@ export function HomeScreen(props: HomeScreenProps) { handleMoveThread, handlePinThread, handleRegenerateThreadTitle, + handleRenameThread, handleSettleThread, handleSnoozeThread, handleUnpinThread, @@ -1066,6 +1073,7 @@ export function HomeScreen(props: HomeScreenProps) { searchQuery={props.searchQuery} onArchiveThread={props.onArchiveThread} onDeleteThread={props.onDeleteThread} + onRenameThread={handleRenameThread} onRegenerateThreadTitle={handleRegenerateThreadTitle} titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} onSelectThread={props.onSelectThread} @@ -1090,6 +1098,7 @@ export function HomeScreen(props: HomeScreenProps) { handleSwipeableClose, handleSwipeableWillOpen, handleRegenerateThreadTitle, + handleRenameThread, machineByEnvironmentId, projectCwdByKey, queuedThreadKeys, diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index 72e45c9bbb..9b59911088 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -4,10 +4,10 @@ import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/threa import * as Cause from "effect/Cause"; import * as Haptics from "expo-haptics"; import { useCallback, useRef } from "react"; -import { Alert } from "react-native"; +import { Alert, Platform } from "react-native"; import { withThreadDismissal } from "./thread-dismissal"; -import { showConfirmDialog } from "../../components/ConfirmDialogHost"; +import { showConfirmDialog, showTextInputDialog } from "../../components/ConfirmDialogHost"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort"; @@ -27,6 +27,7 @@ import { threadDropLifecycle, } from "../threads/threadOrder"; import { getThreadListV2OrderedSection } from "../threads/threadListV2"; +import { resolveThreadTitleRename } from "../threads/thread-title-rename"; /** Version skew: never send settle/unsettle to a server that predates them (capability defaults false on decode for older servers). */ @@ -240,6 +241,7 @@ export function useThreadListActions(): { thread: EnvironmentThreadShell, direction: ThreadMoveDestination, ) => Promise; + readonly renameThread: (thread: EnvironmentThreadShell) => void; readonly regenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise; } { const executeAction = useThreadActionExecutor(); @@ -474,6 +476,50 @@ export function useThreadListActions(): { }, [updateThreadMetadata], ); + const renameThread = useCallback( + (thread: EnvironmentThreadShell) => { + const commit = (title: string) => { + const resolution = resolveThreadTitleRename({ title, originalTitle: thread.title }); + if (resolution.action === "reject-empty") { + Alert.alert("Could not rename thread", "Thread title cannot be empty."); + return; + } + if (resolution.action === "noop") return; + selectionHaptic(); + void updateThreadMetadata({ + environmentId: thread.environmentId, + input: { threadId: thread.id, title: resolution.title }, + }).then((result) => { + if (result._tag === "Success") return; + const error = Cause.squash(result.cause); + Alert.alert( + "Could not rename thread", + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The thread could not be renamed.", + ); + }); + }; + + if (Platform.OS === "ios") { + Alert.prompt( + "Rename thread", + undefined, + (title) => commit(title ?? ""), + "plain-text", + thread.title, + ); + return; + } + showTextInputDialog({ + title: "Rename thread", + initialValue: thread.title, + confirmText: "Rename", + onConfirm: commit, + }); + }, + [updateThreadMetadata], + ); // Plan against the complete section so filtering does not change a move. const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPin, { @@ -653,6 +699,7 @@ export function useThreadListActions(): { pinThread, unpinThread, moveThread, + renameThread, regenerateThreadTitle, }; } diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index c030e49c2b..93d8ad9a1f 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -54,6 +54,7 @@ import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; import { HomeListOptionsProvider } from "../home/home-list-options"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; +import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; import { WorkspaceContentWidthContext } from "./workspace-content-width"; @@ -561,21 +562,31 @@ function AdaptiveWorkspaceLayoutContent( style={sidebarAnimatedStyle} > - - - + ( + + )} + > + + + + ) : null} @@ -601,6 +612,7 @@ function AdaptiveWorkspaceLayoutContent( ; /** * When false the pane animates closed but keeps its content mounted for the @@ -139,10 +141,23 @@ export function WorkspaceInspectorPane(props: { style={inspectorStyle} > - {props.renderInspector?.()} + ( + + )} + > + + ) : null} ); } + +// The render callback must run inside the boundary's child, not while its +// parent constructs the boundary element. +function InspectorRenderer(props: { readonly render?: () => ReactNode }) { + return <>{props.render?.()}; +} diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index ed01fa8048..d89563570e 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -51,11 +51,15 @@ function opaqueNativeHexColor(color: string, background: string): string { if (!rgba || !backgroundHex) return background; const alpha = rgba[4] === undefined ? 1 : Math.min(1, Math.max(0, Number(rgba[4]))); - const channels = [1, 2, 3].map((index) => { - const foreground = Number(rgba[index]); - const behind = Number.parseInt(backgroundHex[index], 16); - return Math.round(foreground * alpha + behind * (1 - alpha)); - }); + const channels: number[] = []; + for (const index of [1, 2, 3]) { + const foregroundChannel = rgba[index]; + const backgroundChannel = backgroundHex[index]; + if (foregroundChannel === undefined || backgroundChannel === undefined) return background; + const foreground = Number(foregroundChannel); + const behind = Number.parseInt(backgroundChannel, 16); + channels.push(Math.round(foreground * alpha + behind * (1 - alpha))); + } return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; } @@ -367,6 +371,9 @@ function addNativeWordDiffRanges( for (let pairIndex = 0; pairIndex < pairedCount; pairIndex += 1) { const deletedRowIndex = deletedRowIndexes[pairIndex]; const addedRowIndex = addedRowIndexes[pairIndex]; + if (deletedRowIndex === undefined || addedRowIndex === undefined) { + continue; + } const deletedRow = nextRows[deletedRowIndex]; const addedRow = nextRows[addedRowIndex]; if (!deletedRow?.content || !addedRow?.content) { diff --git a/apps/mobile/src/features/review/reviewModel.test.ts b/apps/mobile/src/features/review/reviewModel.test.ts index 6a85108945..aadddfc9f0 100644 --- a/apps/mobile/src/features/review/reviewModel.test.ts +++ b/apps/mobile/src/features/review/reviewModel.test.ts @@ -85,7 +85,7 @@ describe("buildReviewSectionItems", () => { }, ]; - const loadedTurnId = getReviewSectionIdForCheckpoint(checkpoints[0]); + const loadedTurnId = getReviewSectionIdForCheckpoint(checkpoints[0]!); const items = buildReviewSectionItems({ checkpoints, gitSections, @@ -93,7 +93,7 @@ describe("buildReviewSectionItems", () => { [loadedTurnId]: "diff --git a/loaded.ts b/loaded.ts", }, loadingTurnIds: { - [getReviewSectionIdForCheckpoint(checkpoints[1])]: true, + [getReviewSectionIdForCheckpoint(checkpoints[1]!)]: true, }, loadingGitSections: false, }); diff --git a/apps/mobile/src/features/terminal/terminalTheme.ts b/apps/mobile/src/features/terminal/terminalTheme.ts index 64a54e8ea3..f7d96b6686 100644 --- a/apps/mobile/src/features/terminal/terminalTheme.ts +++ b/apps/mobile/src/features/terminal/terminalTheme.ts @@ -1,4 +1,8 @@ -import { BUILT_IN_THEMES, getThemeColorsForAppearance } from "@t3tools/shared/themePalettes"; +import { + BUILT_IN_THEMES, + T3_CHAT_THEME, + getThemeColorsForAppearance, +} from "@t3tools/shared/themePalettes"; import { getMobileThemeVariables, @@ -15,9 +19,29 @@ export interface TerminalTheme { readonly border: string; readonly cursorForeground: string; readonly cursorBackground: string; - readonly palette: readonly string[]; + /** The 16 ANSI colors, in order. A fixed tuple so indexed reads are never undefined. */ + readonly palette: TerminalPalette; } +type TerminalPalette = readonly [ + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, + string, +]; + const PIERRE_LIGHT_THEME: TerminalTheme = { // Pierre terminal palette with the app's shared screen background. background: "#f2f2f7", @@ -85,7 +109,7 @@ export function getMobileTerminalTheme( const base = getPierreTerminalTheme(scheme); if (themeId === "t3-code" || themeId === "material-you") return base; - const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0]; + const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? T3_CHAT_THEME; const palette = getThemeColorsForAppearance(theme, scheme) ?? theme.colors; const colors = getMobileThemeVariables(themeId, scheme); const background = themeColorToNativeColor(palette.terminalBackground); diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 3a6f6ed7a9..96d2cb87b8 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -7,8 +7,16 @@ import { } from "@react-navigation/native"; import { SymbolView } from "../../components/AppSymbol"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; -import { useEffect, useRef } from "react"; -import { ActivityIndicator, Alert, Platform, Pressable, ScrollView, View } from "react-native"; +import { useEffect, useRef, useState } from "react"; +import { + ActivityIndicator, + Alert, + Platform, + Pressable, + ScrollView, + TextInput, + View, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { cn } from "../../lib/cn"; @@ -22,6 +30,7 @@ import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { useIncomingShare } from "../sharing/IncomingShareProvider"; import { useNewTaskFlow } from "./new-task-flow-provider"; import { + filterProjectScopes, getProjectScopeAccessibilityLabel, getProjectScopeSelectionTarget, } from "./new-task-project-selection"; @@ -87,6 +96,7 @@ function deriveProjectEmptyState(catalogState: WorkspaceState): { export function NewTaskRouteScreen({ route }: StaticScreenProps) { const projects = useProjects(); + const [searchText, setSearchText] = useState(""); const { projectScopes, selectedEnvironmentId, setProject } = useNewTaskFlow(); const { state: catalogState } = useWorkspaceState(); const navigation = useNavigation(); @@ -107,6 +117,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps(null); const reservedDestinationProject = incomingShare?.destination ? (projects.find( @@ -230,9 +241,46 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps )} + + + + + {searchText.length > 0 ? ( + setSearchText("")} + > + + + ) : null} + + + )} + ) : visibleScopes.length === 0 ? ( + + + No matching projects + + + Try a different project name or workspace path. + + ) : ( - {projectScopes.map((scope, scopeIndex) => { + {visibleScopes.map((scope, scopeIndex) => { const hasMultipleProjects = scope.projects.length > 1; const selectionTarget = getProjectScopeSelectionTarget(scope, selectedEnvironmentId); return ( diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index ad5c6c4a52..9c37a20fe8 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -89,6 +89,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerAttachment } from "../../lib/composerImages"; +import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { editPendingThreadMessage } from "../../state/edit-pending-thread-message"; @@ -991,43 +992,55 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread : "absolute inset-0 bg-screen" } /> - + resetKeys={[props.threadCwd]} + renderFallback={(fallback) => ( + + )} + > + + ) : ( diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 2d5863aa41..0ac308c1a3 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -91,7 +91,7 @@ import { isPdfFile } from "../../lib/filePreview"; import { flattenThemeColor } from "../../lib/mobileTheme"; import { PresentationSource } from "../../components/NativePresentation"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import Animated, { FadeIn, LinearTransition, type SharedValue } from "react-native-reanimated"; +import Animated, { FadeIn, type SharedValue } from "react-native-reanimated"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { useFontFamily } from "../../lib/useFontFamily"; @@ -222,8 +222,6 @@ function formatMessageTime(input: string): string { // Fixed heights mirror renderFeedEntry's classNames and are only used while // text fits at the current font settings. Larger accessibility text is measured. const TURN_FOLD_HEIGHT = 42; // min-h-11 (38.5) + mb-1 (3.5), with the mobile 14px rem -const THREAD_FEED_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); -const THREAD_FEED_IMMEDIATE_TRANSITION = LinearTransition.duration(0); // Let neighboring rows move out of the new rows' space before showing their text. const THREAD_FEED_DISCLOSURE_ENTER_TRANSITION = FadeIn.delay( THREAD_DISCLOSURE_TRANSITION_MS, @@ -2887,17 +2885,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { entry.type === "message" ? `message:${entry.message.role}` : entry.type } getFixedItemSize={getFixedItemSize} - // Android can retain stale native row positions when layout transitions - // race the measurements arriving during sync, even with duration 0. - // Keep its rows on LegendList's non-animated positioning path. On iOS, - // keep a transition installed between disclosures so containers don't remount. - itemLayoutAnimation={ - Platform.OS === "android" - ? undefined - : disclosureToggleSettling - ? THREAD_FEED_LAYOUT_TRANSITION - : THREAD_FEED_IMMEDIATE_TRANSITION - } + // Virtualized rows must move with their measurements. Native layout + // transitions can retain stale positions during sync, even at duration 0. onItemSizeChanged={handleItemSizeChanged} // Measure rows well before they scroll into view so estimate→actual // corrections land offscreen instead of under the user's finger. diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 11130b5454..de2d53ce8d 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -171,6 +171,7 @@ function ThreadNavigationSidebarPane( pinThread, unpinThread, moveThread, + renameThread, regenerateThreadTitle, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); @@ -961,6 +962,7 @@ function ThreadNavigationSidebarPane( onSelectThread={handleSelectThread} onDeleteThread={confirmDeleteThread} onArchiveThread={archiveThread} + onRenameThread={renameThread} onRegenerateThreadTitle={regenerateThreadTitle} titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} settlementSupported={settlementEnvironmentIds.has(thread.environmentId)} @@ -1084,6 +1086,7 @@ function ThreadNavigationSidebarPane( fullSwipeWidth={props.width - 20} onArchiveThread={archiveThread} onDeleteThread={confirmDeleteThread} + onRenameThread={renameThread} onRegenerateThreadTitle={regenerateThreadTitle} titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)} onSelectThread={handleSelectThread} @@ -1128,6 +1131,7 @@ function ThreadNavigationSidebarPane( projectCwdByKey, projectTitleByProjectKey, regenerateThreadTitle, + renameThread, props.onNewThreadInProject, props.onNewThreadOnBranch, props.searchQuery, diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 729430f1f7..927514bc98 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -47,6 +47,7 @@ import { import { Alert, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceState } from "../../state/workspace"; +import { useEnvironmentShellState } from "../../state/shell"; import { restoredNewTaskDraftKey } from "../../state/new-task-draft-key"; import { clearPendingThreadCreationOutcome } from "../../state/pending-thread-creation"; import { @@ -113,6 +114,7 @@ import { ThreadInspectorContentStack, type ThreadInspectorMode, } from "./thread-inspector-content-stack"; +import { threadRouteIsHydrating, threadRouteRecovery } from "./thread-route-hydration"; interface ThreadInspectorSelection { readonly routeThreadIdentity: string | null; @@ -148,7 +150,11 @@ interface ThreadRouteScreenProps extends ThreadRouteScreenRouteProps { readonly renderInspector?: (headerInset: number) => ReactNode; } -function ThreadUnavailableScreen() { +/** Shows recovery only after the target route has reached a terminal unavailable state. */ +function ThreadUnavailableScreen(props: { + readonly actionLabel: string; + readonly onAction: () => void; +}) { return ( ); @@ -177,6 +185,9 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { const threadIdRaw = firstRouteParam(params.threadId); const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null; const routeEnvironmentRuntime = useRemoteEnvironmentRuntime(environmentId); + const routeEnvironmentShellState = useEnvironmentShellState(environmentId); + const { onReconnectEnvironment } = useRemoteConnections(); + const navigation = useNavigation(); const routeConnectionState = routeEnvironmentRuntime?.connectionState ?? (environmentId ? "available" : connectionState); const routeThreadKey = @@ -202,16 +213,52 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { return ; } - const stillHydrating = - workspaceState.isLoadingConnections || - routeConnectionState === "connecting" || - routeConnectionState === "reconnecting"; + const stillHydrating = threadRouteIsHydrating({ + isLoadingConnections: workspaceState.isLoadingConnections, + connectionState: routeConnectionState, + shellStatus: routeEnvironmentShellState.status, + shellHasError: Option.isSome(routeEnvironmentShellState.error), + detailStatus: selectedThreadDetailState.status, + detailHasError: Option.isSome(selectedThreadDetailState.error), + }); if (stillHydrating) { return ; } - return ; + const recovery = threadRouteRecovery({ + hasEnvironmentRuntime: routeEnvironmentRuntime !== null, + connectionState: routeConnectionState, + shellStatus: routeEnvironmentShellState.status, + detailStatus: selectedThreadDetailState.status, + detailHasError: Option.isSome(selectedThreadDetailState.error), + }); + + return ( + { + if (recovery === "view-threads") { + navigation.dispatch(StackActions.replace("Home")); + return; + } + if (recovery === "reconnect-environment") { + onReconnectEnvironment(environmentId); + return; + } + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironments" }, + }); + }} + /> + ); } function ThreadRouteContent( @@ -544,10 +591,19 @@ function ThreadRouteContent( Files={FilesInspector} Git={GitInspector} mode={inspectorMode} + resetKeys={[routeThreadIdentity, selectedThreadCwd]} Route={props.renderInspector ? RouteInspector : undefined} /> ), - [FilesInspector, GitInspector, RouteInspector, inspectorMode, props.renderInspector], + [ + FilesInspector, + GitInspector, + RouteInspector, + inspectorMode, + props.renderInspector, + routeThreadIdentity, + selectedThreadCwd, + ], ); const activeInspectorRenderer = inspectorMode === null ? undefined : renderInspectorStack; // Hand the inspector to the workspace so it renders beside the navigator, diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.test.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.test.tsx index a7bd2cd1fd..19f4ed9a33 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.test.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.test.tsx @@ -43,6 +43,10 @@ vi.mock("../../native/StackHeader", () => ({ })); vi.mock("../../native/native-glass", () => ({ NATIVE_LIQUID_GLASS_SUPPORTED: true })); vi.mock("../../state/server", () => ({ serverEnvironment: {} })); +vi.mock("../../state/preferences", () => ({ + mobilePreferencesAtom: {}, + updateMobilePreferencesAtom: {}, +})); vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: vi.fn() })); vi.mock("./new-task-flow-provider", () => ({ useNewTaskFlow: vi.fn() })); vi.mock("../settings/appearance/AppearancePreferencesProvider", () => ({ @@ -59,6 +63,11 @@ import { ModelRow } from "./ThreadSettingsSheet"; type ElementProps = { readonly children?: ReactNode; readonly numberOfLines?: number; + readonly accessibilityRole?: string; + readonly accessibilityLabel?: string; + readonly accessibilityHint?: string; + readonly accessibilityState?: { readonly disabled?: boolean; readonly selected?: boolean }; + readonly onPress?: () => void; }; function elements(node: ReactNode): ReadonlyArray> { if (node === null || node === undefined || typeof node !== "object") return []; @@ -80,7 +89,20 @@ const option: ModelOption = { capabilities: null, selection: { instanceId: ProviderInstanceId.make("primeAgent"), model: "default" }, }; -const rowProps = { option, selected: false, isFirst: true, isLast: true, onPress: vi.fn() }; +const rowProps = { + option, + selected: false, + isFirst: true, + isLast: true, + onPress: vi.fn(), + isFavorite: false, + favoritesLoaded: true, + onToggleFavorite: vi.fn(), +}; + +function modelChoice(row: ReactElement) { + return elements(row).find((element) => element.props.accessibilityRole === "radio")!; +} describe("ThreadSettingsSheet model row", () => { it.each([PRIME_AGENT_DEFAULT_MODEL_CHANGE_DESCRIPTION, STARTED_THREAD_MODEL_CHANGE_DESCRIPTION])( @@ -92,19 +114,19 @@ describe("ThreadSettingsSheet model row", () => { expect(captions).toHaveLength(1); // The native Text must be allowed to measure all lines of the new-thread remedy. expect(captions[0]?.props.numberOfLines).toBeUndefined(); - expect(row.props).toMatchObject({ + expect(modelChoice(row).props).toMatchObject({ accessibilityLabel: `${option.label}, ${option.subtitle}, ${disabledReason}`, accessibilityRole: "radio", accessibilityState: { checked: false, disabled: true }, disabled: true, }); - expect(row.props.accessibilityHint).toBeUndefined(); + expect(modelChoice(row).props.accessibilityHint).toBeUndefined(); }, ); it("keeps the selected allowed row selectable without a stale reason", () => { const row = ModelRow({ ...rowProps, selected: true }); - expect(row.props).toMatchObject({ + expect(modelChoice(row).props).toMatchObject({ accessibilityLabel: `${option.label}, ${option.subtitle}`, accessibilityRole: "radio", accessibilityState: { checked: true, disabled: false }, @@ -117,7 +139,22 @@ describe("ThreadSettingsSheet model row", () => { expect(text).toContain(option.label); expect(text).toContain(option.subtitle); expect(text.join(" ")).not.toContain("Start a new thread"); - row.props.onPress(); + modelChoice(row).props.onPress?.(); expect(rowProps.onPress).toHaveBeenCalledOnce(); }); + + it("keeps the favorite action separate from selecting a model", () => { + rowProps.onPress.mockClear(); + rowProps.onToggleFavorite.mockClear(); + const row = ModelRow(rowProps); + const favorite = elements(row).find( + (element) => + element.props.accessibilityLabel === + `Add to favorites: ${option.providerLabel}, ${option.label}`, + )!; + expect(favorite.props.accessibilityState).toMatchObject({ disabled: false, selected: false }); + favorite.props.onPress?.(); + expect(rowProps.onToggleFavorite).toHaveBeenCalledOnce(); + expect(rowProps.onPress).not.toHaveBeenCalled(); + }); }); diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 2b831d8999..0beca2d6ea 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -1,11 +1,13 @@ import type { EnvironmentId, ModelSelection, + ProviderInstanceId, ProviderOptionDescriptor, ProviderOptionSelection, RuntimeMode, } from "@t3tools/contracts"; import type { LegendListRenderItemProps } from "@legendapp/list/react-native"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AnimatedLegendList } from "@legendapp/list/reanimated"; import { HeaderHeightContext } from "@react-navigation/elements"; import { @@ -19,6 +21,7 @@ import { type NativeStackNavigationProp, } from "@react-navigation/native-stack"; import * as Haptics from "expo-haptics"; +import { AsyncResult } from "effect/unstable/reactivity"; import { createContext, use, @@ -49,6 +52,7 @@ import { } from "../../native/StackHeader"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { serverEnvironment } from "../../state/server"; +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useAtomCommand } from "../../state/use-atom-command"; import { useNewTaskFlow } from "./new-task-flow-provider"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; @@ -67,9 +71,12 @@ import { selectableChoices, } from "./thread-settings-options"; import { + favoritesFirst, + modelFavoriteKey, modelMatchesCatalogQuery, pendingModelAfterPress, providerSectionIsCollapsed, + toggleModelFavorite, } from "./thread-settings-sheet-state"; /** @@ -96,73 +103,103 @@ const THREAD_SETTINGS_HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects Platform.OS, Platform.Version, ); +const EMPTY_MODEL_FAVORITES: ReadonlyArray<{ + readonly provider: ProviderInstanceId; + readonly model: string; +}> = []; +const FAVORITES_PROVIDER_FILTER = "@favorites"; export function ModelRow(props: { readonly option: ModelOption; readonly selected: boolean; readonly disabledReason?: string | undefined; readonly onPress: () => void; + readonly isFavorite: boolean; + readonly favoritesLoaded: boolean; + readonly onToggleFavorite: () => void; readonly isFirst: boolean; readonly isLast: boolean; }) { const { materialYouStyleLayoutActive } = useAppearancePreferences(); const selectedMaterialRow = materialYouStyleLayoutActive && props.selected; return ( - - - - - {props.option.label} - - {props.option.isDefault ? ( - - Default - + + + + + {props.option.label} + + {props.option.isDefault ? ( + + Default + + ) : null} + {props.option.isLegacy ? ( + + Legacy + + ) : null} + + {props.option.subtitle ? ( + + {props.option.subtitle} + ) : null} - {props.option.isLegacy ? ( - - Legacy - + {props.disabledReason ? ( + + {props.disabledReason} + ) : null} - {props.option.subtitle ? ( - - {props.option.subtitle} - - ) : null} - {props.disabledReason ? ( - - {props.disabledReason} - + {props.selected ? ( + ) : null} - - {props.selected ? ( + + - ) : null} - + + ); } @@ -373,6 +410,9 @@ export function useExistingThreadSettingsRoutePresentation() { type ThreadSettingsSessionValue = { readonly environmentId: EnvironmentId | null; readonly providerGroups: ReadonlyArray; + readonly favoriteKeys: ReadonlySet; + readonly favoritesLoaded: boolean; + readonly toggleFavorite: (option: ModelOption) => void; readonly runtimeMode: RuntimeMode; readonly runtimeChoices: typeof RUNTIME_MODE_CHOICES; readonly onUpdateRuntimeMode: (mode: RuntimeMode) => void; @@ -401,6 +441,34 @@ const ThreadSettingsSessionContext = createContext + new Set( + modelFavorites.map((favorite) => modelFavoriteKey(favorite.provider, favorite.model)), + ), + [modelFavorites], + ); + const toggleFavorite = useCallback( + (option: ModelOption) => { + if (!favoritesLoaded) return; + void Haptics.selectionAsync(); + savePreferences({ + transform: (current) => ({ + modelFavorites: toggleModelFavorite( + current.modelFavorites ?? EMPTY_MODEL_FAVORITES, + option, + ), + }), + }); + }, + [favoritesLoaded, savePreferences], + ); const [showLegacyToggle, setShowLegacyToggle] = useState(false); const [providerFilter, setProviderFilter] = useState(null); const [searchQuery, setSearchQuery] = useState(""); @@ -506,6 +574,9 @@ function ThreadSettingsSessionProvider( () => ({ environmentId: props.environmentId, providerGroups: props.providerGroups, + favoriteKeys, + favoritesLoaded, + toggleFavorite, runtimeMode: displayedRuntimeMode, runtimeChoices, onUpdateRuntimeMode: props.onUpdateRuntimeMode, @@ -531,6 +602,8 @@ function ThreadSettingsSessionProvider( applyOptionChange, commitPendingModel, displayedDescriptors, + favoriteKeys, + favoritesLoaded, providerExpansionOverrides, hasLegacyModels, isApplied, @@ -547,6 +620,7 @@ function ThreadSettingsSessionProvider( searchQuery, showLegacyToggle, toggleProvider, + toggleFavorite, ], ); @@ -613,6 +687,9 @@ function ThreadSettingsModelListRow(props: { isFirst={props.isFirst} isLast={props.isLast} onPress={onPress} + isFavorite={session.favoriteKeys.has(props.option.key)} + favoritesLoaded={session.favoritesLoaded} + onToggleFavorite={() => session.toggleFavorite(props.option)} option={props.option} selected={session.isDisplayed(props.option)} disabledReason={ @@ -652,19 +729,35 @@ function useThreadSettingsCatalogItems( return useMemo( () => session.providerGroups.flatMap((group) => { - if (session.providerFilter !== null && group.providerKey !== session.providerFilter) { + if ( + session.providerFilter !== null && + session.providerFilter !== FAVORITES_PROVIDER_FILTER && + group.providerKey !== session.providerFilter + ) { return []; } const driver = group.models[0]?.providerDriver; - const catalogModels = session.showLegacy - ? group.models - : group.models.filter((model) => !model.isLegacy || session.isDisplayed(model)); - const visibleModels = catalogModels.filter((model) => - modelMatchesCatalogQuery({ - model, - providerLabel: group.providerLabel, - query: session.searchQuery, - }), + const catalogModels = + session.showLegacy || session.providerFilter === FAVORITES_PROVIDER_FILTER + ? group.models + : group.models.filter( + (model) => + !model.isLegacy || + session.isDisplayed(model) || + session.favoriteKeys.has(model.key), + ); + const visibleModels = favoritesFirst( + catalogModels.filter( + (model) => + (session.providerFilter !== FAVORITES_PROVIDER_FILTER || + session.favoriteKeys.has(model.key)) && + modelMatchesCatalogQuery({ + model, + providerLabel: group.providerLabel, + query: session.searchQuery, + }), + ), + session.favoriteKeys, ); if (visibleModels.length === 0) { return []; @@ -708,6 +801,7 @@ function useThreadSettingsCatalogItems( [ session.isApplied, session.isDisplayed, + session.favoriteKeys, session.providerExpansionOverrides, session.providerFilter, session.providerGroups, @@ -838,7 +932,12 @@ function ThreadSettingsMainContent(props: { } else if (item.kind === "empty") { content = ( - No matching models + + {session.providerFilter === FAVORITES_PROVIDER_FILTER && + session.searchQuery.trim().length === 0 + ? "No favorite models" + : "No matching models"} + ); } else { @@ -860,7 +959,7 @@ function ThreadSettingsMainContent(props: { ); }, - [animationsReady, props.onOpenSubmenu], + [animationsReady, props.onOpenSubmenu, session.providerFilter, session.searchQuery], ); return ( @@ -893,6 +992,38 @@ function ThreadSettingsMainContent(props: { placeholderTextColorClassName="accent-placeholder" value={session.searchQuery} /> + + session.setProviderFilter(null)} + > + All + + session.setProviderFilter(FAVORITES_PROVIDER_FILTER)} + > + Favorites + + ) : null} @@ -1047,6 +1178,15 @@ function ThreadSettingsModelsScreen() { state: session.providerFilter === null ? ("on" as const) : ("off" as const), onPress: () => session.setProviderFilter(null), }, + { + type: "action" as const, + title: "Favorites", + state: + session.providerFilter === FAVORITES_PROVIDER_FILTER + ? ("on" as const) + : ("off" as const), + onPress: () => session.setProviderFilter(FAVORITES_PROVIDER_FILTER), + }, ...session.providerGroups.map((group) => ({ type: "action" as const, title: group.providerLabel, @@ -1180,6 +1320,12 @@ function ThreadSettingsModelsScreen() { > All providers + session.setProviderFilter(FAVORITES_PROVIDER_FILTER)} + > + Favorites + {session.providerGroups.map((group) => ( { + const local = makeProject("code", "mac", { title: "Desktop checkout" }); + const remote = makeProject("remote-code", "server", { + workspaceRoot: "/srv/remote-workspace", + }); + const code = makeScope([local, remote]); + const docs = { ...makeScope([makeProject("docs")]), key: "docs", title: "Documentation" }; + const scopes = [code, docs]; + + it("keeps all scopes for an empty or whitespace query", () => { + expect(filterProjectScopes(scopes, "")).toBe(scopes); + expect(filterProjectScopes(scopes, " ")).toBe(scopes); + }); + + it("finds logical names, project titles and workspace paths without case sensitivity", () => { + expect(filterProjectScopes(scopes, " T3 CODE ")).toEqual([code]); + expect(filterProjectScopes(scopes, "DESKTOP")).toEqual([code]); + expect(filterProjectScopes(scopes, "REMOTE-WORKSPACE")).toEqual([code]); + expect(filterProjectScopes(scopes, "documentation")).toEqual([docs]); + expect(filterProjectScopes(scopes, "missing-project")).toEqual([]); + }); + + it("keeps every workspace and the preferred environment when one workspace matches", () => { + const matches = filterProjectScopes(scopes, "REMOTE-WORKSPACE"); + expect(matches[0]).toBe(code); + expect(getProjectScopeSelectionTarget(matches[0]!, EnvironmentId.make("mac"))).toBe(local); + expect(code.projects).toEqual([local, remote]); + }); +}); + function makeProject( id: string, environmentId = "environment", diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index 05fc9e2cfe..4b7aecce04 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -28,6 +28,23 @@ export function getProjectScopeAccessibilityLabel( : `${scope.title}, ${selectionTarget.workspaceRoot}`; } +export function filterProjectScopes( + scopes: ReadonlyArray, + searchText: string, +): ReadonlyArray { + const query = searchText.trim().toLowerCase(); + if (!query) return scopes; + return scopes.filter( + (scope) => + scope.title.toLowerCase().includes(query) || + scope.projects.some( + (project) => + project.title.toLowerCase().includes(query) || + project.workspaceRoot.toLowerCase().includes(query), + ), + ); +} + function getOnlySelectableProject( projectScopes: ReadonlyArray, ): EnvironmentProject | null { diff --git a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx index 9b41ed4e36..5a1601b0e5 100644 --- a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx +++ b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx @@ -1,6 +1,8 @@ import { useEffect, useState, type ComponentType, type ReactNode } from "react"; import { View } from "react-native"; +import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; + export type ThreadInspectorMode = "route" | "git" | "files"; const INSPECTOR_PREWARM_DELAY_MS = 350; @@ -8,6 +10,7 @@ const INSPECTOR_PREWARM_DELAY_MS = 350; function InspectorContentPane(props: { readonly children: ReactNode; readonly mounted: boolean; + readonly resetKeys: readonly [string | null, string | null]; readonly visible: boolean; }) { if (!props.mounted) { @@ -27,7 +30,14 @@ function InspectorContentPane(props: { zIndex: props.visible ? 1 : 0, }} > - {props.children} + ( + + )} + > + {props.children} + ); } @@ -36,6 +46,7 @@ export function ThreadInspectorContentStack(props: { readonly Files: ComponentType; readonly Git: ComponentType; readonly mode: ThreadInspectorMode; + readonly resetKeys: readonly [string | null, string | null]; readonly Route?: ComponentType; }) { const [mountedModes, setMountedModes] = useState>( @@ -78,12 +89,14 @@ export function ThreadInspectorContentStack(props: { @@ -91,6 +104,7 @@ export function ThreadInspectorContentStack(props: { {Route ? ( diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 5247519815..d9082e0e12 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -18,6 +18,7 @@ import { ControlPillMenu } from "../../components/ControlPill"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { cn } from "../../lib/cn"; +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; import { relativeTime } from "../../lib/time"; import { themeColorWithAlpha } from "../../lib/mobileTheme"; @@ -456,6 +457,7 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { const THREAD_ROW_MENU_ACTIONS: MenuAction[] = [ { id: "archive", title: "Archive", image: "archivebox" }, + { id: "rename", title: "Rename", image: "square.and.pencil" }, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ]; @@ -478,6 +480,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { readonly onArchiveThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void; + readonly onRenameThread: (thread: EnvironmentThreadShell) => void; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; readonly titleRegenerationSupported: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; @@ -509,6 +512,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { onSelectThread, onArchiveThread, onDeleteThread, + onRenameThread, onRegenerateThreadTitle, onNewThreadOnBranch, } = props; @@ -553,6 +557,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); + const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]); const handleRegenerateTitle = useCallback( () => onRegenerateThreadTitle(thread), [onRegenerateThreadTitle, thread], @@ -570,11 +575,13 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ] : []), THREAD_ROW_MENU_ACTIONS[0]!, + THREAD_ROW_MENU_ACTIONS[1]!, + { id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" }, ...buildThreadTitleRegenerationMenuItems({ supported: props.titleRegenerationSupported, isRegenerating: thread.titleRegeneration != null, }), - THREAD_ROW_MENU_ACTIONS[1]!, + THREAD_ROW_MENU_ACTIONS[2]!, ], [props.titleRegenerationSupported, thread.branch, thread.titleRegeneration], ); @@ -591,10 +598,14 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { if (nativeEvent.event === "new-thread-on-branch") onNewThreadOnBranch(thread); if (nativeEvent.event === "archive") handleArchive(); + if (nativeEvent.event === "rename") handleRename(); + if (nativeEvent.event === "copy-thread-id") { + copyTextWithHaptic(thread.id, { target: "thread-id" }); + } if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); if (nativeEvent.event === "delete") handleDelete(); }, - [handleArchive, handleDelete, handleRegenerateTitle, onNewThreadOnBranch, thread], + [handleArchive, handleDelete, handleRegenerateTitle, handleRename, onNewThreadOnBranch, thread], ); const statusPill = effectiveStatus ? ( diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 24b3d0cf91..e02390e2b9 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -23,6 +23,7 @@ import { ProjectFavicon } from "../../components/ProjectFavicon"; import { ProviderInstanceIcon } from "../../components/ProviderIcon"; import type { ThreadRowProviderInstance } from "./thread-provider-instance"; import { cn } from "../../lib/cn"; +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { relativeTime } from "../../lib/time"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { @@ -394,6 +395,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly onSelectThread: (thread: EnvironmentThreadShell) => void; readonly onDeleteThread: (thread: EnvironmentThreadShell) => void; readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void; + readonly onRenameThread: (thread: EnvironmentThreadShell) => void; readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => void; readonly onSettleThread: (thread: EnvironmentThreadShell) => Promise; readonly onSnoozeThread: (thread: EnvironmentThreadShell, snoozedUntil: string) => void; @@ -436,6 +438,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { variant, onSelectThread, onDeleteThread, + onRenameThread, onRegenerateThreadTitle, onNewThreadOnBranch, onSettleThread, @@ -488,6 +491,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { settledTimestamp !== null ? relativeTime(settledTimestamp) : threadTimeLabel(thread); const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); + const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]); const handleRegenerateTitle = useCallback( () => onRegenerateThreadTitle(thread), [onRegenerateThreadTitle, thread], @@ -577,12 +581,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { variant, ], ); - const titleRegenerationMenuItems = useMemo( - () => - buildThreadTitleRegenerationMenuItems({ + const titleMenuItems = useMemo( + () => [ + { id: "rename", title: "Rename", image: "square.and.pencil" }, + ...buildThreadTitleRegenerationMenuItems({ supported: props.titleRegenerationSupported, isRegenerating: thread.titleRegeneration != null, }), + ], [props.titleRegenerationSupported, thread.titleRegeneration], ); const snoozableCardMenuActions = useMemo( @@ -595,19 +601,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { subactions: snoozePresetActions, }, ...arrangementMenuItems, - ...titleRegenerationMenuItems, + ...titleMenuItems, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ], - [arrangementMenuItems, snoozePresetActions, titleRegenerationMenuItems], + [arrangementMenuItems, snoozePresetActions, titleMenuItems], ); const cardMenuActions = useMemo( () => [ CARD_MENU_ACTIONS[0]!, ...arrangementMenuItems, - ...titleRegenerationMenuItems, + ...titleMenuItems, ...CARD_MENU_ACTIONS.slice(1), ], - [arrangementMenuItems, titleRegenerationMenuItems], + [arrangementMenuItems, titleMenuItems], ); const slimMenuActions = useMemo( () => [ @@ -615,23 +621,23 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ...arrangementMenuItems.filter( (action) => action.id !== "move-up" && action.id !== "move-down", ), - ...titleRegenerationMenuItems, + ...titleMenuItems, SLIM_MENU_ACTIONS[1]!, ], - [arrangementMenuItems, titleRegenerationMenuItems], + [arrangementMenuItems, titleMenuItems], ); const snoozedMenuActions = useMemo( - () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!], - [titleRegenerationMenuItems], + () => [SNOOZED_MENU_ACTIONS[0]!, ...titleMenuItems, SNOOZED_MENU_ACTIONS[1]!], + [titleMenuItems], ); const legacyMenuActions = useMemo( () => [ LEGACY_MENU_ACTIONS[0]!, ...arrangementMenuItems, - ...titleRegenerationMenuItems, + ...titleMenuItems, LEGACY_MENU_ACTIONS[1]!, ], - [arrangementMenuItems, titleRegenerationMenuItems], + [arrangementMenuItems, titleMenuItems], ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { @@ -645,6 +651,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "move-up") handleMoveUp(); if (nativeEvent.event === "move-down") handleMoveDown(); if (nativeEvent.event === "archive") handleArchive(); + if (nativeEvent.event === "rename") handleRename(); + if (nativeEvent.event === "copy-thread-id") { + copyTextWithHaptic(thread.id, { target: "thread-id" }); + } if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); if (nativeEvent.event === "delete") handleDelete(); const snoozeSelection = resolveThreadListV2SnoozeMenuSelection({ @@ -664,6 +674,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { handleArchive, handleDelete, handleRegenerateTitle, + handleRename, handleMoveDown, handleMoveUp, handlePin, @@ -1111,6 +1122,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { }, ] : []), + { id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" }, ...(snoozedRow ? snoozedMenuActions : !props.settlementSupported diff --git a/apps/mobile/src/features/threads/thread-route-hydration.test.ts b/apps/mobile/src/features/threads/thread-route-hydration.test.ts new file mode 100644 index 0000000000..e5a2177029 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-route-hydration.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { threadRouteIsHydrating, threadRouteRecovery } from "./thread-route-hydration"; + +const settled = { + isLoadingConnections: false, + connectionState: "connected" as const, + shellStatus: "live" as const, + shellHasError: false, + detailStatus: "live" as const, + detailHasError: false, +}; + +describe("threadRouteIsHydrating", () => { + it("waits for shell and thread detail hydration", () => { + expect(threadRouteIsHydrating({ ...settled, shellStatus: "synchronizing" })).toBe(true); + expect(threadRouteIsHydrating({ ...settled, shellStatus: "empty" })).toBe(true); + expect(threadRouteIsHydrating({ ...settled, detailStatus: "synchronizing" })).toBe(true); + expect(threadRouteIsHydrating({ ...settled, detailStatus: "empty" })).toBe(true); + }); + + it("stops waiting once an empty detail has an actionable outcome", () => { + expect( + threadRouteIsHydrating({ + ...settled, + shellStatus: "empty", + shellHasError: true, + detailStatus: "deleted", + }), + ).toBe(false); + expect( + threadRouteIsHydrating({ + ...settled, + detailStatus: "empty", + detailHasError: true, + }), + ).toBe(false); + expect( + threadRouteIsHydrating({ + ...settled, + connectionState: "available", + detailStatus: "empty", + }), + ).toBe(false); + expect(threadRouteIsHydrating({ ...settled, detailStatus: "deleted" })).toBe(false); + }); + + it("prioritizes terminal outcomes over unrelated hydration", () => { + expect( + threadRouteIsHydrating({ + ...settled, + connectionState: "reconnecting", + shellStatus: "synchronizing", + detailStatus: "deleted", + }), + ).toBe(false); + expect( + threadRouteIsHydrating({ + ...settled, + connectionState: "reconnecting", + shellStatus: "synchronizing", + detailHasError: true, + }), + ).toBe(false); + expect( + threadRouteIsHydrating({ + ...settled, + connectionState: "offline", + shellStatus: "synchronizing", + detailStatus: "synchronizing", + }), + ).toBe(false); + }); +}); + +describe("threadRouteRecovery", () => { + const recovery = { + hasEnvironmentRuntime: true, + connectionState: "connected" as const, + shellStatus: "live" as const, + detailStatus: "empty" as const, + detailHasError: false, + }; + + it("returns to the list for a deleted or missing thread", () => { + expect(threadRouteRecovery({ ...recovery, detailStatus: "deleted" })).toBe("view-threads"); + expect(threadRouteRecovery({ ...recovery, detailHasError: true })).toBe("view-threads"); + }); + + it("offers connection recovery for sync failures and settings for an unknown environment", () => { + expect(threadRouteRecovery({ ...recovery, shellStatus: "synchronizing" })).toBe( + "reconnect-environment", + ); + expect(threadRouteRecovery({ ...recovery, connectionState: "offline" })).toBe( + "reconnect-environment", + ); + expect(threadRouteRecovery({ ...recovery, hasEnvironmentRuntime: false })).toBe( + "manage-environments", + ); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-route-hydration.ts b/apps/mobile/src/features/threads/thread-route-hydration.ts new file mode 100644 index 0000000000..d88cde8358 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-route-hydration.ts @@ -0,0 +1,56 @@ +import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; +import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; +import type { EnvironmentThreadStatus } from "@t3tools/client-runtime/state/threads"; + +/** + * Reports whether the route-local projections can still yield the requested + * thread. Explicit terminal outcomes win over unrelated synchronization. + */ +export function threadRouteIsHydrating(input: { + readonly isLoadingConnections: boolean; + readonly connectionState: EnvironmentConnectionPhase; + readonly shellStatus: EnvironmentShellStatus; + readonly shellHasError: boolean; + readonly detailStatus: EnvironmentThreadStatus; + readonly detailHasError: boolean; +}): boolean { + if (input.detailStatus === "deleted" || input.shellHasError || input.detailHasError) { + return false; + } + if (input.isLoadingConnections) { + return true; + } + if ( + input.connectionState === "available" || + input.connectionState === "offline" || + input.connectionState === "error" + ) { + return false; + } + return ( + input.connectionState === "connecting" || + input.connectionState === "reconnecting" || + input.shellStatus === "synchronizing" || + (input.connectionState === "connected" && input.shellStatus === "empty") || + input.detailStatus === "synchronizing" || + (input.connectionState === "connected" && input.detailStatus === "empty") + ); +} + +/** Picks recovery for a missing route using its authoritative outcome. */ +export function threadRouteRecovery(input: { + readonly hasEnvironmentRuntime: boolean; + readonly connectionState: EnvironmentConnectionPhase; + readonly shellStatus: EnvironmentShellStatus; + readonly detailStatus: EnvironmentThreadStatus; + readonly detailHasError: boolean; +}): "manage-environments" | "view-threads" | "reconnect-environment" { + if (!input.hasEnvironmentRuntime) return "manage-environments"; + if ( + input.detailStatus === "deleted" || + (input.connectionState === "connected" && input.shellStatus === "live" && input.detailHasError) + ) { + return "view-threads"; + } + return "reconnect-environment"; +} diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts index 5c6e25f437..e42acd4ede 100644 --- a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts @@ -3,7 +3,13 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderInstanceId, type ProviderOptionSelection } from "@t3tools/contracts"; import type { ModelOption } from "../../lib/modelOptions"; -import { modelMatchesCatalogQuery, pendingModelAfterPress } from "./thread-settings-sheet-state"; +import { + favoritesFirst, + modelFavoriteKey, + modelMatchesCatalogQuery, + pendingModelAfterPress, + toggleModelFavorite, +} from "./thread-settings-sheet-state"; function modelOption( model: string, @@ -28,6 +34,33 @@ function modelOption( } describe("thread settings sheet state", () => { + it("keeps favorites in catalog order ahead of other models", () => { + const models = [modelOption("first"), modelOption("second"), modelOption("third")]; + const favorites = new Set([models[2]!.key, models[0]!.key]); + expect(favoritesFirst(models, favorites).map((model) => model.selection.model)).toEqual([ + "first", + "third", + "second", + ]); + expect(models.map((model) => model.selection.model)).toEqual(["first", "second", "third"]); + }); + + it("adds and removes favorites by provider instance", () => { + const codexModel = modelOption("shared"); + const personalId = ProviderInstanceId.make("codex_personal"); + const personalModel = { + ...codexModel, + key: modelFavoriteKey(personalId, "shared"), + selection: { ...codexModel.selection, instanceId: personalId }, + }; + const favorites = toggleModelFavorite([], codexModel); + expect(toggleModelFavorite(favorites, personalModel)).toEqual([ + { provider: ProviderInstanceId.make("codex"), model: "shared" }, + { provider: personalId, model: "shared" }, + ]); + expect(toggleModelFavorite(favorites, codexModel)).toEqual([]); + }); + it("matches visible model and provider terms", () => { const model = modelOption("gpt-next"); diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts index 1e417b925d..a4fbb38718 100644 --- a/apps/mobile/src/features/threads/thread-settings-sheet-state.ts +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.ts @@ -1,5 +1,40 @@ +import type { ProviderInstanceId } from "@t3tools/contracts"; + import type { ModelOption } from "../../lib/modelOptions"; +export type ModelFavorite = { + readonly provider: ProviderInstanceId; + readonly model: string; +}; + +export function modelFavoriteKey(provider: ProviderInstanceId, model: string): string { + return `${provider}:${model}`; +} + +export function toggleModelFavorite( + favorites: ReadonlyArray, + option: ModelOption, +): ReadonlyArray { + const provider = option.selection.instanceId; + const model = option.selection.model; + return favorites.some((favorite) => favorite.provider === provider && favorite.model === model) + ? favorites.filter((favorite) => favorite.provider !== provider || favorite.model !== model) + : [...favorites, { provider, model }]; +} + +/** Keep catalog order within each group when favorites move to the front. */ +export function favoritesFirst( + models: ReadonlyArray, + favoriteKeys: ReadonlySet, +): ReadonlyArray { + const favorites: ModelOption[] = []; + const others: ModelOption[] = []; + for (const model of models) { + (favoriteKeys.has(model.key) ? favorites : others).push(model); + } + return [...favorites, ...others]; +} + /** Match the terms a user can actually see or recognize in the model picker. */ export function modelMatchesCatalogQuery(input: { readonly model: ModelOption; diff --git a/apps/mobile/src/features/threads/thread-title-rename.test.ts b/apps/mobile/src/features/threads/thread-title-rename.test.ts new file mode 100644 index 0000000000..75bc5cdc41 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-title-rename.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveThreadTitleRename } from "./thread-title-rename"; + +describe("resolveThreadTitleRename", () => { + it("trims a changed title", () => { + expect(resolveThreadTitleRename({ title: " New title ", originalTitle: "Old" })).toEqual({ + action: "rename", + title: "New title", + }); + }); + + it("rejects empty and unchanged titles", () => { + expect(resolveThreadTitleRename({ title: " ", originalTitle: "Old" })).toEqual({ + action: "reject-empty", + }); + expect(resolveThreadTitleRename({ title: " Old ", originalTitle: "Old" })).toEqual({ + action: "noop", + }); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-title-rename.ts b/apps/mobile/src/features/threads/thread-title-rename.ts new file mode 100644 index 0000000000..23f3e449ef --- /dev/null +++ b/apps/mobile/src/features/threads/thread-title-rename.ts @@ -0,0 +1,14 @@ +export type ThreadTitleRenameResolution = + | { readonly action: "rename"; readonly title: string } + | { readonly action: "noop" } + | { readonly action: "reject-empty" }; + +export function resolveThreadTitleRename(input: { + readonly title: string; + readonly originalTitle: string; +}): ThreadTitleRenameResolution { + const title = input.title.trim(); + if (title.length === 0) return { action: "reject-empty" }; + if (title === input.originalTitle) return { action: "noop" }; + return { action: "rename", title }; +} diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index 6baf163cff..40bfe653d3 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -220,12 +220,7 @@ export function UsageLimitsSection({ const colors = useProviderColors(); return ( - {failedLabels.length ? ( - - {failedLabels.join(", ")} could not refresh limits. Showing the last known values. - - ) : null} - {pools.length === 0 ? ( + {pools.length === 0 && notices.length === 0 && failedLabels.length === 0 ? ( {selected.size === 0 ? "Select an environment to see limits." @@ -251,11 +246,36 @@ export function UsageLimitsSection({ ))} ))} - {notices.map((notice) => ( - - {notice} - - ))} + {notices.length > 0 || failedLabels.length > 0 ? ( + + + + {notices.map((notice) => ( + + {notice} + + ))} + {failedLabels.length > 0 ? ( + + {failedLabels.join(", ")} could not refresh limits. + {pools.length > 0 ? " Showing available limits." : ""} Pull down to try again. + + ) : null} + {failedLabels.length === 0 && notices.length > 0 ? ( + Pull down to try again. + ) : null} + + + ) : null} ); } diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.test.ts b/apps/mobile/src/features/usage/UsageLimitsSection.test.ts new file mode 100644 index 0000000000..11f5570c7f --- /dev/null +++ b/apps/mobile/src/features/usage/UsageLimitsSection.test.ts @@ -0,0 +1,123 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + values: [] as unknown[], + cursor: 0, + presentations: new Map(), + refreshProviders: vi.fn(), + autoRefresh: async () => {}, + refreshingRef: { current: false }, +})); +vi.mock("react", () => ({ + useState: (initial: unknown) => { + const index = state.cursor++; + if (!(index in state.values)) { + state.values[index] = typeof initial === "function" ? initial() : initial; + } + return [ + state.values[index], + (next: unknown) => { + state.values[index] = typeof next === "function" ? next(state.values[index]) : next; + }, + ]; + }, + useRef: () => state.refreshingRef, + useEffect: () => {}, + useEffectEvent: (callback: () => Promise) => { + state.autoRefresh = callback; + return callback; + }, +})); +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.presentations })); +vi.mock("react-native", () => ({ + Alert: {}, + AppState: { currentState: "active", addEventListener: () => ({ remove: () => {} }) }, + Pressable: "button", + View: "div", +})); +vi.mock("../../components/AppText", () => ({ AppText: "span" })); +vi.mock("../../components/ProviderIcon", () => ({ ProviderIcon: () => null })); +vi.mock("../../lib/uuid", () => ({ uuidv4: () => "test-uuid" })); +vi.mock("./usageProviders", () => ({ useProviderColors: () => ({}) })); +vi.mock("../../state/presentation", () => ({ + environmentPresentations: { presentationsAtom: null }, +})); +vi.mock("../../state/server", () => ({ serverEnvironment: { refreshProviders: null } })); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.refreshProviders })); + +import { useRefreshLimits } from "./UsageLimitsSection"; +import { refreshUsageLimits } from "@t3tools/client-runtime/state/usage"; + +beforeEach(() => { + state.values = []; + state.cursor = 0; + state.refreshingRef.current = false; + state.refreshProviders.mockReset(); +}); + +it("keeps a newer environment failure when an older refresh finishes", async () => { + const a = EnvironmentId.make("mobile-limits-a"); + const b = EnvironmentId.make("mobile-limits-b"); + const pending = Promise.withResolvers<{ _tag: string }>(); + const read = () => { + state.cursor = 0; + return useRefreshLimits(); + }; + const presentation = (label: string) => ({ + connection: { phase: "connected" }, + entry: { target: { label } }, + }); + state.presentations = new Map([[a, presentation("A")]]); + state.refreshProviders.mockImplementation(({ environmentId }) => + environmentId === a ? pending.promise : Promise.resolve({ _tag: "Failure" }), + ); + const first = read().refresh(); + state.presentations = new Map([ + [a, presentation("A")], + [b, presentation("B")], + ]); + read(); + await state.autoRefresh(); + expect(read().failedLabels).toEqual(["B"]); + pending.resolve({ _tag: "Success" }); + await first; + expect(read().failedLabels).toEqual(["B"]); +}); + +it("does not let an older multi-environment batch clear a newer failure for the same environment", async () => { + const a = EnvironmentId.make("mobile-limits-race-a"); + const b = EnvironmentId.make("mobile-limits-race-b"); + const aFirst = Promise.withResolvers<{ _tag: string }>(); + const bFirst = Promise.withResolvers<{ _tag: string }>(); + const read = (selected: ReadonlySet | null = null) => { + state.cursor = 0; + return useRefreshLimits(selected); + }; + const presentation = (label: string) => ({ + connection: { phase: "connected" }, + entry: { target: { label } }, + }); + state.presentations = new Map([ + [a, presentation("A")], + [b, presentation("B")], + ]); + let aCalls = 0; + state.refreshProviders.mockImplementation(({ environmentId }) => + environmentId === b + ? bFirst.promise + : ++aCalls === 1 + ? aFirst.promise + : Promise.resolve({ _tag: "Failure" }), + ); + read(); + const older = state.autoRefresh(); + aFirst.resolve({ _tag: "Success" }); + await refreshUsageLimits(a, () => aFirst.promise); + const selected = new Set([a]); + await read(selected).refresh(); + expect(read(selected).failedLabels).toEqual(["A"]); + bFirst.resolve({ _tag: "Success" }); + await older; + expect(read(selected).failedLabels).toEqual(["A"]); +}); diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index 12a9127510..9b50333941 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -18,8 +18,13 @@ import { createResetCreditAttempts, resetCreditOutcomeText, } from "@t3tools/shared/usageLimits"; -import { type ReactNode, useEffect, useRef, useState } from "react"; -import { Alert, Pressable, View } from "react-native"; +import { type ReactNode, useEffect, useEffectEvent, useRef, useState } from "react"; +import { + invalidateUsageLimitsRefresh, + refreshUsageLimits, + usageLimitsRefreshScope, +} from "@t3tools/client-runtime/state/usage"; +import { Alert, AppState, Pressable, View } from "react-native"; import { uuidv4 } from "../../lib/uuid"; import { AppText as Text } from "../../components/AppText"; @@ -312,51 +317,100 @@ export function ResetCredits(props: { * Environments whose probe failed are named, since their rows keep showing * the previous quota with nothing else to say so. */ -export function useRefreshLimits(selectedEnvironmentIds: ReadonlySet | null = null) { +export function useRefreshLimits( + selectedEnvironmentIds: ReadonlySet | null = null, + active = false, +) { const presentations = useAtomValue(environmentPresentations.presentationsAtom); const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, }); const [now, setNow] = useState(() => Date.now()); const [refreshing, setRefreshing] = useState(false); - const refreshInFlight = useRef(false); + const refreshingRef = useRef(false); const [failedEnvironments, setFailedEnvironments] = useState< readonly { environmentId: EnvironmentId; label: string }[] >([]); - // Always toggles `refreshing`, even with nothing to probe: Android's - // RefreshControl keeps its spinner up until it sees true then false. - const refresh = async () => { - if (refreshInFlight.current) return; - refreshInFlight.current = true; + const refresh = async (automatic = false) => { const connected = [...presentations].filter( ([environmentId, presentation]) => presentation.connection.phase === "connected" && (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)), ); - setRefreshing(true); try { - const results = await Promise.all( - connected.map(([environmentId]) => refreshProviders({ environmentId, input: {} })), - ); - setFailedEnvironments( - connected - .filter((_, index) => results[index]?._tag === "Failure") - .map(([environmentId, presentation]) => ({ + await Promise.all( + connected.map(async ([environmentId, presentation]) => { + const result = await refreshUsageLimits( environmentId, - label: presentation.entry.target.label, - })), + () => refreshProviders({ environmentId, input: {} }), + automatic, + JSON.stringify([ + presentation.connection.generation ?? null, + usageLimitsRefreshScope(presentation.serverConfig?.providers ?? []), + ]), + ); + if (result === undefined) return; + setFailedEnvironments((previous) => [ + ...previous.filter((failed) => failed.environmentId !== environmentId), + ...(result._tag === "Failure" + ? [{ environmentId, label: presentation.entry.target.label }] + : []), + ]); + }), ); } finally { - refreshInFlight.current = false; setNow(Date.now()); + } + }; + // Always toggles `refreshing`, even with nothing to probe: Android's + // RefreshControl keeps its spinner up until it sees true then false. + const refreshManually = async () => { + if (refreshingRef.current) return; + refreshingRef.current = true; + setRefreshing(true); + try { + await refresh(); + } finally { + refreshingRef.current = false; setRefreshing(false); } }; + const connectedLimitsEnvironments = [...presentations] + .filter( + ([environmentId, presentation]) => + presentation.connection.phase === "connected" && + (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)), + ) + .map(([environmentId, presentation]) => + JSON.stringify([ + environmentId, + presentation.connection.generation ?? null, + usageLimitsRefreshScope(presentation.serverConfig?.providers ?? []), + ]), + ) + .sort() + .join(","); + useEffect(() => { + for (const [environmentId, presentation] of presentations) { + if (presentation.connection.phase !== "connected") + invalidateUsageLimitsRefresh(environmentId); + } + }, [presentations]); + const autoRefreshLimits = useEffectEvent(() => refresh(true)); + useEffect(() => { + if (!active || !connectedLimitsEnvironments) return; + if (AppState.currentState === "active") void autoRefreshLimits(); + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") void autoRefreshLimits(); + }); + return () => subscription.remove(); + }, [active, connectedLimitsEnvironments]); + const failedLabels = failedEnvironments .filter( ({ environmentId }) => selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId), ) .map(({ label }) => label); - return { now, refreshing, failedLabels, refresh }; + return { now, refreshing, failedLabels, refresh: refreshManually }; } diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index a515f2ddd6..58401687c9 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -1,5 +1,5 @@ import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; -import { useNavigation } from "@react-navigation/native"; +import { useIsFocused, useNavigation } from "@react-navigation/native"; import { isCompatibleUsageContractVersion, isModelCostUnknown, @@ -83,7 +83,8 @@ export function UsageRouteScreen() { window, selectedEnvironmentIds, ); - const limits = useRefreshLimits(selectedEnvironmentIds); + const isFocused = useIsFocused(); + const limits = useRefreshLimits(selectedEnvironmentIds, isFocused && tab === "limits"); const days = useMemo( () => enumerateDays(window.sinceDay, window.untilDay), @@ -286,6 +287,19 @@ export function UsageRouteScreen() { {merged.duplicateSources.join(", ")} ) : null} + {merged.approximateEnvironments.length > 0 ? ( + + Totals may count shared usage more than once because these environments run older + servers or have directories whose filesystem identity could not be read:{" "} + {selectedEnvironments + .filter((environment) => + merged.approximateEnvironments.includes(environment.environmentId), + ) + .map((environment) => environment.label) + .join(", ")} + . + + ) : null} {isPending ? ( Scanning provider transcripts… diff --git a/apps/mobile/src/features/voice-input/recordingKeepAwake.test.ts b/apps/mobile/src/features/voice-input/recordingKeepAwake.test.ts new file mode 100644 index 0000000000..b76ba81a6c --- /dev/null +++ b/apps/mobile/src/features/voice-input/recordingKeepAwake.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("expo-keep-awake", () => ({ + activateKeepAwakeAsync: vi.fn(), + deactivateKeepAwake: vi.fn(), +})); +vi.mock("../../lib/uuid", () => ({ uuidv4: vi.fn() })); + +import { holdRecordingAwake } from "./recordingKeepAwake"; + +function deferred(): { readonly promise: Promise; readonly resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe("holdRecordingAwake", () => { + it("releases after a delayed activation without touching a rapid replacement recording", async () => { + const first = deferred(); + const second = deferred(); + const activate = vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + const deactivate = vi.fn().mockResolvedValue(undefined); + const bridge = { activate, deactivate }; + const createId = vi.fn().mockReturnValueOnce("first").mockReturnValueOnce("second"); + + const stopFirst = holdRecordingAwake(bridge, createId); + const firstTag = activate.mock.calls[0]?.[0]; + stopFirst(); + const stopSecond = holdRecordingAwake(bridge, createId); + const secondTag = activate.mock.calls[1]?.[0]; + expect(firstTag).not.toBe(secondTag); + + first.resolve(); + await first.promise; + await Promise.resolve(); + expect(deactivate).toHaveBeenCalledWith(firstTag); + expect(deactivate).not.toHaveBeenCalledWith(secondTag); + + second.resolve(); + await second.promise; + stopSecond(); + await Promise.resolve(); + expect(deactivate).toHaveBeenCalledWith(secondTag); + }); + + it("keeps tags distinct across unmount and remount with late native completion", async () => { + const oldActivation = deferred(); + const activate = vi + .fn() + .mockReturnValueOnce(oldActivation.promise) + .mockResolvedValue(undefined); + const deactivate = vi.fn().mockResolvedValue(undefined); + const bridge = { activate, deactivate }; + const createId = vi.fn().mockReturnValueOnce("old-mount").mockReturnValueOnce("new-mount"); + + const unmount = holdRecordingAwake(bridge, createId); + const oldTag = activate.mock.calls[0]?.[0]; + unmount(); + const stopNewMount = holdRecordingAwake(bridge, createId); + const newTag = activate.mock.calls[1]?.[0]; + await Promise.resolve(); + oldActivation.resolve(); + await oldActivation.promise; + await Promise.resolve(); + expect(oldTag).not.toBe(newTag); + expect(deactivate).toHaveBeenCalledWith(oldTag); + expect(deactivate).not.toHaveBeenCalledWith(newTag); + + stopNewMount(); + await Promise.resolve(); + expect(deactivate).toHaveBeenCalledWith(newTag); + }); +}); diff --git a/apps/mobile/src/features/voice-input/recordingKeepAwake.ts b/apps/mobile/src/features/voice-input/recordingKeepAwake.ts new file mode 100644 index 0000000000..f08cc7ec69 --- /dev/null +++ b/apps/mobile/src/features/voice-input/recordingKeepAwake.ts @@ -0,0 +1,20 @@ +import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; +import { uuidv4 } from "../../lib/uuid"; + +type KeepAwakeBridge = { + readonly activate: (tag: string) => Promise; + readonly deactivate: (tag: string) => Promise; +}; + +export function holdRecordingAwake( + bridge: KeepAwakeBridge = { activate: activateKeepAwakeAsync, deactivate: deactivateKeepAwake }, + createId: () => string = uuidv4, +): () => void { + // An earlier native activation may settle after unmount or Fast Refresh. + const tag = `voice-input:${createId()}`; + const activation = bridge.activate(tag); + void activation.catch(() => {}); + return () => { + void activation.then(() => bridge.deactivate(tag)).catch(() => {}); + }; +} diff --git a/apps/mobile/src/features/voice-input/useVoiceInputController.ts b/apps/mobile/src/features/voice-input/useVoiceInputController.ts index 8d1c9a9780..5a40e44149 100644 --- a/apps/mobile/src/features/voice-input/useVoiceInputController.ts +++ b/apps/mobile/src/features/voice-input/useVoiceInputController.ts @@ -24,6 +24,7 @@ import { type VoiceInputState, } from "@t3tools/client-runtime/voice-input"; import { normalizeVoiceInputDecibels, VOICE_WAVEFORM_SAMPLE_COUNT } from "./voiceInputMetering"; +import { holdRecordingAwake } from "./recordingKeepAwake"; const INITIAL_STATE: VoiceInputState = { phase: "idle", error: null, errorAction: null }; const VOICE_METERING_INTERVAL_MS = 80; @@ -156,6 +157,11 @@ export function useVoiceInputController(input: { useEffect(() => () => controller.dispose(), [controller]); + useEffect(() => { + if (state.phase !== "recording") return; + return holdRecordingAwake(); + }, [state.phase]); + useEffect(() => { if (state.phase !== "preparing" && state.phase !== "recording") return; diff --git a/apps/mobile/src/lib/appLinking.test.ts b/apps/mobile/src/lib/appLinking.test.ts new file mode 100644 index 0000000000..ad75f3271b --- /dev/null +++ b/apps/mobile/src/lib/appLinking.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { shouldHandleAppLink } from "./appLinking"; + +describe("shouldHandleAppLink", () => { + it.each([ + "pylon-code://", + "pylon-code:///", + "pylon-code-dev://", + "pylon-code-dev:///", + "pylon-code-preview://", + "pylon-code-preview:///", + "PYLON-CODE://", + ])("keeps the current route for wake-only link %s", (url) => { + expect(shouldHandleAppLink(url)).toBe(false); + }); + + it.each([ + "pylon-code://threads/env-1/thread-1", + "pylon-code-dev://threads/env-1/thread-1", + "pylon-code-preview://settings/environments", + "pylon-code://pair?pairingUrl=https%3A%2F%2Fexample.test", + "pylon-code://?pairingUrl=https%3A%2F%2Fexample.test", + ])("preserves navigable and pairing link %s", (url) => { + expect(shouldHandleAppLink(url)).toBe(true); + }); + + it.each([ + "pylon-code-dev://expo-development-client/?url=packager", + "pylon-code://expo-sharing/anything", + ])("ignores native lifecycle link %s", (url) => { + expect(shouldHandleAppLink(url)).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/appLinking.ts b/apps/mobile/src/lib/appLinking.ts new file mode 100644 index 0000000000..4d48974449 --- /dev/null +++ b/apps/mobile/src/lib/appLinking.ts @@ -0,0 +1,10 @@ +/** Ignore lifecycle and wake-only URLs before they reach React Navigation. */ +export function shouldHandleAppLink(url: string): boolean { + if (url.includes("expo-development-client") || url.includes("://expo-sharing")) { + return false; + } + + // iOS dictation keyboards can return to the app with only its scheme. Those + // URLs wake the app but have no route, so navigation must keep its stack. + return !/^pylon-code(?:-dev|-preview)?:\/*$/i.test(url); +} diff --git a/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts b/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts index a6f24617a4..130b007878 100644 --- a/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts +++ b/apps/mobile/src/lib/composerAttachmentUploadQueue.test.ts @@ -105,6 +105,31 @@ describe("composer attachment upload queue", () => { queue.dispose(); }); + it("retries a failed attachment when the worker restores requests after reconnect", async () => { + let states: Readonly> = {}; + const upload = vi.fn().mockRejectedValueOnce(new Error("Disconnected")).mockResolvedValue(true); + const queue = createComposerAttachmentUploadQueue({ + upload, + onChange: (next) => { + states = next; + }, + }); + const local = request("failed-upload"); + const key = composerAttachmentUploadKey(environmentId, local.attachment.id); + queue.sync([local]); + await queue.settled(); + expect(states[key]?.status).toBe("failed"); + queue.sync([local]); + await queue.settled(); + expect(upload).toHaveBeenCalledTimes(1); + queue.sync([]); + queue.sync([local]); + await queue.settled(); + expect(upload).toHaveBeenCalledTimes(2); + expect(states[key]?.status).toBe("ready"); + queue.dispose(); + }); + it("ignores a late completion after removal or environment switch", async () => { const gate = Promise.withResolvers(); const started = Promise.withResolvers(); diff --git a/apps/mobile/src/lib/mobileTheme.test-support.ts b/apps/mobile/src/lib/mobileTheme.test-support.ts index a702bb9afb..1e47b0f0ed 100644 --- a/apps/mobile/src/lib/mobileTheme.test-support.ts +++ b/apps/mobile/src/lib/mobileTheme.test-support.ts @@ -14,7 +14,7 @@ export function readDefaultMobileThemeVariables( return Object.fromEntries( Array.from(variant.matchAll(/(--color-[a-z0-9-]+):\s*([^;]+);/gu), ([, name, value]) => [ name, - value.trim(), + (value ?? "").trim(), ]), ) as MobileThemeVariables; } diff --git a/apps/mobile/src/lib/mobileTheme.test.ts b/apps/mobile/src/lib/mobileTheme.test.ts index bf7df9afe4..bcd8f589ec 100644 --- a/apps/mobile/src/lib/mobileTheme.test.ts +++ b/apps/mobile/src/lib/mobileTheme.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { BUILT_IN_THEME_IDS, BUILT_IN_THEMES } from "@t3tools/shared/themePalettes"; +import { BUILT_IN_THEME_IDS, BUILT_IN_THEMES, T3_CHAT_THEME } from "@t3tools/shared/themePalettes"; import { readDefaultMobileThemeVariables } from "./mobileTheme.test-support"; import { @@ -49,7 +49,7 @@ function compositeOver(overlay: string, background: string): string { describe("mobile themes", () => { it("declares every runtime theme variable in the static stylesheet", () => { - const generatedVariables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"); + const generatedVariables = createMobileThemeVariables(T3_CHAT_THEME.colors, "light"); expect(Object.keys(readDefaultMobileThemeVariables("light")).sort()).toEqual( Object.keys(generatedVariables).sort(), ); @@ -152,16 +152,16 @@ describe("mobile themes", () => { }); it("maps semantic palette roles onto every mobile color variable", () => { - const variables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"); + const variables = createMobileThemeVariables(T3_CHAT_THEME.colors, "light"); expect(Object.keys(variables)).toHaveLength(75); expect(variables["--color-sheet-solid"]).toBe( - themeColorToNativeColor(BUILT_IN_THEMES[0].colors.chrome), + themeColorToNativeColor(T3_CHAT_THEME.colors.chrome), ); expect(variables["--color-warning"]).toBe( - themeColorToNativeColor(BUILT_IN_THEMES[0].colors.warningSurface), + themeColorToNativeColor(T3_CHAT_THEME.colors.warningSurface), ); expect(variables["--color-warning-foreground"]).toBe( - themeColorToNativeColor(BUILT_IN_THEMES[0].colors.warningForeground), + themeColorToNativeColor(T3_CHAT_THEME.colors.warningForeground), ); expect(variables["--color-primary"]).not.toBe(variables["--color-screen"]); expect(variables["--color-primary-shadow"]).toBe("#000000"); diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index 79698c2f52..4226f66087 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -1,5 +1,6 @@ import { BUILT_IN_THEMES, + T3_CHAT_THEME, getThemeColorsForAppearance, MOBILE_DEFAULT_THEME_ID, MOBILE_THEME_IDS as SHARED_MOBILE_THEME_IDS, @@ -29,7 +30,9 @@ export const MOBILE_THEME_OPTIONS: ReadonlyArray<{ ...BUILT_IN_THEMES.map((theme) => ({ id: theme.id as MobileThemeId, label: theme.label })), ]; -export type MobileThemeVariable = `--color-${string}`; +// Closed set: every key `createMobileThemeVariables` writes. Reads of a +// misspelled variable then fail to compile instead of yielding undefined. +export type MobileThemeVariable = keyof ReturnType; export type MobileThemeVariables = Readonly>; export function normalizeMobileThemeId(value: unknown): MobileThemeId { @@ -136,9 +139,10 @@ function withAlpha(color: string, alpha: number): string { function rgbChannels(color: string): readonly [number, number, number] | null { const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(color); - return match - ? [Number.parseInt(match[1], 16), Number.parseInt(match[2], 16), Number.parseInt(match[3], 16)] - : null; + if (!match) return null; + const [, red, green, blue] = match; + if (red === undefined || green === undefined || blue === undefined) return null; + return [Number.parseInt(red, 16), Number.parseInt(green, 16), Number.parseInt(blue, 16)]; } /** @@ -216,18 +220,15 @@ function readableForeground(foreground: string, surface: string, minimumContrast } export function themeColorWithAlpha(color: string, alpha: number): string { - const hex = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(color); - if (hex) { - return `rgba(${Number.parseInt(hex[1], 16)}, ${Number.parseInt(hex[2], 16)}, ${Number.parseInt(hex[3], 16)}, ${alpha})`; + const channels = rgbChannels(color); + if (channels) { + return `rgba(${channels[0]}, ${channels[1]}, ${channels[2]}, ${alpha})`; } const rgb = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/.exec(color); return rgb ? `rgba(${rgb[1]}, ${rgb[2]}, ${rgb[3]}, ${alpha})` : color; } -export function createMobileThemeVariables( - colors: ThemeColors, - appearance: MobileThemeAppearance, -): MobileThemeVariables { +export function createMobileThemeVariables(colors: ThemeColors, appearance: MobileThemeAppearance) { const c = nativeColors(colors); return { "--color-screen": c.canvas, @@ -312,7 +313,7 @@ export function createMobileThemeVariables( } export const MOBILE_THEME_VARIABLE_NAMES = Object.keys( - createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"), + createMobileThemeVariables(T3_CHAT_THEME.colors, "light"), ) as ReadonlyArray; export function getMobileThemeVariables( @@ -320,7 +321,7 @@ export function getMobileThemeVariables( appearance: MobileThemeAppearance, overrides: Partial | null = null, ): MobileThemeVariables { - const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0]; + const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? T3_CHAT_THEME; const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors; const baseVariables = createMobileThemeVariables(colors, appearance); @@ -334,7 +335,7 @@ export function getMobileThemePreviewColors( ): ThemePreviewColors { if (themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you") return STANDARD_THEME_PREVIEW_COLORS[appearance]; - const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0]; + const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? T3_CHAT_THEME; const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors; return { canvas: themeColorToNativeColor(colors.canvas), diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index db7289097e..e8178d803c 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -4053,3 +4053,50 @@ it("keeps attachment-only question answers expandable outside mobile work groups expect(running[1]).toBe(group); expect(running[2]?.type).toBe("work-toggle"); }); + +describe("orphaned background tasks never become subagent cards", () => { + // The next provider process settles a watcher it never launched, reporting + // only the task id and status. Ingestion stamps that bare row "agent", and + // judging it alone turned shell watchers into phantom subagents. + it("keeps a settled shell watcher classified as background work", () => { + const thread = makeThread({ + id: ThreadId.make("thread-orphan"), + projectId: ProjectId.make("project-orphan"), + title: "Orphan proof", + activities: [ + makeActivity({ + id: EventId.make("watcher-start"), + kind: "task.started", + summary: "local_bash task started", + createdAt: "2026-09-24T21:16:13.000Z", + payload: { + taskId: "watcher-1", + taskType: "local_bash", + agentKind: "background", + title: "Run the parallel merge queue dispatcher", + }, + }), + makeActivity({ + id: EventId.make("watcher-stop"), + kind: "task.completed", + summary: "Task stopped", + createdAt: "2026-09-24T21:34:03.000Z", + payload: { + taskId: "watcher-1", + status: "stopped", + agentKind: "agent", + title: "Run the parallel merge queue dispatcher", + summary: "Orphaned by a previous Claude Code process exit", + }, + }), + ], + }); + + const rows = buildThreadFeed(thread).flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + ); + expect(rows.length).toBeGreaterThan(0); + expect(rows.every((row) => row.workEntry.isBackgroundTask === true)).toBe(true); + expect(rows.some((row) => row.workEntry.agentSpawn !== undefined)).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index c5ed4e7154..8bd0751884 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1,5 +1,6 @@ import * as Option from "effect/Option"; import { foldUserInputActivities } from "@t3tools/client-runtime/work-log/user-input"; +import { collectBackgroundTaskIds } from "@t3tools/client-runtime/state/subagentRuntime"; import * as Schema from "effect/Schema"; import { requestKindFromRequestType, @@ -377,7 +378,10 @@ function isTerminalTaskUpdate(activity: OrchestrationThreadActivity): boolean { * Terminal rows are kept regardless — with no Agents surface on mobile they * are the terminal signal. */ -function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean { +function isAgentInternalActivity( + activity: OrchestrationThreadActivity, + backgroundTaskIds: ReadonlySet, +): boolean { const payload = activity.payload && typeof activity.payload === "object" ? (activity.payload as Record) @@ -397,7 +401,11 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean } // An agent's own shells stay internal; the agents themselves fold into // their batch. A bypassed batch marker keeps its terminal row. - if (typeof payload.taskId === "string" && payload.agentKind === "agent") { + if ( + typeof payload.taskId === "string" && + payload.agentKind === "agent" && + !backgroundTaskIds.has(payload.taskId) + ) { return false; } if (ownedByAgent) { @@ -409,18 +417,28 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean } /** Agent (non-background) task.started rows seed spawn batches. */ -function isAgentTaskStartedActivity(activity: OrchestrationThreadActivity): boolean { +function isAgentTaskStartedActivity( + activity: OrchestrationThreadActivity, + backgroundTaskIds: ReadonlySet, +): boolean { const payload = activity.payload && typeof activity.payload === "object" ? (activity.payload as Record) : null; - return typeof payload?.taskId === "string" && payload.agentKind === "agent"; + return ( + typeof payload?.taskId === "string" && + payload.agentKind === "agent" && + !backgroundTaskIds.has(payload.taskId) + ); } function deriveWorkLogEntries( activities: ReadonlyArray, ): DerivedWorkLogEntry[] { const ordered = Arr.sort(activities, activityOrder); + // Resolved across the thread: a task that names a background type stays + // background even when a later bare terminal row carries the agent stamp. + const backgroundTaskIds = collectBackgroundTaskIds(ordered); const entries: DerivedWorkLogEntry[] = []; for (const activity of foldUserInputActivities(ordered)) { // Ownership receipts feed Relay recovery and control routing, not the transcript. @@ -436,7 +454,11 @@ function deriveWorkLogEntries( // id and timestamp, unlike progress ticks, whose stable per-task id is // rewritten with a new createdAt on every update (and would otherwise // make the batch row a "fresh" row again on each tick). - if (activity.kind === "task.started" && !isAgentTaskStartedActivity(activity)) continue; + if ( + activity.kind === "task.started" && + !isAgentTaskStartedActivity(activity, backgroundTaskIds) + ) + continue; if (activity.kind === "task.updated" && !isTerminalTaskUpdate(activity)) { const payload = asRecord(activity.payload); if (payload?.agentKind !== "agent" || typeof payload.taskId !== "string") continue; @@ -455,8 +477,8 @@ function deriveWorkLogEntries( if (activity.summary === "Checkpoint captured") continue; if (isNoContentRuntimeWarning(activity)) continue; if (isPlanBoundaryToolActivity(activity)) continue; - if (isAgentInternalActivity(activity)) continue; - entries.push(toDerivedWorkLogEntry(activity)); + if (isAgentInternalActivity(activity, backgroundTaskIds)) continue; + entries.push(toDerivedWorkLogEntry(activity, backgroundTaskIds)); } return collapseDerivedWorkLogEntries(entries); } @@ -486,7 +508,10 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool const decodeQuestionAttachmentAnswer = Schema.decodeUnknownOption(UserInputAttachmentAnswerPayload); -function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry { +function toDerivedWorkLogEntry( + activity: OrchestrationThreadActivity, + backgroundTaskIds: ReadonlySet, +): DerivedWorkLogEntry { const payload = activity.payload && typeof activity.payload === "object" ? (activity.payload as Record) @@ -545,7 +570,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo entry.toolCallId = toolCallId; } if (isTaskActivity && payload) { - if (payload.agentKind !== "agent") { + if ( + payload.agentKind !== "agent" || + (typeof payload.taskId === "string" && backgroundTaskIds.has(payload.taskId)) + ) { entry.isBackgroundTask = true; } const spawnToolCallId = asTrimmedString(payload.toolUseId); diff --git a/apps/mobile/src/lib/wideMarkdownBlocks.ts b/apps/mobile/src/lib/wideMarkdownBlocks.ts index 57588bab2a..054c60be08 100644 --- a/apps/mobile/src/lib/wideMarkdownBlocks.ts +++ b/apps/mobile/src/lib/wideMarkdownBlocks.ts @@ -81,7 +81,11 @@ function hasOrderedListItem(text: string): boolean { const nestedMatch = INDENTED_ORDERED_LIST_ITEM.exec(line); const parentMatch = previousNonEmptyLine === null ? null : ANY_LIST_ITEM.exec(previousNonEmptyLine); - if (nestedMatch && parentMatch && parentMatch[1].length < nestedMatch[1].length) { + if ( + nestedMatch?.[1] !== undefined && + parentMatch?.[1] !== undefined && + parentMatch[1].length < nestedMatch[1].length + ) { return true; } diff --git a/apps/mobile/src/persistence/mobile-preferences.favorites.test.ts b/apps/mobile/src/persistence/mobile-preferences.favorites.test.ts new file mode 100644 index 0000000000..7d4efbe4be --- /dev/null +++ b/apps/mobile/src/persistence/mobile-preferences.favorites.test.ts @@ -0,0 +1,80 @@ +import { ProviderInstanceId } from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { vi } from "vite-plus/test"; + +vi.mock("expo-secure-store", () => ({ + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), + deleteItemAsync: vi.fn(), +})); + +import { MobileDatabase } from "./mobile-database"; +import { make } from "./mobile-preferences"; +import { MobileSecureStorage } from "./mobile-secure-storage"; + +it.effect("persists favorites across a store restart while loading older preferences", () => + Effect.gen(function* () { + let stored = { payload: JSON.stringify({ themeMode: "dark" }), updatedAt: 1 }; + const unused = Effect.die("Unrelated database operation"); + const database = MobileDatabase.of({ + loadCache: () => unused, + listCache: () => unused, + saveCache: () => unused, + removeCache: () => unused, + clearCacheKind: () => unused, + clearEnvironmentCache: () => unused, + clearAllCaches: unused, + inspectCaches: unused, + loadPreferencesJson: Effect.sync(() => Option.some(stored)), + savePreferencesJson: (payload, updatedAt) => + Effect.sync(() => { + stored = { payload, updatedAt }; + }), + }); + const secureStorage = MobileSecureStorage.of({ + getItem: () => Effect.succeed(null), + setItem: () => Effect.void, + removeItem: () => Effect.void, + }); + const makeStore = () => + make().pipe( + Effect.provideService(MobileDatabase, database), + Effect.provideService(MobileSecureStorage, secureStorage), + ); + + const beforeRestart = yield* makeStore(); + expect(yield* beforeRestart.load).toEqual({ themeMode: "dark" }); + const provider = ProviderInstanceId.make("codex_personal"); + yield* beforeRestart.savePatch({ modelFavorites: [{ provider, model: "gpt-6-sol" }] }); + expect(JSON.parse(stored.payload)).toEqual({ + themeMode: "dark", + modelFavorites: [{ provider, model: "gpt-6-sol" }], + }); + + const afterRestart = yield* makeStore(); + expect(yield* afterRestart.load).toEqual({ + themeMode: "dark", + modelFavorites: [{ provider, model: "gpt-6-sol" }], + }); + + stored = { + payload: JSON.stringify({ + themeMode: "dark", + modelFavorites: [ + { provider, model: "gpt-6-sol" }, + { provider: "", model: "invalid" }, + { provider, model: " " }, + { provider, model: 42 }, + ], + }), + updatedAt: stored.updatedAt + 1, + }; + const sanitizedRestart = yield* makeStore(); + expect(yield* sanitizedRestart.load).toEqual({ + themeMode: "dark", + modelFavorites: [{ provider, model: "gpt-6-sol" }], + }); + }), +); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 8209f6103b..86d5ef8a58 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -5,7 +5,7 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; -import type { SidebarProjectGroupingMode } from "@t3tools/contracts"; +import type { ProviderInstanceId, SidebarProjectGroupingMode } from "@t3tools/contracts"; import { MOBILE_THEME_IDS, type MobileThemeId, type MobileThemeMode } from "../lib/mobileTheme"; import * as MobileDatabase from "./mobile-database"; @@ -42,6 +42,11 @@ export interface Preferences { readonly legacyThreadListEnabled?: boolean; /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ readonly planModeEnabled?: boolean; + /** Model favorites belong to this device, like the web client setting. */ + readonly modelFavorites?: ReadonlyArray<{ + readonly provider: ProviderInstanceId; + readonly model: string; + }>; /** Fresh keys reset both shelves to collapsed when users update. */ readonly threadListSettledShelfExpanded?: boolean; readonly threadListSnoozedShelfExpanded?: boolean; @@ -103,6 +108,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { projectGroupingMode?: SidebarProjectGroupingMode; legacyThreadListEnabled?: boolean; planModeEnabled?: boolean; + modelFavorites?: Preferences["modelFavorites"]; threadListSettledShelfExpanded?: boolean; threadListSnoozedShelfExpanded?: boolean; } = {}; @@ -175,6 +181,17 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.planModeEnabled === "boolean") { preferences.planModeEnabled = parsed.planModeEnabled; } + if (Array.isArray(parsed.modelFavorites)) { + preferences.modelFavorites = parsed.modelFavorites.filter( + (favorite) => + typeof favorite === "object" && + favorite !== null && + typeof favorite.provider === "string" && + favorite.provider.length > 0 && + typeof favorite.model === "string" && + favorite.model.trim().length > 0, + ); + } if (typeof parsed.threadListSettledShelfExpanded === "boolean") { preferences.threadListSettledShelfExpanded = parsed.threadListSettledShelfExpanded; } diff --git a/apps/mobile/src/state/preferences.test.ts b/apps/mobile/src/state/preferences.test.ts index c53594eb23..9a84d70cd6 100644 --- a/apps/mobile/src/state/preferences.test.ts +++ b/apps/mobile/src/state/preferences.test.ts @@ -4,6 +4,10 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { vi } from "vite-plus/test"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import { RegistryContext, useAtomSet } from "@effect/atom-react"; +import { createElement } from "react"; +import { renderToString } from "react-dom/server"; vi.mock("expo-secure-store", () => ({ getItemAsync: vi.fn(), @@ -124,6 +128,79 @@ describe("mobile preferences state", () => { }), ); + it.effect("keeps both favorites when the React setter sends updates before a render", () => + Effect.gen(function* () { + let persisted: Preferences = { modelFavorites: [] }; + const state = makePreferencesState({ + load: Effect.succeed(persisted), + savePatch: (patch) => + Effect.sync(() => { + persisted = { ...persisted, ...patch }; + return persisted; + }), + update: (transform) => + Effect.sync(() => { + persisted = { ...persisted, ...transform(persisted) }; + return persisted; + }), + }); + const registry = AtomRegistry.make(); + const unmountPreferences = registry.mount(state.preferencesAtom); + const unmountUpdate = registry.mount(state.updatePreferencesAtom); + yield* AtomRegistry.getResult(registry, state.preferencesAtom, { suspendOnWaiting: true }); + + function useSavePreferences() { + return useAtomSet(state.updatePreferencesAtom); + } + const setters: Array> = []; + function CaptureSetter() { + setters.push(useSavePreferences()); + return null; + } + // Exercise the real React setter, which treats bare functions as updates + // to the atom's read value. Direct registry.set calls bypass that behavior. + renderToString( + createElement(RegistryContext.Provider, { value: registry }, createElement(CaptureSetter)), + ); + const savePreferences = setters[0]!; + const provider = ProviderInstanceId.make("codex"); + savePreferences({ + transform: (current) => ({ + modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "astra" }], + }), + }); + savePreferences({ + transform: (current) => ({ + modelFavorites: [...(current.modelFavorites ?? []), { provider, model: "sol" }], + }), + }); + yield* AtomRegistry.getResult(registry, state.updatePreferencesAtom, { + suspendOnWaiting: true, + }); + + expect(persisted.modelFavorites).toEqual([ + { provider, model: "astra" }, + { provider, model: "sol" }, + ]); + + savePreferences({ + transform: (current) => ({ + modelFavorites: (current.modelFavorites ?? []).filter( + (favorite) => favorite.model !== "astra", + ), + }), + }); + yield* AtomRegistry.getResult(registry, state.updatePreferencesAtom, { + suspendOnWaiting: true, + }); + expect(persisted.modelFavorites).toEqual([{ provider, model: "sol" }]); + + unmountUpdate(); + unmountPreferences(); + registry.dispose(); + }), + ); + it.effect("falls back to empty preferences when secure storage cannot be read", () => Effect.gen(function* () { const state = makePreferencesState({ diff --git a/apps/mobile/src/state/preferences.ts b/apps/mobile/src/state/preferences.ts index d173cf55be..7baff0251c 100644 --- a/apps/mobile/src/state/preferences.ts +++ b/apps/mobile/src/state/preferences.ts @@ -15,6 +15,12 @@ interface OptimisticPreferences { readonly versions: Partial>; } +// A bare function is interpreted by useAtomSet as an update to the command's +// AsyncResult, not as a preference transform. Keep transforms inside a payload. +type PreferencesUpdate = + | Partial + | { readonly transform: (current: Preferences) => Partial }; + /** * Owns the device preference blob for the lifetime of the app registry. * Optimistic patches are kept separately so writes made while persistence is @@ -57,7 +63,14 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime, get) => { + (update: PreferencesUpdate, get) => { + const currentPreferences = get(preferencesAtom); + const patch = + "transform" in update + ? update.transform( + AsyncResult.isSuccess(currentPreferences) ? currentPreferences.value : {}, + ) + : update; const version = ++nextPatchVersion; const current = get(optimisticPatchAtom); const versions = { ...current.versions }; @@ -69,7 +82,9 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime store.savePatch(patch)), + Effect.flatMap((store) => + "transform" in update ? store.update(update.transform) : store.savePatch(patch), + ), Effect.tap((saved) => Effect.sync(() => { get.set(confirmedPreferencesAtom, saved); diff --git a/apps/mobile/src/state/shell.ts b/apps/mobile/src/state/shell.ts index e879dd25e2..e20209c7cd 100644 --- a/apps/mobile/src/state/shell.ts +++ b/apps/mobile/src/state/shell.ts @@ -3,7 +3,12 @@ import { createEnvironmentShellSummaryAtom, createEnvironmentSnapshotAtom, createShellEnvironmentAtoms, + type EnvironmentShellState, } from "@t3tools/client-runtime/state/shell"; +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; @@ -15,3 +20,25 @@ export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, shellStateValueAtom: environmentShell.stateValueAtom, }); + +const EMPTY_ENVIRONMENT_SHELL_STATE_ATOM = Atom.make( + AsyncResult.success({ + snapshot: Option.none(), + status: "empty", + error: Option.none(), + }), +).pipe(Atom.withLabel("mobile-environment-shell:empty")); + +/** Reads one environment's shell projection without waiting on other environments. */ +export function useEnvironmentShellState(environmentId: EnvironmentId | null) { + const result = useAtomValue( + environmentId === null + ? EMPTY_ENVIRONMENT_SHELL_STATE_ATOM + : environmentShell.stateAtom(environmentId), + ); + return Option.getOrElse(AsyncResult.value(result), () => ({ + snapshot: Option.none(), + status: "empty" as const, + error: Option.none(), + })); +} diff --git a/apps/mobile/src/state/thread-outbox-failure-log.test.ts b/apps/mobile/src/state/thread-outbox-failure-log.test.ts new file mode 100644 index 0000000000..ef2727d48f --- /dev/null +++ b/apps/mobile/src/state/thread-outbox-failure-log.test.ts @@ -0,0 +1,147 @@ +import { + EnvironmentId, + MessageId, + OrchestrationDispatchCommandError, + ThreadId, +} from "@t3tools/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + reportOutboxDeliveryFailure, + reportOutboxEditedAfterDelivery, + reportOutboxUploadFailure, +} from "./thread-outbox-failure-log"; + +const message = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("message-1"), +}; + +const debugFlag = globalThis as { __PYLON_OUTBOX_DEBUG__?: boolean }; +let warn: ReturnType; +let log: ReturnType; + +beforeEach(() => { + warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + log = vi.spyOn(console, "log").mockImplementation(() => undefined); + delete debugFlag.__PYLON_OUTBOX_DEBUG__; +}); + +afterEach(() => { + delete debugFlag.__PYLON_OUTBOX_DEBUG__; + vi.restoreAllMocks(); +}); + +describe("thread outbox failure logging", () => { + it("keeps an ordinary offline retry out of warnings while retaining opt-in diagnostics", () => { + const input = { + stage: "start-turn" as const, + error: new Error("Socket is not connected"), + interrupted: false, + context: message, + }; + expect(reportOutboxDeliveryFailure(input)).toBe("retry"); + expect(warn).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + + expect( + reportOutboxDeliveryFailure({ + ...input, + error: { _tag: "RpcClientError", reason: { _tag: "SocketReadError" } }, + }), + ).toBe("retry"); + expect(warn).not.toHaveBeenCalled(); + + debugFlag.__PYLON_OUTBOX_DEBUG__ = true; + expect(reportOutboxDeliveryFailure(input)).toBe("retry"); + expect(log).toHaveBeenCalledWith( + "[pylon-thread-outbox] queued message delivery failed; retrying", + expect.objectContaining({ stage: "start-turn", action: "retry" }), + ); + expect(warn).not.toHaveBeenCalled(); + }); + + it("keeps server decisions and non-socket RPC failures visible as warnings", () => { + expect( + reportOutboxDeliveryFailure({ + stage: "start-turn", + error: new OrchestrationDispatchCommandError({ message: "Rejected" }), + interrupted: false, + context: message, + }), + ).toBe("hold"); + expect(warn).toHaveBeenCalledTimes(1); + + const decodeDefect = { _tag: "RpcClientError", reason: { _tag: "RpcClientDefect" } }; + expect( + reportOutboxDeliveryFailure({ + stage: "settings-sync", + error: decodeDefect, + interrupted: false, + context: message, + }), + ).toBe("retry"); + expect(warn).toHaveBeenCalledTimes(2); + + for (const reasonTag of ["StatusCodeError", "DecodeError", "WorkerError"]) { + expect( + reportOutboxDeliveryFailure({ + stage: "start-turn", + error: { _tag: "RpcClientError", reason: { _tag: reasonTag } }, + interrupted: false, + context: message, + }), + ).toBe("retry"); + } + expect(warn).toHaveBeenCalledTimes(5); + + expect( + reportOutboxDeliveryFailure({ + stage: "start-turn", + error: new Error("RpcClientError: StatusCodeError"), + interrupted: false, + context: message, + }), + ).toBe("retry"); + expect(warn).toHaveBeenCalledTimes(6); + + expect( + reportOutboxDeliveryFailure({ + stage: "start-turn", + error: new Error("Unexpected failure"), + interrupted: false, + context: message, + }), + ).toBe("hold"); + expect(warn).toHaveBeenCalledTimes(7); + }); + + it("treats interrupted requests and retryable uploads as ordinary", () => { + expect( + reportOutboxDeliveryFailure({ + stage: "start-turn", + error: new Error("Interrupted"), + interrupted: true, + context: message, + }), + ).toBe("retry"); + reportOutboxUploadFailure(message, new Error("Socket is not connected")); + expect(warn).not.toHaveBeenCalled(); + + reportOutboxUploadFailure(message, new Error("Invalid attachment")); + expect(warn).toHaveBeenCalledOnce(); + + reportOutboxUploadFailure(message, { + _tag: "RpcClientError", + reason: { _tag: "StatusCodeError" }, + }); + expect(warn).toHaveBeenCalledTimes(2); + }); + + it("does not warn when a user edit wins the delivery cleanup race", () => { + reportOutboxEditedAfterDelivery(message); + expect(warn).not.toHaveBeenCalled(); + expect(log).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/state/thread-outbox-failure-log.ts b/apps/mobile/src/state/thread-outbox-failure-log.ts new file mode 100644 index 0000000000..092191c372 --- /dev/null +++ b/apps/mobile/src/state/thread-outbox-failure-log.ts @@ -0,0 +1,104 @@ +import { + resolveThreadOutboxFailureAction, + shouldRetryThreadOutboxDelivery, + type QueuedThreadMessage, + type ThreadOutboxCommandStage, + type ThreadOutboxFailureAction, +} from "./thread-outbox-model"; + +// The UI's transport-message helper also matches a bare "RpcClientError". For +// logging, require an actual connection symptom so other RPC failures stay visible. +const ORDINARY_CONNECTION_MESSAGES = [ + /\bSocket(?:Read|Write|Open|Close)Error\b/i, + /\bSocket is not connected\b/i, + /Unable to connect to the T3 server WebSocket\./i, + /\bis not connected\.$/i, + /\bdisconnected\.$/i, + /\bcould not establish a WebSocket connection\.$/i, + /\bping timeout\b/i, +] as const; + +function isOrdinaryTransportFailure(error: unknown): boolean { + if (typeof error === "object" && error !== null && "_tag" in error) { + if (error._tag === "RpcClientError") { + const reason: unknown = (error as { readonly reason?: unknown }).reason; + if (typeof reason !== "object" || reason === null || !("_tag" in reason)) return false; + return [ + "SocketReadError", + "SocketWriteError", + "SocketOpenError", + "SocketCloseError", + ].includes(String(reason._tag)); + } + return [ + "ConnectionTransientError", + "EnvironmentRpcUnavailableError", + "EnvironmentNotRegisteredError", + ].includes(String(error._tag)); + } + const message = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" + ? error.message + : null; + return message !== null && ORDINARY_CONNECTION_MESSAGES.some((pattern) => pattern.test(message)); +} + +function debugLog(event: string, details: Record): void { + // Queued sends naturally retry offline. Keep those diagnostics available + // without writing a warning for every backoff attempt. + if ( + (globalThis as { readonly __PYLON_OUTBOX_DEBUG__?: boolean }).__PYLON_OUTBOX_DEBUG__ === true + ) { + console.log(`[pylon-thread-outbox] ${event}`, details); + } +} + +export function reportOutboxDeliveryFailure(input: { + readonly stage: ThreadOutboxCommandStage; + readonly error: unknown; + readonly interrupted: boolean; + readonly context: Record; +}): ThreadOutboxFailureAction { + const action = resolveThreadOutboxFailureAction(input); + const details = { ...input.context, stage: input.stage, action }; + if (action === "retry" && (input.interrupted || isOrdinaryTransportFailure(input.error))) { + debugLog("queued message delivery failed; retrying", details); + } else { + console.warn("[thread-outbox] queued message delivery failed", details); + } + return action; +} + +export function reportOutboxUploadFailure( + message: Pick, + error: unknown, +): void { + const details = { + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + error, + }; + if (shouldRetryThreadOutboxDelivery(error) && isOrdinaryTransportFailure(error)) { + debugLog("attachment upload failed; retrying", details); + } else { + console.warn("[thread-outbox] failed to upload attachments", details); + } +} + +export function reportOutboxEditedAfterDelivery( + message: Pick, +): void { + debugLog("delivered message was edited before cleanup", { + environmentId: message.environmentId, + threadId: message.threadId, + messageId: message.messageId, + }); +} diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index 7f24712305..7bb1d05538 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -13,16 +13,24 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; -import { environmentSnapshotAtom } from "./shell"; +import { environmentShell, environmentSnapshotAtom } from "./shell"; -export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime); +const environmentLiveAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => get(environmentShell.stateValueAtom(environmentId)).status === "live"), +); + +export const threadEnvironment = createThreadEnvironmentAtoms( + connectionAtomRuntime, + environmentSnapshotAtom, + environmentLiveAtom, +); export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( environmentThreads.stateAtom, ); export const environmentThreadShells = createEnvironmentThreadShellAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, - snapshotAtom: environmentSnapshotAtom, + snapshotAtom: threadEnvironment.snapshotAtom, }); const EMPTY_THREAD_STATE_ATOM = Atom.make(AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE)).pipe( diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 914f525deb..f9e245de57 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -22,6 +22,11 @@ import { shouldRefreshProviderModelCatalog, } from "../lib/providerModelSelection"; import { createQueuedModelCatalogRefresh } from "./queued-model-catalog-refresh"; +import { + reportOutboxDeliveryFailure, + reportOutboxEditedAfterDelivery, + reportOutboxUploadFailure, +} from "./thread-outbox-failure-log"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; @@ -53,7 +58,6 @@ import { resolveConfirmedThreadOutboxPlan, resolveThreadOutboxDeliveryAction, resolveThreadOutboxDispatchStep, - resolveThreadOutboxFailureAction, shouldRetryThreadOutboxDelivery, threadOutboxDeliveryHoldsEqual, sourceEpochMismatchHold, @@ -237,14 +241,7 @@ export async function completeQueuedMessageDelivery( ); if (!removed) { forgetAcknowledgedThreadMessage(queuedMessage); - console.warn( - "[thread-outbox] delivered message was edited before cleanup; keeping the newer message", - { - environmentId: queuedMessage.environmentId, - threadId: queuedMessage.threadId, - messageId: queuedMessage.messageId, - }, - ); + reportOutboxEditedAfterDelivery(queuedMessage); return "edited"; } return "removed"; @@ -677,18 +674,16 @@ export function useThreadOutboxDrain(): void { return null; } const error = Cause.squash(commandResult.cause); - const action = resolveThreadOutboxFailureAction({ + const action = reportOutboxDeliveryFailure({ stage, error, interrupted: Cause.hasInterruptsOnly(commandResult.cause), - }); - console.warn("[thread-outbox] queued message delivery failed", { - environmentId: queuedMessage.environmentId, - threadId: queuedMessage.threadId, - messageId: queuedMessage.messageId, - stage, - cause: commandResult.cause, - action, + context: { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + cause: commandResult.cause, + }, }); const epochHold = stage === "start-turn" ? sourceEpochMismatchHold(error) : null; return { @@ -764,7 +759,7 @@ export function useThreadOutboxDrain(): void { return "complete"; } } catch (error) { - console.warn("[thread-outbox] failed to upload attachments", error); + reportOutboxUploadFailure(queuedMessage, error); if (!shouldRetryThreadOutboxDelivery(error)) { const restored = await restoreQueuedMessage( queuedMessage, @@ -882,7 +877,7 @@ export function useThreadOutboxDrain(): void { return "complete"; } } catch (error) { - console.warn("[thread-outbox] failed to upload attachments", error); + reportOutboxUploadFailure(queuedMessage, error); if (!shouldRetryThreadOutboxDelivery(error)) { const restored = await restoreQueuedMessage( queuedMessage, diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index f3763c5654..f6634fbc4c 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -2,6 +2,8 @@ "extends": "expo/tsconfig.base", "compilerOptions": { "allowImportingTsExtensions": true, - "strict": true + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true } } diff --git a/apps/server/package.json b/apps/server/package.json index e48d2dd3f5..e55f206c24 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -22,7 +22,7 @@ "test": "vp test run" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.260", + "@anthropic-ai/claude-agent-sdk": "^0.3.276", "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 1aea931038..651eaf302a 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -38,6 +38,8 @@ const emitXAiAskUserQuestionThenHang = const emitContentThenHang = process.env.T3_ACP_EMIT_CONTENT_THEN_HANG === "1"; const emitPlanThenHang = process.env.T3_ACP_EMIT_PLAN_THEN_HANG === "1"; const emitActiveToolThenHang = process.env.T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG === "1"; +const emitGrokMonitorPostTurnPoll = process.env.T3_ACP_EMIT_GROK_MONITOR_POST_TURN_POLL === "1"; +const emitGrokBackgroundTaskStarted = process.env.T3_ACP_EMIT_GROK_BACKGROUND_TASK_STARTED === "1"; const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; const emitPrivateThoughtChunk = process.env.T3_ACP_EMIT_PRIVATE_THOUGHT_CHUNK === "1"; const assistantMessageIdMode = process.env.T3_ACP_ASSISTANT_MESSAGE_IDS; @@ -935,6 +937,108 @@ const program = Effect.gen(function* () { return yield* Effect.never; } + if (emitGrokMonitorPostTurnPoll) { + const monitorCallId = "call-monitor-1"; + const pollCallId = "call-monitor-poll-1"; + const taskId = "01a05f41-5107-7550-821e-79e8d1cd7687"; + const description = "Watch count-sheet Typst unit until done"; + writeJsonRpcNotification("session/update", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: monitorCallId, + title: "monitor", + kind: "other", + status: "pending", + rawInput: { description }, + _meta: { + "x.ai/tool": { version: 1, name: "monitor", kind: "task", namespace: "grok_build" }, + }, + }, + }); + writeJsonRpcNotification("session/update", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: monitorCallId, + status: "completed", + rawInput: { description }, + rawOutput: { + type: "Monitor", + taskId, + timeoutMs: 36_000_000, + }, + }, + }); + writeJsonRpcNotification("_x.ai/session/prompt_complete", { + sessionId: requestedSessionId, + promptId: promptIdFromRequestMeta(request) ?? "mock-xai-prompt-1", + stopReason: "end_turn", + agentResult: null, + }); + yield* Effect.sleep("120 millis"); + writeJsonRpcNotification("session/update", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: pollCallId, + title: "get_command_or_subagent_output", + kind: "other", + status: "completed", + rawInput: { variant: "TaskOutput", task_ids: [taskId], timeout_ms: 0 }, + rawOutput: { + type: "TaskOutput", + Result: { + task_id: taskId, + command: `[monitor] ${description}`, + status: "completed", + exit_code: 0, + output: "Monitor finished.", + }, + }, + }, + }); + return yield* Effect.never; + } + + if (emitGrokBackgroundTaskStarted) { + const toolCallId = "call-fb9d0000-0000-0000-0000-000000000026"; + const command = "sleep 40; echo done-a"; + writeJsonRpcNotification("session/update", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId, + title: "run_terminal_command", + kind: "execute", + status: "in_progress", + rawInput: { command }, + }, + }); + writeJsonRpcNotification("session/update", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "completed", + rawOutput: { + type: "BackgroundTaskStarted", + task_id: toolCallId, + task_type: "bash", + status: "running", + command, + }, + }, + }); + writeJsonRpcNotification("_x.ai/session/prompt_complete", { + sessionId: requestedSessionId, + promptId: promptIdFromRequestMeta(request) ?? "mock-xai-prompt-1", + stopReason: "end_turn", + agentResult: null, + }); + return yield* Effect.never; + } + if (emitInterleavedAssistantToolCalls) { const toolCallId = "tool-call-1"; diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 000ca084d3..4fb0e26676 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -139,3 +139,10 @@ it("requires operate permission for host retry while preserving read-only listin AuthOrchestrationOperateScope, ); }); + +it("requires operate permission for tool updates even alongside a read-only check", () => { + expect(requiredScopeForDeviceList({ updateTool: "agent", inspectOnly: true })).toBe( + AuthOrchestrationOperateScope, + ); + expect(requiredScopeForDeviceList({ updateTool: "hub" })).toBe(AuthOrchestrationOperateScope); +}); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 31bc327584..6f522d156e 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -208,4 +208,6 @@ export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope /** Retrying can install or restart tools even though ordinary listing is readable. */ export const requiredScopeForDeviceList = (input: DeviceListInput): AuthEnvironmentScope => - input.retryHostId ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope; + input.retryHostId || input.updateTool + ? AuthOrchestrationOperateScope + : AuthOrchestrationReadScope; diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 42fbb7815c..d6163bb463 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -16,6 +16,7 @@ import { import * as NetService from "@t3tools/shared/Net"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; @@ -106,6 +107,7 @@ const makeCliTestServerConfig = (baseDir: string) => otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", + otelEnvironment: OtelEnvironment.none, mode: "web", port: 0, host: "127.0.0.1", diff --git a/apps/server/src/checkpointing/Utils.ts b/apps/server/src/checkpointing/Utils.ts index adc089f624..88ae5b9369 100644 --- a/apps/server/src/checkpointing/Utils.ts +++ b/apps/server/src/checkpointing/Utils.ts @@ -3,10 +3,12 @@ import { CheckpointRef, ProjectId, type ThreadId } from "@t3tools/contracts"; const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; +export function checkpointRefPrefixForThread(threadId: ThreadId): string { + return `${CHECKPOINT_REFS_PREFIX}/${Encoding.encodeBase64Url(threadId)}/`; +} + export function checkpointRefForThreadTurn(threadId: ThreadId, turnCount: number): CheckpointRef { - return CheckpointRef.make( - `${CHECKPOINT_REFS_PREFIX}/${Encoding.encodeBase64Url(threadId)}/turn/${turnCount}`, - ); + return CheckpointRef.make(`${checkpointRefPrefixForThread(threadId)}turn/${turnCount}`); } export function resolveThreadWorkspaceCwd(input: { diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index b15787efeb..20fcf45891 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -18,6 +18,7 @@ import { } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/unstable/cli"; @@ -58,7 +59,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", + otlpServiceName: "pylon-server", + otelEnvironment: OtelEnvironment.none, devAllowedOrigins: [], } as const; @@ -582,6 +584,109 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); + it.effect("zeroes an endpoint stored in Settings when the SDK is disabled", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-otel-off-" }); + const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); + yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); + yield* fs.writeFileString( + derivedPaths.settingsPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + `${JSON.stringify({ + observability: { + otlpTracesUrl: "http://localhost:4318/v1/traces", + otlpMetricsUrl: "http://localhost:4318/v1/metrics", + otlpLogsUrl: "http://localhost:4318/v1/logs", + }, + })}\n`, + ); + + const resolved = yield* resolveServerConfig( + { + mode: Option.some("desktop"), + port: Option.some(4888), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { OTEL_SDK_DISABLED: "true" } })), + NetService.layer, + ), + ), + ); + + // The switch beats every source, including an endpoint stored in Settings. + expect(resolved.otlpTracesUrl).toBeUndefined(); + expect(resolved.otlpMetricsUrl).toBeUndefined(); + expect(resolved.otlpLogsUrl).toBeUndefined(); + expect(resolved.otelEnvironment.disabled).toBe(true); + }), + ); + + it.effect("lets T3CODE_OTEL_SDK_DISABLED=false override an ambient OTEL_SDK_DISABLED=true", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-otel-on-" }); + const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); + yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); + yield* fs.writeFileString( + derivedPaths.settingsPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + `${JSON.stringify({ + observability: { + otlpTracesUrl: "http://localhost:4318/v1/traces", + }, + })}\n`, + ); + + const resolved = yield* resolveServerConfig( + { + mode: Option.some("desktop"), + port: Option.some(4888), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { T3CODE_OTEL_SDK_DISABLED: "false", OTEL_SDK_DISABLED: "true" }, + }), + ), + NetService.layer, + ), + ), + ); + + expect(resolved.otelEnvironment.disabled).toBe(false); + expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); + }), + ); + it.effect("forces noBrowser and disables auto-bootstrap for headless startup presentation", () => Effect.gen(function* () { const { join } = yield* Path.Path; diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index c876450505..3e7d685492 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -4,6 +4,7 @@ import { OtlpProtocol, type SignalExport, } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; import * as Config from "effect/Config"; @@ -106,7 +107,9 @@ const EnvServerConfig = Config.all({ otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( Config.withDefault(10_000), ), - otlpServiceName: Config.string("T3CODE_OTLP_SERVICE_NAME").pipe(Config.withDefault("t3-server")), + otlpServiceName: Config.string("T3CODE_OTLP_SERVICE_NAME").pipe( + Config.withDefault("pylon-server"), + ), otlpHeaders: Config.schema(OtlpHeadersFromString, "T3CODE_OTLP_HEADERS").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -372,7 +375,9 @@ export const resolveServerConfig = ( ); const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); - // Pylon's own OTLP variables name no signal, so the one answer they give + const otel = yield* OtelEnvironment.load; + + // Pylon's inherited OTLP variables name no signal, so the one answer they give // is the answer for all three. const signalExport: SignalExport = { protocol: env.otlpProtocol, @@ -387,20 +392,24 @@ export const resolveServerConfig = ( traceBatchWindowMs: env.traceBatchWindowMs, traceMaxBytes: env.traceMaxBytes, traceMaxFiles: env.traceMaxFiles, - otlpTracesUrl: - env.otlpTracesUrl ?? - bootstrap?.otlpTracesUrl ?? - persistedObservabilitySettings.otlpTracesUrl, - otlpMetricsUrl: - env.otlpMetricsUrl ?? - bootstrap?.otlpMetricsUrl ?? - persistedObservabilitySettings.otlpMetricsUrl, - otlpLogsUrl: - env.otlpLogsUrl ?? bootstrap?.otlpLogsUrl ?? persistedObservabilitySettings.otlpLogsUrl, + otlpTracesUrl: otel.disabled + ? undefined + : (env.otlpTracesUrl ?? + bootstrap?.otlpTracesUrl ?? + persistedObservabilitySettings.otlpTracesUrl), + otlpMetricsUrl: otel.disabled + ? undefined + : (env.otlpMetricsUrl ?? + bootstrap?.otlpMetricsUrl ?? + persistedObservabilitySettings.otlpMetricsUrl), + otlpLogsUrl: otel.disabled + ? undefined + : (env.otlpLogsUrl ?? bootstrap?.otlpLogsUrl ?? persistedObservabilitySettings.otlpLogsUrl), otlpTracesExport: signalExport, otlpMetricsExport: signalExport, otlpLogsExport: signalExport, otlpServiceName: env.otlpServiceName, + otelEnvironment: otel, mode, port, cwd, diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 801d877c9c..6fc0da6a5c 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -16,6 +16,7 @@ import { } from "@t3tools/contracts"; import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { buildTailscaleHttpsBaseUrl, DEFAULT_TAILSCALE_SERVE_PORT, @@ -326,6 +327,7 @@ const makePairServerConfig = Effect.fn(function* (input: { otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "pylon-server", + otelEnvironment: OtelEnvironment.none, mode: "web", port: state.port, host: state.host, diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 573d61432e..b6738325ac 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -17,6 +17,7 @@ import * as Schema from "effect/Schema"; import { sweepStalePendingAttachments } from "./attachmentStore.ts"; import { DEFAULT_SIGNAL_EXPORT, type SignalExport } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; export const DEFAULT_PORT = 3773; @@ -81,6 +82,7 @@ export class ServerConfig extends Context.Service< readonly otlpMetricsExport: SignalExport; readonly otlpLogsExport: SignalExport; readonly otlpServiceName: string; + readonly otelEnvironment: OtelEnvironment.OtelEnvironment; readonly mode: RuntimeMode; readonly port: number; readonly host: string | undefined; @@ -215,6 +217,7 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "pylon-server", + otelEnvironment: OtelEnvironment.none, cwd, baseDir, ...derivedPaths, diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 8f104c8e8d..3bf3f6f7fc 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { DEFAULT_SERVER_SETTINGS, DeviceId, + DeviceOperationError, EnvironmentId, LOCAL_DEVICE_HOST_ID, ProjectId, @@ -69,6 +70,7 @@ const fixture = Effect.fn("fixture")(function* ( failListAfterShutdown = false, runtimeFailure?: DeviceHost.DeviceHostError, inspectError = false, + installTool?: Parameters[3], ) { const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); const starts: string[] = []; @@ -130,11 +132,15 @@ const fixture = Effect.fn("fixture")(function* ( starts.push("stop"); }), }; - const service = yield* makeWithHosts(new Map([[host.id, host]]), undefined, (hostId) => - Effect.sync(() => { - agentConfigs.push(hostId); - return "/test-agent-config.json"; - }), + const service = yield* makeWithHosts( + new Map([[host.id, host]]), + undefined, + (hostId) => + Effect.sync(() => { + agentConfigs.push(hostId); + return "/test-agent-config.json"; + }), + installTool, ).pipe( Effect.provideService(DeviceHost.DeviceHost, host), Effect.provideService( @@ -634,3 +640,67 @@ it.effect("failed read-only discovery preserves lifecycle status and installed i expect(starts).toEqual([]); }).pipe(Effect.scoped), ); + +it.effect( + "manual updates install only the selected tool without enabling access or starting helpers", + () => + Effect.gen(function* () { + const installed: string[] = []; + const { service, starts, agentStarts, requests } = yield* fixture( + Effect.void, + undefined, + false, + undefined, + false, + (tool) => + Effect.sync(() => { + installed.push(tool); + }), + ); + const before = yield* service.state; + const state = yield* service.updateTool("agent"); + expect(installed).toEqual(["agent"]); + expect(state.supportsToolUpdate).toBe(true); + expect(state.hostStatus).toBe(before.hostStatus); + expect(state.agentAccessEnabled).toBe(before.agentAccessEnabled); + expect(state.revision).toBeGreaterThan(before.revision); + expect(starts).toEqual([]); + expect(agentStarts).toEqual([]); + expect(requests).toEqual([]); + yield* service.updateTool("hub"); + expect(installed).toEqual(["agent", "hub"]); + }).pipe(Effect.scoped), +); + +it.effect("failed manual installation leaves lifecycle state unchanged and can be retried", () => + Effect.gen(function* () { + let attempts = 0; + const { service, starts, agentStarts } = yield* fixture( + Effect.void, + undefined, + false, + undefined, + false, + () => + Effect.suspend(() => + ++attempts === 1 + ? Effect.fail( + new DeviceOperationError({ + operation: "update device tool", + reason: "command_failed", + cause: new Error("offline"), + }), + ) + : Effect.void, + ), + ); + const before = yield* service.state; + const result = yield* service.updateTool("agent").pipe(Effect.result); + expect(result._tag).toBe("Failure"); + expect(yield* service.state).toEqual(before); + yield* service.updateTool("agent"); + expect(attempts).toBe(2); + expect(starts).toEqual([]); + expect(agentStarts).toEqual([]); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 17d01de52b..07ffeded90 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -37,7 +37,7 @@ import { } from "@t3tools/contracts"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { ensureAgentDevice } from "./DeviceToolchain.ts"; +import { ensureAgentDevice, ensureDeviceHub } from "./DeviceToolchain.ts"; import * as ServerConfig from "../config.ts"; import { agentDeviceConfigPath, @@ -59,6 +59,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab import * as ServerSettings from "../serverSettings.ts"; import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; +import { isLocalSshDeviceHost, remoteSshDeviceHosts } from "./localSshDeviceHost.ts"; import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -129,6 +130,7 @@ export class DeviceService extends Context.Service< ) => Effect.Effect; /** Refreshes devices only after device support has been enabled. */ readonly list: Effect.Effect; + readonly updateTool: (tool: "hub" | "agent") => Effect.Effect; readonly inspect: Effect.Effect; readonly retryHost: (hostId: DeviceHostId) => Effect.Effect; readonly open: (input: DeviceOpenInput) => Effect.Effect; @@ -185,6 +187,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* reason: "Agent configuration is unavailable in this device service.", }), ), + installTool?: (tool: "hub" | "agent") => Effect.Effect, ) { const settings = yield* ServerSettings.ServerSettingsService; const lifecycleLock = yield* Semaphore.make(1); @@ -208,6 +211,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* const stateRef = yield* SynchronizedRef.make({ state: { supportsHostRetry: true, + supportsToolUpdate: installTool !== undefined, supportsToolInspection: true, hosts: initialHosts, hostStatus: initialSettings.enabled ? "idle" : "disabled", @@ -909,6 +913,21 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* return { ...DeviceService.of({ testHost, + updateTool: (tool) => + lifecycleLock.withPermit( + Effect.gen(function* () { + if (!installTool) + return yield* Effect.fail( + new DeviceOperationError({ + operation: "update device tool", + reason: "request_failed", + cause: new Error("Tool installation is unavailable in this device service."), + }), + ); + yield* installTool(tool); + return yield* inspect; + }), + ), retryHost, inspect, agentCli: Effect.fail( @@ -1032,11 +1051,17 @@ export const make = Effect.gen(function* () { }; const probeContext = yield* Effect.context>>(); + const localTargetContext = + yield* Effect.context>>(); const service = yield* makeWithHosts( hosts, (host) => - SshDeviceHost.probe(host).pipe( - Effect.provide(probeContext), + Effect.gen(function* () { + if (yield* isLocalSshDeviceHost(host).pipe(Effect.provide(localTargetContext))) { + return yield* localHost.summary; + } + return yield* SshDeviceHost.probe(host).pipe(Effect.provide(probeContext)); + }).pipe( Effect.mapError( (error) => new DeviceOperationError({ @@ -1047,12 +1072,29 @@ export const make = Effect.gen(function* () { ), ), configureAgent, + (tool) => + (tool === "hub" ? ensureDeviceHub(config.baseDir) : ensureAgentDevice(config.baseDir)).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.mapError( + (cause) => + new DeviceOperationError({ + operation: "update device tool", + reason: "command_failed", + cause, + }), + ), + ), ); const hostContext = yield* Effect.context>>(); const configured = new Map(); - const reconcile = (next: ReadonlyArray) => + const reconcile = (configuredHosts: ReadonlyArray) => Effect.gen(function* () { + const next = yield* remoteSshDeviceHosts(configuredHosts).pipe( + Effect.provide(localTargetContext), + ); const removed = yield* service.withLifecycleLock( Effect.gen(function* () { const removed: Array<{ id: string; scope: Scope.Closeable }> = []; diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index 037e05186b..e6b608aa45 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -20,17 +20,21 @@ import * as NetService from "@t3tools/shared/Net"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; -const diagnose = (files: ReadonlyArray, environment: NodeJS.ProcessEnv) => +const diagnose = ( + files: ReadonlyArray, + environment: NodeJS.ProcessEnv, + platform: NodeJS.Platform = "darwin", +) => LocalDeviceHost.__testing.platformReason("android").pipe( Effect.provideService(HostProcessEnvironment, environment), - Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService(HostProcessPlatform, platform), Effect.provideService( FileSystem.FileSystem, FileSystem.makeNoop({ exists: (file) => Effect.succeed(files.includes(file)), }), ), - Effect.provide(NodePath.layer), + Effect.provide(platform === "win32" ? NodePath.layerWin32 : NodePath.layerPosix), ); describe("Android SDK availability", () => { @@ -46,7 +50,50 @@ describe("Android SDK availability", () => { const reason = yield* diagnose(["/sdk/platform-tools/adb", "/sdk/emulator/emulator"], { ANDROID_HOME: "/sdk", }); - expect(reason).toContain("Command-line Tools (latest)"); + expect(reason).toContain("Command-line Tools (latest) are missing"); + }), + ); + + it.effect("explains how to upgrade legacy command-line tools", () => + Effect.gen(function* () { + const root = "/test/home/Library/Android/sdk"; + const reason = yield* diagnose( + [`${root}/platform-tools/adb`, `${root}/emulator/emulator`, `${root}/tools/bin/avdmanager`], + { HOME: "/test/home" }, + ); + expect(reason).toContain("older, unsupported version"); + expect(reason).toContain(root); + expect(reason).toContain("Android Studio's SDK Manager under SDK Tools"); + }), + ); + + it.effect("recognizes legacy command-line tools on Windows", () => + Effect.gen(function* () { + const reason = yield* diagnose( + [ + "C:\\Android\\Sdk\\platform-tools\\adb.exe", + "C:\\Android\\Sdk\\emulator\\emulator.exe", + "C:\\Android\\Sdk\\tools\\bin\\avdmanager.bat", + ], + { ANDROID_HOME: "C:\\Android\\Sdk" }, + "win32", + ); + expect(reason).toContain("older, unsupported version"); + }), + ); + + it.effect("accepts latest tools even if legacy tools are installed", () => + Effect.gen(function* () { + const reason = yield* diagnose( + [ + "/sdk/platform-tools/adb", + "/sdk/emulator/emulator", + "/sdk/tools/bin/avdmanager", + "/sdk/cmdline-tools/latest/bin/avdmanager", + ], + { ANDROID_HOME: "/sdk" }, + ); + expect(reason).toBeNull(); }), ); diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 28143f5b8e..139034a178 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -109,8 +109,11 @@ const platformReason = Effect.fn("LocalDeviceHost.platformReason")(function* ( return `Android SDK Platform-Tools are missing from ${sdk.root}. Install them in Android Studio's SDK Manager.`; if (!sdk.emulator) return `Android Emulator is missing from ${sdk.root}. Install it in Android Studio's SDK Manager.`; - if (!sdk.avdmanager) + if (!sdk.avdmanager) { + if (sdk.legacyAvdmanager) + return `The Android SDK command-line tools in ${sdk.root} appear to be an older, unsupported version. Install Android SDK Command-line Tools (latest) in Android Studio's SDK Manager under SDK Tools.`; return `Android SDK Command-line Tools (latest) are missing from ${sdk.root}. Install them in Android Studio's SDK Manager.`; + } return null; }); @@ -160,10 +163,15 @@ const androidSdk = Effect.gen(function* () { platform === "win32" ? "avdmanager.bat" : "avdmanager", ), ); - return { root, adb, emulator, avdmanager }; + const legacyAvdmanager = + !avdmanager && + (yield* exists( + path.join(root, "tools", "bin", platform === "win32" ? "avdmanager.bat" : "avdmanager"), + )); + return { root, adb, emulator, avdmanager, legacyAvdmanager }; } } - return { root: null, adb: false, emulator: false, avdmanager: false }; + return { root: null, adb: false, emulator: false, avdmanager: false, legacyAvdmanager: false }; }); const deviceHostEnvironment = ( diff --git a/apps/server/src/device/localSshDeviceHost.test.ts b/apps/server/src/device/localSshDeviceHost.test.ts new file mode 100644 index 0000000000..d4d202950a --- /dev/null +++ b/apps/server/src/device/localSshDeviceHost.test.ts @@ -0,0 +1,87 @@ +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { + isLocalSshDeviceHost, + LocalDeviceHostAddresses, + remoteSshDeviceHosts, +} from "./localSshDeviceHost.ts"; + +const host = (target: string, port?: number) => ({ + id: target, + label: target, + target, + ...(port ? { port } : {}), +}); +const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command"); + // Any attempt to actually connect fails this test. + expect(command.args).toContain("-G"); + const target = command.args.at(-1); + const configs: Record = { + "mac-mini": "hostname 100.65.180.100\nport 22\n", + remote: "hostname 192.0.2.1\nport 22\n", + loopback: "hostname 127.0.1.1\nport 22\n", + ipv6: "hostname ::1\nport 22\n", + forwarded: "hostname 127.0.0.1\nport 2222\n", + proxy: "hostname 127.0.0.1\nport 22\nproxyjump bastion\n", + command: "hostname 127.0.0.1\nport 22\nproxycommand nc remote 22\n", + unresolved: "hostname example.invalid\nport 22\n", + }; + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: Stream.make(new TextEncoder().encode(configs[target ?? ""] ?? "")), + stderr: Stream.empty, + all: Stream.empty, + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); + }), +); +const provide = ( + effect: Effect.Effect>>, +) => + effect.pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(LocalDeviceHostAddresses, new Set(["100.65.180.100"])), + Effect.provide(NodeServices.layer), + ); + +it.effect("skips SSH aliases resolving to this machine, including loopback", () => + provide( + Effect.gen(function* () { + for (const target of ["mac-mini", "loopback", "ipv6"]) { + expect(yield* isLocalSshDeviceHost(host(target))).toBe(true); + } + }), + ), +); + +it.effect("keeps remote, forwarded, proxied, and unresolved destinations", () => + provide( + Effect.gen(function* () { + for (const target of ["remote", "forwarded", "proxy", "command", "unresolved"]) { + expect(yield* isLocalSshDeviceHost(host(target))).toBe(false); + } + }), + ), +); + +it.effect("removes only self targets from a fanned-out host list", () => + provide( + Effect.gen(function* () { + expect( + yield* remoteSshDeviceHosts([host("mac-mini"), host("remote"), host("forwarded")]), + ).toEqual([host("remote"), host("forwarded")]); + }), + ), +); diff --git a/apps/server/src/device/localSshDeviceHost.ts b/apps/server/src/device/localSshDeviceHost.ts new file mode 100644 index 0000000000..0d1f615e17 --- /dev/null +++ b/apps/server/src/device/localSshDeviceHost.ts @@ -0,0 +1,71 @@ +import * as NodeDnsPromises from "node:dns/promises"; +import * as NodeNet from "node:net"; +import type { SshDeviceHostConfig } from "@t3tools/contracts"; +import * as NodeOS from "node:os"; +import * as Context from "effect/Context"; +import { runSshCommand } from "@t3tools/ssh/command"; +import * as Effect from "effect/Effect"; + +export const LocalDeviceHostAddresses = Context.Reference>( + "LocalDeviceHostAddresses", + { + defaultValue: () => + new Set( + Object.values(NodeOS.networkInterfaces()).flatMap( + (entries) => entries?.map((entry) => entry.address) ?? [], + ), + ), + }, +); + +/** Resolve aliases on the owning environment without opening an SSH connection. */ +export const isLocalSshDeviceHost = Effect.fn("isLocalSshDeviceHost")(function* ( + host: SshDeviceHostConfig, +) { + const result = yield* runSshCommand( + { alias: host.target, hostname: host.target, username: null, port: host.port ?? null }, + { + preHostArgs: ["-G", ...(host.identityFile ? ["-i", host.identityFile] : [])], + timeoutMs: 5000, + }, + ).pipe(Effect.result); + if (result._tag === "Failure") return false; + const config = new Map( + result.success.stdout.split("\n").map((line) => { + const separator = line.indexOf(" "); + return [line.slice(0, separator), line.slice(separator + 1).trim()]; + }), + ); + // A local forwarded port or a proxy can lead to a different machine. + if ( + config.get("port") !== "22" || + ["proxycommand", "proxyjump"].some((key) => config.has(key) && config.get(key) !== "none") + ) + return false; + const hostname = config.get("hostname")?.replace(/^\[|\]$/g, ""); + if (!hostname) return false; + const addresses = NodeNet.isIP(hostname) + ? [hostname] + : yield* Effect.tryPromise(() => NodeDnsPromises.lookup(hostname, { all: true })).pipe( + Effect.map((entries) => entries.map((entry) => entry.address)), + Effect.timeout("2 seconds"), + Effect.orElseSucceed(() => [] as string[]), + ); + const localAddresses = yield* LocalDeviceHostAddresses; + return ( + addresses.length > 0 && + addresses.every( + (address) => localAddresses.has(address) || address === "::1" || address.startsWith("127."), + ) + ); +}); + +export const remoteSshDeviceHosts = Effect.fn("remoteSshDeviceHosts")(function* ( + hosts: ReadonlyArray, +) { + return yield* Effect.filter( + hosts, + (host) => isLocalSshDeviceHost(host).pipe(Effect.map((local) => !local)), + { concurrency: 4 }, + ); +}); diff --git a/apps/server/src/device/sshDeviceScript.test.ts b/apps/server/src/device/sshDeviceScript.test.ts index 89021d8c04..8d9bc77d89 100644 --- a/apps/server/src/device/sshDeviceScript.test.ts +++ b/apps/server/src/device/sshDeviceScript.test.ts @@ -125,6 +125,9 @@ else { const child=spawn(process.execPath,[path.join(path.dirname(process.argv[1 ); await NodeFSP.mkdir(NodePath.join(root, "hosts/one"), { recursive: true }); await NodeFSP.writeFile(NodePath.join(root, "hosts/one/fail-start-once"), ""); + // Unavailable advisory bookkeeping must not prevent either helper from starting. + await NodeFSP.writeFile(NodePath.join(root, "tools/.maintenance-lock"), "blocked"); + await NodeFSP.writeFile(NodePath.join(root, "tools/.users"), "unwritable lease directory"); try { const [manual, concurrent] = await Promise.all([ invoke("one", "start"), diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index a1b9493b13..71f228b3dc 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -11,6 +11,7 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { @@ -60,6 +61,7 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", + otelEnvironment: OtelEnvironment.none, cwd: process.cwd(), baseDir, mode: "web", diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 951003a271..c73f0ed17a 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -220,12 +220,14 @@ export const make = Effect.gen(function* () { attachmentUploads: true, questionAttachments: true, fileAttachments: { maxUploadBytes: PROVIDER_SEND_TURN_MAX_FILE_BYTES }, + pastedTextAttachments: true, pullRequests: true, inlineMessageContext: true, threadSettlement: true, threadAutoSettlement: true, threadRestartContinuation: true, projectSettingsOverrides: true, + worktreeSubmodules: true, defaultRuntimeMode: true, threadSnooze: true, environmentThemes: true, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index d4d22be1d2..a1b1062666 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -15,6 +15,7 @@ import * as PlatformError from "effect/PlatformError"; import * as References from "effect/References"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/unstable/process"; import { expect } from "vite-plus/test"; import type { @@ -1302,7 +1303,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off - "contributor:feature/deleted-fork-branch": JSON.stringify([ + "feature/deleted-fork-branch": JSON.stringify([ { number: 218, title: "Deleted fork branch PR", @@ -1332,8 +1333,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-04-05T15:00:00.000Z", }); expect( - ghCalls.some((call) => call.includes("--head contributor:feature/deleted-fork-branch")), + ghCalls.some((call) => call.includes("--head feature/deleted-fork-branch --state all")), ).toBe(true); + expect(ghCalls.some((call) => call.includes("--head contributor:"))).toBe(false); }), ); @@ -1484,6 +1486,61 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("branch PR lookup rechecks open PRs every minute and settled answers less often", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + for (const branch of ["feature/open-pr", "feature/merged-pr", "feature/no-pr"]) { + yield* runGit(repoDir, ["checkout", "-b", branch, "main"]); + yield* runGit(repoDir, ["push", "-u", "origin", branch]); + } + const pullRequest = (number: number, headRefName: string, state: string) => ({ + number, + title: headRefName, + url: `https://github.com/pingdotgg/codething-mvp/pull/${number}`, + baseRefName: "main", + headRefName, + state, + updatedAt: "2026-04-07T15:00:00Z", + }); + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + "feature/open-pr": encodeCliJson([pullRequest(301, "feature/open-pr", "OPEN")]), + "feature/merged-pr": encodeCliJson([pullRequest(302, "feature/merged-pr", "MERGED")]), + }, + }, + }); + const lookupAll = Effect.forEach( + ["feature/open-pr", "feature/merged-pr", "feature/no-pr"], + (branch) => manager.branchPullRequest({ cwd: repoDir, branch }), + ); + const prListCalls = () => ghCalls.filter((call) => call.startsWith("pr list ")); + + yield* lookupAll; + expect(prListCalls()).toHaveLength(3); + + yield* TestClock.adjust("61 seconds"); + yield* lookupAll; + expect(prListCalls()).toHaveLength(4); + expect(prListCalls().at(-1)).toContain("--head feature/open-pr"); + + // Just inside the 5-minute window only the open PR is asked again. + yield* TestClock.adjust("238 seconds"); + yield* lookupAll; + expect(prListCalls()).toHaveLength(5); + expect(prListCalls().at(-1)).toContain("--head feature/open-pr"); + + // Just past it the settled answers expire too. + yield* TestClock.adjust("2 seconds"); + yield* lookupAll; + expect(prListCalls()).toHaveLength(7); + expect(prListCalls().slice(-2).join("\n")).not.toContain("--head feature/open-pr"); + }), + ); + it.effect("branch PR lookup propagates provider failures", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1780,8 +1837,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListByHeadSelector: { - "alice:feature": output, - "fork:feature": output, feature: output, }, }, @@ -1948,10 +2003,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { @@ -1987,7 +2038,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,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head statemachine --state all --limit 100 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, @@ -2019,7 +2070,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ghScenario: { prListByHeadSelector: { // @effect-diagnostics-next-line preferSchemaOverJson:off - "contributor:main": JSON.stringify([ + main: JSON.stringify([ { number: 777, title: "Fork PR from main", @@ -2053,7 +2104,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,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head main --state all --limit 100 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, @@ -2118,34 +2169,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-04-01T10:00:00Z", }, ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "pingdotgg:effect-atom": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "my-org/upstream:effect-atom": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "pingdotgg:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - state: "OPEN", - updatedAt: "2026-04-01T10:00:00Z", - }, - ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "my-org/upstream:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - state: "OPEN", - updatedAt: "2026-04-01T10:00:00Z", - }, - ]), }, }, }); @@ -2356,7 +2379,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off - "contributor:feature/fork-plain": JSON.stringify([ + "feature/fork-plain": JSON.stringify([ { number: 89, title: "Fork PR pushed without -u", @@ -2376,9 +2399,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const status = yield* manager.status({ cwd: repoDir }); expect(status.pr?.number).toBe(89); - expect(ghCalls.some((call) => call.includes("--head contributor:feature/fork-plain"))).toBe( - true, - ); + expect(ghCalls.some((call) => call.includes("--head feature/fork-plain"))).toBe(true); + expect(ghCalls.some((call) => call.includes("--head contributor:"))).toBe(false); expect(ghCalls.some((call) => call.includes("--head main"))).toBe(false); }), ); @@ -2413,7 +2435,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { prListByHeadSelector: { // Fake gh returns raw JSON stdout, matching the CLI boundary under test. // @effect-diagnostics-next-line preferSchemaOverJson:off - "contributor:feature/fork-settle": JSON.stringify([ + "feature/fork-settle": JSON.stringify([ { number: 91, title: "Fork PR to settle", @@ -3697,7 +3719,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ); it.effect( - "returns existing cross-repo PR metadata using the fork owner selector", + "returns existing cross-repo PR metadata found under the bare branch name", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -3716,8 +3738,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequence: [ - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([ { @@ -3749,7 +3769,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.number).toBe(142); expect( ghCalls.some((call) => - call.includes("pr list --head octocat:statemachine --state open --limit 1"), + call.includes("pr list --head statemachine --state open --limit 100"), ), ).toBe(true); expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); @@ -3815,30 +3835,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { headRefName: "upstream/effect-atom", }, ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "pingdotgg:effect-atom": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "my-org/upstream:effect-atom": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "pingdotgg:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - }, - ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "my-org/upstream:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - }, - ]), }, }, }); @@ -3858,7 +3854,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ); it.effect( - "prefers owner-qualified selectors before bare branch names for cross-repo PRs", + "picks the fork PR among same-named branches from other repositories", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -3890,9 +3886,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { baseRefName: "main", headRefName: "statemachine", }, - ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "octocat:statemachine": JSON.stringify([ { number: 142, title: "Existing fork PR", @@ -3909,8 +3902,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, }, ]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - "fork-seed:statemachine": JSON.stringify([]), }, }, }); @@ -3922,11 +3913,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.status).toBe("opened_existing"); expect(result.pr.number).toBe(142); - - const ownerSelectorCallIndex = ghCalls.findIndex((call) => - call.includes("pr list --head octocat:statemachine --state open --limit 1"), - ); - expect(ownerSelectorCallIndex).toBeGreaterThanOrEqual(0); + expect( + ghCalls.some((call) => /--head [^ ]*:/u.test(call) && call.startsWith("pr list")), + ).toBe(false); expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); }), 12_000, @@ -3955,7 +3944,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ghScenario: { prListByHeadSelector: { // @effect-diagnostics-next-line preferSchemaOverJson:off - "octocat:statemachine": JSON.stringify([ + statemachine: JSON.stringify([ { number: 142, title: "Existing fork PR", @@ -3973,11 +3962,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, ]), // @effect-diagnostics-next-line preferSchemaOverJson:off - "fork-seed:statemachine": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off "t3code/pr-142/statemachine": JSON.stringify([]), - // @effect-diagnostics-next-line preferSchemaOverJson:off - statemachine: JSON.stringify([]), }, }, }); @@ -3990,10 +3975,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.status).toBe("opened_existing"); expect(result.pr.number).toBe(142); - const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 1")); + const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 100")); expect(openLookupCalls).toHaveLength(1); expect(openLookupCalls[0]).toContain( - "pr list --head octocat:statemachine --state open --limit 1", + "pr list --head statemachine --state open --limit 100", ); }), 12_000, @@ -4018,12 +4003,10 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequenceByHeadSelector: { - "octocat:statemachine": [ + statemachine: [ `[{"number":41,"title":"Ambiguous fork PR","url":"https://github.com/pingdotgg/codething-mvp/pull/41","baseRefName":"main","headRefName":"statemachine","state":"OPEN"}]`, `[{"number":142,"title":"Add stacked git actions","url":"https://github.com/pingdotgg/codething-mvp/pull/142","baseRefName":"main","headRefName":"statemachine","state":"OPEN","isCrossRepository":true,"headRepository":{"nameWithOwner":"octocat/codething-mvp"},"headRepositoryOwner":{"login":"octocat"}}]`, ], - "fork-seed:statemachine": ["[]"], - statemachine: ["[]"], }, }, }); @@ -4191,7 +4174,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); - it.effect("generates PR content against the remote base when the local base is stale", () => + it.effect("generates PR content from branch changes when the remote base advances", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); yield* initRepo(repoDir); @@ -4223,7 +4206,15 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "feature/remote-base"]); yield* runGit(repoDir, ["config", "branch.feature/remote-base.gh-merge-base", "main"]); + NodeFS.writeFileSync(NodePath.join(peerDir, "later-main.txt"), "unrelated\n"); + yield* runGit(peerDir, ["add", "later-main.txt"]); + yield* runGit(peerDir, ["commit", "-m", "Later main commit"]); + yield* runGit(peerDir, ["push", "origin", "main"]); + yield* runGit(repoDir, ["fetch", "origin"]); + let generatedCommitSummary = ""; + let generatedDiffSummary = ""; + let generatedDiffPatch = ""; const { manager } = yield* makeManager({ ghScenario: { prListSequence: ["[]", "[]"], @@ -4231,6 +4222,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { textGeneration: { generatePrContent: (input) => { generatedCommitSummary = input.commitSummary; + generatedDiffSummary = input.diffSummary; + generatedDiffPatch = input.diffPatch; return Effect.succeed({ title: "Feature PR", body: "Feature body" }); }, }, @@ -4244,6 +4237,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.status).toBe("created"); expect(generatedCommitSummary).toContain("Feature commit"); expect(generatedCommitSummary).not.toContain("Remote base commit"); + expect(generatedCommitSummary).not.toContain("Later main commit"); + expect(generatedDiffSummary).toContain("feature.txt"); + expect(generatedDiffSummary).not.toContain("later-main.txt"); + expect(generatedDiffPatch).toContain("feature.txt"); + expect(generatedDiffPatch).not.toContain("later-main.txt"); }), ); @@ -4344,7 +4342,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { prListSequenceByHeadSelector: { - "octocat:statemachine": [ + statemachine: [ // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify([]), // @effect-diagnostics-next-line preferSchemaOverJson:off @@ -4366,10 +4364,6 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }, ]), ], - // @effect-diagnostics-next-line preferSchemaOverJson:off - "fork-seed:statemachine": [JSON.stringify([])], - // @effect-diagnostics-next-line preferSchemaOverJson:off - statemachine: [JSON.stringify([])], }, }, }); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 8bec757d12..0c93732bfd 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -31,6 +31,7 @@ import { ModelSelection, type ProjectId, SourceControlProviderError, + type SourceControlProviderKind, type SourceControlWritingStyleSettings, type ThreadId, } from "@t3tools/contracts"; @@ -143,6 +144,12 @@ const STATUS_RESULT_CACHE_CAPACITY = 2_048; // exponentially via prLookupFailureTtl, so throttling pressure still drops // under 429s instead of amplifying it. const PR_LOOKUP_CACHE_TTL = Duration.seconds(60); +// Answers without an open PR ("no PR yet", merged, closed) only change when +// someone opens a PR, and the paths that do that in-app (turn end, push, +// create PR, user refresh) bypass this cache. Re-asking every minute for each +// idle branch was the bulk of background GitHub quota use, so these wait out +// a longer TTL and a PR opened outside the app shows up within minutes. +const PR_LOOKUP_NO_OPEN_PR_CACHE_TTL = Duration.minutes(5); 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; @@ -578,6 +585,26 @@ function parseCustomCommitMessage(raw: string): { subject: string; body: string }; } +// Without the owner selector, a bare branch name also lists same-named +// branches on other forks (`main`, `patch-1`), so GitHub probes ask for a full +// page and let matchesBranchHeadContext pick the right head. gh fetches up to +// 100 in one request, and GitHub prices a first:100 connection like first:1. +const GITHUB_HEAD_BRANCH_PROBE_LIMIT = 100; + +// `gh pr list --head` filters on the head ref name alone and accepts anything, so an +// `owner:branch` or `remote:branch` selector silently lists zero pull requests +// while spending a GraphQL call. Git branch names cannot contain ":", and the +// bare head branch is always among the selectors, so GitHub probes skip them and +// leave the owner check to matchesBranchHeadContext. +function probeableHeadSelectors( + providerKind: SourceControlProviderKind, + headSelectors: ReadonlyArray, +): ReadonlyArray { + return providerKind === "github" + ? headSelectors.filter((selector) => !selector.includes(":")) + : headSelectors; +} + function appendUnique(values: string[], next: string | null | undefined): void { const trimmed = next?.trim() ?? ""; if (trimmed.length === 0 || values.includes(trimmed)) { @@ -1126,7 +1153,9 @@ export const make = Effect.gen(function* () { timeToLive: (exit, key) => { if (Exit.isSuccess(exit)) { prLookupFailureStreakByKey.delete(key); - return PR_LOOKUP_CACHE_TTL; + return exit.value.latest?.state === "open" + ? PR_LOOKUP_CACHE_TTL + : PR_LOOKUP_NO_OPEN_PR_CACHE_TTL; } return nextPrLookupFailureTtl(key); }, @@ -1610,12 +1639,14 @@ export const make = Effect.gen(function* () { | "isCrossRepository" >, ) { - for (const headSelector of headContext.headSelectors) { - const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ + const provider = yield* sourceControlProvider(cwd); + const headSelectors = probeableHeadSelectors(provider.kind, headContext.headSelectors); + for (const headSelector of headSelectors) { + const pullRequests = yield* provider.listChangeRequests({ cwd, headSelector, state: "open", - limit: 1, + limit: provider.kind === "github" ? GITHUB_HEAD_BRANCH_PROBE_LIMIT : 1, }); const normalizedPullRequests = pullRequests.map(toPullRequestInfo); @@ -1640,12 +1671,13 @@ export const make = Effect.gen(function* () { ) { const parsedByNumber = new Map(); - for (const headSelector of headContext.headSelectors) { - const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ + const provider = yield* sourceControlProvider(cwd); + for (const headSelector of probeableHeadSelectors(provider.kind, headContext.headSelectors)) { + const pullRequests = yield* provider.listChangeRequests({ cwd, headSelector, state: "all", - limit: 20, + limit: provider.kind === "github" ? GITHUB_HEAD_BRANCH_PROBE_LIMIT : 20, }); for (const pr of pullRequests.map(toPullRequestInfo)) { @@ -2557,11 +2589,15 @@ export const make = Effect.gen(function* () { }); } - const worktree = yield* gitCore.createWorktree({ - cwd: input.cwd, - refName: localPullRequestBranch, - path: null, - }); + const worktree = yield* gitCore.createWorktree( + { cwd: input.cwd, refName: localPullRequestBranch, path: null }, + { + submodules: yield* projectSettingsFor(input).pipe( + Effect.map((settings) => settings.worktreeSubmodules), + Effect.orElseSucceed(() => null), + ), + }, + ); yield* ensureExistingWorktreeUpstream(worktree.worktree.path); yield* maybeRunSetupScript(worktree.worktree.path); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 9f3176cee8..d11109c2fd 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -86,6 +86,7 @@ export class GitWorkflowService extends Context.Service< readonly fetchRemote: (input: { readonly cwd: string; readonly remoteName: string; + readonly refName?: string; }) => Effect.Effect; readonly remoteExists: (input: { readonly cwd: string; diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 1ed180f3ec..fcd567b267 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -188,6 +188,121 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); + it.effect( + "migrates the complete legacy numbered block once and preserves later browser opt-in", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + const legacy = Keybindings.DEFAULT_KEYBINDINGS.map((rule) => { + if (rule.command.startsWith("thread.jump.")) { + return { key: rule.key, command: rule.command }; + } + if (rule.command.startsWith("modelPicker.jump.")) { + return { key: rule.key, command: rule.command, when: "modelPickerOpen" }; + } + return rule; + }); + yield* writeKeybindingsConfig(keybindingsConfigPath, legacy); + + const keybindings = yield* Keybindings.Keybindings; + yield* keybindings.syncDefaultKeybindingsOnStartup; + const migrated = yield* readKeybindingsConfig(keybindingsConfigPath); + assert.equal(migrated.find((rule) => rule.command === "thread.jump.1")?.when, "isDesktop"); + assert.equal(migrated.find((rule) => rule.command === "thread.jump.2")?.when, "isDesktop"); + assert.equal( + migrated.find((rule) => rule.command === "modelPicker.jump.2")?.when, + "modelPickerOpen && isDesktop", + ); + assert.isTrue(yield* fs.exists(`${keybindingsConfigPath}.desktop-numbered-defaults-v1`)); + + // Removing the condition after migration is a deliberate browser opt-in. + yield* writeKeybindingsConfig( + keybindingsConfigPath, + migrated.map((rule) => + rule.command === "thread.jump.2" ? { key: rule.key, command: rule.command } : rule, + ), + ); + yield* keybindings.syncDefaultKeybindingsOnStartup; + const optedIn = yield* readKeybindingsConfig(keybindingsConfigPath); + assert.isUndefined(optedIn.find((rule) => rule.command === "thread.jump.2")?.when); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect("preserves a partial hand-written browser jump set", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+1", command: "thread.jump.1" }, + { key: "mod+2", command: "thread.jump.2" }, + ]); + + const keybindings = yield* Keybindings.Keybindings; + yield* keybindings.syncDefaultKeybindingsOnStartup; + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + assert.deepEqual(persisted.slice(0, 2), [ + { key: "mod+1", command: "thread.jump.1" }, + { key: "mod+2", command: "thread.jump.2" }, + ]); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect("preserves a mostly-default file with one custom numbered binding", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + const legacy = Keybindings.DEFAULT_KEYBINDINGS.map((rule) => { + if (rule.command.startsWith("thread.jump.")) { + return { key: rule.key, command: rule.command }; + } + if (rule.command.startsWith("modelPicker.jump.")) { + return { key: rule.key, command: rule.command, when: "modelPickerOpen" }; + } + return rule; + }); + const customBrowserRule: KeybindingRule = { + key: "mod+shift+1", + command: "thread.jump.1", + when: "isWeb", + }; + const partlyCustomized = legacy.map((rule) => + rule.command === customBrowserRule.command ? customBrowserRule : rule, + ); + yield* writeKeybindingsConfig(keybindingsConfigPath, partlyCustomized); + + const keybindings = yield* Keybindings.Keybindings; + yield* keybindings.syncDefaultKeybindingsOnStartup; + assert.deepEqual(yield* readKeybindingsConfig(keybindingsConfigPath), partlyCustomized); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "does not mark numbered defaults migrated when config write fails", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + const { dirname } = yield* Path.Path; + const legacy = Keybindings.DEFAULT_KEYBINDINGS.map((rule) => { + if (rule.command.startsWith("thread.jump.")) { + return { key: rule.key, command: rule.command }; + } + if (rule.command.startsWith("modelPicker.jump.")) { + return { key: rule.key, command: rule.command, when: "modelPickerOpen" }; + } + return rule; + }); + yield* writeKeybindingsConfig(keybindingsConfigPath, legacy); + yield* fs.chmod(dirname(keybindingsConfigPath), 0o500); + + const keybindings = yield* Keybindings.Keybindings; + const result = yield* keybindings.syncDefaultKeybindingsOnStartup.pipe(toDetailResult); + yield* fs.chmod(dirname(keybindingsConfigPath), 0o700); + assertFailure(result, "failed to write keybindings config"); + assert.isFalse(yield* fs.exists(`${keybindingsConfigPath}.desktop-numbered-defaults-v1`)); + assert.deepEqual(yield* readKeybindingsConfig(keybindingsConfigPath), legacy); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + it.effect("uses defaults in runtime when config is malformed without overriding file", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 08084a94df..4fa01fccea 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -13,8 +13,10 @@ import { KeybindingShortcut, KeybindingWhenNode, MAX_KEYBINDINGS_COUNT, + MODEL_PICKER_JUMP_KEYBINDING_COMMANDS, ResolvedKeybindingRule, ResolvedKeybindingsConfig, + THREAD_JUMP_KEYBINDING_COMMANDS, type ServerRemoveKeybindingInput, type ServerUpsertKeybindingInput, type ServerConfigIssue, @@ -104,6 +106,43 @@ function isSameKeybindingRule(left: KeybindingRule, right: KeybindingRule): bool ); } +// Earlier releases persisted the entire default set, including browser-wide +// Mod+digit bindings. Match their exact rows; a changed key or context belongs +// to the user and must not be rewritten. +const legacyNumberedDefaults = [ + ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ + key: `mod+${index + 1}`, + command, + })), + ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ + key: `mod+${index + 1}`, + command, + when: "modelPickerOpen", + })), +] satisfies readonly KeybindingRule[]; + +function migrateLegacyNumberedDefaults( + rules: readonly KeybindingRule[], +): readonly KeybindingRule[] { + // Only the complete, unmodified legacy numbered block proves that these + // are inherited defaults. A partial block or a duplicate command may be a + // deliberate custom set; leave it alone even if many rows look identical. + if ( + !legacyNumberedDefaults.every((legacy) => { + const matchingCommand = rules.filter((rule) => rule.command === legacy.command); + return matchingCommand.length === 1 && isSameKeybindingRule(matchingCommand[0]!, legacy); + }) + ) { + return rules; + } + + return rules.map((rule) => { + const legacy = legacyNumberedDefaults.find((entry) => isSameKeybindingRule(entry, rule)); + if (!legacy) return rule; + return DEFAULT_KEYBINDINGS.find((entry) => entry.command === legacy.command) ?? rule; + }); +} + function keybindingShortcutContext(rule: KeybindingRule): string | null { const parsed = parseKeybindingShortcut(rule.key); if (!parsed) return null; @@ -267,6 +306,7 @@ const make = Effect.gen(function* () { const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const browserNumberedDefaultsMarker = `${keybindingsConfigPath}.desktop-numbered-defaults-v1`; const upsertSemaphore = yield* Semaphore.make(1); const resolvedConfigCacheKey = "resolved" as const; const changesPubSub = yield* PubSub.unbounded(); @@ -422,6 +462,22 @@ const make = Effect.gen(function* () { ); }; + const markBrowserNumberedDefaultsMigrated = writeFileStringAtomically({ + filePath: browserNumberedDefaultsMarker, + contents: "1\n", + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new KeybindingsConfigError({ + configPath: browserNumberedDefaultsMarker, + detail: "failed to record numbered keybinding migration", + cause, + }), + ), + ); + const loadConfigStateFromDisk = loadRuntimeCustomKeybindingsConfig().pipe( Effect.map(({ keybindings, issues }) => ({ keybindings: mergeWithDefaultKeybindings(compileResolvedKeybindingsConfig(keybindings)), @@ -453,6 +509,7 @@ const make = Effect.gen(function* () { const configExists = yield* readConfigExists; if (!configExists) { yield* writeConfigAtomically(DEFAULT_KEYBINDINGS); + yield* markBrowserNumberedDefaultsMigrated; yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); return; } @@ -469,7 +526,20 @@ const make = Effect.gen(function* () { yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); return; } - const customConfig = runtimeConfig.keybindings; + const migrationRecorded = yield* fs.exists(browserNumberedDefaultsMarker).pipe( + Effect.mapError( + (cause) => + new KeybindingsConfigError({ + configPath: browserNumberedDefaultsMarker, + detail: "failed to check numbered keybinding migration", + cause, + }), + ), + ); + const customConfig = migrationRecorded + ? runtimeConfig.keybindings + : migrateLegacyNumberedDefaults(runtimeConfig.keybindings); + const migrated = customConfig !== runtimeConfig.keybindings; const existingCommands = new Set(customConfig.map((entry) => entry.command)); const missingDefaults: KeybindingRule[] = []; const shortcutConflictWarnings: Array<{ @@ -506,7 +576,8 @@ const make = Effect.gen(function* () { reason: "shortcut context already used by existing rule", }); } - if (missingDefaults.length === 0) { + if (missingDefaults.length === 0 && !migrated) { + if (!migrationRecorded) yield* markBrowserNumberedDefaultsMigrated; yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); return; } @@ -535,12 +606,14 @@ const make = Effect.gen(function* () { commands: skippedDefaults.map((rule) => rule.command), }); } - if (defaultsToAppend.length === 0) { + if (defaultsToAppend.length === 0 && !migrated) { + if (!migrationRecorded) yield* markBrowserNumberedDefaultsMigrated; yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); return; } yield* writeConfigAtomically([...customConfig, ...defaultsToAppend]); + if (!migrationRecorded) yield* markBrowserNumberedDefaultsMigrated; yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); }), ); diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index ef552f9b04..bdad30d3c3 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -685,6 +685,7 @@ it.effect("pins a provider session to its initial host despite later focus chang environmentId: scope.environmentId, connectionId: "connection-stale", focused: true, + liveTabs: [{ threadId: scope.threadId, tabId: PreviewTabId.make("stale-tab") }], }); expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( "second", @@ -725,6 +726,190 @@ it.effect("pins a provider session to its initial host despite later focus chang ), ); +it.effect("prefers the live tab owner for new sessions without moving existing leases", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const connections = new Map(); + for (const clientId of ["owner", "other"]) { + const requests = requestsFrom( + yield* broker.connect(makeHost({ clientId })), + (connectionId) => connections.set(clientId, connectionId), + ); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + clientId, + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: clientId, + }), + ).pipe(Effect.forkScoped); + } + yield* Effect.yieldNow; + yield* broker.focusHost({ + clientId: "owner", + environmentId: scope.environmentId, + connectionId: connections.get("owner")!, + focused: false, + liveTabs: [ + { threadId: scope.threadId, tabId: PreviewTabId.make("signed-in"), visible: true }, + ], + }); + yield* broker.focusHost({ + clientId: "other", + environmentId: scope.environmentId, + connectionId: connections.get("other")!, + focused: true, + liveTabs: [ + { threadId: scope.threadId, tabId: PreviewTabId.make("signed-in"), visible: false }, + { threadId: scope.threadId, tabId: PreviewTabId.make("other-signed-in"), visible: false }, + { + threadId: ThreadId.make("another-thread"), + tabId: PreviewTabId.make("different-tab"), + visible: true, + }, + ], + }); + expect(yield* broker.invoke({ scope, operation: "evaluate", input: {} })).toBe( + "owner", + ); + const wrongHostError = yield* broker + .invoke({ + scope, + tabId: PreviewTabId.make("other-signed-in"), + operation: "evaluate", + input: {}, + }) + .pipe(Effect.flip); + expect(wrongHostError).toBeInstanceOf(PreviewAutomationNoAvailableHostError); + expect(yield* broker.invoke({ scope, operation: "evaluate", input: {} })).toBe( + "owner", + ); + expect( + yield* broker.invoke({ + scope: { ...scope, providerSessionId: "explicit-owner" }, + tabId: PreviewTabId.make("signed-in"), + operation: "snapshot", + input: {}, + }), + ).toBe("owner"); + expect( + yield* broker.invoke({ + scope: { ...scope, providerSessionId: "other-tab" }, + tabId: PreviewTabId.make("different-tab"), + operation: "evaluate", + input: {}, + }), + ).toBe("other"); + + yield* broker.focusHost({ + clientId: "owner", + environmentId: scope.environmentId, + connectionId: connections.get("owner")!, + focused: false, + liveTabs: [], + }); + expect(yield* broker.invoke({ scope, operation: "evaluate", input: {} })).toBe( + "owner", + ); + expect( + yield* broker.invoke({ + scope: { ...scope, providerSessionId: "after-tab-closed" }, + operation: "evaluate", + input: {}, + }), + ).toBe("other"); + }), + ), +); + +it.effect("retains capability-rich host preference when no host owns the target tab", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + let focusedConnectionId = ""; + for (const [clientId, supportedOperations] of [ + ["focused", ["status"]], + ["background", ["status", "resize"]], + ] as const) { + const requests = requestsFrom( + yield* broker.connect(makeHost({ clientId, supportedOperations })), + (connectionId) => { + if (clientId === "focused") focusedConnectionId = connectionId; + }, + ); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + clientId, + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: clientId, + }), + ).pipe(Effect.forkScoped); + } + yield* Effect.yieldNow; + yield* broker.focusHost({ + clientId: "focused", + environmentId: scope.environmentId, + connectionId: focusedConnectionId, + focused: true, + }); + expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( + "background", + ); + }), + ), +); + +it.effect("fails closed when a named tab's owner cannot perform the requested operation", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + let ownerConnectionId = ""; + for (const [clientId, supportedOperations] of [ + ["owner", ["status"]], + ["other", ["status", "snapshot"]], + ] as const) { + const requests = requestsFrom( + yield* broker.connect(makeHost({ clientId, supportedOperations })), + (connectionId) => { + if (clientId === "owner") ownerConnectionId = connectionId; + }, + ); + yield* Stream.runForEach(requests, (request) => + broker.respond({ + clientId, + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: clientId, + }), + ).pipe(Effect.forkScoped); + } + yield* Effect.yieldNow; + const tabId = PreviewTabId.make("named-tab"); + yield* broker.focusHost({ + clientId: "owner", + environmentId: scope.environmentId, + connectionId: ownerConnectionId, + focused: false, + liveTabs: [{ threadId: scope.threadId, tabId, visible: true }], + }); + const error = yield* broker + .invoke({ + scope: { ...scope, providerSessionId: "fresh-explicit-tab" }, + tabId, + operation: "snapshot", + input: {}, + }) + .pipe(Effect.flip); + expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); + }), + ), +); + it.effect("does not route new operations to legacy hosts that did not advertise support", () => Effect.scoped( Effect.gen(function* () { @@ -921,6 +1106,7 @@ it.effect("fails over a pinned provider session only after its host disconnects" environmentId: scope.environmentId, connectionId: firstConnectionId, focused: true, + liveTabs: [{ threadId: scope.threadId, tabId: firstTabId }], }); expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({ host: "first", diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index 527f71f591..73680eb79e 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -73,6 +73,7 @@ interface ClientConnection { readonly environmentId: PreviewAutomationHost["environmentId"]; readonly supportedOperations: ReadonlySet; readonly focused: boolean; + readonly liveTabs: NonNullable; readonly focusOrder: number; readonly queue: Queue.Queue; } @@ -376,6 +377,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { environmentId: host.environmentId, supportedOperations: new Set(host.supportedOperations ?? PREVIEW_AUTOMATION_V1_OPERATIONS), focused: false, + liveTabs: [], focusOrder: 0, queue, }; @@ -432,6 +434,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { clients.set(host.clientId, { ...currentHost, focused: host.focused, + liveTabs: host.liveTabs ?? currentHost.liveTabs, focusOrder: host.focused ? focusSequence : currentHost.focusOrder, }); return { ...current, clients, focusSequence }; @@ -492,19 +495,38 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { // operation is not silently moved to a newer client: the caller gets a // capability failure and can deliberately start a fresh provider // session. A dead lease is pruned above and may fail over. + const ownsTargetTab = (host: ClientConnection, visibleOnly = false) => + host.liveTabs.some( + (tab) => + tab.threadId === input.scope.threadId && + (!visibleOnly || tab.visible === true) && + (input.tabId === undefined || tab.tabId === input.tabId), + ); + const environmentHosts = Array.from(current.clients.values()).filter( + (host) => host.environmentId === input.scope.environmentId, + ); + const explicitTabOwners = + input.tabId === undefined ? [] : environmentHosts.filter((host) => ownsTargetTab(host)); + const assignedOwnsNamedTab = + explicitTabOwners.length === 0 || + explicitTabOwners.some((host) => host.clientId === assignedConnection?.clientId); + // A named tab with a reporting owner must stay on an owning host. If + // none of those owners supports the operation, fail instead of sending + // the request to a different browser with unrelated cookies and DOM. + const candidateHosts = explicitTabOwners.length > 0 ? explicitTabOwners : environmentHosts; const connection = - hasLiveAssignment && supportsOperation(assignedConnection, input.operation) + hasLiveAssignment && + assignedOwnsNamedTab && + supportsOperation(assignedConnection, input.operation) ? assignedConnection : hasLiveAssignment ? undefined - : Array.from(current.clients.values()) - .filter( - (host) => - host.environmentId === input.scope.environmentId && - supportsOperation(host, input.operation), - ) + : candidateHosts + .filter((host) => supportsOperation(host, input.operation)) .sort( (left, right) => + Number(ownsTargetTab(right, true)) - Number(ownsTargetTab(left, true)) || + Number(ownsTargetTab(right)) - Number(ownsTargetTab(left)) || right.supportedOperations.size - left.supportedOperations.size || Number(right.focused) - Number(left.focused) || right.focusOrder - left.focusOrder, diff --git a/apps/server/src/observability/Layers/Observability.ts b/apps/server/src/observability/Layers/Observability.ts index 8f7b607745..a754933e5d 100644 --- a/apps/server/src/observability/Layers/Observability.ts +++ b/apps/server/src/observability/Layers/Observability.ts @@ -20,6 +20,7 @@ import * as BrowserTraceCollector from "../BrowserTraceCollector.ts"; export const ObservabilityLive = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; + const traces = config.otlpTracesExport; const metrics = config.otlpMetricsExport; // The trace serializer stays in the returned context because the browser @@ -86,6 +87,15 @@ export const ObservabilityLive = Layer.unwrap( resource, }).pipe(Layer.provide(otlpSerializationLayer(metrics.protocol))); - return Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer); + // Logged once the server's loggers are installed, so the warnings use them. + const otelWarningsLayer = Layer.effectDiscard( + Effect.forEach(config.otelEnvironment.warnings, (warning) => Effect.logWarning(warning)), + ); + + return otelWarningsLayer.pipe( + Layer.provideMerge( + Layer.mergeAll(ServerLoggerLive, traceReferencesLayer, tracerLayer, metricsLayer), + ), + ); }), ); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 9a4d3b81b6..bcc7f23d12 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -7,10 +7,12 @@ import * as NodeChildProcess from "node:child_process"; import { VcsProcessTimeoutError, VcsProcessSpawnError, + VcsProcessExitError, ProviderDriverKind, ProviderRuntimeEvent, ProviderSession, ProviderInstanceId, + type OrchestrationEvent, } from "@t3tools/contracts"; import { CommandId, @@ -28,9 +30,11 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as ManagedRuntime from "effect/ManagedRuntime"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; import { it as effectIt } from "@effect/vitest"; import * as RuntimeReceiptBus from "../Services/RuntimeReceiptBus.ts"; import * as Scope from "effect/Scope"; @@ -50,6 +54,7 @@ import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { RuntimeReceiptBusTest } from "./RuntimeReceiptBus.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; +import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import { @@ -71,6 +76,7 @@ import { PullRequestService } from "../../pullRequest/PullRequestService.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); +const encodeUnknownJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); type LegacyProviderRuntimeEvent = { readonly type: string; @@ -314,6 +320,10 @@ describe("CheckpointReactor", () => { readonly checkpointLookupFailure?: ( cwd: string, ) => VcsProcessTimeoutError | VcsProcessSpawnError | undefined; + readonly checkpointCaptureFailure?: (cwd: string) => VcsProcessExitError | undefined; + readonly checkpointDiffFailure?: (cwd: string) => VcsProcessExitError | undefined; + readonly checkpointRepositoryFailure?: (cwd: string) => VcsProcessExitError | undefined; + readonly logMessages?: Array; readonly workspaceRefresh?: (cwd: string) => Effect.Effect; readonly hasSession?: boolean; readonly seedFilesystemCheckpoints?: boolean; @@ -343,6 +353,8 @@ describe("CheckpointReactor", () => { options?.providerName ?? ProviderDriverKind.make("codex"), options?.conversationRollback ?? "relative", ); + const historicalRollbackEvents = Effect.runSync(PubSub.unbounded()); + const historicalRollbackReady = Effect.runSync(Deferred.make()); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionSnapshotQueryLive), Layer.provide(ThreadBackgroundLiveness.layer), @@ -353,6 +365,24 @@ describe("CheckpointReactor", () => { Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); + const historicalEngineLayer = Layer.effect( + OrchestrationEngineService, + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + return OrchestrationEngineService.of({ + ...engine, + streamDomainEvents: Stream.merge( + engine.streamDomainEvents, + Stream.unwrap( + PubSub.subscribe(historicalRollbackEvents).pipe( + Effect.tap(() => Deferred.succeed(historicalRollbackReady, undefined)), + Effect.map(Stream.fromSubscription), + ), + ), + ), + }); + }), + ).pipe(Layer.provide(orchestrationLayer)); const projectionSnapshotLayer = OrchestrationProjectionSnapshotQueryLive.pipe( Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), @@ -404,7 +434,7 @@ describe("CheckpointReactor", () => { ); const layer = CheckpointReactorLive.pipe( - Layer.provideMerge(orchestrationLayer), + Layer.provideMerge(historicalEngineLayer), Layer.provideMerge(projectionSnapshotLayer), Layer.provideMerge(RuntimeReceiptBusTest), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), @@ -420,6 +450,18 @@ describe("CheckpointReactor", () => { const failure = options?.checkpointLookupFailure?.(input.cwd); return failure ? Effect.fail(failure) : store.hasCheckpointRef(input); }, + captureCheckpoint: (input) => { + const failure = options?.checkpointCaptureFailure?.(input.cwd); + return failure ? Effect.fail(failure) : store.captureCheckpoint(input); + }, + diffCheckpoints: (input) => { + const failure = options?.checkpointDiffFailure?.(input.cwd); + return failure ? Effect.fail(failure) : store.diffCheckpoints(input); + }, + isGitRepository: (cwd) => { + const failure = options?.checkpointRepositoryFailure?.(cwd); + return failure ? Effect.fail(failure) : store.isGitRepository(cwd); + }, })), ), ).pipe(Layer.provide(VcsDriverRegistry.layer)), @@ -430,9 +472,56 @@ describe("CheckpointReactor", () => { Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); - - runtime = ManagedRuntime.make(layer); + const layerWithLogger = + options?.logMessages === undefined + ? layer + : layer.pipe( + Layer.provideMerge( + Logger.layer( + [ + Logger.make((event) => { + options.logMessages?.push(Logger.formatStructured.log(event)); + }), + ], + { mergeWithExisting: false }, + ), + ), + ); + + runtime = ManagedRuntime.make(layerWithLogger); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); + const eventStore = await runtime.runPromise( + Effect.service(OrchestrationEventStore).pipe( + Effect.provide(OrchestrationEventStoreLive.pipe(Layer.provide(SqlitePersistenceMemory))), + ), + ); + const publishLegacyRevert = (command: { + readonly commandId: CommandId; + readonly threadId: ThreadId; + readonly turnCount: number; + readonly createdAt: string; + }) => + Effect.gen(function* () { + const event = yield* eventStore.append({ + eventId: EventId.make(`legacy-${command.commandId}`), + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + causationEventId: null, + correlationId: command.commandId, + metadata: {}, + type: "thread.checkpoint-revert-requested", + payload: { + threadId: command.threadId, + turnCount: command.turnCount, + createdAt: command.createdAt, + }, + }); + const persisted = yield* Stream.runCollect(engine.readEvents(event.sequence - 1)); + expect(Array.from(persisted).some((stored) => stored.eventId === event.eventId)).toBe(true); + yield* PubSub.publish(historicalRollbackEvents, event); + }); const snapshotQuery = await runtime.runPromise(Effect.service(ProjectionSnapshotQuery)); const reactor = await runtime.runPromise(Effect.service(CheckpointReactor)); const checkpointStore = await runtime.runPromise( @@ -450,6 +539,7 @@ describe("CheckpointReactor", () => { Queue.offer(receipts, receipt), ).pipe(Effect.forkIn(testScope, { startImmediately: true })); yield* reactor.start().pipe(Scope.provide(testScope)); + yield* Deferred.await(historicalRollbackReady); return receipts; }), ); @@ -539,6 +629,7 @@ describe("CheckpointReactor", () => { return { engine, + publishLegacyRevert, readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), provider, workspaceRefresh, @@ -653,6 +744,152 @@ describe("CheckpointReactor", () => { }), ); + for (const stage of ["capture", "diff"] as const) { + effectIt.effect(`keeps ${stage} VCS failure details out of public activity and logs`, () => + Effect.gen(function* () { + let fail = false; + const logMessages: Array = []; + const failure = (cwd: string) => + fail + ? new VcsProcessExitError({ + operation: "checkpoint-private-operation", + command: "git-private-command-canary", + cwd, + exitCode: 128, + detail: "private-stderr-and-credential-canary", + }) + : undefined; + const harness = yield* Effect.promise(() => + createHarness({ + seedFilesystemCheckpoints: false, + ...(stage === "capture" ? { checkpointCaptureFailure: failure } : {}), + ...(stage === "diff" ? { checkpointDiffFailure: failure } : {}), + logMessages, + }), + ); + const threadId = ThreadId.make("thread-1"); + const turnId = asTurnId(`turn-private-${stage}`); + harness.provider.emit({ + type: "turn.started", + eventId: EventId.make(`evt-private-${stage}-start`), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId, + }); + expect(yield* harness.nextReceipt).toMatchObject({ type: "checkpoint.baseline.captured" }); + fail = true; + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make(`evt-private-${stage}-complete`), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:01.000Z", + threadId, + turnId, + payload: { state: "completed" }, + }); + yield* Effect.promise(harness.drain); + const thread = (yield* Effect.promise(harness.readModel)).threads[0]; + const publicActivity = encodeUnknownJson( + thread?.activities.filter((activity) => activity.kind === "checkpoint.capture.failed"), + ); + expect(publicActivity).toContain("checkpoint.capture.failed"); + const emittedLogs = encodeUnknownJson(logMessages); + if (stage === "diff") { + expect(emittedLogs).toContain("failed to derive checkpoint file summary"); + expect(emittedLogs).toContain("git-exit"); + } + for (const privateCanary of [ + harness.cwd, + "git-private-command-canary", + "private-stderr-and-credential-canary", + ]) { + expect(publicActivity).not.toContain(privateCanary); + expect(emittedLogs).not.toContain(privateCanary); + } + }), + ); + } + + effectIt.effect("keeps repository errors out of public revert failure activity", () => + Effect.gen(function* () { + let fail = false; + const harness = yield* Effect.promise(() => + createHarness({ + checkpointRepositoryFailure: (cwd) => + fail + ? new VcsProcessExitError({ + operation: "private-revert-operation", + command: "git-private-revert-command", + cwd, + exitCode: 128, + detail: "private-revert-stderr-canary", + }) + : undefined, + }), + ); + fail = true; + yield* harness.publishLegacyRevert({ + commandId: CommandId.make("cmd-private-revert-failure"), + threadId: ThreadId.make("thread-1"), + turnCount: 0, + createdAt: "2026-01-01T00:00:00.000Z", + }); + yield* Effect.promise(harness.drain); + const thread = (yield* Effect.promise(harness.readModel)).threads[0]; + const publicActivity = encodeUnknownJson( + thread?.activities.filter((activity) => activity.kind === "checkpoint.revert.failed"), + ); + expect(publicActivity).toContain("checkpoint.revert.failed"); + expect(publicActivity).toContain("git-exit"); + for (const privateCanary of [ + harness.cwd, + "git-private-revert-command", + "private-revert-stderr-canary", + ]) { + expect(publicActivity).not.toContain(privateCanary); + } + }), + ); + + effectIt.effect("logs checkpoint workspace refresh failure without its path or cause", () => + Effect.gen(function* () { + const logMessages: Array = []; + const harness = yield* Effect.promise(() => + createHarness({ + seedFilesystemCheckpoints: false, + workspaceRefresh: () => Effect.die(new Error("private-refresh-cause-canary")), + logMessages, + }), + ); + const threadId = ThreadId.make("thread-1"); + const turnId = asTurnId("turn-private-refresh"); + harness.provider.emit({ + type: "turn.started", + eventId: EventId.make("evt-private-refresh-start"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId, + turnId, + }); + expect(yield* harness.nextReceipt).toMatchObject({ type: "checkpoint.baseline.captured" }); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-private-refresh-complete"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:01.000Z", + threadId, + turnId, + payload: { state: "completed" }, + }); + yield* Effect.promise(harness.drain); + const emittedLogs = encodeUnknownJson(logMessages); + expect(emittedLogs).toContain("failed to refresh checkpoint workspace entries"); + expect(emittedLogs).not.toContain(harness.cwd); + expect(emittedLogs).not.toContain("private-refresh-cause-canary"); + }), + ); + effectIt.effect( "appends isolation failure activity when restoreFiles is true in a shared workspace", () => @@ -719,24 +956,26 @@ describe("CheckpointReactor", () => { (failureKind) => Effect.gen(function* () { let failLookup = false; + const logMessages: Array = []; const harness = yield* Effect.promise(() => createHarness({ seedFilesystemCheckpoints: false, + logMessages, checkpointLookupFailure: (cwd) => !failLookup ? undefined : failureKind === "timeout" ? new VcsProcessTimeoutError({ operation: "test.refLookup", - command: "git", + command: "git-private-ref-canary", cwd, timeoutMs: 30000, }) : new VcsProcessSpawnError({ operation: "test.refLookup", - command: "git", + command: "git-private-ref-canary", cwd, - cause: new Error("transient lookup spawn failure"), + cause: new Error("private-ref-cause-canary"), }), }), ); @@ -763,6 +1002,16 @@ describe("CheckpointReactor", () => { payload: { state: "completed" }, }); yield* Effect.promise(harness.drain); + const emittedLogs = encodeUnknownJson(logMessages); + expect(emittedLogs).toContain(failureKind === "timeout" ? "git-timeout" : "git-spawn"); + for (const privateCanary of [ + harness.cwd, + "git-private-ref-canary", + "private-ref-cause-canary", + checkpointRefForThreadTurn(threadId, 0), + ]) { + expect(emittedLogs).not.toContain(privateCanary); + } const ref = checkpointRefForThreadTurn(threadId, 1); expect(gitShowFileAtRef(harness.cwd, ref, "README.md")).toBe("new snapshot\n"); expect(yield* harness.nextReceipt).toMatchObject({ diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 109150811b..ef3d211311 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -46,6 +46,30 @@ import { RollbackWorkspace } from "../../rollback/RollbackWorkspace.ts"; import * as PullRequestService from "../../pullRequest/PullRequestService.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +// VCS errors can carry absolute paths and command diagnostics in `message`. +// Only these fixed categories may enter public activities or server logs. +const checkpointFailureKind = (error: unknown) => { + if (typeof error !== "object" || error === null || !("_tag" in error)) return "unknown"; + switch (error._tag) { + case "VcsProcessSpawnError": + return "git-spawn"; + case "VcsProcessExitError": + return "git-exit"; + case "VcsProcessTimeoutError": + return "git-timeout"; + case "VcsProcessStdinWriteError": + case "VcsProcessOutputReadError": + case "VcsProcessOutputLimitError": + case "VcsProcessMissingExitCodeError": + return "git-io"; + case "VcsRepositoryDetectionError": + return "repository-detection"; + case "VcsUnsupportedOperationError": + return "unsupported"; + default: + return "unknown"; + } +}; const rollbackUnavailable = { state: "unavailable" as const, reason: @@ -126,7 +150,7 @@ export const make = Effect.gen(function* () { Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning("failed to refresh checkpoint workspace entries", { - cwd, + failureKind: "workspace-entry-refresh", }), ), ), @@ -365,8 +389,8 @@ export const make = Effect.gen(function* () { Effect.catch((error) => Effect.logWarning("checkpoint capture previous ref lookup failed", { threadId: input.threadId, - checkpointRef: fromCheckpointRef, - category: error._tag, + fromTurnCount, + category: checkpointFailureKind(error), }).pipe(Effect.as(false)), ), ); @@ -429,7 +453,7 @@ export const make = Effect.gen(function* () { appendCaptureFailureActivity({ threadId: input.threadId, turnId: input.turnId, - detail: `Checkpoint captured, but turn diff summary is unavailable: ${error.message}`, + detail: `Checkpoint captured, but turn diff summary is unavailable (${checkpointFailureKind(error)}).`, createdAt: input.createdAt, }), ), @@ -438,7 +462,7 @@ export const make = Effect.gen(function* () { threadId: input.threadId, turnId: input.turnId, turnCount: input.turnCount, - detail: error.message, + failureKind: checkpointFailureKind(error), }).pipe(Effect.as([])), ), ); @@ -642,8 +666,7 @@ export const make = Effect.gen(function* () { Effect.logWarning("failed to refresh local git status after turn completion", { threadId: event.threadId, turnId: event.turnId ?? null, - cwd: sessionRuntime.value.cwd, - detail: error.message, + failureKind: checkpointFailureKind(error), }).pipe(Effect.as(null)), ), ); @@ -682,8 +705,7 @@ export const make = Effect.gen(function* () { Effect.catch((error) => Effect.logWarning("failed to refresh pull request status after turn completion", { threadId: input.threadId, - cwd: input.cwd, - detail: error.message, + failureKind: checkpointFailureKind(error), }), ), ); @@ -752,7 +774,7 @@ export const make = Effect.gen(function* () { } return Effect.logWarning("failed to follow worktree branch drift", { threadId: input.threadId, - cause: Cause.pretty(cause), + failureKind: "unexpected", }); }), ); @@ -769,7 +791,7 @@ export const make = Effect.gen(function* () { ? Effect.failCause(cause) : Effect.logWarning("failed to refresh git status after turn completion", { threadId: event.threadId, - cause: Cause.pretty(cause), + failureKind: "unexpected", }), ), ), @@ -994,7 +1016,7 @@ export const make = Effect.gen(function* () { appendRevertFailureActivity({ threadId: event.payload.threadId, turnCount: event.payload.turnCount, - detail: error.message, + detail: `Checkpoint revert could not be verified (${checkpointFailureKind(error)}).`, createdAt, }), ), @@ -1063,7 +1085,7 @@ export const make = Effect.gen(function* () { appendCaptureFailureActivity({ threadId: event.threadId, turnId, - detail: error.message, + detail: `Checkpoint capture failed (${checkpointFailureKind(error)}).`, createdAt, }).pipe(Effect.catch(() => Effect.void)), ), @@ -1106,7 +1128,7 @@ export const make = Effect.gen(function* () { return Effect.logWarning("checkpoint reactor failed to process input", { source: input.source, eventType: input.source === "saga" ? "rollback.saga.reconcile" : input.event.type, - cause: input.source === "saga" ? "rollback saga step failed" : Cause.pretty(cause), + failureKind: input.source === "saga" ? "rollback-saga" : "unexpected", }); }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 05b6833f77..0e73642379 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -3888,6 +3888,41 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { WHERE thread_id = 'thread-stale-user-input' `; assert.deepEqual(threadRows, [{ pendingUserInputCount: 1 }]); + + yield* appendAndProject({ + type: "thread.activity-appended", + eventId: EventId.make("evt-stale-user-input-session-stopped"), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-stale-user-input"), + occurredAt: "2026-02-26T12:35:09.000Z", + commandId: CommandId.make("cmd-stale-user-input-session-stopped"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-stale-user-input-session-stopped"), + metadata: {}, + payload: { + threadId: ThreadId.make("thread-stale-user-input"), + activity: { + id: EventId.make("activity-user-input-session-stopped"), + tone: "error", + kind: "provider.user-input.respond.failed", + summary: "Provider user input response failed", + payload: { + requestId: "user-input-active", + detail: "No active provider session is bound to this thread.", + }, + turnId: null, + createdAt: "2026-02-26T12:35:09.000Z", + }, + }, + }); + assert.deepEqual( + yield* sql<{ readonly pendingUserInputCount: number }>` + SELECT pending_user_input_count AS "pendingUserInputCount" + FROM projection_threads + WHERE thread_id = 'thread-stale-user-input' + `, + [{ pendingUserInputCount: 0 }], + ); }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 3efc3096d6..c6aefdfe33 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -138,6 +138,7 @@ function isStalePendingApprovalFailureDetail(detail: string | null): boolean { return false; } return ( + detail.includes("no active provider session is bound to this thread") || detail.includes("stale pending approval request") || detail.includes("unknown pending approval request") || detail.includes("unknown pending permission request") @@ -204,6 +205,7 @@ function derivePendingUserInputCountFromActivities( activity.kind === "provider.interaction.respond.failed") && detail !== null && (detail.includes("stale pending user-input request") || + detail.includes("no active provider session is bound to this thread") || detail.includes("unknown pending user-input request") || detail.includes("unknown pending user input request") || detail.includes("unknown pending codex user input request") || diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 327efdc30d..bdb882dcc0 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -2670,6 +2670,27 @@ it.effect( ); projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) => { + it.effect("does not return an orphaned question after a stopped-session reply failure", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const snapshotQuery = yield* ProjectionSnapshotQuery; + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, created_at + ) VALUES + ('orphaned-question', 'thread-orphaned', NULL, 'approval', 'user-input.requested', + 'Question', '{"requestId":"request-orphaned"}', '2026-09-24T00:00:00.000Z'), + ('orphaned-failure', 'thread-orphaned', NULL, 'error', + 'provider.user-input.respond.failed', 'Reply failed', + '{"requestId":"request-orphaned","detail":"No active provider session is bound to this thread."}', + '2026-09-24T00:00:01.000Z') + `; + const pending = yield* snapshotQuery.getPendingRequestActivities({ + threadId: ThreadId.make("thread-orphaned"), + }); + assert.deepStrictEqual(pending, []); + }), + ); // A thread shaped like real fan-out usage: user turns interleaved with // subagent turns (no user pending message), plus a turnless straggler user // message and a turnless activity anchored between turns. diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 046c7fd98c..097ac839b2 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1707,12 +1707,16 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { kind IN ('approval.requested', 'approval.resolved', 'user-input.requested', 'user-input.resolved', 'interaction.requested', 'interaction.resolved') OR (kind = 'provider.approval.respond.failed' AND ( + lower(COALESCE(json_extract(payload_json, '$.detail'), '')) LIKE '%no active provider session is bound to this thread%' + OR lower(COALESCE(json_extract(payload_json, '$.detail'), '')) LIKE '%stale pending approval request%' OR lower(COALESCE(json_extract(payload_json, '$.detail'), '')) LIKE '%unknown pending approval request%' OR lower(COALESCE(json_extract(payload_json, '$.detail'), '')) LIKE '%unknown pending permission request%' OR lower(COALESCE(json_extract(payload_json, '$.detail'), '')) LIKE '%unknown pending codex approval request%' )) OR (kind = 'provider.user-input.respond.failed' AND ( + lower(COALESCE(json_extract(payload_json, '$.detail'), '')) LIKE '%no active provider session is bound to this thread%' + OR lower(COALESCE(json_extract(payload_json, '$.detail'), '')) LIKE '%stale pending user-input request%' OR lower(COALESCE(json_extract(payload_json, '$.detail'), '')) LIKE '%unknown pending user-input request%' OR lower(COALESCE(json_extract(payload_json, '$.detail'), '')) LIKE '%unknown pending user input request%' @@ -2186,6 +2190,9 @@ pending_approval_requests AS ( OR ( activity.kind = 'provider.user-input.respond.failed' AND ( + lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%no active provider session is bound to this thread%' + OR lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) LIKE '%stale pending user-input request%' OR lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index a3be323be0..19b1a0fc1e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -2005,6 +2005,100 @@ describe("ProviderCommandReactor", () => { }), ); + effectIt.effect( + "keeps exact admission pending while a provider reports a running prior turn", + () => + Effect.gen(function* () { + const sendEntered = yield* Deferred.make(); + const releaseSend = yield* Deferred.make(); + const requestId = CommandId.make("cmd-running-provider-exact-admission"); + const messageId = asMessageId("message-running-provider-exact-admission"); + const harness = yield* Effect.promise(() => + createHarness({ + startSessionEffect: (session) => Effect.succeed({ ...session, status: "running" }), + sendTurnEffect: () => + Deferred.succeed(sendEntered, undefined).pipe( + Effect.andThen(Deferred.await(releaseSend)), + Effect.as({ threadId: ThreadId.make("thread-1"), turnId: asTurnId("new-turn") }), + ), + }), + ); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: requestId, + threadId: ThreadId.make("thread-1"), + message: { messageId, role: "user", text: "follow up", attachments: [] }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: isoAt(0), + }); + yield* Deferred.await(sendEntered); + let thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session?.status).toBe("starting"); + expect(thread?.session?.pendingTurnRequestId).toBe(requestId); + expect(thread?.session?.pendingTurnMessageId).toBe(messageId); + + yield* Deferred.succeed(releaseSend, undefined); + yield* Effect.promise(() => + waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return thread?.session?.activeTurnId === asTurnId("new-turn"); + }), + ); + thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session?.status).toBe("running"); + expect(thread?.session?.activeTurnRequestId).toBe(requestId); + expect(thread?.session?.pendingTurnRequestId).toBeUndefined(); + }), + ); + + for (const status of ["error", "closed"] as const) { + effectIt.effect(`rejects exact admission when the provider session is ${status}`, () => + Effect.gen(function* () { + const requestId = CommandId.make(`cmd-terminal-provider-${status}`); + const harness = yield* Effect.promise(() => + createHarness({ + startSessionEffect: (session) => Effect.succeed({ ...session, status }), + }), + ); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: requestId, + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId(`message-terminal-provider-${status}`), + role: "user", + text: "follow up", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: isoAt(0), + }); + yield* Effect.promise(() => + waitFor(async () => { + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + return thread?.session?.status === "error"; + }), + ); + const thread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.session?.failedTurnRequestId).toBe(requestId); + expect(harness.sendTurn).not.toHaveBeenCalled(); + }), + ); + } + effectIt.effect("does not disarm when raw turn start ingestion never accepts the CAS", () => Effect.gen(function* () { const testClock = yield* TestClock.make(); @@ -3784,11 +3878,14 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); expect(harness.pruneWorktrees).toHaveBeenCalledWith({ cwd: "/tmp/provider-project" }); - expect(harness.createWorktree).toHaveBeenCalledWith({ - cwd: "/tmp/provider-project", - refName: "feature/restore", - path: worktreePath, - }); + expect(harness.createWorktree).toHaveBeenCalledWith( + { + cwd: "/tmp/provider-project", + refName: "feature/restore", + path: worktreePath, + }, + { submodules: null }, + ); expect(harness.createWorktree.mock.invocationCallOrder[0]).toBeLessThan( harness.startSession.mock.invocationCallOrder[0]!, ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index e5fcf3d49c..40caffc583 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -640,8 +640,14 @@ const make = Effect.gen(function* () { }); // A directory deleted without `git worktree remove` leaves an admin entry // that makes `git worktree add` refuse the path; prune clears it. + const submodules = yield* projectSettingsForThread(thread.id).pipe( + Effect.map((settings) => settings.worktreeSubmodules), + Effect.orElseSucceed(() => null), + ); yield* gitWorkflow.pruneWorktrees({ cwd }).pipe( - Effect.andThen(gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath })), + Effect.andThen( + gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath }, { submodules }), + ), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) @@ -1048,10 +1054,23 @@ const make = Effect.gen(function* () { detail: `Provider session '${session.threadId}' started without a provider instance or session incarnation id.`, }); } + if ( + options?.pendingTurnStart === true && + (session.status === "error" || session.status === "closed") + ) { + return yield* new ProviderAdapterRequestError({ + provider: providerErrorLabel(session.provider), + method: "thread.turn.start", + detail: `Provider session '${session.threadId}' is ${session.status}; it cannot accept a new turn.`, + }); + } const sessionBinding: OrchestrationSession = { threadId, + // The provider can still report the previous turn as running while + // a new exact admission is reserved. The reservation owns the + // projected lifecycle until its matching turn.started is accepted. status: - options?.pendingTurnStart === true && session.status === "ready" + options?.pendingTurnStart === true ? "starting" : mapProviderSessionStatusToOrchestrationStatus(session.status), providerName: session.provider, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 9bb1d25e74..dd5dd4f3dc 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -68,6 +68,7 @@ import * as ThreadPlanProgress from "../ThreadPlanProgress.ts"; import { ProviderRuntimeIngestionLive, runtimeEventToActivities, + splitBufferedAssistantText, } from "./ProviderRuntimeIngestion.ts"; import { DEFAULT_THREAD_TITLE } from "../threadTitles.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; @@ -488,7 +489,23 @@ describe("ProviderRuntimeIngestion", () => { }); }), ).pipe(Layer.provide(projectionSnapshotLayer)); + // Advance pacing deterministically while keeping real sleeps and deadlines. + let clockOffsetMs = 0; + const realClock = Effect.runSync(Effect.service(Clock.Clock)); + const shiftedClock: Clock.Clock = { + currentTimeMillisUnsafe: () => realClock.currentTimeMillisUnsafe() + clockOffsetMs, + currentTimeMillis: Effect.sync(() => realClock.currentTimeMillisUnsafe() + clockOffsetMs), + currentTimeNanosUnsafe: () => + realClock.currentTimeNanosUnsafe() + BigInt(clockOffsetMs) * 1_000_000n, + currentTimeNanos: Effect.sync( + () => realClock.currentTimeNanosUnsafe() + BigInt(clockOffsetMs) * 1_000_000n, + ), + monotonicTimeNanosUnsafe: () => realClock.monotonicTimeNanosUnsafe(), + monotonicTimeNanos: realClock.monotonicTimeNanos, + sleep: (duration) => realClock.sleep(duration), + }; const layer = ProviderRuntimeIngestionLive.pipe( + Layer.provide(Layer.succeed(Clock.Clock, shiftedClock)), Layer.provideMerge(orchestrationLayer), Layer.provideMerge(ingestionProjectionSnapshotLayer), // Single shared liveness instance across ingestion (writer), the @@ -587,6 +604,9 @@ describe("ProviderRuntimeIngestion", () => { dispatch, readModel: () => testRuntime.runPromise(snapshotQuery.getSnapshot()), emit: provider.emit, + advanceClock: (ms: number) => { + clockOffsetMs += ms; + }, emitAndDrain, sqlCount: sqlCounter.count, readTurn: (turnId: TurnId) => @@ -1663,6 +1683,27 @@ describe("ProviderRuntimeIngestion", () => { expect(thread?.session?.pendingTurnRequestId).toBe(requestId); expect(thread?.messages.some((message) => message.role === "assistant")).toBe(false); + // The prior turn can finish on the same provider session after the new + // admission is reserved. Its terminal event must not clear that pending + // admission before the matching turn.started arrives. + await harness.emitAndDrain([ + { + type: "turn.completed", + eventId: asEventId("evt-runtime-session-b-prior-turn-completed"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + threadId, + turnId: asTurnId("turn-runtime-session-b-prior"), + admissionRequestId: CommandId.make("cmd-runtime-session-b-prior"), + sessionIncarnationId: sessionB, + createdAt: "2026-01-01T00:00:02.500Z", + payload: { state: "completed" }, + }, + ]); + thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect(thread?.session?.status).toBe("starting"); + expect(thread?.session?.pendingTurnRequestId).toBe(requestId); + const currentTurnId = asTurnId("turn-runtime-session-b"); harness.emit({ type: "turn.started", @@ -4965,6 +5006,145 @@ describe("ProviderRuntimeIngestion", () => { expect(finalMessage?.streaming).toBe(false); }); + it("delivers finished paragraphs while the rest of the message stays buffered", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const codex = ProviderDriverKind.make("codex"); + const threadId = asThreadId("thread-1"); + const turnId = asTurnId("turn-paragraph-flush"); + const itemId = asItemId("item-paragraph-flush"); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-paragraph-started"), + provider: codex, + createdAt: now, + threadId, + turnId, + }); + await waitForThread( + harness.readModel, + (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === turnId, + ); + + // Each delta lands well outside the pacing window of the one before. + const emitDelta = (eventId: string, delta: string) => { + harness.advanceClock(1_000); + harness.emit({ + type: "content.delta", + eventId: asEventId(eventId), + provider: codex, + createdAt: now, + threadId, + turnId, + itemId, + payload: { streamKind: "assistant_text", delta }, + }); + }; + + emitDelta("evt-paragraph-1", "First paragraph.\n\nSecond para"); + const afterFirst = await waitForThread(harness.readModel, (thread) => + thread.messages.some( + (message: ProviderRuntimeTestMessage) => message.id === `assistant:${itemId}`, + ), + ); + expect( + afterFirst.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`), + ).toMatchObject({ + text: "First paragraph.\n\n", + streaming: true, + }); + + // An open code block holds the whole block until its closing fence lands. + emitDelta("evt-paragraph-2", "graph.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n"); + await harness.drain(); + expect( + (await harness.readModel()).threads + .find((t) => t.id === threadId) + ?.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`)?.text, + ).toBe("First paragraph.\n\nSecond paragraph.\n\n"); + + emitDelta("evt-paragraph-3", "```\n\nTail without newline"); + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-paragraph-completed"), + provider: codex, + createdAt: now, + threadId, + turnId, + itemId, + payload: { itemType: "assistant_message", status: "completed" }, + }); + const finalThread = await waitForThread(harness.readModel, (thread) => + thread.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === `assistant:${itemId}` && !message.streaming, + ), + ); + expect( + finalThread.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`) + ?.text, + ).toBe( + "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst a = 1;\n\nconst b = 2;\n```\n\nTail without newline", + ); + }); + + it("holds paragraphs that finish inside the pacing window and lands them together", async () => { + const harness = await createHarness(); + const codex = ProviderDriverKind.make("codex"); + const threadId = asThreadId("thread-1"); + const turnId = asTurnId("turn-paced"); + const itemId = asItemId("item-paced"); + // Every delta carries the same event time, like OpenCode does for one + // part. Pacing must follow the server clock, not the event stamp. + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-paced-started"), + provider: codex, + createdAt: now, + threadId, + turnId, + }); + await waitForThread( + harness.readModel, + (thread) => thread.session?.status === "running" && thread.session?.activeTurnId === turnId, + ); + // Emit is fire-and-forget, so drain after each delta before moving the + // clock. Otherwise the worker reads a clock that has already advanced. + let clockMs = 0; + const emitDelta = async (eventId: string, delta: string, offsetMs: number) => { + harness.advanceClock(offsetMs - clockMs); + clockMs = offsetMs; + await harness.emitAndDrain([ + { + type: "content.delta", + eventId: asEventId(eventId), + provider: codex, + createdAt: now, + threadId, + turnId, + itemId, + payload: { streamKind: "assistant_text", delta }, + }, + ]); + }; + const messageText = async () => + (await harness.readModel()).threads + .find((t) => t.id === threadId) + ?.messages.find((m: ProviderRuntimeTestMessage) => m.id === `assistant:${itemId}`)?.text; + + await emitDelta("evt-paced-1", "One.\n\n", 0); + await emitDelta("evt-paced-2", "Two.\n\n", 100); + await emitDelta("evt-paced-3", "Three.\n\n", 200); + // The first paragraph lands right away. The next two are inside the window. + expect(await messageText()).toBe("One.\n\n"); + + await emitDelta("evt-paced-4", "Four.\n\n", 500); + expect(await messageText()).toBe("One.\n\nTwo.\n\nThree.\n\nFour.\n\n"); + }); + it("spills oversized buffered deltas and still finalizes full assistant text", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -6797,3 +6977,94 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("runtime still processed"); }); }); + +describe("splitBufferedAssistantText", () => { + it("keeps a partial trailing line buffered", () => { + expect(splitBufferedAssistantText("one\n\ntwo")).toEqual({ ready: "one\n\n", rest: "two" }); + expect(splitBufferedAssistantText("one\ntwo")).toEqual({ ready: "", rest: "one\ntwo" }); + }); + + it("does not split inside an open fence and delivers the block at its closing fence", () => { + const open = "intro\n\n```\ncode\n\nmore\n"; + expect(splitBufferedAssistantText(open)).toEqual({ + ready: "intro\n\n", + rest: "```\ncode\n\nmore\n", + }); + expect(splitBufferedAssistantText(`${open}\`\`\`\nafter`)).toEqual({ + ready: `${open}\`\`\`\n`, + rest: "after", + }); + }); + + it("does not treat a fence with an info string as a closing fence", () => { + const text = "```\n```javascript\nstill code\n\nmore\n"; + expect(splitBufferedAssistantText(text)).toEqual({ ready: "", rest: text }); + }); + + it("treats a fence indented four or more spaces as code, not a closing fence", () => { + const text = "```\n ```\n\nstill code\n"; + expect(splitBufferedAssistantText(text)).toEqual({ ready: "", rest: text }); + expect(splitBufferedAssistantText("```\n ```\nafter")).toEqual({ + ready: "```\n ```\n", + rest: "after", + }); + }); + + it("keeps a fence nested under a list item open across its blank lines", () => { + const text = "- step\n\n ```ts\n a\n\n b\n ```\n\nafter\n"; + expect(splitBufferedAssistantText(text)).toEqual({ + ready: "- step\n\n ```ts\n a\n\n b\n ```\n\n", + rest: "after\n", + }); + }); + + it("does not treat a no-break-space line as blank", () => { + expect(splitBufferedAssistantText("para\n\u00a0\ncont\n\nnext")).toEqual({ + ready: "para\n\u00a0\ncont\n\n", + rest: "next", + }); + }); + + it("treats CRLF blank lines as boundaries", () => { + expect(splitBufferedAssistantText("one\r\n\r\ntwo")).toEqual({ + ready: "one\r\n\r\n", + rest: "two", + }); + }); + + it("only closes a fence with the same marker of equal or greater length", () => { + const text = "````\n```\nstill code\n\n````\n\nout\n"; + expect(splitBufferedAssistantText(text)).toEqual({ + ready: "````\n```\nstill code\n\n````\n\n", + rest: "out\n", + }); + expect(splitBufferedAssistantText("~~~\n```\n\nx\n")).toEqual({ + ready: "", + rest: "~~~\n```\n\nx\n", + }); + }); + + it("delivers tight list items one at a time", () => { + expect(splitBufferedAssistantText("## Steps\n\n- one\n- two\n- thr")).toEqual({ + ready: "## Steps\n\n- one\n- two\n", + rest: "- thr", + }); + expect(splitBufferedAssistantText("1. one\n2. two\n more\n3. t")).toEqual({ + ready: "1. one\n2. two\n more\n", + rest: "3. t", + }); + }); + + it("keeps a partial list marker and list-like code buffered", () => { + expect(splitBufferedAssistantText("intro\n-")).toEqual({ ready: "", rest: "intro\n-" }); + expect(splitBufferedAssistantText("intro\n1.")).toEqual({ ready: "", rest: "intro\n1." }); + // `intro\n- \n` would parse as a setext heading, so a bare marker with only + // trailing whitespace is not a boundary on the partial line either. + expect(splitBufferedAssistantText("intro\n- ")).toEqual({ ready: "", rest: "intro\n- " }); + expect(splitBufferedAssistantText("- one\n")).toEqual({ ready: "", rest: "- one\n" }); + expect(splitBufferedAssistantText("```\n- one\n- two\n")).toEqual({ + ready: "", + rest: "```\n- one\n- two\n", + }); + }); +}); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 7aea6f4d5d..4012a4089f 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -27,6 +27,7 @@ import { } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; import * as Context from "effect/Context"; import * as Data from "effect/Data"; @@ -158,6 +159,7 @@ const BUFFERED_PROPOSED_PLAN_BY_ID_TTL = Duration.minutes(120); const TASK_DESCRIPTION_BY_TASK_CACHE_CAPACITY = 10_000; const TASK_DESCRIPTION_BY_TASK_TTL = Duration.minutes(120); const MAX_BUFFERED_ASSISTANT_CHARS = 24_000; +const MIN_ASSISTANT_DELIVERY_INTERVAL_MS = 400; const STRICT_PROVIDER_LIFECYCLE_GUARD = process.env.T3CODE_STRICT_PROVIDER_LIFECYCLE_GUARD !== "0"; type TurnStartRequestedDomainEvent = Extract< @@ -268,6 +270,51 @@ function hasRenderableAssistantText(text: string | undefined): boolean { return (text?.trim().length ?? 0) > 0; } +// A closing fence may be indented at most three spaces more than its opener. +const MARKDOWN_FENCE_PATTERN = /^( *)(`{3,}|~{3,})/; +const BLANK_LINE_PATTERN = /^[ \t]*$/; +// A complete list marker lets the previous item land before the next is done. +const LIST_ITEM_START_PATTERN = /^[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]/; + +/** Return text whose Markdown shape is complete, leaving the active block buffered. */ +export function splitBufferedAssistantText(text: string): { ready: string; rest: string } { + let openFence: { marker: string; indent: number } | null = null; + let boundary = -1; + let lineStart = 0; + for (;;) { + const newline = text.indexOf("\n", lineStart); + const line = text + .slice(lineStart, newline === -1 ? text.length : newline) + .replace(/[ \t\r]+$/, ""); + if (openFence === null && lineStart > 0 && LIST_ITEM_START_PATTERN.test(line)) { + boundary = lineStart; + } + if (newline === -1) break; + const fenceMatch = MARKDOWN_FENCE_PATTERN.exec(line); + if (fenceMatch) { + const indent = fenceMatch[1]!.length; + const marker = fenceMatch[2]!; + if (openFence === null) { + openFence = { marker, indent }; + } else if ( + marker[0] === openFence.marker[0] && + marker.length >= openFence.marker.length && + indent <= openFence.indent + 3 && + line.length === indent + marker.length + ) { + openFence = null; + boundary = newline + 1; + } + } else if (openFence === null && BLANK_LINE_PATTERN.test(line) && lineStart > 0) { + boundary = newline + 1; + } + lineStart = newline + 1; + } + return boundary === -1 + ? { ready: "", rest: text } + : { ready: text.slice(0, boundary), rest: text.slice(boundary) }; +} + function proposedPlanIdForTurn(threadId: ThreadId, turnId: TurnId): string { return `plan:${threadId}:turn:${turnId}`; } @@ -1618,6 +1665,11 @@ const make = Effect.gen(function* () { timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, lookup: () => Effect.succeed(""), }); + const lastAssistantDeliveryAtByMessageId = yield* Cache.make({ + capacity: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_CACHE_CAPACITY, + timeToLive: BUFFERED_MESSAGE_TEXT_BY_MESSAGE_ID_TTL, + lookup: () => Effect.succeed(0), + }); const assistantSegmentStateByTurnKey = yield* Cache.make({ capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY, @@ -1809,7 +1861,7 @@ const make = Effect.gen(function* () { }); }); - const appendBufferedAssistantText = (messageId: MessageId, delta: string) => + const appendBufferedAssistantText = (messageId: MessageId, delta: string, atMillis: number) => Cache.getOption(bufferedAssistantTextByMessageId, messageId).pipe( Effect.flatMap((existingText) => Effect.gen(function* () { @@ -1817,6 +1869,30 @@ const make = Effect.gen(function* () { onNone: () => delta, onSome: (text) => `${text}${delta}`, }); + const { ready, rest } = splitBufferedAssistantText(nextText); + const lastDeliveredAt = Option.getOrUndefined( + yield* Cache.getOption(lastAssistantDeliveryAtByMessageId, messageId), + ); + if ( + (lastDeliveredAt === undefined || + atMillis - lastDeliveredAt >= MIN_ASSISTANT_DELIVERY_INTERVAL_MS) && + hasRenderableAssistantText(ready) && + rest.length <= MAX_BUFFERED_ASSISTANT_CHARS + ) { + if (rest.length > 0) { + yield* commitRuntimeMutation( + Cache.set(bufferedAssistantTextByMessageId, messageId, rest), + ); + } else { + yield* commitRuntimeMutation( + Cache.invalidate(bufferedAssistantTextByMessageId, messageId), + ); + } + yield* commitRuntimeMutation( + Cache.set(lastAssistantDeliveryAtByMessageId, messageId, atMillis), + ); + return ready; + } if (nextText.length <= MAX_BUFFERED_ASSISTANT_CHARS) { yield* commitRuntimeMutation( Cache.set(bufferedAssistantTextByMessageId, messageId, nextText), @@ -1843,7 +1919,11 @@ const make = Effect.gen(function* () { ); const clearBufferedAssistantText = (messageId: MessageId) => - commitRuntimeMutation(Cache.invalidate(bufferedAssistantTextByMessageId, messageId)); + commitRuntimeMutation(Cache.invalidate(bufferedAssistantTextByMessageId, messageId)).pipe( + Effect.andThen( + commitRuntimeMutation(Cache.invalidate(lastAssistantDeliveryAtByMessageId, messageId)), + ), + ); const appendBufferedProposedPlan = (planId: string, delta: string, createdAt: string) => Cache.getOption(bufferedProposedPlanById, planId).pipe( @@ -2752,7 +2832,13 @@ const make = Effect.gen(function* () { : "buffered", ); if (assistantDeliveryMode === "buffered") { - const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta); + // Provider event timestamps may be fixed for an entire part; pace + // delivery by the ingestion clock instead. + const spillChunk = yield* appendBufferedAssistantText( + assistantMessageId, + assistantDelta, + yield* Clock.currentTimeMillis, + ); if (spillChunk.length > 0) { yield* orchestrationEngine.dispatch({ type: "thread.message.assistant.delta", diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration/PullRequestSyncReactor.test.ts index 414f76f324..1406eae239 100644 --- a/apps/server/src/orchestration/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration/PullRequestSyncReactor.test.ts @@ -1,9 +1,11 @@ import { + EventId, ProjectId, ProviderInstanceId, PullRequestOperationError, ThreadId, type OrchestrationCommand, + type OrchestrationEvent, type OrchestrationProjectShell, type OrchestrationShellSnapshot, type OrchestrationThreadShell, @@ -18,6 +20,7 @@ import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; @@ -31,6 +34,7 @@ import { } from "./Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; import * as PullRequestSyncReactor from "./PullRequestSyncReactor.ts"; +import { resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts"; const NOW = "2026-08-28T12:00:00.000Z"; const PROJECT_ID = ProjectId.make("sync-project"); @@ -152,6 +156,7 @@ function makeSummary( } interface HarnessOptions { + readonly onDispatch?: (command: SyncCommand | LinkCommand) => Effect.Effect; readonly invalidate?: PullRequestService["Service"]["invalidate"]; readonly snapshot: OrchestrationShellSnapshot; readonly summary?: ( @@ -165,6 +170,7 @@ interface HarnessOptions { const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: HarnessOptions) { const activation = yield* Deferred.make(); const snapshots = yield* Ref.make(options.snapshot); + const events = yield* PubSub.unbounded(); const snapshotReads = yield* Queue.unbounded(); const syncCommands = yield* Ref.make>([]); const linkCommands = yield* Ref.make>([]); @@ -188,11 +194,13 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: const dispatch: OrchestrationEngineShape["dispatch"] = (command) => { if (command.type === "thread.pull-request-link.sync") { return Ref.update(syncCommands, (recorded) => [...recorded, command]).pipe( + Effect.andThen(options.onDispatch?.(command) ?? Effect.void), Effect.as({ sequence: 1 }), ); } if (command.type === "thread.pull-request.link") { return Ref.update(linkCommands, (recorded) => [...recorded, command]).pipe( + Effect.andThen(options.onDispatch?.(command) ?? Effect.void), Effect.as({ sequence: 1 }), ); } @@ -213,6 +221,9 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: readEvents: () => Stream.empty, dispatch, streamDomainEvents: Stream.empty, + subscribeDomainEvents: PubSub.subscribe(events).pipe( + Effect.map((subscription) => Stream.fromSubscription(subscription)), + ), latestSequence: Effect.succeed(0), }), Layer.succeed(ServerActivation, Deferred.await(activation)), @@ -220,6 +231,7 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: ); return { + events, activation, snapshots, snapshotReads, @@ -274,6 +286,42 @@ function applySync( } describe("PullRequestSyncReactor", () => { + it.effect("syncs a newly linked merged PR without waiting for the periodic sweep", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("one")]), + summary: (input) => + Effect.succeed(makeSummary(input, { state: "merged", mergedAt: NOW })), + }); + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + const link = makeLink(42); + yield* Ref.set( + fixture.snapshots, + makeSnapshot([makeThread("one", { pullRequests: [link] })]), + ); + yield* PubSub.publish(fixture.events, { + type: "thread.pull-request-linked", + sequence: 2, + eventId: EventId.make("linked"), + aggregateKind: "thread", + aggregateId: ThreadId.make("one"), + occurredAt: NOW, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { threadId: ThreadId.make("one"), link, updatedAt: NOW }, + }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.strictEqual((yield* Ref.get(fixture.syncCommands))[0]?.snapshot.state, "merged"); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); it.effect("retries a failed stack read after the summary becomes terminal", () => Effect.scoped( Effect.gen(function* () { @@ -303,6 +351,7 @@ describe("PullRequestSyncReactor", () => { yield* Effect.gen(function* () { const reactor = yield* startAndSweep(fixture); const commands = yield* Ref.get(fixture.syncCommands); + assert.deepStrictEqual(commands, []); yield* Ref.update(fixture.snapshots, (snapshot) => applySync(snapshot, commands)); yield* sweepAgain(fixture, reactor); assert.strictEqual(attempts, 2); @@ -315,6 +364,85 @@ describe("PullRequestSyncReactor", () => { ), ); + it.effect("retries a failed sibling link before publishing a terminal snapshot", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + let failSibling = true; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("one", { pullRequests: [makeLink(7, { state: "closed" })] }), + ]), + summary: (input) => + Effect.succeed(makeSummary(input, { state: "merged", mergedAt: NOW })), + stack: () => + Effect.succeed({ + id: "stack", + number: 7, + url: "https://github.com/owner/repository/stacks/7", + base: "main", + layers: [ + { number: 7, headBranch: "feature", state: "merged" }, + { number: 8, headBranch: "sibling", state: "open" }, + ], + }), + onDispatch: (command) => + command.type === "thread.pull-request.link" && failSibling + ? Effect.die("temporary link failure") + : Effect.void, + }); + yield* Effect.gen(function* () { + const reactor = yield* startAndSweep(fixture); + assert.deepStrictEqual(yield* Ref.get(fixture.syncCommands), []); + failSibling = false; + yield* sweepAgain(fixture, reactor); + assert.strictEqual((yield* Ref.get(fixture.syncCommands))[0]?.snapshot.state, "merged"); + assert.strictEqual((yield* Ref.get(fixture.linkCommands)).at(-1)?.number, 8); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("concurrent stack reads persist a shared sibling once before syncing both roots", () => + Effect.scoped( + Effect.gen(function* () { + const readsReady = yield* Deferred.make(); + let reads = 0; + let linked = false; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("one", { pullRequests: [makeLink(7), makeLink(8)] })]), + stack: () => + Effect.gen(function* () { + if (++reads === 2) yield* Deferred.succeed(readsReady, undefined); + yield* Deferred.await(readsReady); + return { + id: "stack", + number: 7, + url: "https://github.com/owner/repository/stacks/7", + base: "main", + layers: [{ number: 9, headBranch: "sibling", state: "open" as const }], + }; + }), + onDispatch: (command) => + Effect.gen(function* () { + if (command.type === "thread.pull-request.link") { + yield* Effect.yieldNow; + assert.strictEqual(linked, false); + linked = true; + } else { + assert.strictEqual(linked, true); + } + }), + }); + yield* Effect.gen(function* () { + yield* startAndSweep(fixture); + assert.strictEqual((yield* Ref.get(fixture.linkCommands)).length, 1); + assert.strictEqual((yield* Ref.get(fixture.syncCommands)).length, 2); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("explicit refresh reads a changed stack even when its PR summary is unchanged", () => Effect.scoped( Effect.gen(function* () { @@ -631,20 +759,38 @@ describe("PullRequestSyncReactor", () => { base: "main", layers: [ { number: 41, headBranch: "layer-1", state: "merged" }, - { number: 42, headBranch: "layer-2", state: "open" }, + { number: 42, headBranch: "layer-2", state: "merged" }, { number: 43, headBranch: "layer-3", state: "open" }, ], }; + let thread = makeThread("one", { + pullRequests: [ + makeLink(42, { state: "open" }), + makeLink(41, { state: "merged" }, { source: "stack-dismissed" }), + ], + }); const fixture = yield* makeHarness({ - snapshot: makeSnapshot([ - makeThread("one", { - pullRequests: [ - makeLink(42), - makeLink(41, { state: "merged" }, { source: "stack-dismissed" }), - ], - }), - ]), + snapshot: makeSnapshot([thread]), + summary: (input) => + Effect.succeed(makeSummary(input, { state: "merged", mergedAt: NOW })), stack: () => Effect.succeed(stack), + onDispatch: (command) => + Effect.sync(() => { + thread = + command.type === "thread.pull-request.link" + ? { ...thread, pullRequests: [...thread.pullRequests, makeLink(command.number)] } + : applySync(makeSnapshot([thread]), [command]).threads[0]!; + // Every projected event may wake settlement, including the terminal root update. + assert.isNull( + resolveAutoSettlementAt({ + thread, + pullRequest: null, + now: NOW, + autoSettleAfterDays: null, + autoSettleOnMerge: true, + }), + ); + }), }); yield* Effect.gen(function* () { diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.ts b/apps/server/src/orchestration/PullRequestSyncReactor.ts index 0a39fa5d91..2298530210 100644 --- a/apps/server/src/orchestration/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration/PullRequestSyncReactor.ts @@ -22,6 +22,8 @@ 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 Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import { forkParked } from "../serverActivation.ts"; @@ -149,7 +151,7 @@ export const make = Effect.gen(function* () { (cause: Cause.Cause): Effect.Effect => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning(message, fields); - const sweep = Effect.fn("PullRequestSyncReactor.sweep")(function* () { + const sweep = Effect.fn("PullRequestSyncReactor.sweep")(function* (requestedKey?: string) { const snapshot = yield* snapshots.getShellSnapshot(); const now = yield* DateTime.now; const nowMs = DateTime.toEpochMillis(now); @@ -173,6 +175,7 @@ export const make = Effect.gen(function* () { // 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 persistence = yield* Semaphore.make(1); const syncEntry = Effect.fn("PullRequestSyncReactor.syncEntry")(function* ( entry: LinkEntry, @@ -185,21 +188,8 @@ export const make = Effect.gen(function* () { link.snapshot === null || !snapshotFieldsEqual(link.snapshot, fields) || !stacksEqual(link.stack, nextStack); - if (changed) { - const uuid = yield* crypto.randomUUIDv4; - yield* engine.dispatch({ - type: "thread.pull-request-link.sync", - commandId: CommandId.make(`server:pr-sync:${thread.id}:${uuid}`), - threadId: thread.id, - host: link.host, - repository: link.repository, - number: link.number, - snapshot: { ...fields, syncedAt: nowIso }, - stack: nextStack, - }); - } - if (fetchedStack === null || fetchedStack.stack === null) return; - for (const layer of fetchedStack.stack.layers) { + // Persist discovered siblings before a terminal snapshot can trigger settlement. + 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; @@ -211,25 +201,29 @@ export const make = Effect.gen(function* () { } const url = siblingPullRequestUrl(link.url, layer.number); if (url === null) continue; + const uuid = yield* crypto.randomUUIDv4; + yield* engine.dispatch({ + type: "thread.pull-request.link", + commandId: CommandId.make(`server:pr-stack-link:${thread.id}:${uuid}`), + threadId: thread.id, + ...layerKey, + url, + source: "stack", + }); linkedThisSweep.add(dedupeKey); + } + if (changed) { const uuid = yield* crypto.randomUUIDv4; - yield* engine - .dispatch({ - type: "thread.pull-request.link", - commandId: CommandId.make(`server:pr-stack-link:${thread.id}:${uuid}`), - threadId: thread.id, - ...layerKey, - url, - source: "stack", - }) - .pipe( - Effect.catchCause( - logSkipped("pull request stack layer link skipped", { - threadId: thread.id, - number: layer.number, - }), - ), - ); + yield* engine.dispatch({ + type: "thread.pull-request-link.sync", + commandId: CommandId.make(`server:pr-sync:${thread.id}:${uuid}`), + threadId: thread.id, + host: link.host, + repository: link.repository, + number: link.number, + snapshot: { ...fields, syncedAt: nowIso }, + stack: nextStack, + }); } }); @@ -270,8 +264,11 @@ export const make = Effect.gen(function* () { ) : null; if (needsStack) { - if (fetchedStack === null) retryStacks.add(key); - else retryStacks.delete(key); + if (fetchedStack === null) { + retryStacks.add(key); + return; + } + retryStacks.delete(key); } // The host answered, so the cadence clock ticks even if a dispatch below is rejected. lastSyncedAt.set(key, nowMs); @@ -281,9 +278,13 @@ export const make = Effect.gen(function* () { entries, (entry) => syncEntry(entry, fields, fetchedStack).pipe( - Effect.catchCause( - logSkipped("pull request sync skipped", { threadId: entry.thread.id, key }), - ), + persistence.withPermits(1), + Effect.catchCause((cause) => { + if (!Cause.hasInterruptsOnly(cause)) retryStacks.add(key); + return logSkipped("pull request sync skipped", { threadId: entry.thread.id, key })( + cause, + ); + }), ), { discard: true }, ); @@ -292,22 +293,30 @@ export const make = Effect.gen(function* () { yield* Effect.forEach( groups, ([key, entries]) => - isDue(key, entries, nowMs) + (requestedKey === undefined || requestedKey === key) && isDue(key, entries, nowMs) ? syncGroup(key, entries).pipe( Effect.catchCause(logSkipped("pull request sync skipped", { key })), ) : Effect.void, - { concurrency: 8, discard: true }, + // As wide as one batched summary read, so the sweep's reads on a host arrive together and + // GitHub answers them in one request rather than one `gh pr view` apiece. + { concurrency: 25, discard: true }, ); }); - const worker = yield* makeDrainableWorker(() => - sweep().pipe(Effect.catchCause(logSkipped("pull request sync sweep failed", {}))), + const worker = yield* makeDrainableWorker((key: string | undefined) => + sweep(key).pipe(Effect.catchCause(logSkipped("pull request sync sweep failed", {}))), ); const start: PullRequestSyncReactor["Service"]["start"] = Effect.fn( "PullRequestSyncReactor.start", )(function* () { + const events = yield* engine.subscribeDomainEvents; + yield* forkParked( + Stream.runForEach(events, (event) => + event.type === "thread.pull-request-linked" ? requestSync(event.payload.link) : Effect.void, + ), + ); yield* forkParked( Effect.gen(function* () { yield* worker.enqueue(undefined); @@ -318,8 +327,9 @@ export const make = Effect.gen(function* () { const requestSync: PullRequestSyncReactor["Service"]["requestSync"] = (key) => Effect.suspend(() => { - requested.set(threadPullRequestKeyOf(key), ++requestGeneration); - return worker.enqueue(undefined); + const syncKey = threadPullRequestKeyOf(key); + requested.set(syncKey, ++requestGeneration); + return worker.enqueue(syncKey); }); return { start, drain: worker.drain, requestSync } satisfies PullRequestSyncReactor["Service"]; diff --git a/apps/server/src/orchestration/RelayWorkerBridge.test.ts b/apps/server/src/orchestration/RelayWorkerBridge.test.ts index 727e5260ce..43d1c40910 100644 --- a/apps/server/src/orchestration/RelayWorkerBridge.test.ts +++ b/apps/server/src/orchestration/RelayWorkerBridge.test.ts @@ -213,6 +213,65 @@ describe("RelayWorkerBridge direct receipts", () => { ), ).toEqual([]); }); + + it("binds the installed Claude plugin's unversioned job result", () => { + const result = { + type: "tool_result", + content: [ + { + type: "text", + text: JSON.stringify({ + id: jobId, + status: "queued", + providerType: "codex", + createdAt: "2026-09-24T00:00:00.000Z", + }), + }, + ], + }; + expect( + relayBindingFromToolEvent( + toolEvent({ + toolName: "mcp__plugin_relay-orchestrator_relay__relay_delegate", + result, + }), + ), + ).toEqual([ + { kind: "job", id: jobId, threadId: "thread-a", turnId: "turn-a", toolCallId: "tool-a" }, + ]); + expect( + relayBindingFromToolEvent( + toolEvent({ + toolName: "mcp__relay__relay_delegate", + result, + }), + ), + ).toEqual([ + { kind: "job", id: jobId, threadId: "thread-a", turnId: "turn-a", toolCallId: "tool-a" }, + ]); + expect( + relayBindingFromToolEvent( + toolEvent({ + toolName: "mcp__plugin_relay-orchestrator_relay__relay_panel", + result: { + type: "tool_result", + content: [ + { + type: "text", + text: JSON.stringify({ + id: panelId, + jobs: [jobId], + unstarted: [{ index: 1 }], + }), + }, + ], + }, + }), + ), + ).toEqual([ + { kind: "panel", id: panelId, threadId: "thread-a", turnId: "turn-a", toolCallId: "tool-a" }, + ]); + }); }); describe("Relay observe v1", () => { @@ -267,7 +326,10 @@ const args = process.argv.slice(2); calls.push(args); fs.writeFileSync(callsPath, JSON.stringify(calls)); const id = args[0] === "observe" && args[1] === "--panel" ? args[2] : args[1]; -if (args[0] === "observe" && state[id]) console.log(JSON.stringify(state[id])); +if (args[0] === "observe" && state[id]?.schemaVersion === 1) console.log(JSON.stringify(state[id])); +else if (args[0] === "observe" && state[id]) console.log(JSON.stringify({ usage: "relay init | status [id]", request: "JSON from --request file" })); +else if (args[0] === "status" && state[id]) console.log(JSON.stringify(state[id])); +else if (args[0] === "panel-result" && state[id]) console.log(JSON.stringify(state[id])); else if (args[0] === "cancel" && state[id]) console.log(JSON.stringify({id, status: "cancelling"})); else process.exit(1); `, @@ -362,14 +424,11 @@ persistence("Relay persisted observer and controls", (it) => { toolCallId: "tool-a", status: "completed", data: { - toolName: "mcp__relay__relay_delegate", + toolName: "mcp__plugin_relay-orchestrator_relay__relay_delegate", result: { type: "tool_result", tool_use_id: "tool-a", - content: encodeJson({ - content: [{ type: "text", text: "Worker dispatched" }], - structuredContent: { schemaVersion: 1, kind: "job", jobId, attempt: 1 }, - }), + content: [{ type: "text", text: encodeJson({ id: jobId, status: "queued" }) }], }, }, }; @@ -395,6 +454,55 @@ persistence("Relay persisted observer and controls", (it) => { effort: "high", cancellable: true, }); + const legacyJobId = "job-77777777-7777-4777-8777-777777777777"; + cli.setState({ + [jobId]: job, + [legacyJobId]: { + id: legacyJobId, + status: "running", + providerType: "codex", + model: "gpt-6-sol", + effort: "low", + createdAt: "2026-09-24T00:00:00.000Z", + updatedAt: "2026-09-24T00:00:01.000Z", + }, + }); + yield* bridge.recordToolResult( + toolEvent({ + toolName: "mcp__plugin_relay-orchestrator_relay__relay_delegate", + itemId: "legacy-tool", + turnId: "legacy-turn", + result: { + type: "tool_result", + content: [ + { + type: "text", + text: encodeJson({ + id: legacyJobId, + status: "queued", + providerType: "codex", + }), + }, + ], + }, + }), + ); + yield* bridge.reconcile; + const legacyRows = yield* sql` + SELECT turn_id AS turnId, payload_json AS payload + FROM projection_thread_activities + WHERE kind = 'task.started' + AND json_extract(payload_json, '$.taskId') = ${`relay:${legacyJobId}`} + `; + expect(legacyRows).toHaveLength(1); + expect(legacyRows[0]?.turnId).toBe("legacy-turn"); + expect(decodeJson(legacyRows[0]?.payload)).toMatchObject({ + taskId: `relay:${legacyJobId}`, + source: "relay", + model: "gpt-6-sol", + effort: "low", + toolUseId: "legacy-tool", + }); const wrongThread = yield* Effect.result( bridge.cancel(ThreadId.make("thread-b"), `relay:${jobId}`), ); @@ -412,7 +520,7 @@ persistence("Relay persisted observer and controls", (it) => { yield* unavailable.reconcile; yield* unavailable.reconcile; const idleRows = - yield* sql`SELECT payload_json AS payload FROM projection_thread_activities WHERE kind = 'task.progress' AND summary = 'Relay observer unavailable'`; + yield* sql`SELECT payload_json AS payload FROM projection_thread_activities WHERE kind = 'task.progress' AND summary = 'Relay observer unavailable' AND json_extract(payload_json, '$.taskId') = ${`relay:${jobId}`}`; expect(idleRows).toHaveLength(1); expect(decodeJson(idleRows[0]?.payload)).toMatchObject({ taskId: `relay:${jobId}`, @@ -442,6 +550,7 @@ persistence("Relay persisted observer and controls", (it) => { const repeatedOutage = yield* sql` SELECT payload_json AS payload FROM projection_thread_activities WHERE kind = 'task.progress' AND summary = 'Relay observer unavailable' + AND json_extract(payload_json, '$.taskId') = ${`relay:${jobId}`} ORDER BY sequence `; expect(repeatedOutage).toHaveLength(2); @@ -466,7 +575,7 @@ persistence("Relay persisted observer and controls", (it) => { }); yield* reconnected.reconcile; const completed = - yield* sql`SELECT payload_json AS payload FROM projection_thread_activities WHERE kind = 'task.completed'`; + yield* sql`SELECT payload_json AS payload FROM projection_thread_activities WHERE kind = 'task.completed' AND json_extract(payload_json, '$.taskId') = ${`relay:${jobId}`}`; expect(completed).toHaveLength(1); expect(decodeJson(completed[0]?.payload)).toMatchObject({ taskId: `relay:${jobId}`, @@ -515,6 +624,7 @@ persistence("Relay persisted observer and controls", (it) => { const pendingResume = yield* sql` SELECT turn_id AS turnId, payload_json AS payload FROM projection_thread_activities WHERE kind = 'task.progress' AND activity_id LIKE 'relay-observer-unavailable:%' + AND json_extract(payload_json, '$.taskId') = ${`relay:${jobId}`} ORDER BY sequence DESC LIMIT 1 `; expect(pendingResume).toHaveLength(1); @@ -530,7 +640,7 @@ persistence("Relay persisted observer and controls", (it) => { cli.setState({ [jobId]: { ...job, attempt: 2, sequence: 1, status: "running" } }); yield* resumedBridge.reconcile; const resumed = - yield* sql`SELECT turn_id AS turnId, payload_json AS payload FROM projection_thread_activities WHERE kind = 'task.started' ORDER BY created_at`; + yield* sql`SELECT turn_id AS turnId, payload_json AS payload FROM projection_thread_activities WHERE kind = 'task.started' AND json_extract(payload_json, '$.taskId') = ${`relay:${jobId}`} ORDER BY created_at`; expect(resumed).toHaveLength(2); expect(decodeJson(resumed[1]?.payload)).toMatchObject({ taskId: `relay:${jobId}`, @@ -944,13 +1054,475 @@ persistence("Relay persisted observer and controls", (it) => { Effect.provideService(ServerEnvironment, fakeEnvironment), Effect.provideService(ThreadBackgroundLivenessService, liveness), ); + const callsBeforeBoot = cli.calls().length; yield* bootAfterChurn.reconcile; + const bootCalls = cli.calls().slice(callsBeforeBoot); + const observed = (id: string) => + bootCalls.findIndex((args) => args[0] === "observe" && args[1] === id); + expect(observed(legacyJobId)).toBeGreaterThan(-1); + expect(observed(completedPanelJobId)).toBeGreaterThan(-1); + expect(observed(completedPanelJobId)).toBeLessThan(observed(legacyJobId)); expect( cli .calls() .filter((args) => args[0] === "observe" && args[1]?.startsWith("job-00000000-")), ).toHaveLength(0); + + const missingId = "job-cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) VALUES ( + ${`relay-binding:${missingId}`}, 'thread-a', 'turn-missing', 'info', + 'relay.binding', 'Relay worker bound', + ${encodeJson({ kind: "job", id: missingId, toolCallId: "tool-missing", environmentId: "test-environment" })}, + 9999, '2026-09-24T00:00:00.000Z' + ) + `; + const missingBridge = yield* makeWithCliPath(cli.path).pipe( + Effect.provideService(OrchestrationEngineService, engine), + Effect.provideService(ServerEnvironment, fakeEnvironment), + Effect.provideService(ThreadBackgroundLivenessService, liveness), + ); + yield* missingBridge.reconcile; + yield* missingBridge.reconcile; + yield* missingBridge.reconcile; + // A binding whose worker never reached the thread has nothing to + // downgrade, so its missing Relay files stay quiet rather than + // opening an idle row nobody can act on. + const missingRows = yield* sql` + SELECT payload_json AS payload FROM projection_thread_activities + WHERE kind = 'task.progress' AND json_extract(payload_json, '$.taskId') = ${`relay:${missingId}`} + `; + expect(missingRows).toEqual([]); + const missingPolls = cli + .calls() + .filter((args) => args[0] === "observe" && args[1] === missingId).length; + yield* missingBridge.reconcile; + expect( + cli.calls().filter((args) => args[0] === "observe" && args[1] === missingId), + ).toHaveLength(missingPolls); + + const legacyPanelId = "panel-88888888-8888-4888-8888-888888888888"; + const priorId = "job-99999999-9999-4999-8999-999999999999"; + const currentId = "job-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const otherId = "job-bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + const legacyDate = "2026-09-24T00:00:01.000Z"; + const legacyJobs = [ + { + id: currentId, + status: "running", + panelId: legacyPanelId, + panelMemberIndex: 0, + providerType: "codex", + createdAt: legacyDate, + updatedAt: legacyDate, + }, + { + id: otherId, + status: "running", + panelId: legacyPanelId, + panelMemberIndex: 1, + providerType: "claude-alt", + createdAt: legacyDate, + updatedAt: legacyDate, + }, + ]; + cli.setState({ + [legacyPanelId]: { + id: legacyPanelId, + request: { members: [{}, {}] }, + jobs: legacyJobs, + previousJobs: [priorId], + unstarted: [], + complete: false, + createdAt: legacyDate, + }, + [priorId]: { + id: priorId, + status: "failed", + panelId: legacyPanelId, + panelMemberIndex: 0, + createdAt: legacyDate, + updatedAt: legacyDate, + }, + [currentId]: legacyJobs[0], + [otherId]: legacyJobs[1], + }); + yield* bootAfterChurn.recordToolResult( + toolEvent({ + toolName: "mcp__plugin_relay-orchestrator_relay__relay_panel", + itemId: "legacy-panel-tool", + turnId: "legacy-panel-turn", + result: { + type: "tool_result", + content: [ + { + type: "text", + text: encodeJson({ + id: legacyPanelId, + jobs: [currentId, otherId], + unstarted: [], + }), + }, + ], + }, + }), + ); + yield* bootAfterChurn.reconcile; + const legacyMemberId = `relay-panel:${legacyPanelId}:member:0`; + const legacyMember = yield* sql` + SELECT turn_id AS turnId, payload_json AS payload + FROM projection_thread_activities WHERE kind = 'task.started' + AND json_extract(payload_json, '$.taskId') = ${legacyMemberId} + `; + expect(legacyMember).toHaveLength(1); + expect(legacyMember[0]?.turnId).toBe("legacy-panel-turn"); + expect(decodeJson(legacyMember[0]?.payload)).toMatchObject({ + taskId: legacyMemberId, + attempt: 2_000_001, + parentAgentId: `relay-panel:${legacyPanelId}`, + }); + expect( + (yield* bootAfterChurn.cancel(ThreadId.make("thread-a"), legacyMemberId)).disposition, + ).toBe("cancel-requested"); + expect(cli.calls().some((args) => args[0] === "cancel" && args[1] === currentId)).toBe( + true, + ); }).pipe(Effect.ensuring(Effect.sync(() => cli.cleanup()))); }, ); }); + +function recordingEngine(sql: SqlClient.SqlClient) { + const commands = new Set(); + return { + dispatch: (command: { + readonly type: string; + readonly commandId: string; + readonly threadId: string; + readonly activity?: { + readonly id: string; + readonly turnId: string | null; + readonly kind: string; + readonly tone: string; + readonly summary: string; + readonly payload: unknown; + readonly createdAt: string; + }; + }) => + Effect.gen(function* () { + if (commands.has(command.commandId)) return { sequence: commands.size, eventCount: 0 }; + if (command.type !== "thread.activity.append" || !command.activity) + throw new Error("unexpected command"); + commands.add(command.commandId); + const activity = command.activity; + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) VALUES ( + ${activity.id}, ${command.threadId}, ${activity.turnId}, ${activity.tone}, + ${activity.kind}, ${activity.summary}, ${encodeJson(activity.payload)}, ${commands.size}, ${activity.createdAt} + ) ON CONFLICT(activity_id) DO UPDATE SET + payload_json = excluded.payload_json, sequence = excluded.sequence, + created_at = excluded.created_at + `; + return { sequence: commands.size, eventCount: 1 }; + }), + } as unknown as OrchestrationEngineService["Service"]; +} + +persistence("Relay progress-only outage", (it) => { + it.effect("keeps an outage for a progress-only worker after restart", () => { + const cli = fakeRelayCli(); + cli.setState({}); + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const jobId = "job-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4"; + const taskId = `relay:${jobId}`; + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) VALUES ( + ${`relay-binding:${jobId}`}, 'thread-a', 'turn-progress', 'info', + 'relay.binding', 'Relay worker bound', + ${encodeJson({ kind: "job", id: jobId, toolCallId: "tool-progress", environmentId: "test-environment" })}, + 1, '2026-09-24T00:00:00.000Z' + ), ( + ${`relay-progress:${jobId}`}, 'thread-a', 'turn-progress', 'info', + 'task.progress', 'Relay worker running', + ${encodeJson({ taskId, attempt: 1, relaySequence: 1, status: "running" })}, + 2, '2026-09-24T00:00:01.000Z' + ) + `; + const bridge = yield* makeWithCliPath(cli.path).pipe( + Effect.provideService(OrchestrationEngineService, recordingEngine(sql)), + Effect.provideService(ServerEnvironment, fakeEnvironment), + Effect.provideService(ThreadBackgroundLivenessService, makeLiveness()), + ); + yield* bridge.reconcile; + yield* bridge.reconcile; + yield* bridge.reconcile; + const outage = yield* sql` + SELECT payload_json AS payload FROM projection_thread_activities + WHERE kind = 'task.progress' AND summary = 'Relay observer unavailable' + AND json_extract(payload_json, '$.taskId') = ${taskId} + `; + expect(outage).toHaveLength(1); + expect(decodeJson(outage[0]?.payload)).toMatchObject({ status: "idle" }); + }).pipe(Effect.ensuring(Effect.sync(() => cli.cleanup()))); + }); +}); + +persistence("Relay startup receipt adoption", (it) => { + it.effect("stays silent about adopted historical jobs it never projected", () => { + const cli = fakeRelayCli(); + // The startup scan adopts every persisted Relay receipt, including turns + // whose workers finished and were swept long before this boot. + cli.setState({}); + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const historical = [ + { job: "job-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1", turn: "turn-old-1", tool: "tool-old-1" }, + { job: "job-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2", turn: "turn-old-2", tool: "tool-old-2" }, + ]; + for (const entry of historical) { + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) VALUES ( + ${`persisted-${entry.tool}`}, 'thread-a', ${entry.turn}, 'tool', 'tool.completed', + 'Relay delegate', + ${encodeJson({ + itemType: "mcp_tool_call", + toolCallId: entry.tool, + status: "completed", + data: { + toolName: "mcp__plugin_relay-orchestrator_relay__relay_delegate", + result: { + type: "tool_result", + tool_use_id: entry.tool, + content: [ + { type: "text", text: encodeJson({ id: entry.job, status: "queued" }) }, + ], + }, + }, + })}, + NULL, '2026-09-20T00:00:00.000Z' + ) + `; + } + const bridge = yield* makeWithCliPath(cli.path).pipe( + Effect.provideService(OrchestrationEngineService, recordingEngine(sql)), + Effect.provideService(ServerEnvironment, fakeEnvironment), + Effect.provideService(ThreadBackgroundLivenessService, makeLiveness()), + ); + yield* bridge.reconcile; + yield* bridge.reconcile; + yield* bridge.reconcile; + yield* bridge.reconcile; + + // The scan still adopts them so a worker that outlived a crash can be + // reclaimed, but a job it can never observe stays out of the thread. + const adopted = yield* sql` + SELECT activity_id AS activityId FROM projection_thread_activities + WHERE kind = 'relay.binding' ORDER BY activity_id + `; + expect(adopted).toEqual( + historical.map((entry) => ({ activityId: `relay-binding:${entry.job}` })), + ); + const noise = yield* sql` + SELECT activity_id AS activityId FROM projection_thread_activities + WHERE kind LIKE 'task.%' + `; + expect(noise).toEqual([]); + // Relay answered that these jobs are gone and nothing about them ever + // reached the thread, so they are retired instead of holding a CLI spawn + // for the life of the process. + const settledPolls = cli.calls().filter((args) => args[0] === "observe").length; + expect(settledPolls).toBeGreaterThan(0); + yield* bridge.reconcile; + yield* bridge.reconcile; + expect(cli.calls().filter((args) => args[0] === "observe")).toHaveLength(settledPolls); + + // Retirement is persisted on the binding itself, so a restart does not + // re-probe every job the environment has ever run. + const restarted = yield* makeWithCliPath(cli.path).pipe( + Effect.provideService(OrchestrationEngineService, recordingEngine(sql)), + Effect.provideService(ServerEnvironment, fakeEnvironment), + Effect.provideService(ThreadBackgroundLivenessService, makeLiveness()), + ); + const beforeRestart = cli.calls().filter((args) => args[0] === "observe").length; + yield* restarted.reconcile; + yield* restarted.reconcile; + expect( + cli + .calls() + .slice(beforeRestart) + .filter((args) => args[0] === "observe" && historical.some((e) => e.job === args[1])), + ).toEqual([]); + // No extra rows were written to carry the retirement. + const bindingRows = yield* sql` + SELECT COUNT(*) AS n FROM projection_thread_activities WHERE kind = 'relay.binding' + `; + expect(Number((bindingRows[0] as { n: number }).n)).toBe(historical.length); + + // A worker this environment did project still reports its outage. + const liveJob = "job-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3"; + cli.setState({ + [liveJob]: { + schemaVersion: 1, + kind: "job", + id: liveJob, + attempt: 1, + sequence: 1, + status: "running", + providerType: "codex", + model: "gpt-6-sol", + effort: "high", + activity: { kind: "editing", label: "Editing files" }, + usage: { totalTokens: 4, inputTokens: 3, outputTokens: 1 }, + pending: false, + outcome: null, + }, + }); + yield* bridge.recordToolResult( + toolEvent({ + toolName: "mcp__relay__relay_delegate", + turnId: "turn-live", + itemId: "tool-live", + result: { structuredContent: { schemaVersion: 1, kind: "job", jobId: liveJob } }, + }), + ); + yield* bridge.reconcile; + const started = yield* sql` + SELECT payload_json AS payload FROM projection_thread_activities + WHERE kind = 'task.started' AND json_extract(payload_json, '$.taskId') = ${`relay:${liveJob}`} + `; + expect(started).toHaveLength(1); + + cli.setState({}); + yield* bridge.reconcile; + yield* bridge.reconcile; + yield* bridge.reconcile; + const outage = yield* sql` + SELECT json_extract(payload_json, '$.taskId') AS taskId + FROM projection_thread_activities + WHERE kind = 'task.progress' AND summary = 'Relay observer unavailable' + `; + expect(outage).toEqual([{ taskId: `relay:${liveJob}` }]); + + // Reopening the thread after a restart must not republish the same + // history the previous boot already declined to announce. + const rebooted = yield* makeWithCliPath(cli.path).pipe( + Effect.provideService(OrchestrationEngineService, recordingEngine(sql)), + Effect.provideService(ServerEnvironment, fakeEnvironment), + Effect.provideService(ThreadBackgroundLivenessService, makeLiveness()), + ); + yield* rebooted.reconcile; + yield* rebooted.reconcile; + yield* rebooted.reconcile; + yield* rebooted.reconcile; + const afterReboot = yield* sql` + SELECT DISTINCT json_extract(payload_json, '$.taskId') AS taskId + FROM projection_thread_activities + WHERE kind = 'task.progress' AND summary = 'Relay observer unavailable' + `; + expect(afterReboot).toEqual([{ taskId: `relay:${liveJob}` }]); + }).pipe(Effect.ensuring(Effect.sync(() => cli.cleanup()))); + }); +}); + +persistence("Relay retirement reversal", (it) => { + it.effect("wakes a retired worker for a resume accepted after it was retired", () => { + const cli = fakeRelayCli(); + const revived = "job-bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1"; + cli.setState({}); + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const engine = recordingEngine(sql); + const boot = () => + makeWithCliPath(cli.path).pipe( + Effect.provideService(OrchestrationEngineService, engine), + Effect.provideService(ServerEnvironment, fakeEnvironment), + Effect.provideService(ThreadBackgroundLivenessService, makeLiveness()), + ); + + // Dispatch a worker, then let Relay lose it before anything projects. + const first = yield* boot(); + yield* first.recordToolResult( + toolEvent({ + toolName: "mcp__relay__relay_delegate", + turnId: "turn-1", + itemId: "tool-1", + result: { structuredContent: { schemaVersion: 1, kind: "job", jobId: revived } }, + }), + ); + yield* first.reconcile; + yield* first.reconcile; + yield* first.reconcile; + yield* first.reconcile; + const retired = yield* sql` + SELECT payload_json AS payload FROM projection_thread_activities + WHERE activity_id = ${`relay-binding:${revived}`} + `; + expect(decodeJson(retired[0]?.payload)).toMatchObject({ id: revived }); + expect( + (decodeJson(retired[0]?.payload) as { retiredAfterRowId?: number }).retiredAfterRowId, + ).toBeGreaterThan(0); + + // A restart leaves it asleep. + const asleep = yield* boot(); + const beforeRestart = cli.calls().length; + yield* asleep.reconcile; + yield* asleep.reconcile; + expect( + cli + .calls() + .slice(beforeRestart) + .filter((args) => args[0] === "observe" && args[1] === revived), + ).toEqual([]); + + // A resume recorded after the retirement outranks it. + cli.setState({ + [revived]: { + schemaVersion: 1, + kind: "job", + id: revived, + attempt: 2, + sequence: 1, + status: "running", + providerType: "codex", + model: "gpt-6-sol", + effort: "high", + pending: false, + outcome: null, + }, + }); + yield* asleep.recordToolResult( + toolEvent({ + toolName: "mcp__relay__relay_resume", + turnId: "turn-2", + itemId: "tool-2", + result: { + structuredContent: { schemaVersion: 1, kind: "job", jobId: revived, attempt: 2 }, + }, + }), + ); + const awake = yield* boot(); + const beforeWake = cli.calls().length; + yield* awake.reconcile; + expect( + cli + .calls() + .slice(beforeWake) + .some((args) => args[0] === "observe" && args[1] === revived), + ).toBe(true); + const started = yield* sql` + SELECT payload_json AS payload FROM projection_thread_activities + WHERE kind = 'task.started' AND json_extract(payload_json, '$.taskId') = ${`relay:${revived}`} + `; + expect(started).toHaveLength(1); + }).pipe(Effect.ensuring(Effect.sync(() => cli.cleanup()))); + }); +}); diff --git a/apps/server/src/orchestration/RelayWorkerBridge.ts b/apps/server/src/orchestration/RelayWorkerBridge.ts index cd05b9e9b4..2ba4f53f3a 100644 --- a/apps/server/src/orchestration/RelayWorkerBridge.ts +++ b/apps/server/src/orchestration/RelayWorkerBridge.ts @@ -17,6 +17,7 @@ import { type OrchestrationThreadActivity, } from "@t3tools/contracts"; import * as Context from "effect/Context"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; import * as Layer from "effect/Layer"; @@ -51,6 +52,15 @@ interface RelayBinding { readonly panelId?: string; readonly agentIndex?: number; readonly slotAttempt?: number; + /** + * Watermark written once Relay itself reported this job gone while nothing + * about it had ever reached the thread. Boot skips a retired binding unless + * an activation was recorded after this point, so adopted history stops + * costing a probe on every start. A row id rather than a timestamp: the + * rest of this file already orders recovery that way, and same-millisecond + * writes are common. + */ + readonly retiredAfterRowId?: number; } interface RelayToolReceipt { @@ -154,7 +164,7 @@ function relayReceiptFromToolEvent(event: ProviderRuntimeEvent): RelayToolReceip return undefined; } const result = item?.result ?? data?.result; - const structured = relayStructuredResult(result); + const structured = relayStructuredResult(result) ?? relayLegacyResult(result); if (!structured || structured.schemaVersion !== 1) return undefined; const base = { threadId: event.threadId, @@ -193,6 +203,48 @@ function relayReceiptFromToolEvent(event: ProviderRuntimeEvent): RelayToolReceip return undefined; } +/** The installed Claude plugin still returns its job/panel record as text. */ +function relayLegacyResult(value: unknown): Record | undefined { + const object = record(value); + const content = object?.content; + if (!Array.isArray(content) || content.length > 4) return undefined; + const text = record(content[0])?.text; + if (typeof text !== "string" || text.length > MAX_MCP_RECEIPT_CODE_UNITS) return undefined; + try { + const parsed = record(JSON.parse(text)); + if (!parsed) return undefined; + if ( + typeof parsed.id === "string" && + JOB_ID.test(parsed.id) && + typeof parsed.status === "string" && + parsed.status !== "not_dispatched" + ) { + return { + schemaVersion: 1, + kind: "job", + jobId: parsed.id, + ...(nonNegativeInteger(parsed.attempt) !== undefined ? { attempt: parsed.attempt } : {}), + }; + } + if ( + typeof parsed.id === "string" && + PANEL_ID.test(parsed.id) && + Array.isArray(parsed.jobs) && + parsed.jobs.length <= 8 + ) { + return { + schemaVersion: 1, + kind: "panel", + panelId: parsed.id, + jobIds: parsed.jobs.filter((id): id is string => typeof id === "string" && JOB_ID.test(id)), + }; + } + } catch { + /* Invalid plugin text is not a dispatch receipt. */ + } + return undefined; +} + function relayStructuredResult(value: unknown, depth = 0): Record | undefined { if (depth > 4) return undefined; const object = record(value); @@ -280,6 +332,79 @@ export function parseObservation( return undefined; } +function legacyObservation( + value: unknown, + expectedId: string, + priorJobSlots: ReadonlyMap = new Map(), +): RelayJobObservation | RelayPanelObservation | undefined { + const object = record(value); + if (!object || object.id !== expectedId) return undefined; + if (JOB_ID.test(expectedId)) { + const attempt = nonNegativeInteger(object.attempt) ?? 1; + const sequence = Date.parse(String(object.updatedAt ?? object.createdAt)); + if (attempt < 1 || !Number.isSafeInteger(sequence) || typeof object.status !== "string") + return undefined; + return parseObservation( + { + schemaVersion: 1, + kind: "job", + id: expectedId, + attempt, + sequence, + status: object.status, + providerType: object.providerType, + model: object.actualModel ?? object.model, + effort: object.effort, + panelId: object.panelId, + panelMemberIndex: object.panelMemberIndex, + createdAt: object.createdAt, + updatedAt: object.updatedAt, + }, + expectedId, + ); + } + if ( + !PANEL_ID.test(expectedId) || + !Array.isArray(object.jobs) || + !Array.isArray(object.unstarted) || + object.jobs.length > 8 + ) + return undefined; + const request = record(object.request); + const requested = request?.members; + if (!Array.isArray(requested) || requested.length < 2 || requested.length > 8) return undefined; + const jobs = object.jobs.map(record); + const members = requested.map((_, index) => { + const prior = Array.isArray(object.previousJobs) ? object.previousJobs : []; + const slotAttempt = 1 + prior.filter((id) => priorJobSlots.get(id) === index).length; + const job = jobs.find((candidate) => candidate?.panelMemberIndex === index); + const jobId = typeof job?.id === "string" && JOB_ID.test(job.id) ? job.id : null; + return { + index, + slotAttempt, + state: jobId ? "started" : "unstarted", + jobId, + ...(jobId ? { job: legacyObservation(job, jobId) } : {}), + }; + }); + return parseObservation( + { + schemaVersion: 1, + kind: "panel", + id: expectedId, + createdAt: object.createdAt, + complete: object.complete === true, + members, + }, + expectedId, + ); +} + +function legacyObserveUnavailable(value: unknown): boolean { + const object = record(value); + return typeof object?.usage === "string" && typeof object.request === "string"; +} + function observationFingerprint(observation: RelayJobObservation | RelayPanelObservation): string { return NodeCrypto.createHash("sha256") .update(JSON.stringify(observation)) @@ -363,6 +488,7 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu const seen = new Map(); const currentPanelJobs = new Map>(); const failedObservations = new Map(); + const missingObservationRetryAt = new Map(); const unavailable = new Set(); const outageEpochs = new Map(); const pendingPanelDispatchPolls = new Map(); @@ -370,6 +496,20 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu string, { attempt: number; usage: RelayUsageRollup | undefined } >(); + const legacyPriorJobSlots = new Map>(); + + /** Drop every trace of a binding this process has stopped observing. */ + const retire = (id: string): void => { + active.delete(id); + legacyPriorJobSlots.delete(id); + seen.delete(id); + failedObservations.delete(id); + missingObservationRetryAt.delete(id); + unavailable.delete(id); + outageEpochs.delete(id); + pendingPanelDispatchPolls.delete(id); + priorUsageCache.delete(id); + }; const append = Effect.fn("RelayWorkerBridge.append")(function* ( binding: RelayBinding, @@ -443,6 +583,9 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu ...(nonNegativeInteger(payload.slotAttempt) !== undefined ? { slotAttempt: payload.slotAttempt as number } : {}), + ...(nonNegativeInteger(payload.retiredAfterRowId) !== undefined + ? { retiredAfterRowId: payload.retiredAfterRowId as number } + : {}), }; }; @@ -457,14 +600,14 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu }); const readBindingPage = Effect.fn("RelayWorkerBridge.readBindingPage")(function* ( - afterRowId: number, + beforeRowId: number, ) { const rows = yield* sql` SELECT rowid AS rowId, thread_id AS threadId, turn_id AS turnId, payload_json AS payload FROM projection_thread_activities - WHERE rowid > ${afterRowId} AND kind = 'relay.binding' + WHERE rowid < ${beforeRowId} AND kind = 'relay.binding' AND json_extract(payload_json, '$.environmentId') = ${environmentId} - ORDER BY rowid ASC LIMIT 256 + ORDER BY rowid DESC LIMIT 256 `; return rows.map((raw) => ({ rowId: nonNegativeInteger(record(raw)?.rowId) ?? 0, @@ -729,19 +872,57 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu }, ); - const runCli = Effect.fn("RelayWorkerBridge.runCli")(function* (args: ReadonlyArray) { + const runCli = Effect.fn("RelayWorkerBridge.runCli")(function* ( + args: ReadonlyArray, + timeout = 12_000, + ) { if (!enabled || !cliPath) return Option.none<{ stdout: string; stderr: string }>(); return yield* Effect.tryPromise({ try: () => execFile(process.execPath, [cliPath, ...args], { - timeout: 12_000, - maxBuffer: MAX_OBSERVE_BYTES, + timeout, + maxBuffer: args[0] === "panel-result" ? MAX_MCP_RECEIPT_CODE_UNITS : MAX_OBSERVE_BYTES, windowsHide: true, }), catch: () => undefined, }).pipe(Effect.option); }); + const parseLegacyCliObservation = Effect.fn("RelayWorkerBridge.parseLegacyCliObservation")( + function* (value: unknown, id: string) { + if (!PANEL_ID.test(id)) return legacyObservation(value, id); + const previousJobs = record(value)?.previousJobs; + if (!Array.isArray(previousJobs) || previousJobs.length > 64) return undefined; + const previousIds = previousJobs.filter( + (previousId): previousId is string => + typeof previousId === "string" && JOB_ID.test(previousId), + ); + if (previousIds.length !== previousJobs.length) return undefined; + const priorSlots = legacyPriorJobSlots.get(id) ?? new Map(); + const missing = previousIds.filter((previousId) => !priorSlots.has(previousId)); + // Old retry records are immutable. Cache resolved slots and bound an + // unavailable history lookup so a current panel cannot stall the sweep. + const resolved = yield* Effect.forEach( + missing, + (previousId) => + Effect.gen(function* () { + const result = yield* runCli(["status", previousId], 2_000); + const decoded = Option.isSome(result) ? decodeJson(result.value.stdout) : Option.none(); + const job = Option.isSome(decoded) ? record(decoded.value) : undefined; + const index = nonNegativeInteger(job?.panelMemberIndex); + return job?.id === previousId && job.panelId === id && index !== undefined && index <= 7 + ? ([previousId, index] as const) + : undefined; + }), + { concurrency: 8 }, + ); + if (resolved.some((entry) => entry === undefined)) return undefined; + for (const entry of resolved) if (entry) priorSlots.set(entry[0], entry[1]); + legacyPriorJobSlots.set(id, priorSlots); + return legacyObservation(value, id, priorSlots); + }, + ); + const readLatestTaskActivity = Effect.fn("RelayWorkerBridge.readLatestTaskActivity")(function* ( binding: RelayBinding, ) { @@ -761,10 +942,88 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu }; }); + const hasProjectedWorkerActivity = Effect.fn("RelayWorkerBridge.hasProjectedWorkerActivity")( + function* (binding: RelayBinding) { + const rows = yield* sql` + SELECT 1 FROM projection_thread_activities + WHERE thread_id = ${binding.threadId} AND kind LIKE 'task.%' + AND activity_id NOT LIKE 'relay-observer-unavailable:%' + AND CASE WHEN json_valid(payload_json) THEN json_extract(payload_json, '$.taskId') END = ${bindingTaskId(binding)} + LIMIT 1 + `; + return rows.length > 0; + }, + ); + + /** An activation for this binding (or its panel) recorded after `afterRowId`. */ + const hasActivationSince = Effect.fn("RelayWorkerBridge.hasActivationSince")(function* ( + binding: RelayBinding, + afterRowId: number, + ) { + const rows = yield* sql` + SELECT 1 FROM projection_thread_activities + WHERE thread_id = ${binding.threadId} AND kind = 'relay.activation' + AND rowid > ${afterRowId} + AND json_extract(payload_json, '$.environmentId') = ${environmentId} + AND ( + json_extract(payload_json, '$.id') = ${binding.id} + OR ( + json_extract(payload_json, '$.id') = ${binding.panelId ?? ""} + AND EXISTS ( + SELECT 1 FROM json_each(json_extract(payload_json, '$.jobIds')) + WHERE value = ${binding.id} + ) + ) + ) + LIMIT 1 + `; + return rows.length > 0; + }); + + /** + * Record that Relay reported this job gone while nothing about it had ever + * reached the thread. The binding row is upserted in place, so this costs no + * new activity and stays invisible to the transcript; boot then skips the + * binding instead of re-probing every job the environment has ever run. + */ + const markRetired = Effect.fn("RelayWorkerBridge.markRetired")(function* (binding: RelayBinding) { + const watermark = yield* sql` + SELECT MAX(rowid) AS rowId FROM projection_thread_activities + WHERE thread_id = ${binding.threadId} + `; + const retiredAfterRowId = nonNegativeInteger(record(watermark[0])?.rowId); + if (retiredAfterRowId === undefined) return; + yield* append( + binding, + "relay.binding", + { + kind: binding.kind, + id: binding.id, + toolCallId: binding.toolCallId, + environmentId: binding.environmentId, + ...(binding.panelId ? { panelId: binding.panelId } : {}), + ...(binding.agentIndex !== undefined ? { agentIndex: binding.agentIndex } : {}), + ...(binding.slotAttempt !== undefined ? { slotAttempt: binding.slotAttempt } : {}), + retiredAfterRowId, + }, + "Relay worker bound", + `relay-binding:${binding.id}`, + `retired:${retiredAfterRowId}`, + ); + }); + const shouldObserveOnBoot = Effect.fn("RelayWorkerBridge.shouldObserveOnBoot")(function* ( binding: RelayBinding, bindingRowId: number, ) { + // A retired binding stays asleep unless an activation arrived after it was + // retired. One that did wake still answers to the normal rules below, so a + // resumed worker that has since finished is not re-probed forever. + if ( + binding.retiredAfterRowId !== undefined && + !(yield* hasActivationSince(binding, binding.retiredAfterRowId)) + ) + return false; const latest = yield* readLatestTaskActivity(binding); if (latest.kind !== "task.completed" || bindingRowId > latest.rowId) return true; // A resume or panel continuation accepted after the terminal row must be @@ -958,25 +1217,52 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu ) { const failures = (failedObservations.get(binding.id) ?? 0) + 1; failedObservations.set(binding.id, failures); - if (failures < 3 || unavailable.has(binding.id)) return; + if (failures < 3) return; + if (unavailable.has(binding.id)) { + // An announced outage is not re-announced, but the binding still owns a + // CLI spawn on every sweep until its recheck is pushed back out. + missingObservationRetryAt.set(binding.id, (yield* Clock.currentTimeMillis) + 60_000); + return; + } const taskId = bindingTaskId(binding); + // An outage row explains a worker the thread already shows. The startup + // receipt scan also adopts long-finished historical jobs whose Relay + // files are gone; those never projected a row, so announcing them turns + // every reopened thread into a wall of idle workers. + if (!(yield* hasProjectedWorkerActivity(binding))) { + // Relay itself answered that this job is gone, and nothing about it ever + // reached the thread, so there is no observation left to resume. Retire + // it for good: otherwise every job the environment has ever run is + // re-probed on each boot and holds a CLI spawn a minute in between. A + // worker that does come back re-registers through its own receipt, and + // its activation outranks the retirement. When Relay is absent entirely + // the silence says nothing about the job, so keep the binding live and + // only slow the recheck down. + if (enabled && cliPath) { + yield* markRetired(binding); + retire(binding.id); + } else { + unavailable.add(binding.id); + missingObservationRetryAt.set(binding.id, (yield* Clock.currentTimeMillis) + 60_000); + } + return; + } const latest = yield* readLatestTaskActivity(binding); const payload = latest.payload; const completedAttempt = nonNegativeInteger(payload?.attempt); const completedSequence = nonNegativeInteger(payload?.relaySequence); - if (completedAttempt === undefined || completedSequence === undefined) return; + if (completedAttempt === undefined || completedSequence === undefined) { + unavailable.add(binding.id); + missingObservationRetryAt.set(binding.id, (yield* Clock.currentTimeMillis) + 60_000); + return; + } const pending = latest.kind === "task.completed" ? yield* pendingGenerationAfter(binding, latest.rowId, completedAttempt) : undefined; if (latest.kind === "task.completed") { if (!pending) { - active.delete(binding.id); - seen.delete(binding.id); - failedObservations.delete(binding.id); - unavailable.delete(binding.id); - outageEpochs.delete(binding.id); - priorUsageCache.delete(binding.id); + retire(binding.id); return; } } @@ -1010,16 +1296,27 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu }); const poll = Effect.fn("RelayWorkerBridge.poll")(function* (binding: RelayBinding) { + if ((missingObservationRetryAt.get(binding.id) ?? 0) > (yield* Clock.currentTimeMillis)) return; const result = yield* runCli( binding.kind === "job" ? ["observe", binding.id] : ["observe", "--panel", binding.id], ); - if (Option.isNone(result)) return yield* markObserverUnavailable(binding); - const parsed = decodeJson(result.value.stdout); - const observation = parseObservation( + const parsed = Option.isSome(result) ? decodeJson(result.value.stdout) : Option.none(); + let observation = parseObservation( Option.isSome(parsed) ? parsed.value : undefined, binding.id, ); + if (!observation && Option.isSome(parsed) && legacyObserveUnavailable(parsed.value)) { + const legacy = yield* runCli( + binding.kind === "job" ? ["status", binding.id] : ["panel-result", binding.id], + ); + const decoded = Option.isSome(legacy) ? decodeJson(legacy.value.stdout) : Option.none(); + observation = yield* parseLegacyCliObservation( + Option.isSome(decoded) ? decoded.value : undefined, + binding.id, + ); + } if (!observation) return yield* markObserverUnavailable(binding); + missingObservationRetryAt.delete(binding.id); const wasUnavailableInMemory = unavailable.delete(binding.id); const lastPersisted = seen.has(binding.id) ? undefined : yield* readLatestTaskActivity(binding); const wasUnavailable = @@ -1153,23 +1450,12 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu : `relay-panel-complete:${binding.id}:${attempt}`, fingerprint, ); - active.delete(binding.id); - seen.delete(binding.id); - failedObservations.delete(binding.id); - unavailable.delete(binding.id); - outageEpochs.delete(binding.id); - pendingPanelDispatchPolls.delete(binding.id); - priorUsageCache.delete(binding.id); + retire(binding.id); } else if (!hasRunningMember && (!hasPendingDispatch || pendingPolls >= 3)) { // Failed/cancelled members and unstarted slots remain visible as an // idle panel. A new continuation or member resume receipt reactivates // observation; polling this unchanged snapshot forever is needless. - active.delete(binding.id); - seen.delete(binding.id); - failedObservations.delete(binding.id); - unavailable.delete(binding.id); - outageEpochs.delete(binding.id); - pendingPanelDispatchPolls.delete(binding.id); + retire(binding.id); } return; } @@ -1289,12 +1575,7 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu `relay-complete:${binding.id}:${observation.attempt}`, fingerprint, ); - active.delete(binding.id); - seen.delete(binding.id); - failedObservations.delete(binding.id); - unavailable.delete(binding.id); - outageEpochs.delete(binding.id); - priorUsageCache.delete(binding.id); + retire(binding.id); } }); @@ -1304,12 +1585,14 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu // the crash window after command dispatch and before observer registration. if (!bootstrapped) { yield* recoverPersistedToolReceipts(); - let afterRowId = 0; + // Replay inserts historical bindings in receipt order. Visit newest + // bindings first so current workers project before old missing jobs. + let beforeRowId = Number.MAX_SAFE_INTEGER; for (;;) { - const page = yield* readBindingPage(afterRowId); + const page = yield* readBindingPage(beforeRowId); if (page.length === 0) break; for (const { rowId, binding } of page) { - afterRowId = Math.max(afterRowId, rowId); + beforeRowId = Math.min(beforeRowId, rowId); if (binding && !active.has(binding.id) && (yield* shouldObserveOnBoot(binding, rowId))) { active.set(binding.id, binding); } @@ -1404,13 +1687,25 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu ) return yield* new ProviderCancelSessionAgentError({ reason: "agent-not-active" }); const current = yield* runCli(["observe", "--panel", panelId]); - if (Option.isNone(current)) - return yield* new ProviderCancelSessionAgentError({ reason: "request-failed" }); - const decodedPanel = decodeJson(current.value.stdout); - const observedPanel = parseObservation( + const decodedPanel = Option.isSome(current) + ? decodeJson(current.value.stdout) + : Option.none(); + let observedPanel = parseObservation( Option.isSome(decodedPanel) ? decodedPanel.value : undefined, panelId, ); + if ( + !observedPanel && + Option.isSome(decodedPanel) && + legacyObserveUnavailable(decodedPanel.value) + ) { + const legacy = yield* runCli(["panel-result", panelId]); + const decoded = Option.isSome(legacy) ? decodeJson(legacy.value.stdout) : Option.none(); + observedPanel = yield* parseLegacyCliObservation( + Option.isSome(decoded) ? decoded.value : undefined, + panelId, + ); + } if (observedPanel?.kind !== "panel") return yield* new ProviderCancelSessionAgentError({ reason: "request-failed" }); const member = observedPanel.members.find((candidate) => candidate.index === index); @@ -1441,12 +1736,17 @@ export const makeWithCliPath = Effect.fn("RelayWorkerBridge.makeWithCliPath")(fu if (!binding) return yield* new ProviderCancelSessionAgentError({ reason: "agent-not-active" }); const id = binding.id; const observed = yield* runCli(["observe", id]); - if (Option.isNone(observed)) - return yield* new ProviderCancelSessionAgentError({ reason: "request-failed" }); - const decodedJob = decodeJson(observed.value.stdout); - if ( - parseObservation(Option.isSome(decodedJob) ? decodedJob.value : undefined, id)?.kind !== "job" - ) { + const decodedJob = Option.isSome(observed) ? decodeJson(observed.value.stdout) : Option.none(); + let current = parseObservation(Option.isSome(decodedJob) ? decodedJob.value : undefined, id); + if (!current && Option.isSome(decodedJob) && legacyObserveUnavailable(decodedJob.value)) { + const legacy = yield* runCli(["status", id]); + const decoded = Option.isSome(legacy) ? decodeJson(legacy.value.stdout) : Option.none(); + current = yield* parseLegacyCliObservation( + Option.isSome(decoded) ? decoded.value : undefined, + id, + ); + } + if (current?.kind !== "job") { return yield* new ProviderCancelSessionAgentError({ reason: "request-failed" }); } const result = yield* runCli(["cancel", id]); diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 455cb37a61..99a02d8e5b 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -1,10 +1,12 @@ import { DEFAULT_SERVER_SETTINGS, + EventId, ProjectId, ProviderInstanceId, PullRequestOperationError, ThreadId, type OrchestrationCommand, + type OrchestrationEvent, type OrchestrationProjectShell, type OrchestrationShellSnapshot, type OrchestrationThreadShell, @@ -171,6 +173,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: const settingsReads = yield* Queue.unbounded(); const settingsChanges = yield* PubSub.unbounded(); const mergedPullRequests = yield* PubSub.unbounded(); + const domainEvents = yield* PubSub.unbounded(); const commands = yield* Ref.make>([]); const branchCalls = yield* Ref.make< ReadonlyArray<{ readonly cwd: string; readonly branch: string }> @@ -261,6 +264,9 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: readEvents: () => Stream.empty, dispatch, streamDomainEvents: Stream.empty, + subscribeDomainEvents: PubSub.subscribe(domainEvents).pipe( + Effect.map((subscription) => Stream.fromSubscription(subscription)), + ), latestSequence: Effect.succeed(0), }), Layer.succeed(ServerSettingsService, serverSettings), @@ -283,6 +289,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: summaryRecovery, invalidatedCwds, updateSettings, + publishEvent: (event: OrchestrationEvent) => PubSub.publish(domainEvents, event), publishMerge: PubSub.publish(mergedPullRequests, { projectId: PROJECT_ID, repository: "owner/repository", @@ -306,12 +313,12 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( describe("ThreadSettlementReactor", () => { it.effect( - "settles all-terminal links from snapshots and keeps open or unsynced links active", + "settles synced terminal links immediately while open, unsynced, or running threads wait", () => Effect.scoped( Effect.gen(function* () { yield* TestClock.setTime(Date.parse(NOW)); - const link = (number: number, state: "open" | "merged" | null) => ({ + const link = (number: number, state: "open" | "closed" | "merged" | null) => ({ host: "example.test", repository: "owner/repository", number, @@ -331,14 +338,30 @@ describe("ThreadSettlementReactor", () => { updatedAt: NOW, syncedAt: NOW, mergedAt: state === "merged" ? NOW : null, + closedAt: state === "closed" ? NOW : null, }, }); + const runningSession = { + threadId: ThreadId.make("running"), + status: "running" as const, + providerName: "Codex", + runtimeMode: "full-access" as const, + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }; + const threads = [ + makeThread("merged", { pullRequests: [link(1, "open"), link(2, "merged")] }), + makeThread("closed", { pullRequests: [link(1, "open")] }), + makeThread("open", { pullRequests: [link(1, "open"), link(2, "open")] }), + makeThread("unsynced", { pullRequests: [link(1, "open"), link(2, null)] }), + makeThread("running", { + pullRequests: [link(1, "open")], + session: runningSession, + }), + ]; const fixture = yield* makeHarness({ - snapshot: makeSnapshot([ - makeThread("merged", { pullRequests: [link(1, "merged"), link(2, "merged")] }), - makeThread("open", { pullRequests: [link(1, "merged"), link(2, "open")] }), - makeThread("unsynced", { pullRequests: [link(1, "merged"), link(2, null)] }), - ]), + snapshot: makeSnapshot(threads), settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleOnMerge: true }, branchPullRequest: () => Effect.die("linked threads must not query the branch"), pullRequestSummary: () => Effect.die("linked threads must use their snapshots"), @@ -346,9 +369,66 @@ describe("ThreadSettlementReactor", () => { yield* Effect.gen(function* () { const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + const eventBase = { + sequence: 2, + eventId: EventId.make("pull-request-synced"), + aggregateKind: "thread" as const, + occurredAt: NOW, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + }; + for (const thread of threads) { + const terminalLink = link(1, thread.id === "closed" ? "closed" : "merged"); + yield* Ref.update(fixture.snapshots, (snapshot) => ({ + ...snapshot, + threads: snapshot.threads.map((current) => + current.id === thread.id + ? { ...current, pullRequests: [terminalLink, ...current.pullRequests.slice(1)] } + : current, + ), + })); + yield* fixture.publishEvent({ + ...eventBase, + type: "thread.pull-request-synced", + aggregateId: thread.id, + payload: { + threadId: thread.id, + host: terminalLink.host, + repository: terminalLink.repository, + number: terminalLink.number, + snapshot: terminalLink.snapshot!, + stack: null, + updatedAt: NOW, + }, + }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + } + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map(({ threadId }) => threadId), + [ThreadId.make("merged"), ThreadId.make("closed")], + ); + const readySession = { ...runningSession, status: "ready" as const }; + yield* Ref.update(fixture.snapshots, (snapshot) => ({ + ...snapshot, + threads: snapshot.threads.map((thread) => + thread.id === readySession.threadId ? { ...thread, session: readySession } : thread, + ), + })); + yield* fixture.publishEvent({ + ...eventBase, + type: "thread.session-set", + aggregateId: readySession.threadId, + payload: { threadId: readySession.threadId, session: readySession }, + }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; assert.deepStrictEqual( (yield* Ref.get(fixture.commands)).map(({ threadId }) => threadId), - [ThreadId.make("merged")], + [ThreadId.make("merged"), ThreadId.make("closed"), ThreadId.make("running")], ); assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []); @@ -357,6 +437,109 @@ describe("ThreadSettlementReactor", () => { ), ); + it.effect("rechecks only the affected thread after link and unlink events", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const link = (number: number, state: "open" | "merged") => ({ + host: "example.test", + repository: "owner/repository", + number, + url: `https://example.test/owner/repository/pull/${number}`, + source: "manual" as const, + linkedAt: NOW, + stack: null, + snapshot: { + state, + title: "Review", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: NOW, + syncedAt: NOW, + mergedAt: state === "merged" ? NOW : null, + closedAt: null, + }, + }); + const merged = link(1, "merged"); + const open = link(2, "open"); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("new-link"), + makeThread("removed-link", { pullRequests: [open, merged] }), + ]), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleOnMerge: true, + sidebarAutoSettleAfterDays: null, + }, + branchPullRequest: () => Effect.die("linked event must use the projected snapshot"), + pullRequestSummary: () => Effect.die("linked event must not query a provider"), + }); + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + const eventBase = { + sequence: 2, + eventId: EventId.make("link-change"), + aggregateKind: "thread" as const, + occurredAt: NOW, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + }; + + yield* Ref.update(fixture.snapshots, (snapshot) => ({ + ...snapshot, + threads: snapshot.threads.map((thread) => + thread.id === ThreadId.make("new-link") + ? { ...thread, pullRequests: [merged] } + : thread, + ), + })); + yield* fixture.publishEvent({ + ...eventBase, + type: "thread.pull-request-linked", + aggregateId: ThreadId.make("new-link"), + payload: { threadId: ThreadId.make("new-link"), link: merged, updatedAt: NOW }, + }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + + yield* Ref.update(fixture.snapshots, (snapshot) => ({ + ...snapshot, + threads: snapshot.threads.map((thread) => + thread.id === ThreadId.make("removed-link") + ? { ...thread, pullRequests: [merged] } + : thread, + ), + })); + yield* fixture.publishEvent({ + ...eventBase, + type: "thread.pull-request-unlinked", + aggregateId: ThreadId.make("removed-link"), + payload: { + threadId: ThreadId.make("removed-link"), + host: open.host, + repository: open.repository, + number: open.number, + updatedAt: NOW, + }, + }); + yield* Queue.take(fixture.snapshotReads); + yield* reactor.drain; + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map(({ threadId }) => threadId), + [ThreadId.make("new-link"), ThreadId.make("removed-link")], + ); + assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it("distinguishes a project that inherits the threshold from one that disables it", () => { const inherits = ThreadSettlementReactor.autoSettlementSettingsKey({ ...DEFAULT_SERVER_SETTINGS, @@ -417,12 +600,12 @@ describe("ThreadSettlementReactor", () => { makeThread("reused-detected", { branch: "reused", branchPullRequest: previous }), makeThread("foreign-branch-pr", { branch: "foreign", linkedPullRequest: previous }), makeThread("resumed-manual", { - branch: "main", + branch: "resumed", linkedPullRequest: previous, latestUserMessageAt: "2026-08-28T00:00:00.000Z", }), makeThread("resumed-detected", { - branch: "main", + branch: "resumed", branchPullRequest: previous, latestUserMessageAt: "2026-08-28T00:00:00.000Z", }), @@ -458,6 +641,9 @@ describe("ThreadSettlementReactor", () => { new Set((yield* Ref.get(fixture.commands)).map((command) => command.threadId)), new Set([ThreadId.make("retained-terminal"), ThreadId.make("foreign-branch-pr")]), ); + assert.isFalse( + (yield* Ref.get(fixture.branchCalls)).some(({ branch }) => branch === "resumed"), + ); }).pipe(Effect.provide(fixture.layer)); }), ), diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index b9041d2976..ff6f995df4 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -1,4 +1,9 @@ -import { CommandId, type ServerSettings as ServerSettingsValue } from "@t3tools/contracts"; +import { + CommandId, + type OrchestrationEvent, + type ServerSettings as ServerSettingsValue, + type ThreadId, +} from "@t3tools/contracts"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import * as Cause from "effect/Cause"; @@ -84,6 +89,7 @@ export const make = Effect.gen(function* () { const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* ( mergedPullRequest: PullRequestService.PullRequestMergeEvent | null, + threadId?: ThreadId, ) { const settings = yield* settingsService.getSettings; if (!autoSettlementConfigured(settings)) { @@ -94,7 +100,11 @@ export const make = Effect.gen(function* () { const projects = new Map(snapshot.projects.map((project) => [project.id, project])); // A merge rechecks all candidates, including branches that discovery has // not linked yet. Those lookups can still have cached the PR as open. - const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now)); + const candidates = snapshot.threads.filter( + (thread) => + (threadId === undefined || thread.id === threadId) && + isAutoSettlementCandidate(thread, now), + ); // Return the thread when it still needs a pull request decision. A rejected // dispatch skips it for this snapshot instead of retrying through a lookup. @@ -199,9 +209,30 @@ export const make = Effect.gen(function* () { }; const groups = Map.groupBy(lookupCandidates, lookupKey); + const wouldSettle = Effect.fn("ThreadSettlementReactor.wouldSettle")(function* ( + group: ReadonlyArray<(typeof candidates)[number]>, + pullRequest: SettlementPullRequest, + ) { + const currentSettings = yield* settingsService.getSettings; + const decisionNow = DateTime.formatIso(yield* DateTime.now); + return group.some((thread) => { + const { settings } = resolveProjectSettings(currentSettings, thread.projectId); + return ( + resolveAutoSettlementAt({ + thread, + pullRequest, + now: decisionNow, + autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, + autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, + }) !== null + ); + }); + }); + const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* ( - thread: (typeof candidates)[number], + group: ReadonlyArray<(typeof candidates)[number]>, ) { + const thread = group[0]!; const reference = thread.linkedPullRequest ?? thread.branchPullRequest; if (reference != null) { const matchesMerge = @@ -226,10 +257,21 @@ export const make = Effect.gen(function* () { }, { recoverTransientFailure: false }, ); + const terminal = { + state: summary.state, + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, + } satisfies SettlementPullRequest; const cwd = lookupCwdByThreadId.get(thread.id); if (summary.state !== "open" && thread.branch !== null && cwd !== undefined) { // A reused branch can already have a new open PR while discovery // is replacing its old link. Do not let settlement win that race. + // Only pay for the uncached lookup when this sweep would otherwise + // settle: a terminal link that settles nothing (resumed thread, + // settle-on-merge off) would re-query the host every minute. A + // group that becomes eligible after this check waits for the next + // sweep rather than settling on the unverified link. + if (!(yield* wouldSettle(group, terminal))) return undefined; const current = yield* git.branchPullRequest( { cwd, branch: thread.branch }, { refresh: true }, @@ -243,11 +285,7 @@ export const make = Effect.gen(function* () { return current; } } - return { - state: summary.state, - closedAt: summary.closedAt ?? null, - mergedAt: summary.mergedAt ?? null, - } satisfies SettlementPullRequest; + return terminal; } if (thread.branch === null) return null; const cwd = lookupCwdByThreadId.get(thread.id); @@ -261,7 +299,8 @@ export const make = Effect.gen(function* () { groups.values(), (group) => Effect.gen(function* () { - const pullRequest = yield* pullRequestFor(group[0]!); + const pullRequest = yield* pullRequestFor(group); + if (pullRequest === undefined) return; yield* Effect.forEach(group, (thread) => settleThread(thread, pullRequest), { discard: true, }); @@ -279,8 +318,11 @@ export const make = Effect.gen(function* () { ); }); - const runSweep = (mergedPullRequest: PullRequestService.PullRequestMergeEvent | null) => - sweep(mergedPullRequest).pipe( + const runSweep = ( + mergedPullRequest: PullRequestService.PullRequestMergeEvent | null, + threadId?: ThreadId, + ) => + sweep(mergedPullRequest, threadId).pipe( Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) @@ -289,13 +331,36 @@ export const make = Effect.gen(function* () { }), ), ); - const worker = yield* makeDrainableWorker(() => runSweep(null)); + const worker = yield* makeDrainableWorker((threadId: ThreadId | undefined) => + runSweep(null, threadId), + ); + + const processEvent = (event: OrchestrationEvent) => { + switch (event.type) { + case "thread.pull-request-linked": + case "thread.pull-request-synced": + case "thread.pull-request-unlinked": + // Merge notifications can arrive before the linked snapshot is projected. + // Recheck the persisted state so terminal links settle without the timer. + return worker.enqueue(event.payload.threadId); + case "thread.session-set": + if ( + event.payload.session.status !== "running" && + event.payload.session.status !== "starting" + ) { + return worker.enqueue(event.payload.threadId); + } + break; + } + return Effect.void; + }; const start: ThreadSettlementReactor["Service"]["start"] = Effect.fn( "ThreadSettlementReactor.start", )(function* () { const settingsChanges = yield* settingsService.subscribeChanges; const mergedPullRequests = yield* pullRequests.subscribeMerges; + const events = yield* engine.subscribeDomainEvents; const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie); let lastSettlementSettings = autoSettlementSettingsKey(initialSettings); yield* forkParked( @@ -314,7 +379,8 @@ export const make = Effect.gen(function* () { return worker.enqueue(undefined); }), ); - yield* forkParked(Stream.runForEach(mergedPullRequests, runSweep)); + yield* forkParked(Stream.runForEach(mergedPullRequests, (event) => runSweep(event))); + yield* forkParked(Stream.runForEach(events, processEvent)); }); return { start, drain: worker.drain } satisfies ThreadSettlementReactor["Service"]; diff --git a/apps/server/src/orchestration/decider.sessionLifecycle.test.ts b/apps/server/src/orchestration/decider.sessionLifecycle.test.ts index f60a02abe8..79b2158d40 100644 --- a/apps/server/src/orchestration/decider.sessionLifecycle.test.ts +++ b/apps/server/src/orchestration/decider.sessionLifecycle.test.ts @@ -199,6 +199,12 @@ it.layer(NodeServices.layer)("session lifecycle CAS decider", (it) => { readModel: makeReadModel(current), }); expect(stale).toEqual([]); + + const runningWithPendingRequest = yield* decideOrchestrationCommand({ + command: { ...command, session: { ...acceptedSession, status: "running" } }, + readModel: makeReadModel(current), + }); + expect(runningWithPendingRequest).toEqual([]); }), ); diff --git a/apps/server/src/orchestration/decider.settled.test.ts b/apps/server/src/orchestration/decider.settled.test.ts index dc43a4d672..d4dabdcd22 100644 --- a/apps/server/src/orchestration/decider.settled.test.ts +++ b/apps/server/src/orchestration/decider.settled.test.ts @@ -489,6 +489,23 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => { const settledEvents = Array.isArray(settled) ? settled : [settled]; expect(settledEvents[0]?.type).toBe("thread.settled"); + const stoppedSession = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("cmd-settle-stopped-session-input"), + threadId: ThreadId.make("thread-1"), + }, + readModel: makeReadModel(null, null, makeSession("stopped"), [ + activity("user-input.requested", "orphaned-input", {}), + activity("provider.user-input.respond.failed", "orphaned-input", { + detail: "No active provider session is bound to this thread.", + }), + ]), + }); + expect((Array.isArray(stoppedSession) ? stoppedSession[0] : stoppedSession).type).toBe( + "thread.settled", + ); + // A non-stale respond failure (transient provider error) keeps the // request open: the user can retry, so it is still blocked-on-you. const stillOpen = yield* decideOrchestrationCommand({ diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 40f3d1caaf..5afe8eb9a2 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -69,6 +69,7 @@ function isStaleRequestFailureDetail(payload: Record | null): b const detail = typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null; if (detail === null) return false; return ( + detail.includes("no active provider session is bound to this thread") || detail.includes("stale pending approval request") || detail.includes("unknown pending approval request") || detail.includes("unknown pending permission request") || @@ -2628,6 +2629,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" thread.session.activeTurnId !== null || command.session.pendingTurnRequestId !== command.requestId || command.session.pendingTurnMessageId !== command.messageId || + command.session.status !== "starting" || command.session.providerInstanceId !== command.modelSelection.instanceId || command.session.runtimeMode !== command.runtimeMode || command.session.sessionIncarnationId === undefined || diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ce8da3d5f3..4acb8638f1 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -72,6 +72,8 @@ import Migration0058 from "./Migrations/058_ProjectionProjectsAutoPull.ts"; import Migration0059 from "./Migrations/059_ProjectionThreadPullRequests.ts"; import Migration0060 from "./Migrations/060_ProjectionThreadCompactionQueue.ts"; import Migration0061 from "./Migrations/061_ProjectionThreadMessageContext.ts"; +import Migration0062 from "./Migrations/062_RepairStoppedSessionRequests.ts"; +import Migration0063 from "./Migrations/063_ClearUnprojectedRelayOutages.ts"; /** * Migration loader with all migrations defined inline. * @@ -176,6 +178,8 @@ const migrationEntries = [ [60, "ProjectionThreadCompactionQueue", Migration0060], // Upstream 51 is already Pylon durable rollback; retain that lineage and append. [61, "ProjectionThreadMessageContext", Migration0061], + [62, "RepairStoppedSessionRequests", Migration0062], + [63, "ClearUnprojectedRelayOutages", Migration0063], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/062_RepairStoppedSessionRequests.test.ts b/apps/server/src/persistence/Migrations/062_RepairStoppedSessionRequests.test.ts new file mode 100644 index 0000000000..df7719a772 --- /dev/null +++ b/apps/server/src/persistence/Migrations/062_RepairStoppedSessionRequests.test.ts @@ -0,0 +1,82 @@ +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 * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +import { runMigrations } from "../Migrations.ts"; + +it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()))( + "062_RepairStoppedSessionRequests", + (it) => { + it.effect("repairs orphaned requests without dropping other pending work", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 61 }); + + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, created_at, updated_at, + pending_approval_count, pending_user_input_count + ) VALUES + ('thread-affected', 'project-1', 'Affected', '2026-09-24T00:00:00.000Z', + '2026-09-24T00:00:00.000Z', 1, 3), + ('thread-other', 'project-1', 'Other', '2026-09-24T00:00:00.000Z', + '2026-09-24T00:00:00.000Z', 0, 1) + `; + yield* sql` + INSERT INTO projection_pending_approvals ( + request_id, thread_id, status, created_at + ) VALUES ('approval-orphaned', 'thread-affected', 'pending', + '2026-09-24T00:00:01.000Z') + `; + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, tone, kind, summary, payload_json, sequence, created_at + ) VALUES + ('input-orphaned', 'thread-affected', 'approval', 'user-input.requested', + 'Question', '{"requestId":"input-orphaned"}', 1, '2026-09-24T00:00:01.000Z'), + ('input-retryable', 'thread-affected', 'approval', 'user-input.requested', + 'Question', '{"requestId":"input-retryable"}', 2, '2026-09-24T00:00:02.000Z'), + ('interaction-open', 'thread-affected', 'approval', 'interaction.requested', + 'Interaction', '{"requestId":"interaction-open"}', 3, '2026-09-24T00:00:03.000Z'), + ('input-orphaned-failed', 'thread-affected', 'error', + 'provider.user-input.respond.failed', 'Reply failed', + '{"requestId":"input-orphaned","detail":"No active provider session is bound to this thread."}', + 4, '2026-09-24T00:00:04.000Z'), + ('input-retryable-failed', 'thread-affected', 'error', + 'provider.user-input.respond.failed', 'Reply failed', + '{"requestId":"input-retryable","detail":"Provider timeout"}', + 5, '2026-09-24T00:00:05.000Z'), + ('approval-orphaned-failed', 'thread-affected', 'error', + 'provider.approval.respond.failed', 'Approval failed', + '{"requestId":"approval-orphaned","detail":"No active provider session is bound to this thread."}', + 6, '2026-09-24T00:00:06.000Z'), + ('malformed-tool', 'thread-affected', 'tool', 'tool.completed', + 'Tool completed', '{not-json', 7, '2026-09-24T00:00:07.000Z') + `; + + yield* runMigrations({ toMigrationInclusive: 62 }); + + const threads = yield* sql<{ + readonly threadId: string; + readonly pendingApprovalCount: number; + readonly pendingUserInputCount: number; + }>` + SELECT thread_id AS "threadId", pending_approval_count AS "pendingApprovalCount", + pending_user_input_count AS "pendingUserInputCount" + FROM projection_threads ORDER BY thread_id + `; + assert.deepStrictEqual(threads, [ + { threadId: "thread-affected", pendingApprovalCount: 0, pendingUserInputCount: 2 }, + { threadId: "thread-other", pendingApprovalCount: 0, pendingUserInputCount: 1 }, + ]); + + const approvals = yield* sql<{ readonly status: string }>` + SELECT status FROM projection_pending_approvals WHERE request_id = 'approval-orphaned' + `; + assert.deepStrictEqual(approvals, [{ status: "resolved" }]); + }), + ); + }, +); diff --git a/apps/server/src/persistence/Migrations/062_RepairStoppedSessionRequests.ts b/apps/server/src/persistence/Migrations/062_RepairStoppedSessionRequests.ts new file mode 100644 index 0000000000..5e724a118d --- /dev/null +++ b/apps/server/src/persistence/Migrations/062_RepairStoppedSessionRequests.ts @@ -0,0 +1,105 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +// Older projections retained native requests after the provider session had +// stopped. The reply failure is durable, so repair the cached shell counts +// without rewriting the event history. +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + UPDATE projection_pending_approvals AS pending + SET status = 'resolved', decision = NULL, resolved_at = ( + SELECT MAX(activity.created_at) + FROM projection_thread_activities AS activity + WHERE activity.thread_id = pending.thread_id + AND activity.kind = 'provider.approval.respond.failed' + AND json_extract(activity.payload_json, '$.requestId') = pending.request_id + AND lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%no active provider session is bound to this thread%' + ) + WHERE pending.status = 'pending' + AND EXISTS ( + SELECT 1 FROM projection_thread_activities AS activity + WHERE activity.thread_id = pending.thread_id + AND activity.kind = 'provider.approval.respond.failed' + AND json_extract(activity.payload_json, '$.requestId') = pending.request_id + AND activity.created_at >= pending.created_at + AND lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%no active provider session is bound to this thread%' + ) + `; + + yield* sql` + WITH affected_threads AS ( + SELECT DISTINCT thread_id + FROM projection_thread_activities + WHERE kind = 'provider.user-input.respond.failed' + AND lower(COALESCE(json_extract(payload_json, '$.detail'), '')) + LIKE '%no active provider session is bound to this thread%' + ) + UPDATE projection_threads AS thread + SET pending_approval_count = ( + SELECT COUNT(*) FROM projection_pending_approvals AS approval + WHERE approval.thread_id = thread.thread_id AND approval.status = 'pending' + ), + pending_user_input_count = ( + SELECT COUNT(*) FROM ( + SELECT activity.kind, + ROW_NUMBER() OVER ( + PARTITION BY json_extract(activity.payload_json, '$.requestId') + ORDER BY activity.sequence DESC, activity.created_at DESC, + CASE WHEN activity.kind IN ('user-input.requested', 'interaction.requested') + THEN 0 ELSE 1 END DESC, + activity.activity_id DESC + ) AS request_order + FROM projection_thread_activities AS activity + WHERE activity.thread_id = thread.thread_id + AND json_type( + CASE WHEN json_valid(activity.payload_json) THEN activity.payload_json ELSE '{}' END, + '$.requestId' + ) = 'text' + AND ( + activity.kind IN ('user-input.requested', 'user-input.resolved', + 'interaction.requested', 'interaction.resolved') + OR (activity.kind = 'provider.user-input.respond.failed' AND ( + lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%no active provider session is bound to this thread%' + OR lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%stale pending user-input request%' + OR lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%unknown pending user-input request%' + OR lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%unknown pending user input request%' + OR lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%unknown pending codex user input request%' + )) + OR (activity.kind = 'provider.interaction.respond.failed' AND ( + lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%stale pending interaction request%' + OR lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%unknown pending interaction request%' + )) + ) + ) AS latest + WHERE latest.request_order = 1 + AND latest.kind IN ('user-input.requested', 'interaction.requested') + ) + WHERE thread.thread_id IN (SELECT thread_id FROM affected_threads) + `; + + yield* sql` + UPDATE projection_threads AS thread + SET pending_approval_count = ( + SELECT COUNT(*) FROM projection_pending_approvals AS approval + WHERE approval.thread_id = thread.thread_id AND approval.status = 'pending' + ) + WHERE EXISTS ( + SELECT 1 FROM projection_thread_activities AS activity + WHERE activity.thread_id = thread.thread_id + AND activity.kind = 'provider.approval.respond.failed' + AND lower(COALESCE(json_extract(activity.payload_json, '$.detail'), '')) + LIKE '%no active provider session is bound to this thread%' + ) + `; +}); diff --git a/apps/server/src/persistence/Migrations/063_ClearUnprojectedRelayOutages.test.ts b/apps/server/src/persistence/Migrations/063_ClearUnprojectedRelayOutages.test.ts new file mode 100644 index 0000000000..8035888a52 --- /dev/null +++ b/apps/server/src/persistence/Migrations/063_ClearUnprojectedRelayOutages.test.ts @@ -0,0 +1,65 @@ +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 * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +import { runMigrations } from "../Migrations.ts"; + +it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()))( + "063_ClearUnprojectedRelayOutages", + (it) => { + it.effect("drops outages for workers a thread never started and keeps the rest", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 62 }); + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, tone, kind, summary, payload_json, sequence, created_at + ) VALUES + ('relay-observer-unavailable:job-historical:1', 'thread-a', 'info', 'task.progress', + 'Relay observer unavailable', + '{"taskId":"relay:job-historical","status":"idle","summary":"Relay observer unavailable"}', + 1, '2026-09-24T21:16:16.382Z'), + ('relay-start:job-live:1', 'thread-a', 'info', 'task.started', + 'Relay worker started', '{"taskId":"relay:job-live","attempt":1}', + 2, '2026-09-24T20:00:00.000Z'), + ('relay-observer-unavailable:job-live:1', 'thread-a', 'info', 'task.progress', + 'Relay observer unavailable', + '{"taskId":"relay:job-live","status":"idle","summary":"Relay observer unavailable"}', + 3, '2026-09-24T21:16:16.400Z'), + ('relay-progress:job-historical', 'thread-a', 'info', 'task.progress', + 'Relay worker', '{"taskId":"relay:job-historical","status":"running"}', + 4, '2026-09-24T21:16:16.500Z'), + ('relay-observer-unavailable:job-other-thread:1', 'thread-b', 'info', 'task.progress', + 'Relay observer unavailable', + '{"taskId":"relay:job-other-thread","status":"idle"}', + 5, '2026-09-24T21:16:16.600Z'), + ('relay-start:job-other-thread:1', 'thread-c', 'info', 'task.started', + 'Relay worker started', '{"taskId":"relay:job-other-thread","attempt":1}', + 6, '2026-09-24T20:00:00.000Z') + `; + + yield* runMigrations({ toMigrationInclusive: 63 }); + + const remaining = yield* sql` + SELECT activity_id AS activityId FROM projection_thread_activities + ORDER BY activity_id + `; + assert.deepStrictEqual( + remaining.map((row) => (row as { activityId: string }).activityId), + [ + // A worker with any projected activity keeps its outage, even if + // an older provider never emitted its task.started row. + "relay-observer-unavailable:job-historical:1", + "relay-observer-unavailable:job-live:1", + "relay-progress:job-historical", + "relay-start:job-live:1", + "relay-start:job-other-thread:1", + ], + ); + }), + ); + }, +); diff --git a/apps/server/src/persistence/Migrations/063_ClearUnprojectedRelayOutages.ts b/apps/server/src/persistence/Migrations/063_ClearUnprojectedRelayOutages.ts new file mode 100644 index 0000000000..9167cf2b5b --- /dev/null +++ b/apps/server/src/persistence/Migrations/063_ClearUnprojectedRelayOutages.ts @@ -0,0 +1,28 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +// The Relay worker bridge adopts every persisted Relay receipt on boot, so its +// first run bound each historical job an environment had ever dispatched. Those +// jobs were long gone, and the failed observation published an idle outage row +// into the turn that had launched them -- reopening a thread showed a wall of +// stale workers. The bridge no longer announces a worker it never started; +// clear the rows it already wrote without rewriting the event history. +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + DELETE FROM projection_thread_activities AS outage + WHERE outage.kind = 'task.progress' + AND outage.activity_id LIKE 'relay-observer-unavailable:%' + AND NOT EXISTS ( + SELECT 1 FROM projection_thread_activities AS worker + WHERE worker.thread_id = outage.thread_id + AND worker.kind LIKE 'task.%' + AND worker.activity_id NOT LIKE 'relay-observer-unavailable:%' + AND CASE WHEN json_valid(worker.payload_json) + THEN json_extract(worker.payload_json, '$.taskId') END + = CASE WHEN json_valid(outage.payload_json) + THEN json_extract(outage.payload_json, '$.taskId') END + ) + `; +}); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index cbcf626858..a5461c1e18 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -12,11 +12,7 @@ * * @module provider/Drivers/ClaudeDriver */ -import { - ClaudeSettings, - ProviderDriverKind, - type ServerProviderUsageLimits, -} from "@t3tools/contracts"; +import { ClaudeSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Duration from "effect/Duration"; import * as Crypto from "effect/Crypto"; @@ -29,7 +25,12 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { retainUsageLimits } from "../providerUsageRetention.ts"; +import { + authenticatedUsageIdentity, + matchingAccountUsage, + retainUsageLimitsForAccount, + type AccountUsageReading, +} from "../providerUsageRetention.ts"; import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; @@ -194,7 +195,7 @@ export const ClaudeDriver: ProviderDriver = { cwd, processEnv, ); - const lastKnownUsage = yield* Ref.make(undefined); + const lastKnownUsage = yield* Ref.make(undefined); const usageProbeCache = yield* Cache.makeWith( () => probeClaudeUsageLimits(effectiveConfig, processEnv, cwd).pipe( @@ -236,15 +237,17 @@ export const ClaudeDriver: ProviderDriver = { usageProbeCache, `${capabilitiesCacheKey}:${accountIdentity ?? "unknown"}`, ).pipe( - Effect.map((result) => { - if (!result) return undefined; - return result.accountIdentity && - accountIdentity && - result.accountIdentity !== accountIdentity - ? undefined - : result.usageLimits; - }), - Effect.flatMap((usageLimits) => retainUsageLimits(lastKnownUsage, usageLimits)), + Effect.map((result) => matchingAccountUsage(accountIdentity, result)), + Effect.flatMap((usageLimits) => + retainUsageLimitsForAccount( + lastKnownUsage, + authenticatedUsageIdentity({ + status: "authenticated", + email: accountIdentity, + }), + usageLimits, + ), + ), ), resolveClaudeModelCatalog(manifest), ), @@ -333,6 +336,7 @@ export const ClaudeDriver: ProviderDriver = { accentColor, enabled, snapshot, + invalidateCaches: Cache.invalidateAll(capabilitiesProbeCache), snapshotForCwd, adapter, textGeneration, diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 3020920d28..83bb4202e9 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -29,6 +29,7 @@ import { import { CodexDriver } from "./CodexDriver.ts"; import { writeFakeCli } from "../../testUtils/fakeCli.ts"; import { SHARED_USAGE_CACHE_DIR_ENV } from "../sharedUsageReadCache.ts"; +import { readSharedUsageEntry, sharedUsageReadKey } from "../sharedUsageReadCache.ts"; const encodeFixtureString = Schema.encodeEffect(Schema.fromJsonString(Schema.String)); @@ -61,32 +62,58 @@ const noSpawn = ChildProcessSpawner.make(() => ); it.layer(testLayer)("CodexDriver", (it) => { - for (const readFails of [false, true]) { - it.effect( - `redeems through the initialized CLI and ${readFails ? "preserves a confirmed result when refresh fails" : "reads fresh limits before returning"}`, - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const directory = yield* fs.makeTempDirectoryScoped({ prefix: "pylon-reset-credit-" }); - const logPath = NodePath.join(directory, "requests.jsonl"); - const priorCache = process.env[SHARED_USAGE_CACHE_DIR_ENV]; - yield* Effect.acquireRelease( + for (const scenario of [ + { + name: "reads fresh limits before returning", + readFails: false, + switchBefore: false, + switchOnConsume: false, + }, + { + name: "preserves a confirmed result when refresh fails", + readFails: true, + switchBefore: false, + switchOnConsume: false, + }, + { + name: "rejects a changed live account before consuming", + readFails: false, + switchBefore: true, + switchOnConsume: false, + }, + { + name: "does not cache a result after the account changes mid-reset", + readFails: false, + switchBefore: false, + switchOnConsume: true, + }, + ]) { + it.effect(`redeems through the initialized CLI and ${scenario.name}`, () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "pylon-reset-credit-" }); + const logPath = NodePath.join(directory, "requests.jsonl"); + const accountPath = NodePath.join(directory, "account.txt"); + yield* fs.writeFileString(accountPath, "owner@example.test"); + const priorCache = process.env[SHARED_USAGE_CACHE_DIR_ENV]; + yield* Effect.acquireRelease( + Effect.sync(() => { + process.env[SHARED_USAGE_CACHE_DIR_ENV] = NodePath.join(directory, "usage-cache"); + }), + () => Effect.sync(() => { - process.env[SHARED_USAGE_CACHE_DIR_ENV] = NodePath.join(directory, "usage-cache"); + if (priorCache === undefined) delete process.env[SHARED_USAGE_CACHE_DIR_ENV]; + else process.env[SHARED_USAGE_CACHE_DIR_ENV] = priorCache; }), - () => - Effect.sync(() => { - if (priorCache === undefined) delete process.env[SHARED_USAGE_CACHE_DIR_ENV]; - else process.env[SHARED_USAGE_CACHE_DIR_ENV] = priorCache; - }), - ); - const quotedLogPath = yield* encodeFixtureString(logPath); - const quotedDirectory = yield* encodeFixtureString(directory); - const binaryPath = writeFakeCli({ - directory, - name: "codex", - source: ` - import { appendFileSync } from "node:fs"; + ); + const quotedLogPath = yield* encodeFixtureString(logPath); + const quotedDirectory = yield* encodeFixtureString(directory); + const quotedAccountPath = yield* encodeFixtureString(accountPath); + const binaryPath = writeFakeCli({ + directory, + name: "codex", + source: ` + import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; import { createInterface } from "node:readline"; const lines = createInterface({ input: process.stdin }); lines.on("line", line => { @@ -95,9 +122,14 @@ it.layer(testLayer)("CodexDriver", (it) => { if (request.id === undefined) return; let result; if (request.method === "initialize") result = { userAgent: "codex/1.0", codexHome: ${quotedDirectory}, platformFamily: "unix", platformOs: "macos" }; - else if (request.method === "account/rateLimitResetCredit/consume") result = { outcome: "reset" }; + else if (request.method === "account/read") result = { account: { type: "chatgpt", email: readFileSync(${quotedAccountPath}, "utf8"), planType: "plus" }, requiresOpenaiAuth: false }; + else if (request.method === "skills/list" || request.method === "model/list") result = { data: [] }; + else if (request.method === "account/rateLimitResetCredit/consume") { + if (${scenario.switchOnConsume}) writeFileSync(${quotedAccountPath}, "other@example.test"); + result = { outcome: "reset" }; + } else if (request.method === "account/rateLimits/read") { - if (${readFails}) { + if (${scenario.readFails}) { process.stdout.write(JSON.stringify({ id: request.id, error: { code: -32000, message: "read failed" } }) + "\\n"); return; } @@ -106,28 +138,59 @@ it.layer(testLayer)("CodexDriver", (it) => { process.stdout.write(JSON.stringify({ id: request.id, result }) + "\\n"); }); `, - }); - const instance = yield* CodexDriver.create({ - instanceId: ProviderInstanceId.make("codex-reset-test"), - displayName: "Reset test", - enabled: false, - environment: [], - config: { ...CodexDriver.defaultConfig(), binaryPath, homePath: directory }, - }); - const result = yield* instance.consumeResetCredit!({ requestId: "stable-reset-attempt" }); - expect(result.outcome).toBe("reset"); - if (readFails) expect(result.warning).toContain("updated limits could not be read"); - else expect(result.warning).toBeUndefined(); - const requests = yield* fs.readFileString(logPath); - expect(requests).toContain('"method":"initialized"'); + }); + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make("codex-reset-test"), + displayName: "Reset test", + enabled: true, + environment: [], + config: { ...CodexDriver.defaultConfig(), binaryPath, homePath: directory }, + }); + yield* instance.snapshot.refresh; + const accountCacheKey = sharedUsageReadKey([ + "codex", + directory, + "email:owner@example.test", + ]); + const cacheBefore = yield* readSharedUsageEntry( + NodePath.join(directory, "usage-cache"), + accountCacheKey, + ); + if (scenario.switchBefore) yield* fs.writeFileString(accountPath, "other@example.test"); + const result = yield* instance.consumeResetCredit!({ + requestId: "stable-reset-attempt", + }).pipe(Effect.exit); + if (scenario.switchBefore) expect(result._tag).toBe("Failure"); + else { + expect(result._tag).toBe("Success"); + if (result._tag === "Success") { + expect(result.value.outcome).toBe("reset"); + if (scenario.readFails) + expect(result.value.warning).toContain("updated limits could not be read"); + else if (scenario.switchOnConsume) + expect(result.value.warning).toContain("account changed"); + else expect(result.value.warning).toBeUndefined(); + } + } + const requests = yield* fs.readFileString(logPath); + expect(requests).toContain('"method":"initialized"'); + if (scenario.switchBefore) { + expect(requests).not.toContain('"method":"account/rateLimitResetCredit/consume"'); + } else { expect(requests).toContain('"idempotencyKey":"stable-reset-attempt"'); expect(requests.indexOf('"method":"initialize"')).toBeLessThan( requests.indexOf('"method":"account/rateLimitResetCredit/consume"'), ); expect(requests.indexOf('"method":"account/rateLimitResetCredit/consume"')).toBeLessThan( - requests.indexOf('"method":"account/rateLimits/read"'), + requests.lastIndexOf('"method":"account/rateLimits/read"'), ); - }).pipe(Effect.scoped), + } + if (scenario.switchBefore || scenario.switchOnConsume) { + expect( + yield* readSharedUsageEntry(NodePath.join(directory, "usage-cache"), accountCacheKey), + ).toEqual(cacheBefore); + } + }).pipe(Effect.scoped), ); } diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index a258ac4c06..873f2d9047 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -21,11 +21,7 @@ * * @module provider/Drivers/CodexDriver */ -import { - CodexSettings, - ProviderDriverKind, - type ServerProviderUsageLimits, -} from "@t3tools/contracts"; +import { CodexSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -44,11 +40,13 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; import { + accountProbeStatus, checkCodexProviderStatus, makePendingCodexProvider, probeCodexSkillsForCwd, withCodexAppServerClient, } from "../Layers/CodexProvider.ts"; +import { readCodexAccountId } from "../codexAccountIdentity.ts"; import { CodexResetCreditCoordinator, CODEX_RESET_CREDIT_TIMEOUT, @@ -78,7 +76,13 @@ import { makeProviderSnapshotSettingsSource, type ProviderSnapshotSettings, } from "../providerUpdateSettings.ts"; -import { retainSnapshotUsageLimits, preferFreshUsageReading } from "../providerUsageRetention.ts"; +import { + authenticatedUsageIdentity, + retainSnapshotUsageLimits, + preferFreshUsageReading, + usageReadingForAuth, + type AccountUsageReading, +} from "../providerUsageRetention.ts"; import { codexContinuationIdentity, materializeCodexShadowHome, @@ -211,8 +215,8 @@ export const CodexDriver: ProviderDriver = { // Subscription capacity rides the same probe as install/auth status but // is read over the network, so it fails independently. Keep the last // good reading through those failures rather than blanking the gauge. - const lastKnownUsage = yield* Ref.make(undefined); - const redeemedUsage = yield* Ref.make(undefined); + const lastKnownUsage = yield* Ref.make(undefined); + const redeemedUsage = yield* Ref.make(undefined); const checkProvider = modelManifest.refreshInBackground.pipe( Effect.andThen( Effect.zipWith( @@ -221,7 +225,11 @@ export const CodexDriver: ProviderDriver = { Effect.gen(function* () { const resetReading = yield* Ref.get(redeemedUsage); const nowMs = yield* Effect.clockWith((clock) => clock.currentTimeMillis); - const published = preferFreshUsageReading(snapshot, resetReading, nowMs); + const published = preferFreshUsageReading( + snapshot, + usageReadingForAuth(resetReading, snapshot.auth), + nowMs, + ); return yield* retainSnapshotUsageLimits(lastKnownUsage, published); }), ), @@ -307,77 +315,148 @@ export const CodexDriver: ProviderDriver = { const accountKey = homeLayout.effectiveHomePath ?? homeLayout.sharedHomePath; const consumeResetCredit: NonNullable = (input) => - resetCreditCoordinator - .redeem(accountKey, input.requestId, (idempotencyKey) => - Effect.gen(function* () { - const result = yield* Effect.gen(function* () { - const { client, outcome } = yield* Effect.gen(function* () { - const { client } = yield* withCodexAppServerClient({ - binaryPath: effectiveConfig.binaryPath, - homePath: effectiveConfig.homePath, - launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv), - cwd: process.cwd(), - environment: processEnv, - }); - const { outcome } = yield* client.request( - "account/rateLimitResetCredit/consume", - { idempotencyKey }, - ); - return { client, outcome }; - }).pipe(Effect.timeout(CODEX_RESET_CREDIT_TIMEOUT)); - const reading = yield* client - .request("account/rateLimits/read", undefined) - .pipe(Effect.timeout("5 seconds"), Effect.result); - if (reading._tag === "Failure") - return { - outcome, - warning: - "Codex answered the reset request, but its updated limits could not be read. Refresh to check.", - }; - const checkedAt = DateTime.formatIso(yield* DateTime.now); - const limits = usageLimitsFromCodexRateLimits(reading.success, checkedAt); - if (!limits) - return { - outcome, - warning: - "Codex answered the reset request without reporting updated limits. Refresh to check.", - }; - yield* Ref.set(redeemedUsage, limits); - yield* writeSharedUsageEntry( - yield* resolveSharedUsageCacheDir, - sharedUsageReadKey(["codex", accountKey]), - { version: 1, readAt: checkedAt, usageLimits: limits }, + Effect.gen(function* () { + const selectedIdentity = authenticatedUsageIdentity((yield* snapshot.getSnapshot).auth); + if (!selectedIdentity) { + return yield* Effect.fail( + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Codex account identity is not verified.", + }), + ); + } + // A pending or completed attempt belongs to one account, even if the + // same CLI home signs in as another account before a retry. + const attemptKey = sharedUsageReadKey(["codex-reset", accountKey, selectedIdentity]); + return yield* resetCreditCoordinator.redeem( + attemptKey, + input.requestId, + (idempotencyKey) => + Effect.gen(function* () { + const identityBefore = authenticatedUsageIdentity( + (yield* snapshot.getSnapshot).auth, ); - return { outcome }; - }).pipe(Effect.scoped); - // The direct account read bypasses the shared cache. Even if an older - // status probe finishes now, checkProvider overlays this fresh reading. - const refreshed = yield* snapshot.refresh.pipe(Effect.exit); - return refreshed._tag === "Failure" && !result.warning - ? { - ...result, - warning: - "Codex answered the reset request, but the provider status could not refresh. Refresh to check.", + if (identityBefore !== selectedIdentity) { + return yield* Effect.fail( + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Codex account changed before the reset credit could be used.", + }), + ); + } + const result = yield* Effect.gen(function* () { + const { client, sharedHomePath, outcome } = yield* Effect.gen(function* () { + const { client, sharedHomePath } = yield* withCodexAppServerClient({ + binaryPath: effectiveConfig.binaryPath, + homePath: effectiveConfig.homePath, + launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv), + cwd: process.cwd(), + environment: processEnv, + }); + const liveAccount = yield* client.request("account/read", {}); + const liveIdentity = authenticatedUsageIdentity( + accountProbeStatus(liveAccount, yield* readCodexAccountId(sharedHomePath)) + .auth, + ); + if (!identityBefore || liveIdentity !== identityBefore) { + return yield* Effect.fail( + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Codex account changed before the reset credit could be used.", + }), + ); + } + const { outcome } = yield* client.request( + "account/rateLimitResetCredit/consume", + { idempotencyKey }, + ); + return { client, sharedHomePath, outcome }; + }).pipe(Effect.timeout(CODEX_RESET_CREDIT_TIMEOUT)); + const reading = yield* client + .request("account/rateLimits/read", undefined) + .pipe(Effect.timeout("5 seconds"), Effect.result); + if (reading._tag === "Failure") + return { + outcome, + warning: + "Codex answered the reset request, but its updated limits could not be read. Refresh to check.", + }; + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const limits = usageLimitsFromCodexRateLimits(reading.success, checkedAt); + if (!limits) + return { + outcome, + warning: + "Codex answered the reset request without reporting updated limits. Refresh to check.", + }; + const accountAfter = yield* client + .request("account/read", {}) + .pipe(Effect.option); + const liveIdentityAfter = + accountAfter._tag === "Some" + ? authenticatedUsageIdentity( + accountProbeStatus( + accountAfter.value, + yield* readCodexAccountId(sharedHomePath), + ).auth, + ) + : undefined; + const snapshotIdentityAfter = authenticatedUsageIdentity( + (yield* snapshot.getSnapshot).auth, + ); + if ( + identityBefore && + liveIdentityAfter === identityBefore && + snapshotIdentityAfter === identityBefore + ) { + yield* Ref.set(redeemedUsage, { + identity: identityBefore, + usageLimits: limits, + }); + yield* writeSharedUsageEntry( + yield* resolveSharedUsageCacheDir, + sharedUsageReadKey(["codex", accountKey, identityBefore]), + { version: 1, readAt: checkedAt, usageLimits: limits }, + ); + } else { + return { + outcome, + warning: + "Codex answered the reset request, but the account changed before its limits could be verified. Refresh to check.", + }; } - : result; - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ), - ) - .pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: - "Codex could not redeem the reset credit. Retry to check the same attempt.", - cause, - }), - ), + return { outcome }; + }).pipe(Effect.scoped); + // The direct account read bypasses the shared cache. Even if an older + // status probe finishes now, checkProvider overlays this fresh reading. + const refreshed = yield* snapshot.refresh.pipe(Effect.exit); + return refreshed._tag === "Failure" && !result.warning + ? { + ...result, + warning: + "Codex answered the reset request, but the provider status could not refresh. Refresh to check.", + } + : result; + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), ); + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Codex could not redeem the reset credit. Retry to check the same attempt.", + cause, + }), + ), + ); return { instanceId, diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 5466af802e..8715dd9835 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -137,11 +137,11 @@ export const CursorDriver: ProviderDriver = { }); const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); - const discoverModels = yield* makeCursorModelDiscovery(effectiveConfig, processEnv); + const modelDiscovery = yield* makeCursorModelDiscovery(effectiveConfig, processEnv); const checkProvider = checkCursorProviderStatus( effectiveConfig, processEnv, - discoverModels, + modelDiscovery.discover, ).pipe( Effect.map(stampIdentity), Effect.provideService(Crypto.Crypto, crypto), @@ -196,6 +196,7 @@ export const CursorDriver: ProviderDriver = { accentColor, enabled, snapshot, + invalidateCaches: modelDiscovery.invalidate, snapshotForCwd: (cwd) => !effectiveConfig.enabled ? snapshot.getSnapshot diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 028322f50c..4565e8dccd 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -10179,4 +10179,70 @@ describe("ClaudeAdapterLive", () => { Effect.provide(harness.layer), ); }); + + it.effect("attributes live subagent tool heartbeats when Claude omits task_id", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const progressFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "tool.progress"), + Stream.take(4), + Stream.runCollect, + Effect.forkChild, + ); + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: session.threadId, input: "Delegate work" }); + + harness.query.emit({ + type: "system", + subtype: "task_started", + task_id: "task-heartbeat", + description: "Inspect code", + task_type: "local_agent", + tool_use_id: "tool-task-heartbeat", + uuid: "task-heartbeat-start", + session_id: "sdk-heartbeat", + } as unknown as SDKMessage); + const heartbeat = (uuid: string, parentToolUseId: string, taskId?: string) => + harness.query.emit({ + type: "tool_progress", + tool_use_id: `tool-${uuid}`, + tool_name: "Bash", + elapsed_time_seconds: 30, + parent_tool_use_id: parentToolUseId, + ...(taskId ? { task_id: taskId } : {}), + uuid, + session_id: "sdk-heartbeat", + } as unknown as SDKMessage); + heartbeat("live", "tool-task-heartbeat"); + heartbeat("explicit", "tool-task-heartbeat", "task-explicit"); + heartbeat("unknown", "tool-unknown"); + harness.query.emit({ + type: "system", + subtype: "task_notification", + task_id: "task-heartbeat", + status: "completed", + uuid: "task-heartbeat-end", + session_id: "sdk-heartbeat", + } as unknown as SDKMessage); + heartbeat("late", "tool-task-heartbeat"); + + const progress = Array.from(yield* Fiber.join(progressFiber)); + assert.deepEqual( + progress.map((event) => + event.type === "tool.progress" && event.payload.taskId + ? String(event.payload.taskId) + : undefined, + ), + ["task-heartbeat", "task-explicit", undefined, undefined], + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 3f74b6b855..e45474b80a 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -4346,6 +4346,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }; if (message.type === "tool_progress") { + // Claude omits task_id on subagent tool heartbeats but includes the + // launching Task tool's id. Recover only a still-live agent: a late + // heartbeat for a completed task must not revive its activity row. + const owningTaskId = agentIdForParentToolUse(context.taskAgents, message.parent_tool_use_id); + const taskId = + message.task_id ?? + (owningTaskId && context.liveTaskIds.has(owningTaskId) ? owningTaskId : undefined); yield* offerRuntimeEvent(context.sessionIncarnationId, { ...base, type: "tool.progress", @@ -4353,7 +4360,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( toolUseId: message.tool_use_id, toolName: message.tool_name, elapsedSeconds: message.elapsed_time_seconds, - ...(message.task_id ? { taskId: RuntimeTaskId.make(message.task_id) } : {}), + ...(taskId ? { taskId: RuntimeTaskId.make(taskId) } : {}), ...(message.parent_tool_use_id !== null ? { parentToolUseId: message.parent_tool_use_id } : {}), diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 94cd8ba582..fe319f45a9 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -240,6 +240,8 @@ it("labels non-ChatGPT account types", () => { }); const CODEX_HOME = "/home/someone/.codex"; +const ACCOUNT_A = "email:a@example.com"; +const ACCOUNT_B = "email:b@example.com"; const usageAt = (checkedAt: string): ServerProviderUsageLimits => ({ source: "codexAppServer", @@ -272,7 +274,7 @@ const readAfterMissedRead = (input: { readonly age: Duration.Duration }) => else process.env[SHARED_USAGE_CACHE_DIR_ENV] = priorCache; }), ); - const cacheKey = sharedUsageReadKey(["codex", CODEX_HOME]); + const cacheKey = sharedUsageReadKey(["codex", CODEX_HOME, ACCOUNT_A]); const readAt = DateTime.formatIso(DateTime.makeUnsafe(0)); yield* writeSharedUsageEntry(cacheDir, cacheKey, { version: 1, @@ -283,6 +285,7 @@ const readAfterMissedRead = (input: { readonly age: Duration.Duration }) => yield* TestClock.adjust(input.age); const result = yield* readCodexRateLimitsShared({ sharedHomePath: CODEX_HOME, + accountIdentity: ACCOUNT_A, read: MISSED_READ, }); return { result, entry: yield* readSharedUsageEntry(cacheDir, cacheKey), readAt }; @@ -311,4 +314,34 @@ it.layer(NodeServices.layer)("codex capacity across a missed read", (it) => { assert.deepStrictEqual(result, {}); }), ); + + it.effect("does not read account A's shared quota after the same home signs into B", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "pylon-codex-usage-" }); + const priorCache = process.env[SHARED_USAGE_CACHE_DIR_ENV]; + yield* Effect.acquireRelease( + Effect.sync(() => { + process.env[SHARED_USAGE_CACHE_DIR_ENV] = cacheDir; + }), + () => + Effect.sync(() => { + if (priorCache === undefined) delete process.env[SHARED_USAGE_CACHE_DIR_ENV]; + else process.env[SHARED_USAGE_CACHE_DIR_ENV] = priorCache; + }), + ); + const readAt = DateTime.formatIso(yield* DateTime.now); + yield* writeSharedUsageEntry(cacheDir, sharedUsageReadKey(["codex", CODEX_HOME, ACCOUNT_A]), { + version: 1, + readAt, + usageLimits: usageAt(readAt), + }); + const result = yield* readCodexRateLimitsShared({ + sharedHomePath: CODEX_HOME, + accountIdentity: ACCOUNT_B, + read: MISSED_READ, + }); + assert.deepStrictEqual(result, {}); + }).pipe(Effect.scoped), + ); }); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 871578cd02..b52b6fe3b2 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -55,7 +55,7 @@ import { sharedUsageReadKey, writeSharedUsageEntry, } from "../sharedUsageReadCache.ts"; -import { isRetainedUsageFresh } from "../providerUsageRetention.ts"; +import { authenticatedUsageIdentity, isRetainedUsageFresh } from "../providerUsageRetention.ts"; import packageJson from "../../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); @@ -402,14 +402,22 @@ const failedRead = Effect.fn("readCodexRateLimitsShared.failed")(function* ( */ export const readCodexRateLimitsShared = Effect.fn("readCodexRateLimitsShared")(function* (input: { readonly sharedHomePath: string; + readonly accountIdentity: string | undefined; readonly read: Effect.Effect; }): Effect.fn.Return< Pick, never, FileSystem.FileSystem | Path.Path > { + // A home can sign out and into another account without changing path. An + // unidentified account may make a live read, but must never reuse the old + // home's shared quota file. + if (!input.accountIdentity) { + const rateLimits = yield* input.read; + return rateLimits ? { rateLimits } : {}; + } const cacheDir = yield* resolveSharedUsageCacheDir; - const cacheKey = sharedUsageReadKey(["codex", input.sharedHomePath]); + const cacheKey = sharedUsageReadKey(["codex", input.sharedHomePath, input.accountIdentity]); const nowMs = yield* Effect.clockWith((clock) => clock.currentTimeMillis); const shared = decideSharedUsageRead(yield* readSharedUsageEntry(cacheDir, cacheKey), nowMs); if (shared.kind === "fresh") return { sharedUsageLimits: shared.usageLimits }; @@ -526,11 +534,14 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun } satisfies CodexAppServerProviderSnapshot; } - // The account id is read alongside the app-server requests rather than - // before the spawn: the probe's timeout is measured from its start, and a - // filesystem read ahead of the spawn would let it expire with nothing to - // close. - const [skillsResponse, models, rateLimitsRead, accountId] = yield* Effect.all( + // Read the account id after the app-server starts, then bind the shared + // quota file to this account before looking it up. A home path alone is + // reused after sign-out and cannot identify a subscription. + const accountId = yield* readCodexAccountId(sharedHomePath); + const accountIdentity = authenticatedUsageIdentity( + accountProbeStatus(accountResponse, accountId).auth, + ); + const [skillsResponse, models, rateLimitsRead] = yield* Effect.all( [ client.request("skills/list", { cwds: [input.cwd], @@ -539,6 +550,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun accountResponse.account?.type === "chatgpt" ? readCodexRateLimitsShared({ sharedHomePath, + accountIdentity, read: client .request("account/rateLimits/read", undefined) .pipe( @@ -551,7 +563,6 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun : Effect.succeed>( {}, ), - readCodexAccountId(sharedHomePath), ], { concurrency: "unbounded" }, ); @@ -659,7 +670,7 @@ const makePendingCodexProvider = ( }); }); -function accountProbeStatus( +export function accountProbeStatus( account: CodexAppServerProviderSnapshot["account"], accountId: string | undefined, ): { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 3cbf840237..3a509c4f08 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -16,6 +16,7 @@ import { describeMcpElicitation, hasConfiguredMcpServer, isRecoverableThreadResumeError, + isThreadWriterLockedError, makeMemoryConsolidationNotificationFilter, openCodexThread, readCodexThread, @@ -871,6 +872,44 @@ describe("isRecoverableThreadResumeError", () => { }); }); +describe("isThreadWriterLockedError", () => { + it("matches the app-server writer-lock refusal", () => { + NodeAssert.equal( + isThreadWriterLockedError( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "thread 01a0ccd7-23c1-7c00-9cbd-eafe286d36b5 already has an active writer", + }), + ), + true, + ); + }); + + it("ignores other resume failures", () => { + NodeAssert.equal( + isThreadWriterLockedError( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "thread not found", + }), + ), + false, + ); + }); + + it("is not treated as a recoverable resume error", () => { + NodeAssert.equal( + isRecoverableThreadResumeError( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "thread 01a0ccd7-23c1-7c00-9cbd-eafe286d36b5 already has an active writer", + }), + ), + false, + ); + }); +}); + describe("openCodexThread", () => { it.effect("resumes metadata when historical turns contain unknown error values", () => Effect.gen(function* () { @@ -1097,4 +1136,33 @@ describe("openCodexThread", () => { NodeAssert.equal(error.errorMessage, "timed out waiting for server"); }), ); + + it.effect("explains a writer-locked conversation without starting a fresh thread", () => + Effect.gen(function* () { + const refusal = new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "thread 01a0ccd7-23c1-7c00-9cbd-eafe286d36b5 already has an active writer", + }); + const client = { + request: () => Effect.die("A writer-locked conversation must not start a fresh thread"), + raw: { request: () => Effect.fail(refusal) }, + }; + + const error = yield* openCodexThread({ + client, + threadId: ThreadId.make("thread-1"), + runtimeMode: "full-access", + cwd: "/tmp/project", + requestedModel: "gpt-5.3-codex", + serviceTier: undefined, + resumeThreadId: "01a0ccd7-23c1-7c00-9cbd-eafe286d36b5", + }).pipe(Effect.flip); + + NodeAssert.ok(isCodexAppServerRequestError(error)); + NodeAssert.ok(error.errorMessage.includes("01a0ccd7-23c1-7c00-9cbd-eafe286d36b5")); + NodeAssert.match(error.errorMessage, /writer lock/i); + NodeAssert.match(error.errorMessage, /close/i); + NodeAssert.equal(error.cause, refusal); + }), + ); }); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 81ca7b2cb6..ade2386346 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -78,6 +78,11 @@ const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ "no rollout found", ]; +// The app-server refuses a resume while another Codex host holds the conversation's +// writer lock. Resuming is the only correct outcome, so this must not fall back to a +// fresh thread: that would strand the conversation's history behind a new one. +const THREAD_WRITER_LOCKED_ERROR_SNIPPET = "already has an active writer"; + export function hasConfiguredMcpServer(appServerArgs: ReadonlyArray | undefined): boolean { return appServerArgs?.some((argument) => argument.includes("mcp_servers.")) === true; } @@ -717,6 +722,19 @@ function classifyCodexStderrLine(rawLine: string): { readonly message: string } return { message: line }; } +export function isThreadWriterLockedError(error: unknown): boolean { + const message = (error instanceof Error ? error.message : String(error)).toLowerCase(); + return message.includes(THREAD_WRITER_LOCKED_ERROR_SNIPPET); +} + +function describeThreadWriterLock(resumeThreadId: string): string { + return ( + `Codex conversation ${resumeThreadId} is open in another Codex app, which holds its writer lock. ` + + "Close that conversation there, most often in the Codex desktop app, then send again. " + + "This thread keeps its history instead of continuing in a new conversation." + ); +} + export function isRecoverableThreadResumeError(error: unknown): boolean { const message = (error instanceof Error ? error.message : String(error)).toLowerCase(); if (!message.includes("thread")) { @@ -801,6 +819,15 @@ export const openCodexThread = (input: { ), ), ), + Effect.mapError((error) => + isThreadWriterLockedError(error) + ? CodexErrors.CodexAppServerRequestError.internalError( + describeThreadWriterLock(resumeThreadId), + undefined, + { method: "thread/resume", operation: "receive-response", cause: error }, + ) + : error, + ), Effect.catchIf( (error) => !input.strictResume && isRecoverableThreadResumeError(error), (error) => diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 7337a19010..dd314b0ec9 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -626,7 +626,7 @@ describe("discoverCursorModelsViaAcp", () => { apiEndpoint: "", customModels: [], }; - const discover = yield* makeCursorModelDiscovery(settings, { + const { discover, invalidate } = yield* makeCursorModelDiscovery(settings, { ...process.env, T3_ACP_REQUEST_LOG_PATH: requestLogPath, }); @@ -639,6 +639,10 @@ describe("discoverCursorModelsViaAcp", () => { yield* fileSystem.writeFileString(requestLogPath, ""); expect(yield* discover(about)).toEqual(first); expect(yield* fileSystem.readFileString(requestLogPath)).toBe(""); + yield* invalidate; + expect(yield* discover(about)).toEqual(first); + expect(yield* fileSystem.readFileString(requestLogPath)).toContain("initialize"); + yield* fileSystem.writeFileString(requestLogPath, ""); yield* discover({ ...about, version: "2026.08.12" }); expect(yield* fileSystem.readFileString(requestLogPath)).toContain("initialize"); yield* fileSystem.writeFileString(requestLogPath, ""); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 1d80fa20b8..e46989ab62 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -588,8 +588,11 @@ export const makeCursorModelDiscovery = Effect.fn("makeCursorModelDiscovery")(fu Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero, }, ); - return (about: Pick) => - Cache.get(cache, JSON.stringify([about.version, about.auth])); + return { + discover: (about: Pick) => + Cache.get(cache, JSON.stringify([about.version, about.auth])), + invalidate: Cache.invalidateAll(cache), + }; }); function getCursorFallbackModels( diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index b14edfcc0b..e38de31156 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -25,8 +25,10 @@ import { TurnId, type ProviderRuntimeEvent, } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { ServerConfig } from "../../config.ts"; +import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; import { grokPromptSettlementBelongsToContext, isGrokEnterPlanModeToolCall, @@ -34,8 +36,7 @@ import { nextGrokPlanModeActive, selectGrokPermissionOptionId, } from "./GrokAdapter.ts"; -import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + const decodeGrokSettings = Schema.decodeSync(GrokSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); @@ -251,6 +252,65 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + for (const taskType of ["monitor", "shell"] as const) { + it.effect(`emits the ${taskType} background lifecycle`, () => + Effect.gen(function* () { + const threadId = ThreadId.make(`grok-background-${taskType}`); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + [taskType === "monitor" + ? "T3_ACP_EMIT_GROK_MONITOR_POST_TURN_POLL" + : "T3_ACP_EMIT_GROK_BACKGROUND_TASK_STARTED"]: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const events: ProviderRuntimeEvent[] = []; + const finished = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + events.push(event); + if (event.type === (taskType === "monitor" ? "task.completed" : "turn.completed")) { + yield* Deferred.succeed(finished, undefined); + } + }), + ).pipe(Effect.forkChild); + yield* adapter.startSession({ threadId, cwd: process.cwd(), runtimeMode: "full-access" }); + yield* adapter.sendTurn({ threadId, input: "watch the unit" }); + yield* Deferred.await(finished).pipe(Effect.timeout("3 seconds")); + + const started = events.find((event) => event.type === "task.started"); + const taskId = + taskType === "monitor" + ? "01a05f41-5107-7550-821e-79e8d1cd7687" + : "call-fb9d0000-0000-0000-0000-000000000026"; + assert.equal(started?.payload.taskType, taskType); + assert.equal(started?.payload.taskId, taskId); + if (taskType === "monitor") { + const completed = events.find((event) => event.type === "task.completed"); + const turnEnd = events.findIndex((event) => event.type === "turn.completed"); + assert.equal(completed?.payload.status, "completed"); + assert.equal(completed?.payload.taskId, taskId); + assert.equal(completed?.turnId, undefined); + assert.isAtLeast(turnEnd, 0); + assert.isAbove( + events.findIndex((event) => event.type === "task.completed"), + turnEnd, + ); + assert.deepEqual( + events + .slice(turnEnd + 1) + .filter((event) => event.type === "item.updated" || event.type === "item.completed"), + [], + ); + } else { + assert.equal(started?.payload.description, "sleep 40; echo done-a"); + } + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + } + it.effect("sends runtime context with the current model without changing saved prompts", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-runtime-context"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 1f0053da34..2a92dba3d3 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -68,6 +68,10 @@ import { normalizeGrokReasoningEffort, resolveGrokAcpBaseModelId, } from "../acp/GrokAcpSupport.ts"; +import { + buildGrokBackgroundTaskEvents, + type GrokBackgroundTaskRecord, +} from "../acp/XAiBackgroundTasks.ts"; import { extractGrokPlanMarkdownFromToolCallData, extractXAiAskUserQuestions, @@ -174,6 +178,10 @@ interface GrokSessionContext { currentModelId: string | undefined; currentReasoningEffort: string | undefined; stopped: boolean; + /** Live monitor and shell identities, with their originating turns. */ + readonly backgroundTasks: Map; + readonly completedBackgroundTaskIds: Map; + readonly ambiguousBackgroundTaskIds: Set; } function settlePendingApprovalsAsCancelled( @@ -1337,6 +1345,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ? normalizeGrokReasoningEffort(requestedStartReasoningEffort) : currentStartReasoningEffort, stopped: false, + backgroundTasks: new Map(), + completedBackgroundTaskIds: new Map(), + ambiguousBackgroundTaskIds: new Set(), }; const nf = yield* Stream.runDrain( @@ -1359,6 +1370,30 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } const notificationTurnId = resolveNotificationTurnId(ctx); + if ( + event._tag === "ToolCallUpdated" && + !ctx.stopped && + (notificationTurnId === undefined || + !ctx.interruptedTurnIds.has(notificationTurnId)) + ) { + for (const taskEvent of buildGrokBackgroundTaskEvents({ + tasks: ctx.backgroundTasks, + completedTaskIds: ctx.completedBackgroundTaskIds, + ambiguousTaskIds: ctx.ambiguousBackgroundTaskIds, + toolCallId: event.toolCall.toolCallId, + rawInput: event.toolCall.data.rawInput, + rawOutput: event.toolCall.data.rawOutput, + toolCallStatus: event.toolCall.status, + turnId: notificationTurnId, + })) { + yield* offerRuntimeEvent(ctx.sessionIncarnationId, { + ...taskEvent, + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + }); + } + } if ( notificationTurnId === undefined || ctx.interruptedTurnIds.has(notificationTurnId) diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 1e228af47d..9c6a647e22 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -57,6 +57,7 @@ import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; +import * as Scheduler from "effect/Scheduler"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -3057,6 +3058,34 @@ routing.layer("ProviderServiceLive routing", (it) => { assert.include(followUpInput.input ?? "", '[Attached file "report.pdf" is saved at: '); assert.include(followUpInput.input ?? "", `${fileAttachment.id}.pdf]`); assert.deepEqual(followUpInput.attachments, [fileAttachment]); + const pastedTextAttachment = { + type: "file" as const, + id: "thread-attach-12345678-1234-1234-1234-123456789abc-txt", + name: "pasted-text.txt", + mimeType: "text/plain;charset=utf-8", + sizeBytes: 32_768, + source: { _tag: "pasted-text" as const }, + }; + routing.codex.sendTurn.mockClear(); + yield* provider.sendTurn({ + threadId: session.threadId, + input: "Investigate this crash", + attachments: [pastedTextAttachment], + }); + const pastedInput = routing.codex.sendTurn.mock.calls[0]?.[0] as ProviderSendTurnInput; + assert.include(pastedInput.input ?? "", '[Pasted text "pasted-text.txt" is saved at: '); + assert.include(pastedInput.input ?? "", ". Inspect it as needed.]"); + assert.deepEqual(pastedInput.attachments, [pastedTextAttachment]); + + routing.codex.followUp.mockClear(); + yield* provider.followUp({ + threadId: session.threadId, + input: "And inspect this paste", + attachments: [pastedTextAttachment], + }); + const pastedFollowUp = routing.codex.followUp.mock.calls[0]?.[0] as ProviderFollowUpInput; + assert.include(pastedFollowUp.input ?? "", '[Pasted text "pasted-text.txt" is saved at: '); + assert.deepEqual(pastedFollowUp.attachments, [pastedTextAttachment]); yield* provider.stopSession({ threadId: session.threadId }); }), @@ -6680,6 +6709,58 @@ turnAnalytics.layer("ProviderServiceLive turn analytics", (it) => { const validation = makeProviderServiceLayer(); validation.layer("ProviderServiceLive validation", (it) => { + it.effect("rejects input that leaves no room for pasted-text attachment context", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const attachment = { + type: "file" as const, + id: "thread-attach-12345678-1234-1234-1234-123456789abc-txt", + name: "pasted-text.txt", + mimeType: "text/plain;charset=utf-8", + sizeBytes: 32_768, + source: { _tag: "pasted-text" as const }, + }; + validation.codex.sendTurn.mockClear(); + + const failure = yield* provider + .sendTurn({ + threadId: asThreadId("thread-pasted-text-context-limit"), + input: "x".repeat(PROVIDER_SEND_TURN_MAX_INPUT_CHARS), + attachments: [attachment], + }) + .pipe(Effect.flip); + + assert.instanceOf(failure, ProviderValidationError); + assert.include(failure.issue, String(PROVIDER_SEND_TURN_MAX_INPUT_CHARS)); + assert.equal(validation.codex.sendTurn.mock.calls.length, 0); + }), + ); + + it.effect("rejects unsafe pasted-text attachment IDs before provider dispatch", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + validation.codex.sendTurn.mockClear(); + const failure = yield* provider + .sendTurn({ + threadId: asThreadId("thread-pasted-text-invalid-path"), + input: "Inspect this paste", + attachments: [ + { + type: "file", + id: "../unsafe", + name: "pasted-text.txt", + mimeType: "text/plain;charset=utf-8", + sizeBytes: 32_768, + source: { _tag: "pasted-text" }, + }, + ], + }) + .pipe(Effect.flip); + assert.instanceOf(failure, ProviderValidationError); + assert.equal(validation.codex.sendTurn.mock.calls.length, 0); + }), + ); + it.effect("rejects citation-expanded input over the provider character limit", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; @@ -6917,6 +6998,34 @@ class RuntimeReaper extends Context.Service()( describe("agent browser access", () => { const projectId = ProjectId.make("project-browser-access"); + it.effect("subscribes to adapter events before exposing the provider service", () => + Effect.gen(function* () { + const codex = makeFakeCodexAdapter(); + const events = yield* PubSub.unbounded(); + let subscribed = false; + const adapter: ProviderAdapterShape = { + ...codex.adapter, + streamEvents: Stream.unwrap( + Effect.map(PubSub.subscribe(events), (subscription) => { + // This runs only after PubSub has registered the subscription, not merely when + // the stream wrapper is constructed. + subscribed = true; + return Stream.fromSubscription(subscription); + }), + ), + }; + yield* Effect.gen(function* () { + yield* ProviderService.ProviderService; + assert.isTrue(subscribed); + }).pipe(Effect.provide(makeAgentBrowserProviderLayer(false, { ...codex, adapter }, {}))); + }).pipe( + // Keep this one fiber from yielding before the assertion. The old deferred fork then + // reproducibly leaves `subscribed` false; immediate startup passes without a timer. + Effect.provideService(Scheduler.PreventSchedulerYield, true), + Effect.provide(NodeServices.layer), + ), + ); + const makeBrowserAccessProjectionLayer = ( threadId: ThreadId, status: "found" | "missing" | "failed" = "found", @@ -7165,6 +7274,7 @@ describe("agent browser access", () => { subscribe: unused(), inspect: unused(), retryHost: unused, + updateTool: unused, configure: unused, list: unused(), open: unused, diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2fc810c8a3..5494ff8356 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -528,30 +528,41 @@ const correlateRuntimeEventWithInstance = ( * makes the path line the sole channel a non-image attachment has on those * providers, which is why follow-ups need it exactly as much as turns do. * - * A context block that would push the input past the provider limit is left - * out. Unresolvable ids are skipped here and surface as adapter errors when the - * file is read. + * Ordinary context that would push input past the provider limit is left out. + * A pasted-text path is required: silently omitting it would leave the agent + * unable to inspect the folded paste. Unresolvable ids are skipped here and + * surface as adapter errors when the file is read. */ const appendAttachmentContext = ( attachmentsDir: string, input: string | undefined, attachments: ReadonlyArray, -): string | undefined => { +): { readonly input: string | undefined; readonly pastedTextOverflow: boolean } => { let inputWithContext = input; + let pastedTextOverflow = false; const append = (context: string | undefined) => { - if (context === undefined) return; + if (context === undefined) return true; const candidate = inputWithContext ? `${inputWithContext}\n\n${context}` : context; if (candidate.length <= PROVIDER_SEND_TURN_MAX_INPUT_CHARS) { inputWithContext = candidate; + return true; } + return false; }; for (const attachment of attachments) { const attachmentPath = resolveAttachmentPath({ attachmentsDir, attachment }); - append( + const isPastedText = + attachment.type === "file" && + "source" in attachment && + attachment.source?._tag === "pasted-text"; + const appended = append( attachmentPath === null ? undefined - : `[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`, + : isPastedText + ? `[Pasted text "${attachment.name}" is saved at: ${attachmentPath}. Inspect it as needed.]` + : `[Attached ${attachment.type} "${attachment.name}" is saved at: ${attachmentPath}]`, ); + if (isPastedText && (attachmentPath === null || !appended)) pastedTextOverflow = true; } for (const attachment of attachments) { const source = isChatImageAttachment(attachment) ? attachment.source : undefined; @@ -583,7 +594,7 @@ const appendAttachmentContext = ( : undefined, ); } - return inputWithContext; + return { input: inputWithContext, pastedTextOverflow }; }; const makeProviderService = Effect.fn("makeProviderService")(function* ( @@ -1758,6 +1769,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // Publish the new adapter set before any replacement stream can emit. yield* Ref.set(subscribedAdapters, next); for (const [id, adapter] of newSubscriptions) { + // A provider can emit a receipt synchronously during an API call. Subscribe before + // returning from reconciliation so that first receipt cannot outrun a deferred fork. yield* Stream.runForEach(adapter.streamEvents, (event) => processRuntimeEvent( { @@ -1767,7 +1780,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }, event, ), - ).pipe(Effect.forkScoped); + ).pipe(Effect.forkScoped({ startImmediately: true })); } }); @@ -2324,11 +2337,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // Pylon keeps the attachment context in a shared helper; feed it the // citation-expanded text so both transforms land in the provider prompt. - const inputTextWithAttachmentContext = appendAttachmentContext( + const attachmentContext = appendAttachmentContext( serverConfig.attachmentsDir, inputTextWithCitations, attachments, ); + if (attachmentContext.pastedTextOverflow) { + return yield* toValidationError( + "ProviderService.sendTurn", + `Pasted-text attachment path is unavailable or exceeds the ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS} character input limit`, + ); + } + const inputTextWithAttachmentContext = attachmentContext.input; const input = { ...parsed, @@ -3356,11 +3376,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // generic file attached to a follow-up is lost outright: every adapter // except OpenCode skips non-images, so the path line is the only thing // that tells the agent the file exists. - const followUpInputText = appendAttachmentContext( + const followUpContext = appendAttachmentContext( serverConfig.attachmentsDir, input.input, input.attachments, ); + if (followUpContext.pastedTextOverflow) { + return yield* toValidationError( + "ProviderService.followUp", + `Pasted-text attachment path is unavailable or exceeds the ${PROVIDER_SEND_TURN_MAX_INPUT_CHARS} character input limit`, + ); + } + const followUpInputText = followUpContext.input; return yield* followUpSession({ ...input, ...(followUpInputText !== undefined ? { input: followUpInputText } : {}), diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index f779280e6a..b5866d1e1c 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -219,8 +219,12 @@ describe("resolveProviderCatalog", () => { }); }); +// Remote fixtures date after the bundle so a fetch still outranks it. +const REMOTE_UPDATED_AT = "2099-01-01T00:00:00Z"; + const REMOTE_MANIFEST: ModelManifestData = { version: 1, + updatedAt: REMOTE_UPDATED_AT, currentModels: { codex: ["remote-model"], claudeAgent: ["remote-agent-model"], @@ -229,6 +233,7 @@ const REMOTE_MANIFEST: ModelManifestData = { const REMOTE_CLAUDE_MANIFEST: ModelManifestData = { version: 1, + updatedAt: REMOTE_UPDATED_AT, currentModels: {}, providers: { claudeAgent: { @@ -363,6 +368,84 @@ describe("ModelManifest service", () => { ); }); + it.live("explicit refresh bypasses fresh memory and disk caches", () => { + let fetchCount = 0; + const updated: ModelManifestData = { + ...REMOTE_MANIFEST, + currentModels: { codex: ["gpt-reloaded"] }, + }; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST); + assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 1); + + const rebooted = yield* make; + assert.deepStrictEqual(yield* rebooted.refresh, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 1); + assert.deepStrictEqual(yield* rebooted.forceRefresh, updated); + assert.strictEqual(fetchCount, 2); + assert.deepStrictEqual(yield* rebooted.current, updated); + assert.deepStrictEqual(yield* (yield* make).current, updated); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-force-refresh-test", + response: () => Response.json(fetchCount++ === 0 ? REMOTE_MANIFEST : updated), + }), + ), + ); + }); + + it.live("explicit refresh retries immediately after failure and preserves last-good data", () => { + let fetchCount = 0; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, REMOTE_MANIFEST); + assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); + assert.deepStrictEqual(yield* service.current, REMOTE_MANIFEST); + assert.deepStrictEqual(yield* (yield* make).current, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 2); + assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 3); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-force-retry-test", + response: () => + fetchCount++ === 1 + ? new Response(null, { status: 503 }) + : Response.json(REMOTE_MANIFEST), + }), + ), + ); + }); + + it.live("explicit refresh bypasses the retry delay after an initial failure", () => { + let fetchCount = 0; + return Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + assert.strictEqual(fetchCount, 1); + assert.deepStrictEqual(yield* service.forceRefresh, REMOTE_MANIFEST); + assert.strictEqual(fetchCount, 2); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-force-initial-retry-test", + response: () => + fetchCount++ === 0 + ? new Response(null, { status: 503 }) + : 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; @@ -384,6 +467,24 @@ describe("ModelManifest service", () => { ), ); + it.live("keeps a newer bundled manifest when the hosted catalog lags behind", () => + Effect.gen(function* () { + const service = yield* make; + assert.deepStrictEqual(yield* service.refresh, BUNDLED_MODEL_MANIFEST); + const rebooted = yield* make; + assert.deepStrictEqual(yield* rebooted.current, BUNDLED_MODEL_MANIFEST); + }).pipe( + Effect.scoped, + Effect.provide( + serviceLayers({ + prefix: "model-manifest-older-remote-test", + response: () => + Response.json({ ...REMOTE_MANIFEST, updatedAt: "2020-01-01T00:00:00.000Z" }), + }), + ), + ), + ); + it.live("keeps the bundled manifest when the remote payload is malformed", () => Effect.gen(function* () { const service = yield* make; diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index bcf9ccfa8b..c74bb2f7c0 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -95,6 +95,12 @@ const ManifestProviderCatalog = 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.check(Schema.isNonEmpty(), Schema.isMaxLength(64)), Schema.Array( @@ -155,6 +161,13 @@ export const decodeManifestJson = (input: string) => 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; +} + export interface ResolvedManifestModel { readonly model: ServerProviderModel; readonly adapter: unknown; @@ -341,6 +354,8 @@ export class ModelManifest extends Context.Service< readonly current: Effect.Effect; /** Manifest after a TTL-gated remote refresh; never fails. */ readonly refresh: Effect.Effect; + /** Explicit refresh bypasses freshness and retry timers, retaining last-good data. */ + readonly forceRefresh: Effect.Effect; /** Forks `refresh` into the service's own scope. Drivers call this from * provider checks: the fetch is process-shared state, so it must survive * the teardown of whichever instance happened to trigger it. */ @@ -352,6 +367,7 @@ export class ModelManifest extends Context.Service< const BundledOnlyModelManifest: ModelManifest["Service"] = { current: Effect.succeed(BUNDLED_MODEL_MANIFEST), refresh: Effect.succeed(BUNDLED_MODEL_MANIFEST), + forceRefresh: Effect.succeed(BUNDLED_MODEL_MANIFEST), refreshInBackground: Effect.void, }; @@ -382,13 +398,20 @@ 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; }), ); - const refresh = Effect.fn("ModelManifest.refresh")(function* () { + const refresh = Effect.fn("ModelManifest.refresh")(function* (force = false) { yield* ensureDiskCacheLoaded; const now = yield* Clock.currentTimeMillis; // A timestamp in the future means the wall clock moved backwards (the @@ -396,8 +419,8 @@ export const make = Effect.gen(function* () { // it as expired: the refetch rewrites both timestamps and self-heals. const isWithin = (sinceMs: number | null, windowMs: number) => sinceMs !== null && now >= sinceMs && now - sinceMs < windowMs; - if (isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest; - if (isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest; + if (!force && isWithin(fetchedAtMs, MANIFEST_TTL_MS)) return manifest; + if (!force && isWithin(lastAttemptMs, MANIFEST_RETRY_MS)) return manifest; // The same switch that gates provider CLI update checks. It stops network // fetches only: a manifest already cached on disk from an earlier fetch @@ -430,6 +453,9 @@ export const make = Effect.gen(function* () { Effect.catchCause(() => Effect.succeed(null)), ); if (fetched === null) return manifest; + // The hosted catalog can lag behind a newly shipped bundle. Do not + // downgrade the live model list or persist that older copy across restarts. + if (manifestUpdatedAtMs(fetched) < manifestUpdatedAtMs(manifest)) return manifest; manifest = fetched; fetchedAtMs = now; @@ -450,6 +476,7 @@ export const make = Effect.gen(function* () { return ModelManifest.of({ current: ensureDiskCacheLoaded.pipe(Effect.map(() => manifest)), refresh: guardedRefresh, + forceRefresh: refreshSemaphore.withPermits(1)(refresh(true)), refreshInBackground: Effect.forkIn(guardedRefresh, serviceScope).pipe(Effect.asVoid), }); }); diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index 46b0f1cf80..2ca4fedcf7 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -84,6 +84,8 @@ export interface ProviderInstance { readonly enabled: boolean; readonly snapshot: ServerProviderShape; readonly snapshotForCwd?: (cwd: string) => Effect.Effect; + /** Invalidate provider-owned discovery caches before an explicit refresh. */ + readonly invalidateCaches?: Effect.Effect; readonly adapter: ProviderAdapterShape; readonly textGeneration: TextGeneration.TextGeneration["Service"]; readonly refreshModels?: () => Effect.Effect; diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts index 15ba88da3b..aee10782a6 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -279,6 +279,51 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }), ); + it.effect("keeps folded clipboard text out of native context", () => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const pastedText = { + ...textAttachment, + name: "pasted-text.txt", + mimeType: "text/plain", + source: { _tag: "pasted-text" as const }, + } satisfies ChatAttachment; + yield* fixture.write(pastedText, "A very large crash report"); + + const prompt = yield* buildAntigravityPrompt({ + input: "Inspect the pasted text only as needed.", + attachments: [pastedText], + attachmentsDir: fixture.attachmentsDir, + }); + + expect(prompt).toEqual([{ type: "text", text: "Inspect the pasted text only as needed." }]); + }), + ); + + it.effect("rejects missing folded clipboard text", () => + Effect.gen(function* () { + const fixture = yield* makeAttachmentFixture(); + const pastedText = { + ...textAttachment, + name: "pasted-text.txt", + mimeType: "text/plain", + source: { _tag: "pasted-text" as const }, + } satisfies ChatAttachment; + + const error = yield* buildAntigravityPrompt({ + input: "Inspect the pasted text only as needed.", + attachments: [pastedText], + attachmentsDir: fixture.attachmentsDir, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "AcpRequestError", + code: -32602, + errorMessage: "Could not read attachment 'pasted-text.txt'.", + }); + }), + ); + it.effect("sends supported audio files as native audio content", () => Effect.gen(function* () { const fixture = yield* makeAttachmentFixture(); diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index 96bbee73a4..88be6e4b5b 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -289,6 +289,13 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi let totalBytes = 0; for (const attachment of input.attachments ?? []) { + const isPastedText = + attachment.type === "file" && + "source" in attachment && + attachment.source?._tag === "pasted-text"; + // ProviderService has already put the file path in the text block. Keep a + // folded clipboard paste lazy so the agent can search or sample it rather + // than paying to embed the entire resource in context immediately. const mimeType = attachment.mimeType.toLowerCase().split(";", 1)[0] ?? ""; const image = attachment.type === "image" && IMAGE_MIME_TYPES.has(mimeType); const audio = attachment.type === "file" && AUDIO_MIME_TYPES.has(mimeType); @@ -321,6 +328,14 @@ export const buildAntigravityPrompt = Effect.fn("buildAntigravityPrompt")(functi ), ), ); + if (isPastedText) { + if (info.type !== "File") { + return yield* EffectAcpErrors.AcpRequestError.invalidParams( + `Could not read attachment '${attachment.name}'.`, + ); + } + continue; + } const size = Number(info.size); const limit = image ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES diff --git a/apps/server/src/provider/acp/CursorTransportFailure.test.ts b/apps/server/src/provider/acp/CursorTransportFailure.test.ts index dcd4f9996b..fdde19b934 100644 --- a/apps/server/src/provider/acp/CursorTransportFailure.test.ts +++ b/apps/server/src/provider/acp/CursorTransportFailure.test.ts @@ -38,6 +38,8 @@ describe("CursorTransportFailure", () => { "Error: ConnectError: [unauthenticated] sign in", "Error: ConnectError: [permission_denied] subscription required", "Error: HTTP 500 from the application being debugged", + "Error: RetriableError: [internal] Failed to run step, exceeded max retries", + "Error: RetriableError: [internal] Failed to run step, exceeded max retries\n at step (cli.js:1:2)", ])("preserves prose, code and non-transport errors: %s", (message) => { expect(failureFor([...message])).toBeUndefined(); }); diff --git a/apps/server/src/provider/acp/CursorTransportFailure.ts b/apps/server/src/provider/acp/CursorTransportFailure.ts index 99765c74eb..e6cadb23dc 100644 --- a/apps/server/src/provider/acp/CursorTransportFailure.ts +++ b/apps/server/src/provider/acp/CursorTransportFailure.ts @@ -1,6 +1,7 @@ const maxLineLength = 4096; +// Cursor also uses RetriableError for agent-loop failures; preserve those diagnostics. const transportError = - /^Error: (?:RetriableError: .+|ConnectError: \[(?:unavailable|aborted|deadline_exceeded)\].*)$/; + /^Error: (?:RetriableError: (?!\[internal\]).+|ConnectError: \[(?:unavailable|aborted|deadline_exceeded)\].*)$/; const serverError = "Something went wrong communicating with the server. Please try again."; interface ReplyState { diff --git a/apps/server/src/provider/acp/XAiBackgroundTasks.test.ts b/apps/server/src/provider/acp/XAiBackgroundTasks.test.ts new file mode 100644 index 0000000000..3f77ca451a --- /dev/null +++ b/apps/server/src/provider/acp/XAiBackgroundTasks.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from "vite-plus/test"; +import { TurnId } from "@t3tools/contracts"; + +import { + buildGrokBackgroundTaskEvents, + type GrokBackgroundTaskRecord, +} from "./XAiBackgroundTasks.ts"; + +const turnId = TurnId.make("turn-1"); +const monitor = { type: "Monitor", taskId: "monitor-1", timeoutMs: 60_000 }; +const shell = { type: "BackgroundTaskStarted", task_id: "shell-1", command: "sleep 40" }; + +function mapper() { + const tasks = new Map(); + const completedTaskIds = new Map(); + const ambiguousTaskIds = new Set(); + const update = ( + rawOutput: unknown, + overrides: Partial[0]> = {}, + ) => + buildGrokBackgroundTaskEvents({ + tasks, + completedTaskIds, + ambiguousTaskIds, + toolCallId: "call-1", + rawInput: { description: "Watch" }, + rawOutput, + toolCallStatus: "completed", + turnId, + ...overrides, + }); + return { tasks, completedTaskIds, ambiguousTaskIds, update }; +} + +describe("Grok background tasks", () => { + it.each([ + [monitor, "monitor-1", "monitor", "Watch"], + [shell, "shell-1", "shell", "sleep 40"], + ] as const)("starts and deduplicates %j", (output, id, taskType, description) => { + const { tasks, update } = mapper(); + expect(update(output)).toEqual([ + { + type: "task.started", + turnId, + payload: { taskId: id, taskType, description, title: description, toolUseId: "call-1" }, + }, + ]); + expect(update(output)).toEqual([]); + expect(tasks.size).toBe(1); + }); + + it("accepts automatic backgrounding before the tool becomes terminal", () => { + const { update } = mapper(); + expect(update(shell, { toolCallStatus: "inProgress" })[0]?.type).toBe("task.started"); + expect(update(monitor, { toolCallStatus: "inProgress" })).toEqual([]); + expect(update(monitor, { toolCallStatus: "failed" })).toEqual([]); + }); + + it.each([ + ["running", null, "task.progress", undefined], + ["pending", null, "task.progress", undefined], + ["completed", 0, "task.completed", "completed"], + ["success", 0, "task.completed", "completed"], + ["succeeded", 0, "task.completed", "completed"], + ["failed", 1, "task.completed", "failed"], + ["error", 1, "task.completed", "failed"], + ["stopped", 137, "task.completed", "stopped"], + ["killed", 137, "task.completed", "stopped"], + ["cancelled", 137, "task.completed", "stopped"], + [undefined, 0, "task.completed", "completed"], + [undefined, 1, "task.completed", "failed"], + ])("maps poll status %s / exit %s", (status, exit_code, type, expectedStatus) => { + const { tasks, update } = mapper(); + update(monitor); + const events = update({ + type: "TaskOutput", + Result: { + task_id: "monitor-1", + command: "[monitor:Watch]", + status, + exit_code, + output: "\n result\nmore", + }, + }); + expect(events).toEqual([ + { + type, + turnId, + payload: { + taskId: "monitor-1", + taskType: "monitor", + description: "Watch", + title: "Watch", + toolUseId: "call-1", + summary: "result", + ...(expectedStatus ? { status: expectedStatus } : {}), + }, + }, + ]); + expect(tasks.size).toBe(type === "task.progress" ? 1 : 0); + }); + + it.each([undefined, TurnId.make("turn-2")])( + "does not attribute old tasks to a later turn: %s", + (laterTurnId) => { + const { tasks, update } = mapper(); + update(shell); + const events = update( + { + type: "TaskOutput", + Result: { task_id: "shell-1", command: "sleep 40", status: "completed" }, + }, + { turnId: laterTurnId }, + ); + expect(events).toHaveLength(1); + expect(events[0]?.turnId).toBeUndefined(); + expect(tasks.size).toBe(0); + }, + ); + + it("starts unknown poll tasks before progress or completion and ignores subagents", () => { + const { tasks, update } = mapper(); + const events = update({ + type: "TaskOutput", + MultiResult: { + results: [ + { task_id: "shell-1", command: "sleep 40", status: "running" }, + { task_id: "monitor-1", command: "[monitor] Watch", status: "completed" }, + { task_id: "agent-1", command: "[subagent:executor] work", status: "running" }, + ], + }, + }); + expect(events.map(({ type }) => type)).toEqual([ + "task.started", + "task.progress", + "task.started", + "task.completed", + ]); + expect(events.map(({ payload }) => payload.taskType)).toEqual([ + "shell", + "shell", + "monitor", + "monitor", + ]); + expect([...tasks.keys()]).toEqual(["shell-1"]); + expect(events.map((event) => event.turnId)).toEqual([ + undefined, + undefined, + undefined, + undefined, + ]); + }); + + it("retires only successfully killed tasks, including mixed results", () => { + const { tasks, update } = mapper(); + update(shell); + update(monitor); + const result = { type: "KillTask", Result: { task_id: "shell-1", outcome: "killed" } }; + expect(update(result, { toolCallStatus: "failed" })).toEqual([]); + expect(tasks.size).toBe(2); + const events = update({ + type: "KillTask", + MultiResult: { + results: [ + result.Result, + { task_id: "monitor-1", outcome: "error" }, + { task_id: "unknown", outcome: "killed" }, + ], + }, + }); + expect(events).toEqual([ + { + type: "task.completed", + turnId, + payload: { + taskId: "shell-1", + taskType: "shell", + description: "sleep 40", + title: "sleep 40", + toolUseId: "call-1", + status: "stopped", + }, + }, + ]); + expect([...tasks.keys()]).toEqual(["monitor-1"]); + }); + + it("ignores repeated terminal polls and gives a reused provider id a new runtime identity", () => { + const { tasks, completedTaskIds, update } = mapper(); + update(shell); + const terminal = { + type: "TaskOutput", + Result: { task_id: "shell-1", command: "sleep 40", status: "completed" }, + }; + expect(update(terminal).map((event) => event.type)).toEqual(["task.completed"]); + expect(update(terminal)).toEqual([]); + expect(completedTaskIds.get("shell-1")).toBe("call-1"); + expect(update(shell, { toolCallId: "call-2", turnId: TurnId.make("turn-2") })).toMatchObject([ + { type: "task.started", payload: { taskId: "shell-1#call-2", toolUseId: "call-2" } }, + ]); + expect(tasks.get("shell-1")?.payload.taskId).toBe("shell-1#call-2"); + }); + + it("remembers terminal identities for the session even after many other completions", () => { + const { completedTaskIds, update } = mapper(); + const completed = (id: string) => ({ + type: "TaskOutput", + Result: { task_id: id, command: "echo done", status: "completed" }, + }); + update(completed("old-task")); + for (let index = 0; index < 513; index += 1) { + update(completed(`task-${index}`)); + } + expect(completedTaskIds.size).toBe(514); + expect(update(completed("old-task"))).toEqual([]); + }); + + it("fails closed when two live tool calls claim the same provider task id", () => { + const { tasks, ambiguousTaskIds, update } = mapper(); + update(shell); + expect(update(shell, { toolCallId: "call-2" })).toMatchObject([ + { type: "task.completed", payload: { taskId: "shell-1", status: "stopped" } }, + ]); + expect(ambiguousTaskIds.has("shell-1")).toBe(true); + expect(tasks.has("shell-1")).toBe(false); + expect( + update({ + type: "TaskOutput", + Result: { task_id: "shell-1", command: "sleep 40", status: "completed" }, + }), + ).toEqual([]); + }); + + it("bounds long descriptions and output lines before projecting them", () => { + const { update } = mapper(); + const [started] = update(monitor, { + rawInput: { description: "a".repeat(2_000) }, + }); + expect(started?.type).toBe("task.started"); + if (started?.type !== "task.started") throw new Error("expected task.started"); + expect(started?.payload.description).toHaveLength(200); + const [progress] = update({ + type: "TaskOutput", + Result: { + task_id: "monitor-1", + command: "[monitor] Watch", + status: "running", + output: "b".repeat(2_000), + }, + }); + expect(progress?.type).toBe("task.progress"); + if (progress?.type !== "task.progress") throw new Error("expected task.progress"); + expect(progress?.payload.summary).toHaveLength(512); + }); + + it.each([ + null, + [], + {}, + { type: "Text", text: "subagent_id: fake\ntype: executor\ndescription: fake" }, + { type: "Monitor", taskId: " " }, + { type: "BackgroundTaskStarted", task_id: "shell-1" }, + { + type: "TaskOutput", + MultiResult: { + results: [null, {}, { task_id: "task", command: "sleep 40", exit_code: Infinity }], + }, + }, + ])("ignores malformed or unrelated outputs: %j", (output) => { + const { tasks, update } = mapper(); + expect(update(output)).toEqual([]); + expect(tasks.size).toBe(0); + }); +}); diff --git a/apps/server/src/provider/acp/XAiBackgroundTasks.ts b/apps/server/src/provider/acp/XAiBackgroundTasks.ts new file mode 100644 index 0000000000..85dbd5b767 --- /dev/null +++ b/apps/server/src/provider/acp/XAiBackgroundTasks.ts @@ -0,0 +1,183 @@ +import { + RuntimeTaskId, + type ProviderRuntimeTaskStartedEvent, + type ProviderRuntimeTaskProgressEvent, + type ProviderRuntimeTaskCompletedEvent, + type TurnId, +} from "@t3tools/contracts"; + +type TaskEvent = + | Pick + | Pick + | Pick; + +export interface GrokBackgroundTaskRecord { + readonly rawTaskId: string; + readonly payload: { + readonly taskId: RuntimeTaskId; + readonly taskType: "monitor" | "shell"; + readonly description: string; + readonly title: string; + readonly toolUseId?: string; + }; + readonly turnId: TurnId | undefined; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function text(value: unknown): string | undefined { + return typeof value === "string" ? value.trim() || undefined : undefined; +} + +function lifecycle(status: unknown, exitCode: unknown) { + switch (text(status)?.toLowerCase()) { + case "pending": + case "running": + return "running"; + case "completed": + case "success": + case "succeeded": + return "completed"; + case "failed": + case "error": + return "failed"; + case "stopped": + case "killed": + case "cancelled": + return "stopped"; + default: + return typeof exitCode === "number" && Number.isFinite(exitCode) + ? exitCode === 0 + ? "completed" + : "failed" + : undefined; + } +} + +/** Map Grok's discriminated tool results, including notifications after the turn ends. */ +export function buildGrokBackgroundTaskEvents(input: { + readonly tasks: Map; + readonly completedTaskIds: Map; + readonly ambiguousTaskIds: Set; + readonly toolCallId: string; + readonly rawInput: unknown; + readonly rawOutput: unknown; + readonly toolCallStatus: string | undefined; + readonly turnId?: TurnId | undefined; +}): TaskEvent[] { + const { tasks, completedTaskIds, ambiguousTaskIds, toolCallId, toolCallStatus, turnId } = input; + const output = record(input.rawOutput); + const events: TaskEvent[] = []; + if ( + toolCallStatus !== "completed" && + toolCallStatus !== "failed" && + output.type !== "BackgroundTaskStarted" + ) { + return events; + } + const attribution = (task: GrokBackgroundTaskRecord) => + task.turnId !== undefined && task.turnId === turnId ? { turnId } : {}; + const start = ( + id: string, + taskType: "monitor" | "shell", + description: string, + toolUseId?: string, + ) => { + if (ambiguousTaskIds.has(id)) return undefined; + const known = tasks.get(id); + if (known) { + if (!toolUseId || known.payload.toolUseId === toolUseId) return known; + // Polls identify only the provider ID. Once two live tool calls reuse it, + // their later results cannot be attributed safely to either activation. + tasks.delete(id); + completedTaskIds.set(id, known.payload.toolUseId); + ambiguousTaskIds.add(id); + events.push({ + type: "task.completed", + payload: { ...known.payload, status: "stopped" }, + ...attribution(known), + }); + return undefined; + } + const previousToolUseId = completedTaskIds.get(id); + if (completedTaskIds.has(id) && (!toolUseId || previousToolUseId === toolUseId)) { + return undefined; + } + const task: GrokBackgroundTaskRecord = { + rawTaskId: id, + payload: { + taskId: RuntimeTaskId.make(completedTaskIds.has(id) ? `${id}#${toolUseId}` : id), + taskType, + description: description.slice(0, 200), + title: description.slice(0, 200), + ...(toolUseId ? { toolUseId } : {}), + }, + // Polls can rediscover older tasks without establishing their originating turn. + turnId: toolUseId ? turnId : undefined, + }; + completedTaskIds.delete(id); + tasks.set(id, task); + events.push({ type: "task.started", payload: task.payload, ...attribution(task) }); + return task; + }; + const complete = ( + task: GrokBackgroundTaskRecord, + status: "completed" | "failed" | "stopped", + summary?: string, + ) => { + tasks.delete(task.rawTaskId); + completedTaskIds.delete(task.rawTaskId); + completedTaskIds.set(task.rawTaskId, task.payload.toolUseId); + events.push({ + type: "task.completed", + payload: { ...task.payload, status, ...(summary ? { summary } : {}) }, + ...attribution(task), + }); + }; + + if (output.type === "Monitor" && toolCallStatus === "completed") { + const id = text(output.taskId); + if (id) start(id, "monitor", text(record(input.rawInput).description) ?? "Monitor", toolCallId); + } else if (output.type === "BackgroundTaskStarted") { + const id = text(output.task_id) ?? text(output.taskId); + const command = text(output.command); + if (id && command) start(id, "shell", command.split("\n")[0]!.slice(0, 200), toolCallId); + } else if (output.type === "TaskOutput" || output.type === "KillTask") { + const results = record(output.MultiResult).results; + for (const value of Array.isArray(results) ? results : [output.Result]) { + const result = record(value); + const id = text(result.task_id); + if (!id) continue; + if (output.type === "KillTask") { + const task = tasks.get(id); + if (task && toolCallStatus === "completed" && result.outcome === "killed") + complete(task, "stopped"); + continue; + } + const command = text(result.command); + const status = lifecycle(result.status, result.exit_code); + if (!command || !status || command.startsWith("[subagent:")) continue; + const task = start(id, /^\[monitor[:\]]/.test(command) ? "monitor" : "shell", command); + if (!task) continue; + const summary = text(result.output) + ?.split("\n") + .find((line) => line.trim()) + ?.trim() + .slice(0, 512); + if (status === "running") { + events.push({ + type: "task.progress", + payload: { ...task.payload, ...(summary ? { summary } : {}) }, + ...attribution(task), + }); + } else { + complete(task, status, summary); + } + } + } + return events; +} diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 395b1cef21..88a01477e6 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-24T07:48:28Z", "currentModels": { "codex": [ "gpt-6-astra", @@ -8,7 +9,7 @@ "gpt-daybreak-blue-latest", "gpt-daybreak-red-latest" ], - "claudeAgent": ["claude-fable-5-1", "claude-opus-5", "claude-sonnet-5"], + "claudeAgent": ["claude-fable-5-1", "claude-opus-5-5", "claude-opus-5", "claude-sonnet-5"], "antigravity": ["gemini-3.8-flash-high", "gemini-3.8-flash-medium", "gemini-3.8-flash-low"] }, "providers": { diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index 35a7791c62..c66a2f36a2 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -318,4 +318,18 @@ describe("toOpenCodeFileParts", () => { ["application/pdf", "text/markdown", "image/png"], ); }); + + it("keeps folded clipboard text on the lazy path fallback", () => { + const parts = toOpenCodeFileParts({ + attachments: [ + { + ...attachment("text/plain"), + source: { _tag: "pasted-text" as const }, + }, + ], + resolveAttachmentPath: () => "/tmp/pasted-text.txt", + }); + + NodeAssert.deepEqual(parts, []); + }); }); diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index ae23bccc10..4bb43cf2a2 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -491,6 +491,13 @@ export function toOpenCodeFileParts(input: { const parts: Array = []; for (const attachment of input.attachments ?? []) { + if ( + attachment.type === "file" && + "source" in attachment && + attachment.source?._tag === "pasted-text" + ) { + continue; + } if (!isOpenCodeNativeFilePart(attachment)) { continue; } diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonAdapter.test.ts b/apps/server/src/provider/prime/PrimeAgentDaemonAdapter.test.ts index ca3c2ae7d6..15db0b597b 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonAdapter.test.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonAdapter.test.ts @@ -6661,6 +6661,39 @@ describe("PrimeAgentDaemonAdapter", () => { }, }); } + yield* offer(captures, { + _tag: "ChildUpdated", + child: { + id: "child-private-progress", + parentId: "parent", + activeSessionId: "native-private-100", + model: "child-model", + label: "private-safe-label", + status: "running", + tokenCount: 10, + toolUseCount: 1, + activity: { kind: "executing", toolName: "bash" }, + progressNote: "Checking the failing test", + lastActivityAt: 1_000, + }, + }); + yield* awaitObservedType(subscription.observed, "task.progress"); + yield* offer(captures, { + _tag: "ChildUpdated", + child: { + id: "child-private-progress", + parentId: "parent", + activeSessionId: "native-private-100", + model: "child-model", + label: "private-safe-label", + status: "running", + tokenCount: 10, + toolUseCount: 1, + activity: { kind: "executing", toolName: "bash" }, + progressNote: "Checking the failing test", + lastActivityAt: 2_000, + }, + }); captures.agentRoster = [ { id: "child-private-progress", @@ -6676,6 +6709,7 @@ describe("PrimeAgentDaemonAdapter", () => { activity: { kind: "executing", toolName: "bash" }, answerPreview: "private-answer-roster", recap: "private-recap-roster", + progressNote: "Checking the failing test", }, ]; expect( @@ -6718,17 +6752,21 @@ describe("PrimeAgentDaemonAdapter", () => { yield* awaitObservedType(subscription.observed, "session.goal.updated"); const durableEvents = subscription.events.slice(durableStart); - expect(durableEvents.filter((event) => event.type === "task.progress")).toEqual([]); + expect(durableEvents.filter((event) => event.type === "task.progress")).toEqual([ + expect.objectContaining({ + payload: expect.objectContaining({ summary: "Checking the failing test" }), + }), + ]); const completed = durableEvents.filter((event) => event.type === "task.completed"); expect(completed).toHaveLength(1); expect(completed[0]).toMatchObject({ payload: { taskId: "child-private-progress", status: "failed", + summary: "Failed", typedUsage: { totalTokens: 12, toolUses: 1, durationMs: 1_500 }, }, }); - expect(completed[0]?.payload).not.toHaveProperty("summary"); const persisted = encodeUnknownJson(durableEvents); expect(persisted).not.toContain("private-answer"); expect(persisted).not.toContain("private-recap"); diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonAdapter.ts b/apps/server/src/provider/prime/PrimeAgentDaemonAdapter.ts index 18a0fb462b..3ecc76033b 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonAdapter.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonAdapter.ts @@ -531,6 +531,7 @@ type PrimeAgentDaemonChildDurableProjection = { readonly messageable: boolean; readonly waiting: boolean; readonly lastToolName: string | undefined; + readonly progressNote: string | undefined; readonly tokenCount: number | undefined; readonly toolUseCount: number | undefined; readonly terminalDurationMs: number | undefined; @@ -560,6 +561,7 @@ function durableChildProjection( child.status === "running" && child.activity?.kind !== "waiting" ? durableChildText(child.activity?.toolName) : undefined, + progressNote: active ? durableChildText(child.progressNote) : undefined, tokenCount: durableChildCount(child.tokenCount), toolUseCount: durableChildCount(child.toolUseCount), terminalDurationMs: active ? undefined : durableChildCount(child.durationMs), @@ -581,6 +583,7 @@ function durableChildChanged( left.messageable !== right.messageable || left.waiting !== right.waiting || left.lastToolName !== right.lastToolName || + left.progressNote !== right.progressNote || left.tokenCount !== right.tokenCount || left.toolUseCount !== right.toolUseCount || left.terminalDurationMs !== right.terminalDurationMs diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonEvents.test.ts b/apps/server/src/provider/prime/PrimeAgentDaemonEvents.test.ts index 1a7e865f87..7829ea5431 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonEvents.test.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonEvents.test.ts @@ -819,6 +819,26 @@ describe("PrimeAgentDaemonEvents", () => { _tag: "ChildUpdated", child: { id: "child-1", status: "running", activity: { toolName: "ipython" } }, }); + expect( + decodePrimeAgentDaemonEvent( + sessionEvent({ + type: "rlm_child_update", + child: { + ...child, + progressNote: "Checking tests", + lastActivityAt: 1_700_000_000_000, + activityStaleMs: 600_000, + }, + }), + ), + ).toMatchObject({ + _tag: "ChildUpdated", + child: { + progressNote: "Checking tests", + lastActivityAt: 1_700_000_000_000, + activityStaleMs: 600_000, + }, + }); const queueChanged = decodePrimeAgentDaemonEvent( sessionEvent({ type: "session_action_update", actions }), ); diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonEvents.ts b/apps/server/src/provider/prime/PrimeAgentDaemonEvents.ts index de46966fe7..335895f01d 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonEvents.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonEvents.ts @@ -387,6 +387,9 @@ const rlmChild = Schema.Struct({ toolUseCount: Schema.optional(Schema.Number), tokenCount: Schema.optional(Schema.Number), recap: Schema.optional(Schema.String), + progressNote: Schema.optional(Schema.String), + lastActivityAt: Schema.optional(Schema.Finite), + activityStaleMs: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), sessionDir: Schema.String, activity: Schema.optional( Schema.Struct({ @@ -1309,6 +1312,9 @@ export type PrimeDaemonEvent = ( readonly toolUseCount?: number | undefined; readonly tokenCount?: number | undefined; readonly recap?: string | undefined; + readonly progressNote?: string | undefined; + readonly lastActivityAt?: number | undefined; + readonly activityStaleMs?: number | undefined; readonly activity?: | { readonly kind: "waiting" | "writing" | "executing"; @@ -1656,6 +1662,9 @@ function mapChild( toolUseCount: value.toolUseCount, tokenCount: value.tokenCount, recap: optionalBounded(value.recap, MAX_PREVIEW_LENGTH), + progressNote: optionalBounded(value.progressNote, 512), + lastActivityAt: value.lastActivityAt, + activityStaleMs: value.activityStaleMs, activity: value.activity, error: optionalBounded(value.error, MAX_PREVIEW_LENGTH), }; diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonManager.test.ts b/apps/server/src/provider/prime/PrimeAgentDaemonManager.test.ts index aecfc50823..ee2f0c00d7 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonManager.test.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonManager.test.ts @@ -944,7 +944,9 @@ describe("PrimeAgentDaemonManager lifecycle", () => { shutdownRejects: true, shutdownDoesNotExit: true, postKillExitHangs: true, - shutdownTimeout: Duration.millis(5), + // This timeout also covers real private-socket cleanup before the exit wait. + // Leave room for filesystem scheduling under parallel CI load. + shutdownTimeout: Duration.millis(250), }); return closeFixtureLive(fixture).pipe( Effect.andThen( diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonRuntimeEvents.test.ts b/apps/server/src/provider/prime/PrimeAgentDaemonRuntimeEvents.test.ts index a4f078db40..4b42987701 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonRuntimeEvents.test.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonRuntimeEvents.test.ts @@ -660,6 +660,9 @@ describe("mapPrimeAgentDaemonRuntimeEventDrafts", () => { tokenCount: 90, answerPreview: "private live answer", recap: "private recap", + progressNote: "Checking the failing test", + lastActivityAt: 1_700_000_000_000, + activityStaleMs: 600_000, error: "private transient error", activity: { kind: "executing", toolName: "bash" }, }, @@ -676,6 +679,7 @@ describe("mapPrimeAgentDaemonRuntimeEventDrafts", () => { taskId: RuntimeTaskId.make("child-1"), description: "Review tests", status: "running", + summary: "Checking the failing test", typedUsage: { totalTokens: 90, toolUses: 4, durationMs: 250 }, lastToolName: "bash", taskType: "subagent", @@ -693,6 +697,8 @@ describe("mapPrimeAgentDaemonRuntimeEventDrafts", () => { expect(JSON.stringify(running)).not.toContain("private live answer"); expect(JSON.stringify(running)).not.toContain("private recap"); expect(JSON.stringify(running)).not.toContain("private transient error"); + expect(JSON.stringify(running)).not.toContain("activityStaleMs"); + expect(JSON.stringify(running)).not.toContain("lastActivityAt"); const terminal = mapPrimeAgentDaemonRuntimeEventDrafts({ ...context, @@ -716,12 +722,12 @@ describe("mapPrimeAgentDaemonRuntimeEventDrafts", () => { payload: { taskId: RuntimeTaskId.make("child-1"), status: "failed", + summary: "Failed", typedUsage: { totalTokens: 100 }, messageable: false, timelineBypass: true, }, }); - expect(terminal[0]?.payload).not.toHaveProperty("summary"); expect(JSON.stringify(terminal)).not.toContain("private terminal"); }); diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonRuntimeEvents.ts b/apps/server/src/provider/prime/PrimeAgentDaemonRuntimeEvents.ts index b2912965b9..566f6f90c3 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonRuntimeEvents.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonRuntimeEvents.ts @@ -307,7 +307,8 @@ function childDraft( }; case "running": { const lastToolName = boundedNonEmpty(child.activity?.toolName, MAX_SCALAR_LENGTH); - if (child.activity?.kind === "waiting") { + const progressNote = boundedNonEmpty(child.progressNote?.trim(), 512); + if (child.activity?.kind === "waiting" && progressNote === undefined) { return { ...runtimeBase(input), type: "task.updated", @@ -320,7 +321,8 @@ function childDraft( payload: { taskId, description, - status: "running", + status: child.activity?.kind === "waiting" ? "waiting" : "running", + ...(progressNote === undefined ? {} : { summary: progressNote }), ...(typedUsage === undefined ? {} : { typedUsage }), ...(lastToolName === undefined ? {} : { lastToolName }), ...linkage, @@ -337,6 +339,8 @@ function childDraft( taskId, status: child.status === "done" ? "completed" : child.status === "error" ? "failed" : "stopped", + summary: + child.status === "done" ? "Completed" : child.status === "error" ? "Failed" : "Stopped", ...(typedUsage === undefined ? {} : { typedUsage }), ...linkage, }, diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonSessionRuntime.test.ts b/apps/server/src/provider/prime/PrimeAgentDaemonSessionRuntime.test.ts index c28abbde45..022be73c24 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonSessionRuntime.test.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonSessionRuntime.test.ts @@ -12668,6 +12668,111 @@ describe("PrimeAgentDaemonSessionRuntime", () => { }), ); + it.effect("rejects a private leaf response from before daemon reconnect", () => + Effect.scoped( + Effect.gen(function* () { + let releaseOldState!: (value: unknown) => void; + let observeOldRequest!: () => void; + let currentLeaf = "leaf-source-private"; + let firstRequest = true; + const oldRequestStarted = new Promise((resolve) => { + observeOldRequest = resolve; + }); + const test = fixture({ + rawSnapshot: { + ...snapshot(), + state: { ...snapshot().state, leafId: "leaf-source-private" }, + }, + getStateImpl: () => { + if (firstRequest) { + firstRequest = false; + observeOldRequest(); + return new Promise((resolve) => { + releaseOldState = resolve; + }); + } + return Promise.resolve({ + sessionId: "session-1", + activeSessionId: "active-secret-1", + leafId: currentLeaf, + }); + }, + }); + const runtime = yield* test.make(); + const inspection = yield* runtime.inspectConversationLeaf.pipe( + Effect.result, + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.promise(() => oldRequestStarted); + yield* Effect.promise(() => + test.emit({ type: "connection_status", status: "reconnecting" }), + ); + yield* Effect.promise(() => test.emit({ type: "session_resynced", snapshot: snapshot(9) })); + expect(runtime.resolveReconnectSnapshot(1, true)).toBe(true); + yield* Effect.promise(() => test.emit({ type: "connection_status", status: "connected" })); + releaseOldState({ + sessionId: "session-1", + activeSessionId: "active-secret-1", + leafId: "leaf-target-private", + }); + const result = yield* Fiber.join(inspection); + expect(result._tag).toBe("Failure"); + expect(yield* runtime.inspectConversationLeaf).toBe("leaf-source-private"); + currentLeaf = "leaf-third-private"; + expect(yield* runtime.inspectConversationLeaf).toBe("leaf-third-private"); + }), + ), + ); + + it.effect("rejects a private navigation response from before daemon reconnect", () => + Effect.scoped( + Effect.gen(function* () { + let releaseOldNavigation!: (value: unknown) => void; + let observeOldNavigation!: () => void; + let currentLeaf = "leaf-source-private"; + const oldNavigationStarted = new Promise((resolve) => { + observeOldNavigation = resolve; + }); + const test = fixture({ + rawSnapshot: { + ...snapshot(), + state: { ...snapshot().state, leafId: currentLeaf }, + }, + getStateImpl: () => + Promise.resolve({ + sessionId: "session-1", + activeSessionId: "active-secret-1", + leafId: currentLeaf, + }), + navigateTreeImpl: () => { + observeOldNavigation(); + return new Promise((resolve) => { + releaseOldNavigation = resolve; + }); + }, + }); + const runtime = yield* test.make(); + const navigation = yield* runtime + .navigateConversationLeaf({ + desiredLeafId: "leaf-target-private", + allowedSourceLeafId: "leaf-source-private", + }) + .pipe(Effect.result, Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => oldNavigationStarted); + yield* Effect.promise(() => + test.emit({ type: "connection_status", status: "reconnecting" }), + ); + yield* Effect.promise(() => test.emit({ type: "session_resynced", snapshot: snapshot(9) })); + expect(runtime.resolveReconnectSnapshot(1, true)).toBe(true); + yield* Effect.promise(() => test.emit({ type: "connection_status", status: "connected" })); + currentLeaf = "leaf-third-private"; + releaseOldNavigation({ cancelled: false }); + expect((yield* Fiber.join(navigation))._tag).toBe("Failure"); + expect(yield* runtime.inspectConversationLeaf).toBe("leaf-third-private"); + }), + ), + ); + it.effect("rejects a third leaf and keeps response-loss target proof inspectable", () => Effect.gen(function* () { let leafId = "leaf-source-private"; diff --git a/apps/server/src/provider/prime/PrimeAgentDaemonSessionRuntime.ts b/apps/server/src/provider/prime/PrimeAgentDaemonSessionRuntime.ts index 4dc5f1cce0..fb01b7f2ec 100644 --- a/apps/server/src/provider/prime/PrimeAgentDaemonSessionRuntime.ts +++ b/apps/server/src/provider/prime/PrimeAgentDaemonSessionRuntime.ts @@ -5605,6 +5605,11 @@ export const makePrimeAgentDaemonSessionRuntime = Effect.fn("makePrimeAgentDaemo Predicate.isFunction(connection!.getState) && Predicate.isFunction(connection!.navigateTree); + const conversationRequestIsCurrent = (generation: number) => + generation === connectionGeneration && + (reconnectResolution === undefined || reconnectResolution.settled) && + client.hello?.supervisorGeneration?.trim() === conversationRuntimeGeneration; + const readConversationLeaf = Effect.fn( "PrimeAgentDaemonSessionRuntime.inspectConversationLeaf", )(function* () { @@ -5617,6 +5622,14 @@ export const makePrimeAgentDaemonSessionRuntime = Effect.fn("makePrimeAgentDaemo ); } const getState = yield* requireMethod("inspect-conversation-leaf", connection!.getState); + const requestGeneration = connectionGeneration; + if (!conversationRequestIsCurrent(requestGeneration)) { + return yield* runtimeError( + "inspect-conversation-leaf", + "request-failed", + "Prime Agent conversation connection changed before inspection.", + ); + } const output = yield* Effect.tryPromise({ try: () => getState.call(connection), catch: () => @@ -5636,6 +5649,13 @@ export const makePrimeAgentDaemonSessionRuntime = Effect.fn("makePrimeAgentDaemo ), }), ); + if (!conversationRequestIsCurrent(requestGeneration)) { + return yield* runtimeError( + "inspect-conversation-leaf", + "request-failed", + "Prime Agent conversation connection changed during inspection.", + ); + } const decoded = decodePrivateConversationLeafState(output); if ( Option.isNone(decoded) || @@ -5705,6 +5725,14 @@ export const makePrimeAgentDaemonSessionRuntime = Effect.fn("makePrimeAgentDaemo "navigate-conversation-leaf", connection!.navigateTree, ); + const requestGeneration = connectionGeneration; + if (!conversationRequestIsCurrent(requestGeneration)) { + return yield* runtimeError( + "navigate-conversation-leaf", + "request-failed", + "Prime Agent conversation connection changed before navigation.", + ); + } const output = yield* Effect.tryPromise({ try: () => navigate.call(connection, input.desiredLeafId, { summarize: false }), catch: () => @@ -5724,6 +5752,13 @@ export const makePrimeAgentDaemonSessionRuntime = Effect.fn("makePrimeAgentDaemo ), }), ); + if (!conversationRequestIsCurrent(requestGeneration)) { + return yield* runtimeError( + "navigate-conversation-leaf", + "request-failed", + "Prime Agent conversation connection changed during navigation.", + ); + } const result = decodePrivateConversationNavigationResult(output); if (Option.isNone(result) || result.value.cancelled || result.value.aborted === true) { return yield* runtimeError( diff --git a/apps/server/src/provider/providerUsageRetention.test.ts b/apps/server/src/provider/providerUsageRetention.test.ts index 1a43b35335..e82dfbdd7e 100644 --- a/apps/server/src/provider/providerUsageRetention.test.ts +++ b/apps/server/src/provider/providerUsageRetention.test.ts @@ -5,10 +5,14 @@ import * as Ref from "effect/Ref"; import * as TestClock from "effect/testing/TestClock"; import { + authenticatedUsageIdentity, isRetainedUsageFresh, + matchingAccountUsage, retainSnapshotUsageLimits, - retainUsageLimits, + retainUsageLimitsForAccount, preferFreshUsageReading, + usageReadingForAuth, + type AccountUsageReading, } from "./providerUsageRetention.ts"; const NOW = Date.parse("2026-08-06T12:00:00.000Z"); @@ -71,17 +75,67 @@ describe("isRetainedUsageFresh", () => { }); }); -describe("retainUsageLimits", () => { +const ACCOUNT_A = "email:a@example.com"; +const ACCOUNT_B = "email:b@example.com"; + +it("binds an authenticated Codex reading to both account id and live email", () => { + assert.strictEqual( + authenticatedUsageIdentity({ + status: "authenticated", + accountId: "account-1", + email: "A@Example.com", + }), + "account:account-1|email:a@example.com", + ); + assert.isUndefined(authenticatedUsageIdentity({ status: "unknown", email: "a@example.com" })); +}); + +it("does not overlay a direct reset reading onto another or unverified account", () => { + const direct = { identity: ACCOUNT_A, usageLimits: usage("2026-08-06T11:59:00.000Z") }; + assert.strictEqual( + usageReadingForAuth(direct, { status: "authenticated", email: "a@example.com" }), + direct.usageLimits, + ); + assert.isUndefined( + usageReadingForAuth(direct, { status: "authenticated", email: "b@example.com" }), + ); + assert.isUndefined(usageReadingForAuth(direct, { status: "unknown" })); +}); + +it("accepts a Claude OAuth read only when its account probe agrees with the current account", () => { + const limits = usage("2026-08-06T11:59:00.000Z"); + assert.strictEqual( + matchingAccountUsage("B@Example.com", { + accountIdentity: "b@example.com", + usageLimits: limits, + }), + limits, + ); + assert.isUndefined( + matchingAccountUsage("b@example.com", { + accountIdentity: "a@example.com", + usageLimits: limits, + }), + ); + assert.isUndefined( + matchingAccountUsage("b@example.com", { accountIdentity: undefined, usageLimits: limits }), + ); +}); + +describe("retainUsageLimitsForAccount", () => { it.effect("remembers a successful reading", () => Effect.gen(function* () { yield* TestClock.setTime(NOW); - const lastKnown = yield* Ref.make(undefined); + const lastKnown = yield* Ref.make(undefined); const fresh = usage("2026-08-06T11:59:00.000Z"); - const result = yield* retainUsageLimits(lastKnown, fresh); + const result = yield* retainUsageLimitsForAccount(lastKnown, ACCOUNT_A, fresh); assert.strictEqual(result, fresh); - assert.strictEqual(yield* Ref.get(lastKnown), fresh); + assert.deepStrictEqual(yield* Ref.get(lastKnown), { + identity: ACCOUNT_A, + usageLimits: fresh, + }); }), ); @@ -90,9 +144,12 @@ describe("retainUsageLimits", () => { Effect.gen(function* () { yield* TestClock.setTime(NOW); const previous = usage("2026-08-06T11:59:00.000Z"); - const lastKnown = yield* Ref.make(previous); + const lastKnown = yield* Ref.make({ + identity: ACCOUNT_A, + usageLimits: previous, + }); - const result = yield* retainUsageLimits(lastKnown, undefined); + const result = yield* retainUsageLimitsForAccount(lastKnown, ACCOUNT_A, undefined); assert.strictEqual(result, previous); }), @@ -102,9 +159,12 @@ describe("retainUsageLimits", () => { Effect.gen(function* () { yield* TestClock.setTime(NOW); const ancient = usage("2020-01-01T00:00:00.000Z"); - const lastKnown = yield* Ref.make(ancient); + const lastKnown = yield* Ref.make({ + identity: ACCOUNT_A, + usageLimits: ancient, + }); - const result = yield* retainUsageLimits(lastKnown, undefined); + const result = yield* retainUsageLimitsForAccount(lastKnown, ACCOUNT_A, undefined); assert.isUndefined(result); // Cleared as well, so it cannot resurface later. @@ -114,9 +174,53 @@ describe("retainUsageLimits", () => { it.effect("has nothing to show before any reading succeeds", () => Effect.gen(function* () { - const lastKnown = yield* Ref.make(undefined); + const lastKnown = yield* Ref.make(undefined); + + assert.isUndefined(yield* retainUsageLimitsForAccount(lastKnown, ACCOUNT_A, undefined)); + }), + ); + + it.effect("clears account A after an authenticated switch to B with a failed read", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const previous = usage("2026-08-06T11:59:00.000Z"); + const lastKnown = yield* Ref.make({ + identity: ACCOUNT_A, + usageLimits: previous, + }); + assert.isUndefined(yield* retainUsageLimitsForAccount(lastKnown, ACCOUNT_B, undefined)); + assert.isUndefined(yield* Ref.get(lastKnown)); + assert.isUndefined(yield* retainUsageLimitsForAccount(lastKnown, ACCOUNT_A, undefined)); + }), + ); + + it.effect("does not retain an unidentified account's reading", () => + Effect.gen(function* () { + const lastKnown = yield* Ref.make(undefined); + const fresh = usage("2026-08-06T11:59:00.000Z"); + assert.strictEqual(yield* retainUsageLimitsForAccount(lastKnown, undefined, fresh), fresh); + assert.isUndefined(yield* Ref.get(lastKnown)); + }), + ); - assert.isUndefined(yield* retainUsageLimits(lastKnown, undefined)); + it.effect("hides an A reading during unknown auth, then reuses it only for verified A", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const previous = usage("2026-08-06T11:59:00.000Z"); + const lastKnown = yield* Ref.make({ + identity: ACCOUNT_A, + usageLimits: previous, + }); + assert.isUndefined(yield* retainUsageLimitsForAccount(lastKnown, undefined, undefined)); + assert.deepStrictEqual(yield* Ref.get(lastKnown), { + identity: ACCOUNT_A, + usageLimits: previous, + }); + assert.strictEqual( + yield* retainUsageLimitsForAccount(lastKnown, ACCOUNT_A, undefined), + previous, + ); + assert.isUndefined(yield* retainUsageLimitsForAccount(lastKnown, ACCOUNT_B, undefined)); }), ); }); @@ -124,9 +228,10 @@ describe("retainUsageLimits", () => { describe("retainSnapshotUsageLimits", () => { const snapshot = (input: { readonly auth: "authenticated" | "unauthenticated" | "unknown"; + readonly email?: string; readonly usageLimits?: ServerProviderUsageLimits; }) => ({ - auth: { status: input.auth }, + auth: { status: input.auth, ...(input.email ? { email: input.email } : {}) }, ...(input.usageLimits ? { usageLimits: input.usageLimits } : {}), }); @@ -136,11 +241,14 @@ describe("retainSnapshotUsageLimits", () => { Effect.gen(function* () { yield* TestClock.setTime(NOW); const previous = usage("2026-08-06T11:59:00.000Z"); - const lastKnown = yield* Ref.make(previous); + const lastKnown = yield* Ref.make({ + identity: ACCOUNT_A, + usageLimits: previous, + }); const result = yield* retainSnapshotUsageLimits( lastKnown, - snapshot({ auth: "authenticated" }), + snapshot({ auth: "authenticated", email: "a@example.com" }), ); assert.strictEqual(result.usageLimits, previous); @@ -150,16 +258,20 @@ describe("retainSnapshotUsageLimits", () => { it.effect("returns the probe untouched when it carries its own reading", () => Effect.gen(function* () { yield* TestClock.setTime(NOW); - const lastKnown = yield* Ref.make(undefined); + const lastKnown = yield* Ref.make(undefined); const probed = snapshot({ auth: "authenticated", + email: "a@example.com", usageLimits: usage("2026-08-06T11:59:00.000Z"), }); const result = yield* retainSnapshotUsageLimits(lastKnown, probed); assert.strictEqual(result, probed); - assert.strictEqual(yield* Ref.get(lastKnown), probed.usageLimits); + assert.deepStrictEqual(yield* Ref.get(lastKnown), { + identity: ACCOUNT_A, + usageLimits: probed.usageLimits, + }); }), ); @@ -168,9 +280,10 @@ describe("retainSnapshotUsageLimits", () => { it.effect("clears the reading when the account signs out", () => Effect.gen(function* () { yield* TestClock.setTime(NOW); - const lastKnown = yield* Ref.make( - usage("2026-08-06T11:59:00.000Z"), - ); + const lastKnown = yield* Ref.make({ + identity: ACCOUNT_A, + usageLimits: usage("2026-08-06T11:59:00.000Z"), + }); const result = yield* retainSnapshotUsageLimits( lastKnown, @@ -181,4 +294,20 @@ describe("retainSnapshotUsageLimits", () => { assert.strictEqual(yield* Ref.get(lastKnown), undefined); }), ); + + it.effect("does not attach account A's reading to an authenticated B snapshot", () => + Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const lastKnown = yield* Ref.make({ + identity: ACCOUNT_A, + usageLimits: usage("2026-08-06T11:59:00.000Z"), + }); + const result = yield* retainSnapshotUsageLimits( + lastKnown, + snapshot({ auth: "authenticated", email: "b@example.com" }), + ); + assert.isUndefined(result.usageLimits); + assert.isUndefined(yield* Ref.get(lastKnown)); + }), + ); }); diff --git a/apps/server/src/provider/providerUsageRetention.ts b/apps/server/src/provider/providerUsageRetention.ts index b9e1b5a3c3..5fb3384d2e 100644 --- a/apps/server/src/provider/providerUsageRetention.ts +++ b/apps/server/src/provider/providerUsageRetention.ts @@ -58,32 +58,89 @@ export function isRetainedUsageFresh(input: { * number beats no number when deciding where to send work, and `checkedAt` * travels with it so the client can say how old it is. */ -export function retainUsageLimits( - lastKnown: Ref.Ref, +export interface AccountUsageReading { + readonly identity: string; + readonly usageLimits: ServerProviderUsageLimits; +} + +/** Only stable, affirmative account identity can authorize a retained reading. */ +export function authenticatedUsageIdentity(auth: ServerProviderAuth): string | undefined { + if (auth.status !== "authenticated") return undefined; + // Account id and live CLI email are independent signals. Keeping both + // prevents a changing auth.json from assigning B's CLI read to A's id. + if (auth.accountId && auth.email) { + return `account:${auth.accountId}|email:${auth.email.trim().toLowerCase()}`; + } + if (auth.accountId) return `account:${auth.accountId}`; + if (auth.email) return `email:${auth.email.trim().toLowerCase()}`; + return undefined; +} + +/** A direct post-reset read may overlay only the same authenticated account. */ +export function usageReadingForAuth( + reading: AccountUsageReading | undefined, + auth: ServerProviderAuth, +): ServerProviderUsageLimits | undefined { + const identity = authenticatedUsageIdentity(auth); + return identity && reading?.identity === identity ? reading.usageLimits : undefined; +} + +/** A separate OAuth usage read must agree with the authenticated account. */ +export function matchingAccountUsage( + expectedEmail: string | undefined, + result: + | { + readonly accountIdentity: string | undefined; + readonly usageLimits: ServerProviderUsageLimits | undefined; + } + | undefined, +): ServerProviderUsageLimits | undefined { + if (!result) return undefined; + if ( + expectedEmail && + result.accountIdentity?.trim().toLowerCase() !== expectedEmail.trim().toLowerCase() + ) { + return undefined; + } + return result.usageLimits; +} + +export function retainUsageLimitsForAccount( + lastKnown: Ref.Ref, + identity: string | undefined, usageLimits: ServerProviderUsageLimits | undefined, ): Effect.Effect { return Effect.gen(function* () { if (usageLimits) { - yield* RefModule.set(lastKnown, usageLimits); + if (identity) yield* RefModule.set(lastKnown, { identity, usageLimits }); return usageLimits; } const retained = yield* RefModule.get(lastKnown); if (!retained) return undefined; + // A temporarily unknown auth result cannot authorize publication, but it + // also cannot prove the signed-in account changed. Hold the reading for a + // later matching identity without showing it during the unknown interval. + if (identity === undefined) return undefined; + if (retained.identity !== identity) { + yield* RefModule.set(lastKnown, undefined); + return undefined; + } const nowMs = yield* Effect.clockWith((clock) => clock.currentTimeMillis); - if (isRetainedUsageFresh({ checkedAt: retained.checkedAt, nowMs })) return retained; + if (isRetainedUsageFresh({ checkedAt: retained.usageLimits.checkedAt, nowMs })) { + return retained.usageLimits; + } yield* RefModule.set(lastKnown, undefined); return undefined; }); } /** - * Apply {@link retainUsageLimits} to a whole probed snapshot. + * Apply account-scoped retention to a whole probed snapshot. * - * A signed-out account has no capacity to retain, so its reading is cleared - * rather than carried; anything else keeps the last good reading through a - * failed or timed-out read. Codex reads its windows over the network inside - * the status probe, where one slow answer would otherwise blank the gauge - * until the next poll. + * A signed-out account has no capacity to retain. A failed or timed-out read + * may reuse the last reading only while the authenticated account identity + * remains the same. Codex reads its windows over the network inside the + * status probe, where one slow answer would otherwise blank the gauge. */ export function retainSnapshotUsageLimits< Snapshot extends { @@ -91,7 +148,7 @@ export function retainSnapshotUsageLimits< readonly usageLimits?: ServerProviderUsageLimits | undefined; }, >( - lastKnown: Ref.Ref, + lastKnown: Ref.Ref, snapshot: Snapshot, ): Effect.Effect { return Effect.gen(function* () { @@ -100,7 +157,11 @@ export function retainSnapshotUsageLimits< const { usageLimits: _usageLimits, ...withoutUsage } = snapshot; return withoutUsage as Snapshot; } - const usageLimits = yield* retainUsageLimits(lastKnown, snapshot.usageLimits); + const usageLimits = yield* retainUsageLimitsForAccount( + lastKnown, + authenticatedUsageIdentity(snapshot.auth), + snapshot.usageLimits, + ); if (usageLimits === snapshot.usageLimits) return snapshot; return usageLimits ? { ...snapshot, usageLimits } : snapshot; }); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 8416083061..ed8710ee53 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -1,5 +1,6 @@ import { afterEach, assert, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; @@ -193,77 +194,137 @@ afterEach(() => { }); layer("GitHubPullRequestCli.layer", (it) => { - it.effect("reads linked pull request status with the overview fields in one request", () => + it.effect("reads linked pull requests on one host together, filed back by position", () => Effect.gen(function* () { + const node = (number: number) => ({ + number, + title: `Pull request ${number}`, + url: `https://github.com/acme/web/pull/${number}`, + author: { __typename: "User", login: "octocat", name: "Octo Cat", avatarUrl: null }, + baseRefName: "main", + headRefName: `feat/${number}`, + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + reviewDecision: null, + latestReviews: { nodes: [{ state: "APPROVED", author: { login: "reviewer" } }] }, + additions: 12, + deletions: 3, + changedFiles: 2, + updatedAt: "2026-08-24T12:34:56.000Z", + mergedAt: null, + closedAt: null, + commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] }, + }); mockedExecute.mockReturnValueOnce( Effect.succeed( output( // @effect-diagnostics-next-line preferSchemaOverJson:off JSON.stringify({ - number: 7, - title: "Reuse the summary", - url: "https://github.com/acme/web/pull/7", - author: { login: "octocat", name: "Octo Cat" }, - baseRefName: "main", - headRefName: "feat/summary", - state: "OPEN", - isDraft: false, - mergeable: "MERGEABLE", - reviewDecision: "APPROVED", - additions: 12, - deletions: 3, - changedFiles: 2, - createdAt: "2026-08-20T00:00:00.000Z", - updatedAt: "2026-08-24T12:34:56.000Z", - reviewRequests: [], - labels: [], - statusCheckRollup: [ - { __typename: "CheckRun", status: "COMPLETED", conclusion: "SUCCESS", name: "ci" }, - ], - body: "", + data: { s0: { pullRequest: node(7) }, s1: { pullRequest: node(8) } }, }), ), ), ); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const summary = yield* cli.getPullRequestSummary({ - cwd: "/w", - repository: "acme/web", - host: "github.com", - number: 7, - }); + const reads = yield* Effect.forEach( + [7, 8], + (number) => + cli.getPullRequestSummary({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number, + }), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* TestClock.adjust("10 millis"); + const [seven, eight] = yield* Fiber.join(reads); assert.deepStrictEqual( { - number: summary.number, - state: summary.state, - headBranch: summary.headBranch, - isDraft: summary.isDraft, - author: summary.author?.login, - additions: summary.additions, - deletions: summary.deletions, - changedFiles: summary.changedFiles, - reviewDecision: summary.reviewDecision, - checksState: summary.checksState, - mergeability: summary.mergeability, + number: seven?.number, + state: seven?.state, + headBranch: seven?.headBranch, + author: seven?.author?.login, + changedFiles: seven?.changedFiles, + reviewDecision: seven?.reviewDecision, + checksState: seven?.checksState, + mergeability: seven?.mergeability, }, { number: 7, state: "open", - headBranch: "feat/summary", - isDraft: false, + headBranch: "feat/7", author: "octocat", - additions: 12, - deletions: 3, changedFiles: 2, reviewDecision: "approved", checksState: "passing", mergeability: "mergeable", }, ); + assert.strictEqual(eight?.headBranch, "feat/8"); expect(mockedExecute).toHaveBeenCalledOnce(); - expect(mockedExecute.mock.calls[0]?.[0]?.args).toEqual([ + const document = callAt(0).args.at(-1) ?? ""; + expect(document).toContain( + 's0: repository(owner: "acme", name: "web") { pullRequest(number: 7)', + ); + expect(document).toContain("pullRequest(number: 8)"); + }), + ); + + it.effect("reads a pull request the batch said nothing about on its own", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output('{"data":{"s0":{"pullRequest":null}}}'))) + .mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + number: 7, + title: "Reuse the summary", + url: "https://github.com/acme/web/pull/7", + author: { login: "octocat", name: "Octo Cat" }, + baseRefName: "main", + headRefName: "feat/summary", + state: "OPEN", + isDraft: false, + mergeable: "MERGEABLE", + reviewDecision: "APPROVED", + additions: 12, + deletions: 3, + changedFiles: 2, + createdAt: "2026-08-20T00:00:00.000Z", + updatedAt: "2026-08-24T12:34:56.000Z", + reviewRequests: [], + labels: [], + statusCheckRollup: [ + { + __typename: "CheckRun", + status: "COMPLETED", + conclusion: "SUCCESS", + name: "ci", + }, + ], + body: "", + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const read = yield* cli + .getPullRequestSummary({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("10 millis"); + const summary = yield* Fiber.join(read); + + assert.strictEqual(summary.headBranch, "feat/summary"); + assert.strictEqual(summary.checksState, "passing"); + assert.strictEqual(mockedExecute.mock.calls.length, 2); + expect(callAt(1).args).toEqual([ "pr", "view", "7", @@ -272,7 +333,46 @@ layer("GitHubPullRequestCli.layer", (it) => { "--json", expect.stringContaining("statusCheckRollup"), ]); - expect(mockedGetPullRequest).not.toHaveBeenCalled(); + }), + ); + + it.effect("does not fan out summary reads after a rejected GraphQL batch", () => + Effect.gen(function* () { + for (const response of [ + Effect.succeed(output('{"data":null,"errors":[{"message":"API rate limit exceeded"}]}')), + Effect.fail( + new GitHubCli.GitHubCliRateLimitError({ + command: "gh", + cwd: "/w", + cause: new Error("API rate limit exceeded"), + }), + ), + ]) { + mockedExecute.mockReset().mockReturnValueOnce(response); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const reads = yield* Effect.all([ + cli + .getPullRequestSummary({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }) + .pipe(Effect.forkChild), + cli + .getPullRequestSummary({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 8, + }) + .pipe(Effect.forkChild), + ]); + yield* TestClock.adjust("10 millis"); + const exits = yield* Effect.forEach(reads, (read) => Fiber.await(read)); + expect(exits.every((exit) => exit._tag === "Failure")).toBe(true); + expect(mockedExecute).toHaveBeenCalledOnce(); + } }), ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index d90d7378fa..b6c9dae750 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -1,7 +1,12 @@ import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; +import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Request from "effect/Request"; +import * as RequestResolver from "effect/RequestResolver"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { @@ -36,6 +41,7 @@ import { decodeActorAvatarsJson, decodePullRequestActivityJson, decodePullRequestDetailJson, + type GitHubPullRequestSummary, decodePullRequestFilesJson, decodePullRequestHeadsJson, decodePullRequestListJson, @@ -43,6 +49,8 @@ import { decodePullRequestSearchJson, decodePullRequestStacksJson, decodePullRequestStatsJson, + decodePullRequestSummariesJson, + GitHubSummaryBatchUnavailableError, decodeReactionSubjectScopeJson, decodeRepositoryAccessJson, decodeReviewerCandidatesJson, @@ -53,6 +61,7 @@ import { decodeReviewThreadCommentsJson, decodeReviewThreadsJson, buildPullRequestStatsGraphQlQuery, + buildPullRequestSummariesGraphQlQuery, buildPullRequestStackMembershipsGraphQlQuery, decodePullRequestStackMembershipsJson, encodeGraphQlRequestJson, @@ -393,6 +402,23 @@ export interface GitHubPullRequestStat { */ const STAT_ALIASES_PER_REQUEST = 25; const STAT_REQUEST_CONCURRENCY = 4; +/** + * How long a summary read waits for company. The background sync asks for every linked pull + * request at once, and each read reaches the resolver after its own cache check, so a batch + * needs a moment longer than one scheduler tick to gather them. + */ +const SUMMARY_BATCH_WINDOW = "10 millis"; + +class PullRequestSummaryRead extends Request.Class< + { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + }, + ProviderChangeRequestSummary, + GitHubPullRequestCliError +> {} export interface GitHubPullRequestSearchBatch { /** Rows across every repository asked for, newest update first, each naming its own. */ @@ -1467,6 +1493,139 @@ export const make = Effect.gen(function* () { ), ); + // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands + // the thread overview its author, diff stat, review decision and checks in the same read. + const viewPullRequestSummary = (input: PullRequestSummaryRead) => + github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + String(input.number), + ...repositoryArgs(input), + "--json", + PULL_REQUEST_DETAIL_JSON_FIELDS, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodePullRequestDetailJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestSummary", + cause: decoded.failure, + }), + ); + } + const detail = decoded.success; + return Effect.succeed({ + number: detail.number, + title: detail.title, + url: detail.url, + headBranch: detail.headBranch, + baseBranch: detail.baseBranch, + state: detail.state, + updatedAt: detail.updatedAt, + closedAt: detail.closedAt ?? null, + mergedAt: detail.mergedAt ?? null, + isDraft: detail.isDraft, + author: detail.author, + additions: detail.additions, + deletions: detail.deletions, + changedFiles: detail.changedFiles, + reviewDecision: detail.reviewDecision, + checksState: detail.checksState, + mergeability: detail.mergeability, + }); + }), + ); + + /** + * Summaries asked for together, on one host under one credential, share aliased GraphQL reads + * of twenty-five: the background sync reads every linked pull request each minute, and one + * `gh pr view` apiece is most of what it spends. Whatever the batch cannot answer — a selector + * GraphQL cannot address, a pull request GitHub returned nothing for — is read on its own. + */ + const summaryResolver = RequestResolver.makeGrouped({ + key: ({ request, context }) => + JSON.stringify([ + request.host.toLowerCase(), + Context.getOrElse(context, GitHubCli.PinnedGitHubCredential, () => null) + ?.credentialFingerprint ?? null, + Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), + ]), + resolver: (entries) => { + const [first] = entries; + const batchable = entries.filter( + (entry) => buildPullRequestSummariesGraphQlQuery([entry.request]) !== null, + ); + const query = buildPullRequestSummariesGraphQlQuery(batchable.map((entry) => entry.request)); + const batched = + query === null + ? Effect.succeed(new Map()) + : graphqlRead({ + cwd: first.request.cwd, + host: first.request.host, + operation: "getPullRequestSummary", + query, + decode: decodePullRequestSummariesJson, + }); + return batched.pipe( + // A GraphQL error anywhere fails the whole document — one repository gone or out of + // reach — so a batch that could not be read leaves every entry to its own read. A paused + // budget is the exception: reading one at a time would only spend what is being saved. + Effect.catchCauseIf( + (cause) => + !Cause.hasInterruptsOnly(cause) && + !Cause.findErrorOption(cause).pipe( + Option.exists( + (error) => + error._tag === "SourceControlRateLimitPausedError" || + error._tag === "GitHubCliRateLimitError" || + (error._tag === "GitHubPullRequestReadError" && + error.cause instanceof GitHubSummaryBatchUnavailableError), + ), + ), + (cause) => + Effect.logDebug("batched pull request summary read failed", { cause }).pipe( + Effect.as(new Map()), + ), + ), + Effect.flatMap((summaries) => { + const unanswered = entries.filter((entry) => { + const summary = summaries.get(batchable.indexOf(entry)); + if (summary === undefined) return true; + entry.completeUnsafe(Exit.succeed(summary)); + return false; + }); + return Effect.forEach( + unanswered, + (entry) => + viewPullRequestSummary(entry.request).pipe( + Effect.exit, + Effect.map((exit) => entry.completeUnsafe(exit)), + ), + { concurrency: STAT_REQUEST_CONCURRENCY, discard: true }, + ); + }), + Effect.catchCause((cause) => + Effect.sync(() => { + for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); + }), + ), + ); + }, + }).pipe( + RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), + RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), + ); + const getPullRequestSummary: GitHubPullRequestCli["Service"]["getPullRequestSummary"] = (input) => + Effect.request(new PullRequestSummaryRead(input), summaryResolver); + return GitHubPullRequestCli.of({ getViewerLogin: (input) => github.execute({ cwd: input.cwd, args: ["api", "user", "--jq", ".login"] }).pipe( @@ -1678,56 +1837,7 @@ export const make = Effect.gen(function* () { ).pipe(Effect.map((results) => results.flat())); }, - // One `gh pr view` either way; asking for the detail fields costs nothing extra and hands - // the thread overview its author, diff stat, review decision and checks in the same read. - getPullRequestSummary: (input) => - github - .execute({ - cwd: input.cwd, - args: [ - "pr", - "view", - String(input.number), - ...repositoryArgs(input), - "--json", - PULL_REQUEST_DETAIL_JSON_FIELDS, - ], - }) - .pipe( - Effect.flatMap((result) => { - const decoded = decodePullRequestDetailJson(result.stdout.trim()); - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitHubPullRequestReadError({ - command: "gh", - cwd: input.cwd, - operation: "getPullRequestSummary", - cause: decoded.failure, - }), - ); - } - const detail = decoded.success; - return Effect.succeed({ - number: detail.number, - title: detail.title, - url: detail.url, - headBranch: detail.headBranch, - baseBranch: detail.baseBranch, - state: detail.state, - updatedAt: detail.updatedAt, - closedAt: detail.closedAt ?? null, - mergedAt: detail.mergedAt ?? null, - isDraft: detail.isDraft, - author: detail.author, - additions: detail.additions, - deletions: detail.deletions, - changedFiles: detail.changedFiles, - reviewDecision: detail.reviewDecision, - checksState: detail.checksState, - mergeability: detail.mergeability, - }); - }), - ), + getPullRequestSummary, getPullRequestDetail, listWorkflowRunsRequiringApproval, diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index a4f866b59a..e84703f241 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1081,8 +1081,13 @@ it.effect("publishes a merge for immediate settlement only after host confirmati // Queueing succeeds while the host still reports an open PR. yield* service.runAction({ ...reference, action: "merge" }); + const queuedRefresh = Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)); confirmationFails = true; yield* service.runAction({ ...reference, action: "merge" }); + assert.isAbove( + Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)), + queuedRefresh, + ); confirmationFails = false; state = "merged"; yield* TestClock.setTime(Date.parse(mergedAt)); @@ -1101,6 +1106,53 @@ it.effect("publishes a merge for immediate settlement only after host confirmati ), ); +it.effect("refreshes every reader before a queued merge confirmation finishes", () => + Effect.scoped( + Effect.gen(function* () { + const confirmationStarted = yield* Deferred.make(); + const confirm = yield* Deferred.make(); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + getChangeRequestSummary: () => + Effect.gen(function* () { + yield* Deferred.succeed(confirmationStarted, undefined); + yield* Deferred.await(confirm); + return changeRequest(1, "2026-09-16T00:00:00.000Z"); + }), + }), + ], + }); + const merges = yield* service.subscribeMerges; + const observedMerge = yield* Stream.runHead(merges).pipe( + Effect.forkChild({ startImmediately: true }), + ); + const readers = yield* Effect.forEach([0, 1], () => + Stream.runHead(service.subscribeRefreshes).pipe( + Effect.forkChild({ startImmediately: true }), + ), + ); + const action = yield* service + .runAction({ ...reference, action: "merge" }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(confirmationStarted); + const revisions = yield* Effect.forEach(readers, (reader) => + Fiber.join(reader).pipe(Effect.map(Option.getOrThrow)), + ); + assert.isAbove(revisions[0]!, 0); + assert.strictEqual(revisions[0], revisions[1]); + assert.isUndefined(action.pollUnsafe()); + yield* Deferred.succeed(confirm, undefined); + yield* Fiber.join(action); + assert.isUndefined(observedMerge.pollUnsafe()); + }), + ), +); + it.effect("refuses an action this viewer may not take, and says what access it takes", () => Effect.gen(function* () { let ran: string | null = null; @@ -2254,10 +2306,16 @@ it.effect("invalidates the cached activity after reacting, like the other mutati ], }); + yield* service.refreshAfterTurn(reference.projectId); + const previousRefresh = Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)); yield* service.activity(reference); assert.strictEqual(activityCalls, 1); yield* service.setReaction({ ...reference, content: "heart", reacted: true }); + assert.isAbove( + Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)), + previousRefresh, + ); yield* service.activity(reference); assert.strictEqual(activityCalls, 2); @@ -3192,31 +3250,106 @@ it.effect("explicit and turn invalidations make the next listing ask the host ag }), ); -it.effect("a mutation makes the next listing ask the host again, with no client asking", () => - Effect.gen(function* () { - let hostCalls = 0; - const service = yield* makeService({ - projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], - providers: [ - fakeProvider("github", { - listChangeRequests: () => { - hostCalls += 1; - return Effect.succeed({ items: [], truncated: false, continues: false }); - }, - }), - ], - }); +it.effect("close and reopen notify subscribed readers after invalidating their cached state", () => + Effect.scoped( + Effect.gen(function* () { + let hostCalls = 0; + let state: "open" | "closed" = "open"; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + getChangeRequestSummary: () => + Effect.succeed({ ...changeRequest(1, "2026-09-16T00:00:00.000Z"), state }), + runAction: (input) => + Effect.sync(() => { + state = input.action === "close" ? "closed" : "open"; + }), + listChangeRequests: () => { + hostCalls += 1; + return Effect.succeed({ items: [], truncated: false, continues: false }); + }, + }), + ], + }); - yield* service.list({ state: "open" }); - yield* service.runAction({ - projectId: "p1" as ProjectId, - repository: "acme/web", - number: 1, - action: "close", - }); - yield* service.list({ state: "open" }); - assert.strictEqual(hostCalls, 2); - }), + yield* service.refreshAfterTurn(reference.projectId); + yield* service.list({ state: "open" }); + assert.strictEqual((yield* service.summary(reference)).state, "open"); + for (const action of ["close", "reopen"] as const) { + const refreshed = yield* service.subscribeRefreshes.pipe( + Stream.drop(1), + Stream.take(1), + Stream.mapEffect(() => + Effect.gen(function* () { + yield* service.list({ state: "open" }); + return yield* service.summary(reference); + }), + ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + yield* service.runAction({ ...reference, action }); + assert.strictEqual( + Option.getOrThrow(yield* Fiber.join(refreshed)).state, + action === "close" ? "closed" : "open", + ); + } + assert.strictEqual(hostCalls, 3); + }), + ), +); + +it.effect("explicit invalidation refreshes origin readers after a routed host mutation", () => + Effect.scoped( + Effect.gen(function* () { + let state: "open" | "closed" = "open"; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + getChangeRequestSummary: () => + Effect.succeed({ ...changeRequest(1, "2026-09-16T00:00:00.000Z"), state }), + }), + ], + }); + yield* service.refreshAfterTurn(reference.projectId); + let revision = Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)); + // The sync reactor invalidates before reading; it must not notify itself again. + yield* service.invalidate({ reference }); + assert.strictEqual( + Option.getOrThrow(yield* Stream.runHead(service.subscribeRefreshes)), + revision, + ); + assert.strictEqual((yield* service.summary(reference)).state, "open"); + + for (const nextState of ["closed", "open"] as const) { + const refreshed = yield* service.subscribeRefreshes.pipe( + Stream.drop(1), + Stream.take(1), + Stream.mapEffect((nextRevision) => + service + .summary(reference) + .pipe(Effect.map((summary) => ({ revision: nextRevision, state: summary.state }))), + ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + state = nextState; + yield* service.invalidate({ reference }, { notifyReaders: true }); + const result = Option.getOrThrow(yield* Fiber.join(refreshed)); + assert.strictEqual(result.state, nextState); + assert.isAbove(result.revision, revision); + revision = result.revision; + } + }), + ), ); it.effect("does not cache a failed listing", () => diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index c61ce781a8..ff53821727 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -214,7 +214,10 @@ export class PullRequestService extends Context.Service< readonly setLabels: ( input: PullRequestLabelChangeInput, ) => Effect.Effect; - readonly invalidate: (input: PullRequestInvalidateInput) => Effect.Effect; + readonly invalidate: ( + input: PullRequestInvalidateInput, + options?: { readonly notifyReaders?: boolean }, + ) => Effect.Effect; } >()("t3/pullRequest/PullRequestService") {} @@ -2732,10 +2735,12 @@ export const make = Effect.gen(function* () { return { stats: [...held, ...result.stats] }; }); - const invalidate: PullRequestService["Service"]["invalidate"] = (input) => { + const invalidate: PullRequestService["Service"]["invalidate"] = Effect.fn( + "PullRequestService.invalidate", + )(function* (input, options) { const reference = input.reference; if (reference !== undefined) { - return canonicalRef(reference).pipe( + yield* canonicalRef(reference).pipe( Effect.flatMap((ref) => readCache .invalidate(refScope(ref)) @@ -2743,12 +2748,15 @@ export const make = Effect.gen(function* () { ), Effect.ignore, ); - } - return Effect.sync(() => { + } else { listingsEpoch = ++epochCounter; viewersByHost.clear(); - }).pipe(Effect.andThen(Cache.invalidateAll(viewerFlights))); - }; + yield* Cache.invalidateAll(viewerFlights); + } + if (options?.notifyReaders) { + yield* SubscriptionRef.set(pullRequestRefreshes, ++epochCounter); + } + }); const refreshAfterTurn: PullRequestService["Service"]["refreshAfterTurn"] = (projectId) => Effect.suspend(() => { @@ -2767,9 +2775,7 @@ export const make = Effect.gen(function* () { .pipe(Effect.andThen(SubscriptionRef.set(pullRequestRefreshes, listingsEpoch))); }); - // 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. + // Invalidate before notifying every client so mounted readers immediately fetch the edit. const invalidatedByMutation = ( method: (input: I) => Effect.Effect, @@ -2787,6 +2793,7 @@ export const make = Effect.gen(function* () { }), ), ); + yield* SubscriptionRef.set(pullRequestRefreshes, listingsEpoch); }); const runActionAndInvalidate: PullRequestService["Service"]["runAction"] = Effect.fn( "PullRequestService.runActionAndInvalidate", @@ -2798,6 +2805,7 @@ export const make = Effect.gen(function* () { ); bumpRefEpoch({ ...ref, repository }); listingsEpoch = ++epochCounter; + yield* SubscriptionRef.set(pullRequestRefreshes, listingsEpoch); if (input.action === "merge") { // A successful merge action can merely enqueue the PR or enable auto-merge. const confirmed = yield* summaryUncached({ ...input, repository }).pipe( diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index 2c3ac6f7c0..801c439a7d 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -2,6 +2,8 @@ import * as Result from "effect/Result"; import { describe, expect, it } from "vite-plus/test"; import { + buildPullRequestSummariesGraphQlQuery, + decodePullRequestSummariesJson, buildReviewSubmissionJson, buildPullRequestStackMembershipsGraphQlQuery, decodePullRequestStackMembershipsJson, @@ -1708,3 +1710,54 @@ describe("pull request stack membership batches", () => { ); }); }); + +describe("batched pull request summaries", () => { + it("refuses a repository GraphQL cannot address, rather than writing it into the document", () => { + expect( + buildPullRequestSummariesGraphQlQuery([{ repository: 'acme/web") { x } #', number: 1 }]), + ).toBeNull(); + expect( + buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 0 }]), + ).toBeNull(); + expect(buildPullRequestSummariesGraphQlQuery([])).toBeNull(); + }); + + it("files each answer by its alias and skips what GitHub or the decoder could not give", () => { + const decoded = decodePullRequestSummariesJson( + JSON.stringify({ + data: { + s0: { + pullRequest: { + number: 7, + title: "Merged", + url: "https://github.com/acme/web/pull/7", + author: { __typename: "Bot", login: "renovate", avatarUrl: "https://a/r.png" }, + headRefName: "feat/seven", + baseRefName: "main", + state: "MERGED", + mergedAt: "2026-08-24T00:00:00Z", + closedAt: "2026-08-24T00:00:00Z", + updatedAt: "2026-08-24T00:00:00Z", + commits: { nodes: [{ commit: { statusCheckRollup: { state: "FAILURE" } } }] }, + }, + }, + s1: { pullRequest: null }, + s2: { pullRequest: { number: 9 } }, + rateLimit: { cost: 1 }, + }, + }), + ); + expect(Result.isSuccess(decoded)).toBe(true); + if (!Result.isSuccess(decoded)) return; + expect([...decoded.success.keys()]).toEqual([0]); + expect(decoded.success.get(0)).toMatchObject({ + number: 7, + state: "merged", + mergedAt: "2026-08-24T00:00:00Z", + author: { login: "renovate", isBot: true }, + checksState: "failing", + mergeability: "unknown", + additions: 0, + }); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 1ebe34ae27..f45416bc91 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -1725,6 +1725,139 @@ export function decodePullRequestStatsJson( return Result.succeed(stats); } +/** + * The fields a linked thread keeps current, for many pull requests in one aliased read. Same + * shape as the search row where the two overlap: the checks arrive as GitHub's one-word rollup + * rather than the whole check list `gh pr view` hands back, which is what keeps a batch cheap. + */ +const PULL_REQUEST_SUMMARY_SELECTION = + "number title url state isDraft mergeable reviewDecision additions deletions changedFiles " + + "updatedAt mergedAt closedAt headRefName baseRefName " + + "author { __typename login avatarUrl ... on User { name } } " + + "latestReviews(first: 20) { nodes { state author { login } } } " + + "commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }"; + +/** + * Summaries for pull requests anywhere on one host, one aliased lookup each. Checked and written + * into the document the way `buildPullRequestStatsGraphQlQuery` does, so null means "do not send". + */ +export function buildPullRequestSummariesGraphQlQuery( + changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, +): string | null { + if (changeRequests.length === 0) return null; + const selections: string[] = []; + for (const [index, changeRequest] of changeRequests.entries()) { + const [owner, name, ...rest] = changeRequest.repository.trim().split("/"); + if (rest.length > 0 || owner === undefined || name === undefined) return null; + if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; + if (!Number.isSafeInteger(changeRequest.number) || changeRequest.number <= 0) return null; + selections.push( + ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { ${PULL_REQUEST_SUMMARY_SELECTION} } }`, + ); + } + return `query PullRequestSummaries {\n${selections.join("\n")}\n}`; +} + +const RawSummarySchema = Schema.Struct({ + ...RawSearchItemSchema.fields, + changedFiles: Schema.optional(Schema.NullOr(Schema.Int)), + additions: Schema.optional(Schema.NullOr(Schema.Int)), + deletions: Schema.optional(Schema.NullOr(Schema.Int)), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + createdAt: Schema.optional(Schema.String), +}); +const decodeSummaries = decodeJsonResult( + Schema.Struct({ + errors: Schema.optional(Schema.Array(Schema.Unknown)), + data: Schema.optional( + Schema.NullOr( + Schema.Record( + Schema.String, + Schema.NullOr(Schema.Struct({ pullRequest: Schema.optional(Schema.Unknown) })), + ), + ), + ), + }), +); +const decodeSummaryEntry = Schema.decodeUnknownExit(RawSummarySchema); + +/** A failed GraphQL document is not a set of missing aliases to retry individually. */ +export class GitHubSummaryBatchUnavailableError extends Error {} + +export interface GitHubPullRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly headBranch: string; + readonly baseBranch: string; + readonly state: PullRequestState; + readonly isDraft: boolean; + readonly closedAt: string | null; + readonly mergedAt: string | null; + readonly updatedAt: string; + readonly author: PullRequestActor | null; + readonly additions: number; + readonly deletions: number; + readonly changedFiles: number; + readonly reviewDecision: PullRequestReviewDecision | null; + readonly checksState: PullRequestChecksState | null; + readonly mergeability: PullRequestMergeability; +} + +/** + * Summaries by the position they were asked in. A pull request GitHub answered nothing for, or + * one whose fields no longer decode, is absent rather than failing the rest of the batch. + */ +export function decodePullRequestSummariesJson( + raw: string, +): Result.Result< + ReadonlyMap, + DecodeFailure | GitHubSummaryBatchUnavailableError +> { + const decoded = decodeSummaries(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + if (decoded.success.data == null && (decoded.success.errors?.length ?? 0) > 0) { + return Result.fail(new GitHubSummaryBatchUnavailableError("GitHub rejected the summary batch")); + } + const summaries = new Map(); + for (const [alias, value] of Object.entries(decoded.success.data ?? {})) { + const index = /^s(\d+)$/.exec(alias)?.[1]; + if (index === undefined || value?.pullRequest == null) continue; + const entry = decodeSummaryEntry(value.pullRequest); + if (!Exit.isSuccess(entry)) continue; + const pr = entry.value; + summaries.set(Number(index), { + number: pr.number, + title: pr.title, + url: pr.url, + headBranch: pr.headRefName, + baseBranch: pr.baseRefName, + state: toState(pr), + isDraft: pr.isDraft ?? false, + closedAt: trimmed(pr.closedAt), + mergedAt: trimmed(pr.mergedAt), + updatedAt: pr.updatedAt, + author: toActor(pr.author), + additions: pr.additions ?? 0, + deletions: pr.deletions ?? 0, + changedFiles: pr.changedFiles ?? 0, + reviewDecision: toReviewDecisionWithReviews( + pr.reviewDecision, + (pr.latestReviews?.nodes ?? []).flatMap((review) => (review === null ? [] : [review])), + ), + // One enum for the head commit, dressed as a single check like the search row's. + checksState: rollupChecksState( + (pr.commits?.nodes ?? []).flatMap((commitNode) => { + const state = trimmed(commitNode?.commit?.statusCheckRollup?.state); + return state === null ? [] : [{ state }]; + }), + ), + mergeability: toMergeability(pr.mergeable), + }); + } + return Result.succeed(summaries); +} + export function decodePullRequestDetailJson( raw: string, ): Result.Result { diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 02931d0534..2ea053a668 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -325,6 +325,9 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { const projectId = "project-1" as ProjectId; const activeThreadId = "thread-active" as ThreadId; const idleThreadId = "thread-idle" as ThreadId; + const oldCompletedId = "thread-old-completed" as ThreadId; + const newCompletedId = "thread-new-completed" as ThreadId; + const freshMessageId = "thread-fresh-message" as ThreadId; const baseThread = { projectId, @@ -351,6 +354,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { expect( AgentAwarenessRelay.resolveAgentAwarenessRelayActiveThreadIds({ environmentId, + startedAt: Date.parse(now), projects: [ { id: projectId, @@ -374,6 +378,44 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { ...baseThread, id: idleThreadId, }, + { + ...baseThread, + id: oldCompletedId, + latestTurn: { + turnId: "turn-old" as TurnId, + state: "completed", + requestedAt: "2026-05-24T00:00:00.000Z", + startedAt: "2026-05-24T00:00:00.000Z", + completedAt: "2026-05-24T00:01:00.000Z", + assistantMessageId: null, + }, + }, + { + ...baseThread, + id: newCompletedId, + latestTurn: { + turnId: "turn-new" as TurnId, + state: "completed", + requestedAt: "2026-05-25T00:00:01.000Z", + startedAt: "2026-05-25T00:00:01.000Z", + completedAt: "2026-05-25T00:00:02.000Z", + assistantMessageId: null, + }, + }, + { + ...baseThread, + id: freshMessageId, + latestUserMessageAt: "2026-05-25T00:00:01.000Z", + session: { + threadId: freshMessageId, + status: "ready", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-05-25T00:00:02.000Z", + }, + }, { ...baseThread, id: "thread-missing-project" as ThreadId, @@ -389,7 +431,101 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }, ], }), - ).toEqual([activeThreadId]); + ).toEqual([activeThreadId, newCompletedId]); + expect( + AgentAwarenessRelay.resolveAgentAwarenessRelayActiveThreadIds({ + environmentId, + startedAt: Date.parse(now), + pendingAdmissionThreads: new Set([newCompletedId]), + projects: [{ id: projectId, title: "Pylon" }], + threads: [ + { + ...baseThread, + id: newCompletedId, + latestTurn: { + turnId: "turn-new" as TurnId, + state: "completed", + requestedAt: "2026-05-25T00:00:01.000Z", + startedAt: "2026-05-25T00:00:01.000Z", + completedAt: "2026-05-25T00:00:02.000Z", + assistantMessageId: null, + }, + }, + ], + }), + ).toEqual([]); + // A provider running event proves work even when a no-checkpoint turn + // settles to ready without materializing latestTurn. + expect( + AgentAwarenessRelay.resolveAgentAwarenessRelayActiveThreadIds({ + environmentId, + startedAt: Date.parse(now), + freshLifecycleThreads: new Map([ + [ + freshMessageId, + { + phase: "completed", + sequence: 2, + sessionStatus: "ready", + sessionUpdatedAt: "2026-05-25T00:00:02.000Z", + sessionIncarnationId: undefined, + }, + ], + ]), + projects: [{ id: projectId, title: "Pylon" }], + threads: [ + { + ...baseThread, + id: freshMessageId, + session: { + threadId: freshMessageId, + status: "ready", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-05-25T00:00:02.000Z", + }, + }, + ], + }), + ).toEqual([freshMessageId]); + // A running event may arrive before an old ready shell catches up, even + // when both states were stamped in the same millisecond. + expect( + AgentAwarenessRelay.resolveAgentAwarenessRelayActiveThreadIds({ + environmentId, + startedAt: Date.parse(now), + freshLifecycleThreads: new Map([ + [ + freshMessageId, + { + phase: "running", + sequence: 1, + sessionStatus: null, + sessionUpdatedAt: "2026-05-25T00:00:01.000Z", + sessionIncarnationId: undefined, + }, + ], + ]), + projects: [{ id: projectId, title: "Pylon" }], + threads: [ + { + ...baseThread, + id: freshMessageId, + session: { + threadId: freshMessageId, + status: "ready", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-05-25T00:00:01.000Z", + }, + }, + ], + }), + ).toEqual([]); }); it.effect("signs the activity publish JWT and rejects tampering", () => diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 144d43a029..91f8bf45cf 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -267,8 +267,44 @@ export function resolveAgentAwarenessRelayPublishSnapshot(input: { }; } +type FreshLifecycleProof = { + readonly phase: "running" | "completed" | "failed"; + readonly sequence: number; + readonly sessionStatus: "ready" | "idle" | "error" | null; + readonly sessionUpdatedAt: string; + readonly sessionIncarnationId: string | undefined; +}; + +function terminalWorkSinceStart( + thread: OrchestrationThreadShell, + startedAt: number, + phase: "completed" | "failed", + freshLifecycle?: FreshLifecycleProof, +): boolean { + // An observed new lifecycle takes precedence over a checkpoint from the + // previous turn, which can remain in the shell during admission. + if (freshLifecycle === undefined) { + return Date.parse(thread.latestTurn?.completedAt ?? "") > startedAt; + } + if (freshLifecycle.phase === "running") return false; + // For an observed terminal transition, the projected shell must have caught + // up to that same session even when an older checkpoint is still present. + const session = thread.session; + return ( + session !== null && + ((phase === "completed" && freshLifecycle.phase === "completed") || + (phase === "failed" && freshLifecycle.phase === "failed")) && + session.status === freshLifecycle.sessionStatus && + session.sessionIncarnationId === freshLifecycle.sessionIncarnationId && + Date.parse(session.updatedAt) >= Date.parse(freshLifecycle.sessionUpdatedAt) + ); +} + export function resolveAgentAwarenessRelayActiveThreadIds(input: { readonly environmentId: EnvironmentId; + readonly startedAt: number; + readonly freshLifecycleThreads?: ReadonlyMap; + readonly pendingAdmissionThreads?: ReadonlySet; readonly projects: ReadonlyArray>; readonly threads: ReadonlyArray; }): ReadonlyArray { @@ -279,12 +315,22 @@ export function resolveAgentAwarenessRelayActiveThreadIds(input: { if (!project) { return false; } + const state = projectThreadAwareness({ + environmentId: input.environmentId, + project, + thread, + }); return ( - projectThreadAwareness({ - environmentId: input.environmentId, - project, - thread, - }) !== null + state !== null && + (state.phase !== "completed" && state.phase !== "failed" + ? true + : !input.pendingAdmissionThreads?.has(thread.id) && + terminalWorkSinceStart( + thread, + input.startedAt, + state.phase, + input.freshLifecycleThreads?.get(thread.id), + )) ); }) .map((thread) => thread.id); @@ -298,8 +344,12 @@ export const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; const crypto = yield* Crypto.Crypto; const cloudLinkKeyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); + const startedAt = (yield* DateTime.now).epochMilliseconds; const activeSnapshotPublishedRef = yield* Ref.make(false); const publishedStateByThreadRef = yield* Ref.make(new Map()); + const publishedNonNullThreads = new Set(); + const freshLifecycleThreads = new Map(); + const pendingAdmissionThreads = new Set(); const readSecretString = (name: string) => secrets @@ -417,6 +467,19 @@ export const make = Effect.gen(function* () { }); const publishIdentity = agentAwarenessPublishIdentity(snapshot.state); const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef); + if (snapshot.state?.phase === "completed" || snapshot.state?.phase === "failed") { + // A new admission can leave the previous completed checkpoint projected + // until its running shell arrives. This fence applies even when the + // thread has an older published state. + if (pendingAdmissionThreads.has(threadId)) return; + const proof = freshLifecycleThreads.get(threadId); + if ( + (proof !== undefined || !publishedNonNullThreads.has(threadId)) && + (Option.isNone(thread) || + !terminalWorkSinceStart(thread.value, startedAt, snapshot.state.phase, proof)) + ) + return; + } if (publishedStateByThread.get(threadId) === publishIdentity) { // The projection is back at (or never left) the last published state, so // any pending deferred confirmation is moot. Leaving the deadline in @@ -444,7 +507,7 @@ export const make = Effect.gen(function* () { const requiresConfirmation = (snapshot.state === null && publishedStateByThread.get(threadId) !== agentAwarenessPublishIdentity(null)) || - (snapshot.state?.phase === "completed" && !publishedStateByThread.has(threadId)); + (snapshot.state?.phase === "completed" && !publishedNonNullThreads.has(threadId)); if (requiresConfirmation) { const nowMs = (yield* DateTime.now).epochMilliseconds; const deadline = publishConfirmDeadlines.get(threadId); @@ -498,6 +561,15 @@ export const make = Effect.gen(function* () { nextPublishedStates.set(threadId, publishIdentity); return nextPublishedStates; }); + if (snapshot.state === null) { + freshLifecycleThreads.delete(threadId); + pendingAdmissionThreads.delete(threadId); + } else { + publishedNonNullThreads.add(threadId); + if (snapshot.state.phase === "completed" || snapshot.state.phase === "failed") { + freshLifecycleThreads.delete(threadId); + } + } }); const publishThread: AgentAwarenessRelay["Service"]["publishThread"] = (threadId) => @@ -529,6 +601,9 @@ export const make = Effect.gen(function* () { const snapshot = yield* snapshotQuery.getShellSnapshot(); const activeThreadIds = resolveAgentAwarenessRelayActiveThreadIds({ environmentId, + startedAt, + freshLifecycleThreads, + pendingAdmissionThreads, projects: snapshot.projects, threads: snapshot.threads, }); @@ -610,12 +685,69 @@ export const make = Effect.gen(function* () { ); yield* forkParked( Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { + // This PubSub stream starts with the subscription and does not replay + // past events. A timestamp fence would discard valid events emitted + // later in the same millisecond as startup. const threadId = eventThreadId(event); if (threadId === null) { return Effect.logDebug("agent activity publishing ignored event without thread id", { eventType: event.type, }); } + if ( + (event.type === "thread.turn-start-requested" || + event.type === "thread.message-sent") && + event.metadata.historyImport !== true + ) { + freshLifecycleThreads.delete(threadId); + pendingAdmissionThreads.add(threadId); + publishConfirmDeadlines.delete(threadId); + } + if (event.type === "thread.session-set" && event.metadata.historyImport !== true) { + const session = event.payload.session; + const previous = freshLifecycleThreads.get(threadId); + if (session.status === "running") { + pendingAdmissionThreads.delete(threadId); + publishConfirmDeadlines.delete(threadId); + freshLifecycleThreads.set(threadId, { + phase: "running", + sequence: event.sequence, + sessionStatus: null, + sessionUpdatedAt: session.updatedAt, + sessionIncarnationId: session.sessionIncarnationId, + }); + } else if (session.status === "error") { + pendingAdmissionThreads.delete(threadId); + publishConfirmDeadlines.delete(threadId); + freshLifecycleThreads.set(threadId, { + phase: "failed", + sequence: event.sequence, + sessionStatus: "error", + sessionUpdatedAt: session.updatedAt, + sessionIncarnationId: session.sessionIncarnationId, + }); + } else if (session.status === "ready" || session.status === "idle") { + if ( + previous?.phase === "running" && + event.sequence > previous.sequence && + session.sessionIncarnationId === previous.sessionIncarnationId + ) { + freshLifecycleThreads.set(threadId, { + phase: "completed", + sequence: event.sequence, + sessionStatus: session.status, + sessionUpdatedAt: session.updatedAt, + sessionIncarnationId: session.sessionIncarnationId, + }); + } + } else { + // A new admission or stop invalidates an older completion proof + // even while the projector still serves the prior ready shell. + freshLifecycleThreads.delete(threadId); + pendingAdmissionThreads.add(threadId); + publishConfirmDeadlines.delete(threadId); + } + } if (!shouldPublishAgentAwarenessEvent(event)) { return Effect.logDebug( "agent activity publishing ignored event without activity changes", diff --git a/apps/server/src/relay/AgentAwarenessRelayStartup.test.ts b/apps/server/src/relay/AgentAwarenessRelayStartup.test.ts new file mode 100644 index 0000000000..95887f6342 --- /dev/null +++ b/apps/server/src/relay/AgentAwarenessRelayStartup.test.ts @@ -0,0 +1,488 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import type { + EnvironmentId, + ExecutionEnvironmentDescriptor, + OrchestrationEvent, + OrchestrationProjectShell, + OrchestrationShellSnapshot, + OrchestrationThreadShell, + ProjectId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { CommandId, ProviderInstanceId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +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 Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "../orchestration/Services/OrchestrationEngine.ts"; +import { + ProjectionSnapshotQuery, + type ProjectionSnapshotQueryShape, +} from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { + RELAY_ENVIRONMENT_CREDENTIAL_SECRET, + RELAY_URL_SECRET, + PUBLISH_AGENT_ACTIVITY_SECRET, +} from "../cloud/config.ts"; +import * as AgentAwarenessRelay from "./AgentAwarenessRelay.ts"; + +const encodeSecret = (value: string): Uint8Array => new TextEncoder().encode(value); + +function makeMemorySecretStore() { + const values = new Map(); + const store = { + get: ((name) => + Effect.sync(() => { + const value = values.get(name); + return value === undefined ? Option.none() : Option.some(Uint8Array.from(value)); + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["get"], + set: ((name, value) => + Effect.sync(() => { + values.set(name, Uint8Array.from(value)); + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["set"], + create: ((name, value) => + Effect.sync(() => { + values.set(name, Uint8Array.from(value)); + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["create"], + getOrCreateRandom: ((name, bytes) => + Effect.sync(() => { + const existing = values.get(name); + if (existing) { + return existing; + } + const generated = new Uint8Array(bytes); + values.set(name, generated); + return generated; + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["getOrCreateRandom"], + remove: ((name) => + Effect.sync(() => { + values.delete(name); + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["remove"], + } satisfies ServerSecretStore.ServerSecretStore["Service"]; + return { + store, + setString: (name: string, value: string) => store.set(name, encodeSecret(value)), + }; +} + +describe.sequential("AgentAwarenessRelay startup", () => { + it.effect("does not alert for historical completions after startup", () => + Effect.scoped( + Effect.gen(function* () { + const secrets = makeMemorySecretStore(); + const now = yield* DateTime.now; + const old = DateTime.formatIso(DateTime.add(now, { days: -7 })); + // Live PubSub events can share the startup millisecond and are still new. + const afterStartup = DateTime.formatIso(now); + const threadId = "thread-old" as ThreadId; + const freshDoneThreadId = "thread-new-done" as ThreadId; + const invalidatedThreadId = "thread-new-admission" as ThreadId; + const projectId = "project-1" as ProjectId; + const environmentId = "env-1" as EnvironmentId; + const project = { + id: projectId, + title: "T3 Code", + workspaceRoot: "/workspace", + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: old, + updatedAt: old, + } satisfies OrchestrationProjectShell; + const completedTurn = { + turnId: "turn-1" as TurnId, + state: "completed", + requestedAt: old, + startedAt: old, + completedAt: old, + assistantMessageId: null, + } as const; + const completedThread = { + id: threadId, + projectId, + title: "Old task", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + pullRequests: [], + latestTurn: completedTurn, + createdAt: old, + updatedAt: old, + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: old, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + } satisfies OrchestrationThreadShell; + let currentThread: OrchestrationThreadShell | null = completedThread; + let resolveFreshDoneQuery: (() => void) | undefined; + const freshDoneQuery = new Promise((resolve) => { + resolveFreshDoneQuery = resolve; + }); + let watchFreshDoneQuery = false; + let invalidatedQueries = 0; + let resolveInvalidatedQueries: (() => void) | undefined; + const invalidatedQuery = new Promise((resolve) => { + resolveInvalidatedQueries = resolve; + }); + let freshDoneQueries = 0; + let resolveFreshReadyQuery: (() => void) | undefined; + const freshReadyQuery = new Promise((resolve) => { + resolveFreshReadyQuery = resolve; + }); + let publishes = 0; + const publishPaths: string[] = []; + const countFor = (id: ThreadId) => + publishPaths.filter((path) => path.includes(`/threads/${id}/`)).length; + let expectedThreadId = threadId; + let resolveNewQuery: (() => void) | undefined; + const newQuery = new Promise((resolve) => { + resolveNewQuery = resolve; + }); + let resolvePublished: (() => void) | undefined; + const published = new Promise((resolve) => { + resolvePublished = resolve; + }); + const events = yield* Queue.unbounded(); + const originalFetch = globalThis.fetch; + globalThis.fetch = ((input: Parameters[0]) => { + publishes += 1; + const path = String(input); + publishPaths.push(path); + if (path.includes(`/threads/${expectedThreadId}/`)) resolvePublished?.(); + return Promise.resolve(Response.json({ ok: true, deliveries: [] })); + }) as unknown as typeof fetch; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + globalThis.fetch = originalFetch; + }), + ); + yield* secrets.setString(RELAY_URL_SECRET, "https://relay.example.test"); + yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential"); + yield* secrets.setString(PUBLISH_AGENT_ACTIVITY_SECRET, "true"); + + const layer = Layer.mergeAll( + Layer.succeed(ServerSecretStore.ServerSecretStore, secrets.store), + Layer.succeed(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Effect.succeed(environmentId), + getDescriptor: Effect.succeed({ + environmentId, + label: "Test Desktop", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + } satisfies ExecutionEnvironmentDescriptor), + }), + Layer.succeed(OrchestrationEngineService, { + streamDomainEvents: Stream.fromQueue(events), + } as OrchestrationEngineShape), + Layer.succeed(ProjectionSnapshotQuery, { + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 1, + projects: [project], + threads: currentThread ? [currentThread] : [], + updatedAt: old, + } satisfies OrchestrationShellSnapshot), + getThreadShellById: (requestedThreadId: ThreadId) => + Effect.sync(() => { + if (currentThread?.session?.lastError === "new failure") resolveNewQuery?.(); + if (requestedThreadId === invalidatedThreadId) { + invalidatedQueries += 1; + if (invalidatedQueries >= 3) resolveInvalidatedQueries?.(); + } + if (watchFreshDoneQuery && currentThread?.id === freshDoneThreadId) { + freshDoneQueries += 1; + resolveFreshDoneQuery?.(); + if (freshDoneQueries >= 2) resolveFreshReadyQuery?.(); + } + return Option.fromNullishOr( + currentThread?.id === requestedThreadId ? currentThread : null, + ); + }), + getProjectShellById: () => Effect.succeed(Option.some(project)), + } as unknown as ProjectionSnapshotQueryShape), + ); + + yield* Effect.gen(function* () { + const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; + yield* relay.start(); + yield* relay.publishThread(threadId); + expect(publishes).toBe(0); + + currentThread = { + ...completedThread, + latestTurn: null, + latestUserMessageAt: DateTime.formatIso(DateTime.add(now, { seconds: 1 })), + session: { + threadId, + status: "ready", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: DateTime.formatIso(DateTime.add(now, { seconds: 2 })), + }, + }; + expect( + AgentAwarenessRelay.resolveAgentAwarenessRelayPublishSnapshot({ + environmentId, + threadId, + thread: Option.some(currentThread), + project: Option.some(project), + }).state?.phase, + ).toBe("completed"); + yield* relay.publishThread(threadId); + expect(publishes).toBe(0); + + currentThread = { + ...completedThread, + session: { + threadId, + status: "error", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: "old failure", + updatedAt: old, + }, + }; + yield* relay.publishThread(threadId); + expect(publishes).toBe(0); + + currentThread = { + ...completedThread, + latestTurn: null, + session: { + threadId, + status: "error", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: "new failure", + updatedAt: afterStartup, + }, + }; + yield* Queue.offer(events, { + type: "thread.session-set", + sequence: 1, + eventId: "event-new-failure", + commandId: CommandId.make("command-new-failure"), + aggregateKind: "thread", + aggregateId: threadId, + actor: { kind: "provider" }, + metadata: {}, + payload: { threadId, session: currentThread.session }, + occurredAt: afterStartup, + } as unknown as OrchestrationEvent); + yield* Effect.promise(() => newQuery); + yield* Effect.promise(() => published); + expect(publishes).toBe(1); + + let resolveCompleted: (() => void) | undefined; + const completed = new Promise((resolve) => { + resolveCompleted = resolve; + }); + resolvePublished = resolveCompleted; + currentThread = { + ...completedThread, + latestTurn: null, + session: { + threadId, + status: "ready", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: afterStartup, + }, + }; + yield* Queue.offer(events, { + type: "thread.session-set", + sequence: 2, + eventId: "event-fast-completion", + commandId: CommandId.make("command-fast-completion"), + aggregateKind: "thread", + aggregateId: threadId, + actor: { kind: "provider" }, + metadata: {}, + payload: { threadId, session: { ...currentThread.session, status: "running" } }, + occurredAt: afterStartup, + } as unknown as OrchestrationEvent); + yield* Queue.offer(events, { + type: "thread.session-set", + sequence: 3, + eventId: "event-fast-completion-ready", + commandId: CommandId.make("command-fast-completion-ready"), + aggregateKind: "thread", + aggregateId: threadId, + actor: { kind: "provider" }, + metadata: {}, + payload: { threadId, session: currentThread.session }, + occurredAt: afterStartup, + } as unknown as OrchestrationEvent); + yield* Effect.promise(() => completed); + expect(publishes).toBe(2); + + expectedThreadId = freshDoneThreadId; + currentThread = { + ...completedThread, + id: freshDoneThreadId, + latestTurn: null, + session: { + threadId: freshDoneThreadId, + status: "ready", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: afterStartup, + }, + }; + yield* relay.publishThread(freshDoneThreadId); + expect(countFor(freshDoneThreadId)).toBe(0); + let resolveFirstDone: (() => void) | undefined; + const firstDone = new Promise((resolve) => { + resolveFirstDone = resolve; + }); + resolvePublished = resolveFirstDone; + watchFreshDoneQuery = true; + yield* Queue.offer(events, { + type: "thread.session-set", + sequence: 4, + eventId: "event-first-done", + commandId: CommandId.make("command-first-done"), + aggregateKind: "thread", + aggregateId: freshDoneThreadId, + actor: { kind: "provider" }, + metadata: {}, + payload: { + threadId: freshDoneThreadId, + session: { ...currentThread.session, status: "running" }, + }, + occurredAt: afterStartup, + } as unknown as OrchestrationEvent); + yield* Effect.promise(() => freshDoneQuery); + expect(countFor(freshDoneThreadId)).toBe(0); + yield* Queue.offer(events, { + type: "thread.session-set", + sequence: 5, + eventId: "event-first-done-ready", + commandId: CommandId.make("command-first-done-ready"), + aggregateKind: "thread", + aggregateId: freshDoneThreadId, + actor: { kind: "provider" }, + metadata: {}, + payload: { threadId: freshDoneThreadId, session: currentThread.session }, + occurredAt: afterStartup, + } as unknown as OrchestrationEvent); + yield* Effect.promise(() => freshReadyQuery); + yield* Effect.yieldNow; + expect(countFor(freshDoneThreadId)).toBe(0); + yield* TestClock.adjust("5 seconds"); + yield* Effect.promise(() => firstDone); + expect(countFor(freshDoneThreadId)).toBe(1); + + let resolveTombstone: (() => void) | undefined; + const tombstone = new Promise((resolve) => { + resolveTombstone = resolve; + }); + resolvePublished = resolveTombstone; + currentThread = null; + yield* relay.publishThread(freshDoneThreadId); + expect(countFor(freshDoneThreadId), publishPaths.join(", ")).toBe(1); + yield* TestClock.adjust("5 seconds"); + yield* Effect.promise(() => tombstone); + expect(countFor(freshDoneThreadId)).toBe(2); + yield* relay.publishThread(freshDoneThreadId); + expect(countFor(freshDoneThreadId)).toBe(2); + + // A new admission invalidates the prior Done proof while the shell + // still projects the previous ready state. + currentThread = { + ...completedThread, + id: invalidatedThreadId, + latestTurn: { ...completedTurn, completedAt: afterStartup }, + session: { + threadId: invalidatedThreadId, + status: "ready", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: afterStartup, + }, + }; + yield* Queue.offer(events, { + type: "thread.session-set", + sequence: 6, + eventId: "event-admission-running", + commandId: CommandId.make("command-admission-running"), + aggregateKind: "thread", + aggregateId: invalidatedThreadId, + actor: { kind: "provider" }, + metadata: {}, + payload: { + threadId: invalidatedThreadId, + session: { ...currentThread.session, status: "running" }, + }, + occurredAt: afterStartup, + } as unknown as OrchestrationEvent); + yield* Queue.offer(events, { + type: "thread.session-set", + sequence: 7, + eventId: "event-admission-ready", + commandId: CommandId.make("command-admission-ready"), + aggregateKind: "thread", + aggregateId: invalidatedThreadId, + actor: { kind: "provider" }, + metadata: {}, + payload: { threadId: invalidatedThreadId, session: currentThread.session }, + occurredAt: afterStartup, + } as unknown as OrchestrationEvent); + yield* Queue.offer(events, { + type: "thread.session-set", + sequence: 8, + eventId: "event-new-admission-starting", + commandId: CommandId.make("command-new-admission-starting"), + aggregateKind: "thread", + aggregateId: invalidatedThreadId, + actor: { kind: "provider" }, + metadata: {}, + payload: { + threadId: invalidatedThreadId, + session: { ...currentThread.session, status: "starting" }, + }, + occurredAt: afterStartup, + } as unknown as OrchestrationEvent); + yield* Effect.promise(() => invalidatedQuery); + yield* TestClock.adjust("5 seconds"); + expect(countFor(invalidatedThreadId)).toBe(0); + }).pipe( + Effect.provide( + AgentAwarenessRelay.layer.pipe( + Layer.provide(layer), + Layer.provideMerge(NodeServices.layer), + ), + ), + ); + }), + ), + ); +}); diff --git a/apps/server/src/rollback/RollbackSagaRunner.test.ts b/apps/server/src/rollback/RollbackSagaRunner.test.ts index 05711e5df2..2e6456593b 100644 --- a/apps/server/src/rollback/RollbackSagaRunner.test.ts +++ b/apps/server/src/rollback/RollbackSagaRunner.test.ts @@ -11,10 +11,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import { CheckpointStore } from "../checkpointing/CheckpointStore.ts"; +import { checkpointRefForThreadTurn } from "../checkpointing/Utils.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { RuntimeReceiptBus, @@ -39,6 +42,7 @@ const now = "2026-08-31T00:00:00.000Z"; const privateTargetCanary = "PRIVATE_TARGET_LEAF_CANARY"; const privateSourceCanary = "PRIVATE_SOURCE_LEAF_CANARY"; const privatePreimageCanary = "/private/preimage/canary"; +const privateFailureCanary = "PRIVATE_PROMPT_TOOL_CREDENTIAL_ERROR_CANARY"; type ProviderMode = "success" | "unknown-target" | "stayed-source" | "wrong-target"; @@ -53,9 +57,9 @@ const makeState = (operationId: string): RollbackSagaState => ({ targetRevision: 1, sourceTurnId: null, targetTurnId: null, - sourceCheckpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-runner/turn/2"), + sourceCheckpointRef: checkpointRefForThreadTurn(threadId, 2), sourceCheckpointOid: "a".repeat(40), - targetCheckpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-runner/turn/1"), + targetCheckpointRef: checkpointRefForThreadTurn(threadId, 1), targetCheckpointOid: "b".repeat(40), targetCheckpointDigest: "target-tree-digest", providerInstanceId, @@ -111,6 +115,7 @@ const makeEnvironment = ( const providerApplies: string[] = []; const commands: OrchestrationCommand[] = []; const runtimeReceipts: OrchestrationRuntimeReceipt[] = []; + const emittedLogs: Array = []; const preimage: RollbackWorkspacePreimage = { backupPath: privatePreimageCanary, @@ -235,9 +240,9 @@ const makeEnvironment = ( return Effect.void; case "unknown-target": providerDigest = "provider-target"; - return Effect.fail("provider-timeout"); + return Effect.fail(privateFailureCanary); case "stayed-source": - return Effect.fail("provider-failed"); + return Effect.fail(privateFailureCanary); case "wrong-target": providerDigest = "provider-wrong"; return Effect.void; @@ -296,7 +301,7 @@ const makeEnvironment = ( workspaceCalls.push("cleanupPreimage"); if (cleanupFailures > 0) { cleanupFailures -= 1; - return Effect.fail("cleanup-failed"); + return Effect.fail(privateFailureCanary); } preimageCleaned = true; return Effect.void; @@ -352,7 +357,19 @@ const makeEnvironment = ( Effect.provideService(CheckpointStore, checkpointStore as never), Effect.provideService(RuntimeReceiptBus, receipts), Effect.provideService(RollbackFaultInjector, fault), - Effect.provide(NodeServices.layer), + Effect.provide( + Layer.mergeAll( + Logger.layer( + [ + Logger.make( + (event) => void emittedLogs.push(Logger.formatStructured.log(event)), + ), + ], + { mergeWithExisting: false }, + ), + NodeServices.layer, + ), + ), ); }; @@ -389,7 +406,19 @@ const makeEnvironment = ( projectionCommits, commands, runtimeReceipts, + emittedLogs, }), + assertPrivateDataAbsentFromPublicOutput: () => { + const publicOutput = JSON.stringify({ commands, runtimeReceipts, emittedLogs }); + for (const canary of [ + privateTargetCanary, + privateSourceCanary, + privatePreimageCanary, + privateFailureCanary, + ]) { + assert.notInclude(publicOutput, canary); + } + }, }; }; @@ -409,6 +438,7 @@ it.effect("commits last, clears private state, and never publishes private canar const runner = yield* environment.makeRunner(); yield* runner.run("operation-success", false); const snapshot = environment.snapshot(); + environment.assertPrivateDataAbsentFromPublicOutput(); assert.equal(snapshot.record.state.phase, "complete"); assert.isTrue(snapshot.record.terminal); @@ -550,6 +580,21 @@ it.effect("compensates workspace and provider when the provider stays at source" }), ); +it.effect("does not replay a persisted preimage under a mismatched checkpoint owner", () => + Effect.gen(function* () { + const environment = makeEnvironment("operation-owner-mismatch", "stayed-source"); + environment.setPersistedState({ + sourceCheckpointRef: CheckpointRef.make("refs/t3/checkpoints/foreign/turn/2"), + }); + const runner = yield* environment.makeRunner(); + yield* runner.run("operation-owner-mismatch", false); + const snapshot = environment.snapshot(); + assert.equal(snapshot.record.state.phase, "manual-recovery"); + assert.isFalse(snapshot.workspaceCalls.includes("restorePreimage")); + assert.equal(snapshot.workspaceDigest, "workspace-target"); + }), +); + it.effect( "fails closed in manual recovery when inspection finds an unrelated provider anchor", () => @@ -694,6 +739,7 @@ it.effect("releases only the worker owner and retries post-commit cleanup idempo assert.isFalse(complete.lease); assert.equal(complete.projectionCommits, 1); assert.isTrue(complete.preimageCleaned); + environment.assertPrivateDataAbsentFromPublicOutput(); }), ); @@ -750,6 +796,7 @@ for (const faultLabel of compensationFaultLabels) { assert.equal(snapshot.providerDigest, "provider-source"); assert.equal(snapshot.projectionCommits, 0); assert.isFalse(snapshot.lease); + environment.assertPrivateDataAbsentFromPublicOutput(); }), ); } @@ -767,6 +814,7 @@ it.effect("restores durable manual recovery status after a crash before status p yield* restartedRunner.run(operationId, true); const snapshot = environment.snapshot(); assert.equal(snapshot.record.state.phase, "manual-recovery"); + environment.assertPrivateDataAbsentFromPublicOutput(); assert.isFalse(snapshot.record.terminal); assert.isTrue(snapshot.lease); const statuses = snapshot.commands.filter( @@ -810,6 +858,7 @@ for (const faultLabel of restartFaultLabels) { yield* restartedRunner.run(operationId, true); const snapshot = environment.snapshot(); assert.equal(snapshot.record.state.phase, "complete"); + environment.assertPrivateDataAbsentFromPublicOutput(); assert.isTrue(snapshot.record.terminal); assert.isFalse(snapshot.lease); assert.equal(snapshot.workspaceDigest, "workspace-target"); @@ -874,6 +923,7 @@ for (const failure of ["provider", "projection"] as const) { snapshot.record.state.lastErrorCode, failure === "provider" ? "provider-target-retry-exhausted" : "projection-commit-cas-failed", ); + environment.assertPrivateDataAbsentFromPublicOutput(); if (failure === "projection") assert.deepEqual(snapshot.providerApplies, ["target", "source"]); const status = snapshot.commands.findLast( diff --git a/apps/server/src/rollback/RollbackSagaRunner.ts b/apps/server/src/rollback/RollbackSagaRunner.ts index 9a0ca895e5..7567868a7b 100644 --- a/apps/server/src/rollback/RollbackSagaRunner.ts +++ b/apps/server/src/rollback/RollbackSagaRunner.ts @@ -189,18 +189,26 @@ export const make = Effect.gen(function* () { record = workspaceStarted.value; const preimage = privatePreimage(record.state); if (preimage !== null) { - workspaceProved = yield* workspace - .restorePreimage({ - cwd: record.state.workspaceCwd, - preimage, - }) - .pipe( - Effect.tap(() => after("side-effect:workspace-compensated", record.operationId)), - Effect.match({ - onFailure: () => false, - onSuccess: (receipt) => receipt.digest === preimage.digest, - }), - ); + const validOwner = + record.state.sourceCheckpointRef === + checkpointRefForThreadTurn(record.state.threadId, record.state.sourceRevision) && + record.state.targetCheckpointRef === + checkpointRefForThreadTurn(record.state.threadId, record.state.targetRevision); + workspaceProved = validOwner + ? yield* workspace + .restorePreimage({ + cwd: record.state.workspaceCwd, + threadId: record.state.threadId, + preimage, + }) + .pipe( + Effect.tap(() => after("side-effect:workspace-compensated", record.operationId)), + Effect.match({ + onFailure: () => false, + onSuccess: (receipt) => receipt.digest === preimage.digest, + }), + ) + : false; } const workspaceComplete = yield* update(record, { @@ -332,6 +340,7 @@ export const make = Effect.gen(function* () { .capturePreimage({ operationId: state.operationId, cwd: state.workspaceCwd, + threadId: state.threadId, targetCheckpointOid: state.targetCheckpointOid, }) .pipe(Effect.result); diff --git a/apps/server/src/rollback/RollbackWorkspace.test.ts b/apps/server/src/rollback/RollbackWorkspace.test.ts index 9aa0b9c2ef..0ecba08840 100644 --- a/apps/server/src/rollback/RollbackWorkspace.test.ts +++ b/apps/server/src/rollback/RollbackWorkspace.test.ts @@ -8,6 +8,8 @@ import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; import * as NodeOS from "node:os"; import * as NodeUtil from "node:util"; +import { ThreadId } from "@t3tools/contracts"; +import { checkpointRefForThreadTurn } from "../checkpointing/Utils.ts"; import * as ServerConfig from "../config.ts"; import { RollbackWorkspace, layer as RollbackWorkspaceLive } from "./RollbackWorkspace.ts"; @@ -33,6 +35,9 @@ layer("RollbackWorkspace", (it) => { (cwd) => Effect.gen(function* () { const workspace = yield* RollbackWorkspace; + const threadId = ThreadId.make("thread-test"); + const targetRef = checkpointRefForThreadTurn(threadId, 1); + const sourceRef = checkpointRefForThreadTurn(threadId, 2); yield* Effect.promise(async () => { await run(cwd, ["init", "-b", "main"]); @@ -64,7 +69,7 @@ layer("RollbackWorkspace", (it) => { "-m", "target checkpoint", ]); - await run(cwd, ["update-ref", "refs/t3/checkpoints/thread-test/turn/1", targetOid]); + await run(cwd, ["update-ref", targetRef, targetOid]); await run(cwd, ["reset", "--hard", "HEAD"]); await NodeFSP.writeFile(NodePath.join(cwd, "tracked.txt"), "staged\n"); @@ -83,7 +88,7 @@ layer("RollbackWorkspace", (it) => { NodePath.join(cwd, "ignored-target.txt"), "source ignored path\n", ); - await run(cwd, ["update-ref", "refs/t3/checkpoints/thread-test/turn/2", "HEAD"]); + await run(cwd, ["update-ref", sourceRef, "HEAD"]); }); const sourceStatus = yield* Effect.promise(() => @@ -95,11 +100,12 @@ layer("RollbackWorkspace", (it) => { ); const target = yield* workspace.resolveCheckpoint({ cwd, - checkpointRef: "refs/t3/checkpoints/thread-test/turn/1", + checkpointRef: targetRef, }); const preimage = yield* workspace.capturePreimage({ operationId: "operation-workspace", cwd, + threadId, targetCheckpointOid: target.oid, }); assert.notEqual(preimage.digest.length, 0); @@ -149,17 +155,11 @@ layer("RollbackWorkspace", (it) => { "refs/heads/main", ); assert.equal( - yield* Effect.promise(() => - run(cwd, ["rev-parse", "refs/t3/checkpoints/thread-test/turn/2"]), - ), + yield* Effect.promise(() => run(cwd, ["rev-parse", sourceRef])), yield* Effect.promise(() => run(cwd, ["rev-parse", "HEAD"])), ); - yield* Effect.promise(async () => { - await run(cwd, ["update-ref", "-d", "refs/t3/checkpoints/thread-test/turn/2"]); - await run(cwd, ["update-ref", "refs/t3/checkpoints/rogue/turn/99", "HEAD"]); - }); - const restored = yield* workspace.restorePreimage({ cwd, preimage }); + const restored = yield* workspace.restorePreimage({ cwd, threadId, preimage }); assert.equal(restored.digest, preimage.digest); assert.equal( yield* Effect.promise(() => run(cwd, ["status", "--porcelain=v1", "-uall"])), @@ -179,6 +179,16 @@ layer("RollbackWorkspace", (it) => { ), sourceRefs, ); + const driftRef = checkpointRefForThreadTurn(threadId, 3); + yield* Effect.promise(() => run(cwd, ["update-ref", driftRef, "HEAD"])); + const driftRestore = yield* workspace + .restorePreimage({ cwd, threadId, preimage }) + .pipe(Effect.exit); + assert.equal(driftRestore._tag, "Failure"); + assert.equal( + yield* Effect.promise(() => run(cwd, ["rev-parse", driftRef])), + yield* Effect.promise(() => run(cwd, ["rev-parse", "HEAD"])), + ); assert.equal( yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(cwd, "nested", "untracked.txt"), "utf8"), @@ -222,6 +232,11 @@ layer("RollbackWorkspace", (it) => { (root) => Effect.gen(function* () { const workspace = yield* RollbackWorkspace; + const threadId = ThreadId.make("linked"); + const siblingThreadId = ThreadId.make("sibling"); + const targetRef = checkpointRefForThreadTurn(threadId, 1); + const siblingFirstRef = checkpointRefForThreadTurn(siblingThreadId, 1); + const siblingSecondRef = checkpointRefForThreadTurn(siblingThreadId, 2); const main = NodePath.join(root, "main"); const linked = NodePath.join(root, "linked"); yield* Effect.promise(async () => { @@ -237,7 +252,7 @@ layer("RollbackWorkspace", (it) => { await run(linked, ["add", "tracked.txt"]); const tree = await run(linked, ["write-tree"]); const checkpoint = await run(linked, ["commit-tree", tree, "-m", "linked checkpoint"]); - await run(linked, ["update-ref", "refs/t3/checkpoints/linked/turn/1", checkpoint]); + await run(linked, ["update-ref", targetRef, checkpoint]); await run(linked, ["reset", "--hard", "HEAD"]); await NodeFSP.writeFile(NodePath.join(linked, "tracked.txt"), "linked pre-image\n"); await run(linked, ["add", "tracked.txt"]); @@ -249,15 +264,37 @@ layer("RollbackWorkspace", (it) => { assert.equal(mainIdentity.gitCommonDir, linkedIdentity.gitCommonDir); assert.notEqual(mainIdentity.workspaceKey, linkedIdentity.workspaceKey); + const siblingFirst = yield* Effect.promise(async () => { + const oid = await run(main, [ + "commit-tree", + "HEAD^{tree}", + "-m", + "sibling first checkpoint", + ]); + await run(main, ["update-ref", siblingFirstRef, oid]); + return oid; + }); + const checkpoint = yield* workspace.resolveCheckpoint({ cwd: linked, - checkpointRef: "refs/t3/checkpoints/linked/turn/1", + checkpointRef: targetRef, }); const preimage = yield* workspace.capturePreimage({ operationId: "operation-linked", cwd: linked, + threadId, targetCheckpointOid: checkpoint.oid, }); + assert.deepEqual(preimage.ownedRefs, [{ ref: targetRef, oid: checkpoint.oid }]); + const siblingAdvanced = yield* Effect.promise(() => + run(main, ["commit-tree", "HEAD^{tree}", "-m", "sibling advanced checkpoint"]), + ); + yield* Effect.promise(async () => { + // Both worktrees share the Git common dir, but hold different + // rollback leases. B may write its refs while A is compensating. + await run(main, ["update-ref", siblingFirstRef, siblingAdvanced]); + await run(main, ["update-ref", siblingSecondRef, siblingFirst]); + }); yield* workspace.applyCheckpoint({ cwd: linked, checkpointOid: checkpoint.oid }); assert.equal( yield* Effect.promise(() => @@ -265,8 +302,26 @@ layer("RollbackWorkspace", (it) => { ), "checkpoint\n", ); - const restored = yield* workspace.restorePreimage({ cwd: linked, preimage }); + // Simulate a pre-upgrade persisted preimage containing all repository + // checkpoint refs. B's saved OID must have no recovery authority. + const legacyPreimage = { + ...preimage, + ownedRefs: [...preimage.ownedRefs, { ref: siblingFirstRef, oid: siblingFirst }], + }; + const restored = yield* workspace.restorePreimage({ + cwd: linked, + threadId, + preimage: legacyPreimage, + }); assert.equal(restored.digest, preimage.digest); + assert.equal( + yield* Effect.promise(() => run(main, ["rev-parse", siblingFirstRef])), + siblingAdvanced, + ); + assert.equal( + yield* Effect.promise(() => run(main, ["rev-parse", siblingSecondRef])), + siblingFirst, + ); assert.equal( yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(linked, "tracked.txt"), "utf8"), diff --git a/apps/server/src/rollback/RollbackWorkspace.ts b/apps/server/src/rollback/RollbackWorkspace.ts index 1cce852ca6..a7a0e36826 100644 --- a/apps/server/src/rollback/RollbackWorkspace.ts +++ b/apps/server/src/rollback/RollbackWorkspace.ts @@ -9,6 +9,8 @@ import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; import * as NodeUtil from "node:util"; +import type { ThreadId } from "@t3tools/contracts"; +import { checkpointRefPrefixForThread } from "../checkpointing/Utils.ts"; import { ServerConfig } from "../config.ts"; const execFileAsync = NodeUtil.promisify(NodeChildProcess.execFile); @@ -63,10 +65,12 @@ export interface RollbackWorkspaceShape { readonly capturePreimage: (input: { readonly operationId: string; readonly cwd: string; + readonly threadId: ThreadId; readonly targetCheckpointOid: string; }) => Effect.Effect; readonly restorePreimage: (input: { readonly cwd: string; + readonly threadId: ThreadId; readonly preimage: RollbackWorkspacePreimage; }) => Effect.Effect; readonly applyCheckpoint: (input: { @@ -229,37 +233,43 @@ async function clearMutablePaths( } } -async function restoreCheckpointRefs( +async function assertCheckpointRefsUnchanged( cwd: string, - ownedRefs: ReadonlyArray<{ readonly ref: string; readonly oid: string }>, + threadId: ThreadId, + savedRefs: ReadonlyArray<{ readonly ref: string; readonly oid: string }>, ): Promise { - const currentRaw = await git(cwd, [ - "for-each-ref", - "--format=%(refname)%00%(objectname)", - "refs/t3/checkpoints", - ]); - const currentRefs = currentRaw + if (!Array.isArray(savedRefs)) throw new Error("invalid-checkpoint-preimage"); + const prefix = checkpointRefPrefixForThread(threadId); + const expected = new Map(); + for (const entry of savedRefs) { + if (typeof entry?.ref !== "string" || typeof entry.oid !== "string") + throw new Error("invalid-checkpoint-preimage"); + // Older durable preimages contain every repository ref. Their only authority + // comes from the saga's persisted thread identity, never from this list. + if (!entry.ref.startsWith(prefix)) continue; + if (!/^[0-9a-f]{40,64}$/u.test(entry.oid) || expected.has(entry.ref)) + throw new Error("invalid-checkpoint-preimage"); + expected.set(entry.ref, entry.oid); + } + const current = await listThreadCheckpointRefs(cwd, threadId); + if (current.length !== expected.size || current.some(({ ref, oid }) => expected.get(ref) !== oid)) + throw new Error("checkpoint-ref-drift"); +} + +async function listThreadCheckpointRefs( + cwd: string, + threadId: ThreadId, +): Promise> { + const prefix = checkpointRefPrefixForThread(threadId); + const raw = await git(cwd, ["for-each-ref", "--format=%(refname)%00%(objectname)", prefix]); + return raw .split("\n") .filter(Boolean) - .map((line) => line.split("\0")[0] ?? ""); - const desired = new Map(ownedRefs.map((entry) => [entry.ref, entry.oid])); - const commands = [ - ...currentRefs.filter((ref) => !desired.has(ref)).map((ref) => `delete ${ref}`), - ...ownedRefs.map((entry) => `update ${entry.ref} ${entry.oid}`), - ]; - if (commands.length === 0) return; - await new Promise((resolve, reject) => { - const child = NodeChildProcess.execFile( - "git", - ["update-ref", "--stdin"], - { cwd, timeout: 30_000, windowsHide: true }, - (error) => { - if (error) reject(error); - else resolve(); - }, - ); - child.stdin?.end(`${commands.join("\n")}\n`); - }); + .map((line) => { + const [ref = "", oid = ""] = line.split("\0"); + return { ref, oid }; + }) + .filter(({ ref }) => ref.startsWith(prefix)); } async function resolveHead(cwd: string): Promise<{ symbolic: string | null; oid: string | null }> { @@ -407,18 +417,7 @@ const make = Effect.gen(function* () { ); if (indexExisted) await NodeFSP.copyFile(indexPath, NodePath.join(backupPath, "index")); const head = await resolveHead(identity.cwd); - const refsRaw = await git(identity.cwd, [ - "for-each-ref", - "--format=%(refname)%00%(objectname)", - "refs/t3/checkpoints", - ]); - const ownedRefs = refsRaw - .split("\n") - .filter(Boolean) - .map((line) => { - const [ref = "", oid = ""] = line.split("\0"); - return { ref, oid }; - }); + const ownedRefs = await listThreadCheckpointRefs(identity.cwd, input.threadId); const receipt = await inspectWorkspace(identity.cwd, mutablePaths); return { backupPath, @@ -443,6 +442,7 @@ const make = Effect.gen(function* () { beforeHead.oid !== input.preimage.headOid ) throw new Error("head-drift"); + await assertCheckpointRefsUnchanged(identity.cwd, input.threadId, input.preimage.ownedRefs); await clearMutablePaths(identity.cwd, input.preimage.paths); await copyBackup(NodePath.join(input.preimage.backupPath, "workspace"), identity.cwd); if (input.preimage.indexExisted) { @@ -454,7 +454,7 @@ const make = Effect.gen(function* () { } else { await NodeFSP.rm(input.preimage.indexPath, { force: true }); } - await restoreCheckpointRefs(identity.cwd, input.preimage.ownedRefs); + await assertCheckpointRefsUnchanged(identity.cwd, input.threadId, input.preimage.ownedRefs); const receipt = await inspectWorkspace(identity.cwd, input.preimage.paths); if (receipt.digest !== input.preimage.digest) throw new Error("preimage-postcondition"); return receipt; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index e3eb8af1cb..ad3522a770 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -142,6 +142,12 @@ import { OrchestrationEventStore } from "./persistence/Services/OrchestrationEve import { RollbackSagaRepository } from "./persistence/Services/RollbackSagas.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ModelManifest from "./provider/ModelManifest.ts"; +import { + ProviderVersionCache, + type ProviderVersionCacheEntry, +} from "./provider/providerMaintenance.ts"; +import type { ProviderInstance } from "./provider/ProviderDriver.ts"; import { ProviderAdapterUnsupportedOperationError, ProviderValidationError, @@ -225,6 +231,7 @@ import { } from "../integration/TransferBudgetReport.integration.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import { DEFAULT_SIGNAL_EXPORT, otlpSerializationLayer } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; const defaultProjectId = ProjectId.make("project-default"); const defaultThreadId = ThreadId.make("thread-default"); @@ -506,6 +513,8 @@ const buildAppUnderTest = (options?: { antigravityInstallation?: Partial; environmentTheme?: Partial; providerRegistry?: Partial; + modelManifest?: Partial; + providerVersionCache?: Map; providerService?: Partial; rollbackSagaRepository?: Partial; serverSettings?: Partial; @@ -568,6 +577,7 @@ const buildAppUnderTest = (options?: { otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "t3-server", + otelEnvironment: OtelEnvironment.none, mode: "desktop", port: 0, host: "127.0.0.1", @@ -767,6 +777,14 @@ const buildAppUnderTest = (options?: { getInstance: () => Effect.succeed(undefined), ...options?.layers?.providerInstances, }), + Layer.mock(ModelManifest.ModelManifest)({ + current: Effect.succeed(ModelManifest.BUNDLED_MODEL_MANIFEST), + refresh: Effect.succeed(ModelManifest.BUNDLED_MODEL_MANIFEST), + forceRefresh: Effect.succeed(ModelManifest.BUNDLED_MODEL_MANIFEST), + refreshInBackground: Effect.void, + ...options?.layers?.modelManifest, + }), + Layer.succeed(ProviderVersionCache, options?.layers?.providerVersionCache ?? new Map()), Layer.mock(EnvironmentTheme.EnvironmentThemeService)({ current: Effect.succeed([]), streamChanges: Stream.empty, @@ -6428,6 +6446,93 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + for (const mode of ["all", "targeted", "background"] as const) { + it.effect(`provider refresh invalidates owned caches before probing (${mode})`, () => { + const driver = ProviderDriverKind.make("codex"); + const instanceIds = [ProviderInstanceId.make("codex"), ProviderInstanceId.make("codex_work")]; + const packageNames = ["@example/personal", "@example/work"]; + const versionCache = new Map( + packageNames.map((name) => [ + name, + { expiresAt: Number.MAX_SAFE_INTEGER, version: "1.0.0" }, + ]), + ); + const invalidated: string[] = []; + const freshMaintenance: string[] = []; + let manifestRefreshed = false; + let probed = false; + const instances = instanceIds.map( + (instanceId, index) => + ({ + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: instanceId }, + displayName: undefined, + enabled: true, + invalidateCaches: Effect.sync(() => { + invalidated.push(instanceId); + }), + snapshot: { + resolveMaintenance: (options) => + Effect.sync(() => { + assert.isTrue(options?.fresh); + freshMaintenance.push(instanceId); + return makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: packageNames[index]!, + }); + }), + getSnapshot: Effect.never, + refresh: Effect.never, + streamChanges: Stream.empty, + }, + adapter: {} as ProviderInstance["adapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }) satisfies ProviderInstance, + ); + const expected = + mode === "background" ? [] : mode === "targeted" ? [instanceIds[1]!] : instanceIds; + const probe = Effect.sync(() => { + probed = true; + assert.equal(manifestRefreshed, mode !== "background"); + assert.deepEqual(invalidated.toSorted(), expected.toSorted()); + assert.deepEqual(freshMaintenance.toSorted(), expected.toSorted()); + for (let index = 0; index < instanceIds.length; index++) { + assert.equal( + versionCache.has(packageNames[index]!), + !expected.includes(instanceIds[index]!), + ); + } + return []; + }); + return Effect.gen(function* () { + yield* buildAppUnderTest({ + layers: { + modelManifest: { + forceRefresh: Effect.sync(() => { + manifestRefreshed = true; + return ModelManifest.BUNDLED_MODEL_MANIFEST; + }), + }, + providerVersionCache: versionCache, + providerInstances: { listInstances: Effect.succeed(instances) }, + providerRegistry: { refresh: () => probe, refreshInstance: () => probe }, + }, + }); + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.serverRefreshProviders]({ + ...(mode === "targeted" ? { instanceId: instanceIds[1]! } : {}), + ...(mode !== "background" ? { refreshModels: true } : {}), + }), + ), + ); + assert.isTrue(probed); + }).pipe(Effect.provide(NodeHttpServer.layerTest)); + }); + } + it.effect("serves config on reconnect without starting provider probes", () => Effect.gen(function* () { const refresh = vi.fn(() => Effect.never); @@ -11119,6 +11224,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.deepEqual(fetchRemote.mock.calls[0]?.[0], { cwd: "/tmp/project", remoteName: "origin", + refName: "main", }); assert.deepEqual(remoteBranchExists.mock.calls[0]?.[0], { cwd: "/tmp/project", diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index d6abdfd197..c259526a2a 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -9,6 +9,7 @@ import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; +import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import * as ServerConfig from "./config.ts"; import { ServerLoggerLive } from "./serverLogger.ts"; @@ -57,6 +58,7 @@ const configLayer = (overrides: Partial) = otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, otlpServiceName: "pylon-server", + otelEnvironment: OtelEnvironment.none, cwd: baseDir, baseDir, ...derivedPaths, diff --git a/apps/server/src/sourceControl/PrTemplateDetection.test.ts b/apps/server/src/sourceControl/PrTemplateDetection.test.ts index 5b91e63111..410981603d 100644 --- a/apps/server/src/sourceControl/PrTemplateDetection.test.ts +++ b/apps/server/src/sourceControl/PrTemplateDetection.test.ts @@ -55,6 +55,9 @@ const runWithTempDirectory = ( const fileSystem = yield* FileSystem.FileSystem; const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-pr-template-" }); yield* runGit(cwd, ["init", "--initial-branch=main"]); + // Auto maintenance may still write object packs after the scoped fixture + // starts removing this temporary repository under parallel CI load. + yield* runGit(cwd, ["config", "gc.auto", "0"]); yield* runGit(cwd, ["config", "user.email", "test@example.com"]); yield* runGit(cwd, ["config", "user.name", "Test User"]); return yield* test(cwd); diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index ec1c88ccfd..549d0a96a0 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -8,7 +8,9 @@ import * as NodeSqlite from "node:sqlite"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { mergeUsage } from "@t3tools/shared/usageMerge"; import { + EnvironmentId, ProviderDriverKind, ProviderInstanceId, ServerProviderInstancesMutationId, @@ -139,7 +141,7 @@ describe("UsageService", () => { await NodeFSP.mkdir(NodePath.join(claudeHome, "projects"), { recursive: true }); await NodeFSP.writeFile( NodePath.join(claudeHome, "projects", "session.jsonl"), - claudeLine(2, 7), + claudeLine(1, 5) + claudeLine(2, 7), ); await NodeFSP.mkdir(NodePath.join(codexHome, "sessions"), { recursive: true }); await NodeFSP.symlink(codexHome, alias, "junction"); @@ -148,14 +150,15 @@ describe("UsageService", () => { [ { type: "session_meta", payload: { id: "codex-account-session" } }, { type: "turn_context", payload: { model: "gpt-5.6-sol" } }, - { + // A-B-A at one timestamp must preserve both equal A events. + ...[11, 12, 11].map((outputTokens) => ({ type: "event_msg", timestamp: "2026-08-01T10:00:00Z", payload: { type: "token_count", - info: { last_token_usage: { input_tokens: 10, output_tokens: 11 } }, + info: { last_token_usage: { input_tokens: 10, output_tokens: outputTokens } }, }, - }, + })), ] .map((line) => encodeUnknownJsonString(line)) .join("\n") + "\n", @@ -215,7 +218,21 @@ describe("UsageService", () => { ), ); const summary = yield* service.readSummary(WINDOW); - assert.strictEqual(totalOutputTokens(summary), 36); + assert.strictEqual(totalOutputTokens(summary), 59); + yield* Effect.promise(() => + NodeFSP.rename( + NodePath.join(codexHome, "sessions", "rollout.jsonl"), + NodePath.join(codexHome, "sessions", "moved.jsonl"), + ), + ); + const moved = yield* service.readSummary(WINDOW); + assert.deepStrictEqual(moved.buckets, summary.buckets); + yield* Effect.promise(() => + NodeFSP.rm(NodePath.join(codexHome, "sessions"), { recursive: true }), + ); + const removed = yield* service.readSummary(WINDOW); + assert.deepStrictEqual(removed.buckets, summary.buckets); + const sources = summary.sources.filter((source) => source.status === "ok"); assert.strictEqual(sources.length, 4); assert.strictEqual( @@ -226,6 +243,20 @@ describe("UsageService", () => { sources.filter((source) => source.fingerprint.provider === "codex").length, 1, ); + assert.strictEqual( + sources.reduce( + (sum, source) => + sum + + (source.buckets ?? []).reduce((total, bucket) => total + bucket.totals.outputTokens, 0), + 0, + ), + totalOutputTokens(summary), + ); + assert.isTrue( + sources.every((source) => + source.buckets?.every((bucket) => bucket.provider === source.fingerprint.provider), + ), + ); }).pipe(Effect.scoped), ); @@ -458,6 +489,128 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("preserves saved tokens, costs and sessions after transcript cleanup and restart", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const alias = NodePath.join(home, "claude-alias"); + yield* Effect.promise(() => + NodeFSP.symlink(NodePath.join(home, "claude"), alias, "junction"), + ); + const content = claudeLine(1, 5); + yield* Effect.promise(() => NodeFSP.writeFile(transcript, content)); + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const first = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(first), 5); + assert.isAbove(first.buckets[0]?.costUsd ?? 0, 0); + + yield* Effect.promise(() => NodeFSP.rm(transcript)); + const deleted = yield* service.readSummary(WINDOW); + assert.deepStrictEqual(deleted.buckets, first.buckets); + assert.deepStrictEqual(deleted.sources, first.sources); + + const restarted = yield* UsageService.make; + const restored = yield* restarted.readSummary(WINDOW); + assert.deepStrictEqual(restored.buckets, first.buckets); + assert.deepStrictEqual(restored.sources, first.sources); + + // A moved transcript must not count the saved usage twice. + yield* Effect.promise(() => NodeFSP.writeFile(transcript + ".jsonl", content)); + const moved = yield* restarted.readSummary(WINDOW); + assert.deepStrictEqual(moved.buckets, first.buckets); + assert.strictEqual(moved.sources[0]?.distinctSessions, 1); + + const replacementProjects = NodePath.join(home, "replacement-projects"); + yield* Effect.promise(() => NodeFSP.mkdir(replacementProjects)); + yield* Effect.promise(() => + NodeFSP.rm(NodePath.join(home, "claude", "projects"), { recursive: true }), + ); + const afterRootCleanup = yield* UsageService.make; + const missingRoot = yield* afterRootCleanup.readSummary(WINDOW); + assert.deepStrictEqual(missingRoot.buckets, first.buckets); + assert.strictEqual(missingRoot.sources[0]?.distinctSessions, 1); + assert.strictEqual(missingRoot.sources[0]?.status, "ok"); + assert.deepStrictEqual(missingRoot.sources[0]?.fingerprint, first.sources[0]?.fingerprint); + yield* Effect.promise(async () => { + const projects = NodePath.join(home, "claude", "projects"); + await NodeFSP.rename(replacementProjects, projects); + await NodeFSP.writeFile(NodePath.join(projects, "new.jsonl"), claudeLine(2, 7)); + }); + const recreated = yield* afterRootCleanup.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(recreated), 12); + assert.deepStrictEqual(recreated.sources[0]?.fingerprint, first.sources[0]?.fingerprint); + + const merged = mergeUsage( + [ + { + environmentId: EnvironmentId.make("cleanup-test"), + label: "test", + summary: recreated, + }, + { + environmentId: EnvironmentId.make("other-environment"), + label: "before cleanup", + summary: first, + }, + ], + missingRoot.contractVersion, + ); + assert.strictEqual(merged.outputTokens, 12); + assert.strictEqual(merged.sessions, 1); + assert.strictEqual(merged.costUsd, recreated.buckets[0]?.costUsd); + + const outsideWindow = yield* restarted.readSummary({ + ...WINDOW, + sinceDay: UsageDay.make("2026-08-02"), + }); + assert.deepStrictEqual(outsideWindow.buckets, []); + assert.strictEqual(outsideWindow.sources[0]?.distinctSessions, 0); + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-cleanup-test", + home, + settings: { providers: { ...settings.providers, claudeAgent: { homePath: alias } } }, + ratesDocument: { + "claude-fable-5": { input_cost_per_token: 1e-5, output_cost_per_token: 5e-5 }, + }, + }), + ), + ); + }).pipe(Effect.scoped), + ); + + it.live("drops stale source identities after an empty account home is replaced", () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const replacement = NodePath.join(home, "replacement-claude"); + yield* Effect.promise(() => + NodeFSP.mkdir(NodePath.join(replacement, "projects"), { recursive: true }), + ); + yield* Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const settingsService = yield* ServerSettings.ServerSettingsService; + const service = yield* UsageService.make; + yield* service.readSummary(WINDOW); + const cachePath = NodePath.join(config.stateDir, "usage-scan-cache.json"); + const oldDir = NodePath.join(home, "claude", "projects"); + assert.include(yield* Effect.promise(() => NodeFSP.readFile(cachePath, "utf8")), oldDir); + + yield* settingsService.updateSettings({ + providers: { claudeAgent: { homePath: replacement } }, + }); + yield* service.readSummary(WINDOW); + const persisted = yield* Effect.promise(() => NodeFSP.readFile(cachePath, "utf8")); + assert.notInclude(persisted, oldDir); + assert.include(persisted, NodePath.join(replacement, "projects")); + }).pipe( + Effect.provide( + serviceLayers({ prefix: "usage-service-source-prune-test", home, settings }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live("does not share an in-flight scan after custom prices change", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -769,6 +922,120 @@ describe("UsageService", () => { ), ); + it.live("retains Antigravity usage after conversation cleanup and service restart", () => + Effect.gen(function* () { + yield* setup; + const config = yield* ServerConfig.ServerConfig; + const instanceId = ProviderInstanceId.make("antigravity-retained"); + const profileDir = resolveAntigravityProfileDirectory(config.stateDir, instanceId); + const convDir = NodePath.join(profileDir, "antigravity-acp", "conversations"); + yield* Effect.promise(() => NodeFSP.mkdir(convDir, { recursive: true })); + const dbPath = NodePath.join(convDir, "retained.db"); + seedAntigravityDb(dbPath, [ + { + idx: 0, + blob: encodeSyntheticGenMetadataBlob({ + modelName: "gemini-2.5-pro", + timestampSeconds: 1785578400n, + inputTokens: 100, + outputTokens: 40, + }), + }, + ]); + + const service = yield* UsageService.make; + const first = yield* service.readSummary(WINDOW); + yield* Effect.promise(() => NodeFSP.rm(convDir, { recursive: true })); + const afterCleanup = yield* service.readSummary(WINDOW); + const restarted = yield* UsageService.make; + const afterRestart = yield* restarted.readSummary(WINDOW); + + for (const [phase, summary] of [ + ["cleanup", afterCleanup], + ["restart", afterRestart], + ] as const) { + assert.deepStrictEqual(summary.buckets, first.buckets, phase); + const source = summary.sources.find( + (item) => + item.fingerprint.provider === "antigravity" && + item.fingerprint.resolvedHomePath === convDir, + ); + assert.isDefined(source); + assert.strictEqual(source.status, "ok"); + assert.strictEqual(source.scannedFiles, 1); + } + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-antigravity-retained-test", + home: "", + settings: { + providers: {}, + providerInstances: { + [ProviderInstanceId.make("antigravity-retained")]: { + driver: "antigravity", + enabled: true, + }, + }, + }, + }), + ), + Effect.scoped, + ), + ); + + it.live( + "retains standalone Antigravity history after its directory is removed and service restarts", + () => + Effect.gen(function* () { + yield* setup; + const config = yield* ServerConfig.ServerConfig; + const convDir = NodePath.join(config.stateDir, "standalone-antigravity", "conversations"); + yield* Effect.promise(() => NodeFSP.mkdir(convDir, { recursive: true })); + seedAntigravityDb(NodePath.join(convDir, "session.db"), [ + { + idx: 0, + blob: encodeSyntheticGenMetadataBlob({ + modelName: "gemini-2.5-pro", + timestampSeconds: 1785578400n, + inputTokens: 100, + outputTokens: 40, + }), + }, + ]); + + yield* Effect.gen(function* () { + const firstService = yield* UsageService.make; + const first = yield* firstService.readSummary(WINDOW); + assert.isTrue(first.buckets.some((bucket) => bucket.provider === "antigravity")); + yield* Effect.promise(() => NodeFSP.rm(convDir, { recursive: true })); + + for (const summary of [ + yield* firstService.readSummary(WINDOW), + yield* (yield* UsageService.make).readSummary(WINDOW), + ]) { + assert.deepStrictEqual(summary.buckets, first.buckets); + const source = summary.sources.find( + (item) => + item.fingerprint.provider === "antigravity" && + item.fingerprint.resolvedHomePath === convDir, + ); + assert.isDefined(source); + assert.strictEqual(source.status, "ok"); + } + }).pipe(Effect.provideService(UsageService.StandaloneAntigravityConversations, convDir)); + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-standalone-antigravity-retained-test", + home: "", + settings: { providers: {}, providerInstances: {} }, + }), + ), + Effect.scoped, + ), + ); + it.live( "reports honest missing status when configured Antigravity instance has no conversations dir on disk", () => diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 122b89436e..7ad01d09c4 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -14,6 +14,8 @@ * @module UsageService */ import * as NodeOS from "node:os"; +// @effect-diagnostics nodeBuiltinImport:off - Context.Reference defaults cannot request Path. +import * as NodePath from "node:path"; import { ClaudeSettings, @@ -90,6 +92,18 @@ const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; /** Longest window the UI offers, plus slack. Older entries are pruned. */ const CACHE_RETENTION_DAYS = 90; +const MAX_ANTIGRAVITY_FILES_PER_DIR = 500; +const MAX_ANTIGRAVITY_BYTES_PER_DIR = 256 * 1024 * 1024; +const MAX_ANTIGRAVITY_RECORDS_PER_DIR = 50_000; + +/** The standalone Antigravity home belongs to this server environment. */ +export const StandaloneAntigravityConversations = Context.Reference( + "StandaloneAntigravityConversations", + { + defaultValue: () => + NodePath.join(NodeOS.homedir(), ".gemini", "antigravity-cli", "conversations"), + }, +); const decodeCodexSettings = Schema.decodeOption(CodexSettings); const decodeClaudeSettings = Schema.decodeOption(ClaudeSettings); @@ -110,6 +124,11 @@ const encodeRatesCache = Schema.encodeEffect( const ScanCacheJson = Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec); const decodeScanCacheFile = Schema.decodeUnknownEffect(ScanCacheJson); const encodeScanCacheFile = Schema.encodeEffect(ScanCacheJson); +const encodeUsageRecordKey = Schema.encodeSync(ScanCacheJson); +const CachedSource = Schema.Struct({ dir: Schema.String, volumeId: Schema.String }); +const decodeCachedSources = Schema.decodeUnknownOption( + Schema.Struct({ sources: Schema.Record(Schema.String, CachedSource) }), +); export class UsageService extends Context.Service< UsageService, @@ -156,7 +175,12 @@ export const make = Effect.gen(function* () { const hostEnvironment = yield* HostProcessEnvironment; const fileCache: ScanCache = new Map(); + const sourceCache = new Map(); let cacheDirty = false; + const isWithinDirectory = (filePath: string, dir: string) => { + const relative = path.relative(dir, filePath); + return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative); + }; const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); const scanCachePath = path.join(config.stateDir, "usage-scan-cache.json"); @@ -250,9 +274,16 @@ export const make = Effect.gen(function* () { /** Resolves the transcript directory for each provider. */ const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* ( settings: ServerSettingsValue, + retentionCutoffMs: number, ) { - const dirs: Array<{ provider: UsageProviderKind; dir: string; fileName?: string }> = []; + const dirs: Array<{ + provider: UsageProviderKind; + dir: string; + volumeId: string; + fileName?: string; + }> = []; const seen = new Set(); + const activeSourceKeys = new Set(); for (const driver of ["claudeAgent", "codex", "grok"] as const) { // Disabled accounts still have history. Explicit default slots replace // the legacy settings, just as they do in the provider registry. @@ -289,14 +320,42 @@ export const make = Effect.gen(function* () { ); } const directory = path.resolve(home, provider === "claude" ? "projects" : "sessions"); - // Account aliases and Codex auth overlays can share the same history. + const sourceKey = provider + "\0" + directory; + const previous = sourceCache.get(sourceKey); + // Keep canonical paths and source fingerprints stable after root cleanup, + // including aliases and clients merging pre-cleanup environment summaries. const dir = yield* fileSystem .realPath(directory) - .pipe(Effect.orElseSucceed(() => directory)); + .pipe(Effect.orElseSucceed(() => previous?.dir ?? directory)); + const currentVolumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); + const hasRetainedHistory = fileCache + .entries() + .some( + ([filePath, entry]) => + entry.provider === provider && + entry.mtimeMs >= retentionCutoffMs && + entry.records.length + entry.tailRecords.length > 0 && + isWithinDirectory(filePath, dir), + ); + // A recreated directory still reports the retained history under its old identity. + const volumeId = + previous?.dir === dir && (hasRetainedHistory || !currentVolumeId) + ? previous.volumeId || currentVolumeId + : currentVolumeId; + if (previous?.dir !== dir || previous.volumeId !== volumeId) { + sourceCache.set(sourceKey, { dir, volumeId }); + cacheDirty = true; + } const key = `${provider}\0${dir}`; + activeSourceKeys.add(sourceKey); if (seen.has(key)) continue; seen.add(key); - dirs.push({ provider, dir, ...(provider === "grok" ? { fileName: "updates.jsonl" } : {}) }); + dirs.push({ + provider, + dir, + volumeId, + ...(provider === "grok" ? { fileName: "updates.jsonl" } : {}), + }); } } // Antigravity profile roots: resolve configured instances @@ -324,21 +383,69 @@ export const make = Effect.gen(function* () { } // Standalone ~/.gemini/antigravity-cli/conversations when present - const standaloneDir = path.join( - NodeOS.homedir(), - ".gemini", - "antigravity-cli", - "conversations", - ); + const standaloneDir = yield* StandaloneAntigravityConversations; const standaloneExists = yield* fileSystem .exists(standaloneDir) .pipe(Effect.catchCause(() => Effect.succeed(false))); - if (standaloneExists) { + const hasStandaloneHistory = fileCache + .entries() + .some( + ([filePath, entry]) => + entry.provider === "antigravity" && + entry.mtimeMs >= retentionCutoffMs && + entry.records.length + entry.tailRecords.length > 0 && + isWithinDirectory(filePath, standaloneDir), + ); + if (standaloneExists || hasStandaloneHistory) { antigravityRoots.add(standaloneDir); } for (const root of antigravityRoots) { - dirs.push({ provider: "antigravity" as const, dir: root }); + const directory = path.resolve(root); + const sourceKey = "antigravity\0" + directory; + const previous = sourceCache.get(sourceKey); + // Pylon's Antigravity profile fingerprint is the configured profile + // path, not its realpath. Keep it stable across profile cleanup. + const dir = directory; + const currentVolumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); + const hasRetainedHistory = fileCache + .entries() + .some( + ([filePath, entry]) => + entry.provider === "antigravity" && + entry.mtimeMs >= retentionCutoffMs && + entry.records.length + entry.tailRecords.length > 0 && + isWithinDirectory(filePath, dir), + ); + const volumeId = + previous?.dir === dir && (hasRetainedHistory || !currentVolumeId) + ? previous.volumeId || currentVolumeId + : currentVolumeId; + if (previous?.dir !== dir || previous.volumeId !== volumeId) { + sourceCache.set(sourceKey, { dir, volumeId }); + cacheDirty = true; + } + activeSourceKeys.add(sourceKey); + dirs.push({ provider: "antigravity", dir, volumeId }); + } + + // Old configured homes can disappear from settings. Keep their identity + // only while an in-retention cached transcript could use it again. + for (const [sourceKey, source] of sourceCache) { + if (activeSourceKeys.has(sourceKey)) continue; + const provider = sourceKey.split("\0", 1)[0]; + const hasRetainedHistory = fileCache + .entries() + .some( + ([filePath, entry]) => + entry.provider === provider && + entry.mtimeMs >= retentionCutoffMs && + entry.records.length + entry.tailRecords.length > 0 && + isWithinDirectory(filePath, source.dir), + ); + if (hasRetainedHistory) continue; + sourceCache.delete(sourceKey); + cacheDirty = true; } return dirs; @@ -359,6 +466,11 @@ export const make = Effect.gen(function* () { ); if (document === null) return; for (const [path, entry] of decodeScanCache(document)) fileCache.set(path, entry); + const sources = decodeCachedSources(document); + if (Option.isSome(sources)) { + for (const [key, source] of Object.entries(sources.value.sources)) + sourceCache.set(key, source); + } }), ); @@ -366,7 +478,10 @@ export const make = Effect.gen(function* () { if (!cacheDirty) return; // Cleared only after the write lands, so a failed persist is retried on // the next scan instead of leaving disk permanently stale. - yield* encodeScanCacheFile(encodeScanCache(fileCache)).pipe( + yield* encodeScanCacheFile({ + ...encodeScanCache(fileCache), + sources: Object.fromEntries(sourceCache), + }).pipe( Effect.flatMap((serialized) => fileSystem.writeFileString(scanCachePath, serialized)), Effect.map(() => { cacheDirty = false; @@ -417,7 +532,8 @@ export const make = Effect.gen(function* () { ); // A read failure is not an empty transcript: caching it under this // (size, mtime) would silently drop the file's usage until it changes. - if (parsed === null) return []; + if (parsed === null) + return cached?.provider === provider ? [...cached.records, ...cached.tailRecords] : []; // Stored already de-duplicated within the file, which is 99% of all // duplicates. The aggregator still runs the cross-file dedupe pass. One @@ -452,24 +568,25 @@ export const make = Effect.gen(function* () { readonly status?: "ok" | "missing" | "partial" | "failed"; readonly malformedRecords?: number; readonly message?: string | null; + readonly nativeBudget?: { + readonly files: number; + readonly bytes: number; + readonly records: number; + }; } const collectDirs = Effect.fn("UsageService.collectDirs")(function* ( windowStartMs: number, settings: ServerSettingsValue, + retentionCutoffMs: number, ) { // 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(settings).pipe( + const dirs = yield* resolveTranscriptDirs(settings, retentionCutoffMs).pipe( Effect.provideService(Path.Path, path), ); const scanned: ScannedDir[] = []; - // Bound native SQLite work across all instance roots, including warm-cache records. - let nativeFiles = 0; - let nativeBytes = 0; - let nativeRecords = 0; - for (const { provider, dir, fileName } of dirs) { - const volumeId = yield* Effect.promise(() => readDirectoryVolumeId(dir)); + for (const { provider, dir, volumeId, fileName } of dirs) { const exists = yield* fileSystem .exists(dir) .pipe(Effect.catchCause(() => Effect.succeed(false))); @@ -479,6 +596,10 @@ export const make = Effect.gen(function* () { } if (provider === "antigravity") { + // Native SQLite limits apply to each profile directory independently. + let nativeFiles = 0; + let nativeBytes = 0; + let nativeRecords = 0; const dirEntries = yield* fileSystem .readDirectory(dir) .pipe(Effect.catchCause(() => Effect.succeed(null))); @@ -497,10 +618,6 @@ export const make = Effect.gen(function* () { const dbFileNames = dirEntries.filter((name) => name.endsWith(".db")).sort(); - const MAX_ANTIGRAVITY_FILES_PER_DIR = 500; - const MAX_ANTIGRAVITY_BYTES_PER_DIR = 256 * 1024 * 1024; - const MAX_ANTIGRAVITY_RECORDS_PER_DIR = 50_000; - const parsedFiles: { path: string; records: readonly UsageRecord[] }[] = []; let dirMalformedRows = 0; let dirTruncated = false; @@ -683,6 +800,7 @@ export const make = Effect.gen(function* () { status, malformedRecords: dirMalformedRows, message, + nativeBudget: { files: nativeFiles, bytes: nativeBytes, records: nativeRecords }, }); continue; } @@ -749,11 +867,13 @@ export const make = Effect.gen(function* () { const windowStartMs = (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; + const retentionCutoffMs = startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000; + // 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(false), collectDirs(windowStartMs, settings)], + [ensureRates(false), collectDirs(windowStartMs, settings, retentionCutoffMs)], { concurrency: 2 }, ); @@ -768,8 +888,6 @@ export const make = Effect.gen(function* () { }); const sources: UsageSource[] = []; - const livePaths = new Set(); - const walkedRoots: string[] = []; for (const { provider, @@ -779,60 +897,114 @@ export const make = Effect.gen(function* () { status, malformedRecords, message, + nativeBudget, } of scannedDirs) { - if (files === null) { - sources.push({ - fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, - status: "missing", - scannedFiles: 0, - skippedFiles: 0, - malformedRecords: 0, - distinctSessions: 0, - message: "No transcript directory on this environment.", - }); - continue; + const retainedFiles = [...(files ?? [])]; + let retainedTruncated = false; + let retainedNativeFiles = nativeBudget?.files ?? 0; + let retainedNativeBytes = nativeBudget?.bytes ?? 0; + let retainedNativeRecords = nativeBudget?.records ?? 0; + const livePaths = new Set(retainedFiles.map((file) => file.path)); + // Cleanup may remove transcripts, but the usage we already saved still + // contributes to this source. Keep the normal aggregation and dedupe path. + for (const [filePath, entry] of fileCache) { + if ( + entry.provider !== provider || + entry.mtimeMs < retentionCutoffMs || + livePaths.has(filePath) || + !isWithinDirectory(filePath, dir) + ) + continue; + if (provider === "antigravity") { + const recordCount = entry.records.length + entry.tailRecords.length; + if ( + retainedNativeFiles + 1 > MAX_ANTIGRAVITY_FILES_PER_DIR || + retainedNativeBytes + entry.size > MAX_ANTIGRAVITY_BYTES_PER_DIR || + retainedNativeRecords + recordCount > MAX_ANTIGRAVITY_RECORDS_PER_DIR + ) { + retainedTruncated = true; + continue; + } + retainedNativeFiles++; + retainedNativeBytes += entry.size; + retainedNativeRecords += recordCount; + } + retainedFiles.push({ path: filePath, records: [...entry.records, ...entry.tailRecords] }); } - - walkedRoots.push(dir); let scannedFiles = 0; let skippedFiles = 0; // Distinct per directory. Buckets carry per-cell session counts, but a // session spans days and models, so clients total this figure instead. const sessionIds = new Set(); + // Keep attribution at the physical directory boundary. The primary + // aggregator decides scan-wide de-duplication first, so source buckets + // partition the same accepted records as the legacy flat buckets. + const sourceAggregator = new UsageAggregator({ + timeZone: input.timeZone, + sinceDay: input.sinceDay, + untilDay: input.untilDay, + resolution: input.resolution ?? "day", + ...hourlyWindow, + rates, + priceOverrides: createOverrideRateTable(settings.usagePriceOverrides), + }); - for (const file of files) { - livePaths.add(file.path); + for (const file of retainedFiles) { if (file.records.length === 0) { skippedFiles += 1; continue; } scannedFiles += 1; + const codexEventOccurrences = new Map(); for (const record of file.records) { - // Only sessions that contributed in-window count: the mtime slack - // admits boundary files whose records fall outside the range. - if (aggregator.add(record) && record.sessionId.length > 0) { - sessionIds.add(record.sessionId); + let usageRecord = record; + if (record.provider === "codex" && record.sessionId.length > 0) { + // Match moved rollout copies without collapsing repeated equal events + // within one rollout (timestamps can have only second precision). + const key = encodeUsageRecordKey([ + record.provider, + record.sessionId, + record.timestampMs, + record.model, + record.totals, + ]); + const occurrence = (codexEventOccurrences.get(key) ?? 0) + 1; + codexEventOccurrences.set(key, occurrence); + usageRecord = { ...record, dedupeKey: key + ":" + occurrence }; + } + // Only sessions contributing in-window count; the mtime slack can + // admit boundary files whose records fall outside the range. + if (aggregator.add(usageRecord)) { + sourceAggregator.add(usageRecord); + if (record.sessionId.length > 0) sessionIds.add(record.sessionId); } } } sources.push({ fingerprint: { hostId, provider, resolvedHomePath: dir, volumeId }, - status: status ?? "ok", + // Saved records remain available after cleanup. Preserve Pylon's native + // partial/failed scan status when an Antigravity read was incomplete. + status: retainedTruncated + ? "partial" + : files === null + ? scannedFiles === 0 + ? "missing" + : "ok" + : status === "failed" && scannedFiles > 0 + ? "partial" + : (status ?? "ok"), + buckets: sourceAggregator.finish().buckets, scannedFiles, skippedFiles, malformedRecords: malformedRecords ?? 0, distinctSessions: sessionIds.size, - message: message ?? null, + message: + files === null ? "No transcript directory on this environment." : (message ?? null), }); } - const pruned = pruneScanCache(fileCache, { - livePaths, - walkedRoots, - windowStartMs, - retentionCutoffMs: startedAtMs - CACHE_RETENTION_DAYS * 24 * 60 * 60 * 1000, - }); + const pruned = pruneScanCache(fileCache, retentionCutoffMs); if (pruned > 0) cacheDirty = true; yield* persistScanCache(); diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index fdb0aabafa..cc1bdbcc16 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -194,88 +194,17 @@ describe("pruneScanCache", () => { it("drops entries older than retention", () => { const cache = cacheWith([["/old.jsonl", 500, [record()]]]); - const removed = pruneScanCache(cache, { - livePaths: new Set(), - walkedRoots: ["/"], - windowStartMs: 400, - retentionCutoffMs, - }); + const removed = pruneScanCache(cache, retentionCutoffMs); expect(removed).toBe(1); expect(cache.size).toBe(0); }); - it("drops in-window entries whose file has disappeared", () => { + it("keeps entries whose file has disappeared", () => { const cache = cacheWith([["/gone.jsonl", 5000, [record()]]]); - pruneScanCache(cache, { - livePaths: new Set(), - walkedRoots: ["/"], - windowStartMs: 4000, - retentionCutoffMs, - }); - - expect(cache.size).toBe(0); - }); - - it("keeps entries outside the walked window that are still within retention", () => { - // Viewing 7 days must not evict the 30-day entries, which that walk never - // looked for and so cannot prove are gone. - const cache = cacheWith([["/older-but-valid.jsonl", 2000, [record()]]]); - - const removed = pruneScanCache(cache, { - livePaths: new Set(), - walkedRoots: ["/"], - windowStartMs: 4000, - retentionCutoffMs, - }); - - expect(removed).toBe(0); - expect(cache.size).toBe(1); - }); - - it("keeps entries the walk saw", () => { - const cache = cacheWith([["/live.jsonl", 5000, [record()]]]); - - pruneScanCache(cache, { - livePaths: new Set(["/live.jsonl"]), - walkedRoots: ["/"], - windowStartMs: 4000, - retentionCutoffMs, - }); - - expect(cache.size).toBe(1); - }); -}); - -describe("pruneScanCache with an unwalked root", () => { - it("keeps in-window entries for a provider whose directory was not walked", () => { - // A missing provider root or failed settings read leaves livePaths without - // that provider's files. Its warm entries must survive the pass. - const cache = cacheWith([["/codex/sessions/a.jsonl", 5000, [record()]]]); - - const removed = pruneScanCache(cache, { - livePaths: new Set(), - walkedRoots: ["/claude/projects"], - windowStartMs: 4000, - retentionCutoffMs: 1000, - }); - - expect(removed).toBe(0); - expect(cache.size).toBe(1); - }); - - it("keeps entries under a sibling path that only shares the walked root prefix", () => { - const cache = cacheWith([["/claude/projects-copy/a.jsonl", 5000, [record()]]]); - - const removed = pruneScanCache(cache, { - livePaths: new Set(), - walkedRoots: ["/claude/projects"], - windowStartMs: 4000, - retentionCutoffMs: 1000, - }); + pruneScanCache(cache, retentionCutoffMs); - expect(removed).toBe(0); expect(cache.size).toBe(1); }); }); diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 04f9597f42..8439d5ca74 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -14,9 +14,6 @@ * * @module usageScanCache */ -// @effect-diagnostics nodeBuiltinImport:off -import * as NodePath from "node:path"; - import type { UsageProviderKind } from "@t3tools/contracts"; import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; @@ -302,47 +299,11 @@ function decodeCodexState(value: unknown): CodexScanState | null | undefined { }; } -export interface PruneOptions { - /** Files the walk just saw. Only meaningful inside the walked window. */ - readonly livePaths: ReadonlySet; - /** - * Roots the walk actually completed. Absence from `livePaths` only proves a - * file is gone when its root was walked: a provider whose directory failed to - * resolve this pass must not have its warm entries purged. - */ - readonly walkedRoots: readonly string[]; - /** Start of the walked window; entries older than this were not looked for. */ - readonly windowStartMs: number; - /** Entries older than this are dropped regardless. */ - readonly retentionCutoffMs: number; -} - -/** - * Drops aged-out entries, and entries for files that have disappeared. - * - * The walk only covers the requested window, so absence from `livePaths` only - * proves deletion for entries *inside* that window. Pruning everything the walk - * missed would evict the 30-day entries every time someone looked at 7 days. - * - * Replaces an earlier record cap that cleared the whole cache once exceeded, - * which meant a large enough window never warmed up at all. - */ -export function pruneScanCache(cache: ScanCache, options: PruneOptions): number { +/** Keeps saved usage after transcript cleanup, until the reporting retention expires. */ +export function pruneScanCache(cache: ScanCache, retentionCutoffMs: number): number { let removed = 0; for (const [path, entry] of cache) { - const agedOut = entry.mtimeMs < options.retentionCutoffMs; - const underWalkedRoot = options.walkedRoots.some((root) => { - const relative = NodePath.relative(root, path); - return ( - relative === "" || - (relative !== ".." && - !relative.startsWith(`..${NodePath.sep}`) && - !NodePath.isAbsolute(relative)) - ); - }); - const deleted = - underWalkedRoot && entry.mtimeMs >= options.windowStartMs && !options.livePaths.has(path); - if (agedOut || deleted) { + if (entry.mtimeMs < retentionCutoffMs) { cache.delete(path); removed += 1; } diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 7d89a827ca..5808d8ad4e 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -30,6 +30,7 @@ import { type VcsRemoveWorktreeInput, type VcsStatusInput, type VcsStatusResult, + type WorktreeSubmodules, } from "@t3tools/contracts"; import { makeGitVcsDriverCore, @@ -128,6 +129,9 @@ export interface CreateWorktreeProgress { total: number; }) => Effect.Effect; readonly onSubmodulesStarted?: () => Effect.Effect; + readonly onSubmodulesDisabled?: (input: { + source: "settings" | "t3.json"; + }) => Effect.Effect; readonly onSubmoduleLine?: (line: string) => Effect.Effect; readonly onSubmodulesFinished?: (input: { ok: boolean; @@ -137,6 +141,8 @@ export interface CreateWorktreeProgress { export interface CreateWorktreeOptions { readonly progress?: CreateWorktreeProgress; + /** Null defers to the newly created checkout's t3.json. */ + readonly submodules?: WorktreeSubmodules | null; } export interface GitCommitProgress { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index f93a74f5d3..691909d25d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -26,6 +26,7 @@ import { GitCommandError, ReviewDiffPreviewInput, type ReviewDiffFileContentsInput, + type WorktreeSubmodules, } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; import { gitCommandDuration } from "../observability/Metrics.ts"; @@ -2134,9 +2135,113 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(result.branch, current); }), ); + + it.effect("rejects a missing branch without restoring a matching dirty file", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* writeTextFile(cwd, "obsolete-branch", "original\n"); + yield* git(cwd, ["add", "obsolete-branch"]); + yield* git(cwd, ["commit", "-m", "tracked file"]); + yield* git(cwd, ["branch", "obsolete-branch"]); + yield* git(cwd, ["branch", "-D", "obsolete-branch"]); + yield* writeTextFile(cwd, "obsolete-branch", "uncommitted work\n"); + + const result = yield* driver + .switchRef({ cwd, refName: "obsolete-branch" }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + assert.equal( + yield* fileSystem.readFileString(path.join(cwd, "obsolete-branch")), + "uncommitted work\n", + ); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), initialBranch); + }), + ); + + it.effect("still creates and reuses remote tracking branches and allows detached refs", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "origin", "HEAD:refs/heads/remote-only"]); + + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = yield* driver.switchRef({ cwd, refName: "origin/remote-only" }); + assert.equal(result.refName, "remote-only"); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "origin/remote-only", + ); + yield* driver.switchRef({ cwd, refName: initialBranch }); + } + const commit = yield* git(cwd, ["rev-parse", "HEAD"]); + const detached = yield* driver.switchRef({ cwd, refName: commit }); + assert.equal(detached.refName, null); + assert.equal(yield* git(cwd, ["rev-parse", "HEAD"]), commit); + }), + ); }); describe("worktree operations", () => { + it.effect( + "uses parallel checkout while preserving filters, hooks, and configured workers", + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["config", "filter.test.smudge", "sed s/original/filtered/g"]); + yield* writeTextFile(cwd, ".gitattributes", "asset.txt filter=test\n"); + yield* writeTextFile(cwd, "asset.txt", "original\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "filtered asset"]); + yield* writeTextFile( + cwd, + ".git/hooks/post-checkout", + "#!/bin/sh\ngit config checkout.workers > checkout-workers\nexit 0\n", + ); + yield* fs.chmod(path.join(cwd, ".git/hooks/post-checkout"), 0o755); + + for (const [configured, expected] of [ + [null, "0"], + ["1", "1"], + ] as const) { + if (configured !== null) yield* git(cwd, ["config", "checkout.workers", configured]); + const worktreePath = path.join( + yield* makeTmpDir("git-worktrees-"), + `workers-${expected}`, + ); + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: `feature/workers-${expected}`, + }); + assert.equal( + yield* fs.readFileString(path.join(worktreePath, "checkout-workers")), + `${expected}\n`, + ); + assert.equal( + yield* fs.readFileString(path.join(worktreePath, "asset.txt")), + "filtered\n", + ); + assert.equal( + yield* git(worktreePath, ["rev-parse", "HEAD"]), + yield* git(cwd, ["rev-parse", "HEAD"]), + ); + } + }), + ); it("parses checkout progress lines from git's stderr", () => { assert.deepStrictEqual(parseGitCheckoutProgressLine("Updating files: 78% (2104/2700)"), { percent: 78, @@ -2266,11 +2371,11 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); - it.effect("reports checkout progress while creating a worktree", () => + it.effect("reports checkout progress during parallel worktree creation", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); - for (let index = 0; index < 5; index += 1) { + for (let index = 0; index < 200; index += 1) { yield* writeTextFile(cwd, `file-${index}.txt`, `${index}\n`); } yield* git(cwd, ["add", "."]); @@ -2304,7 +2409,7 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const updates = yield* Ref.get(seen); assert.isAbove(updates.length, 1); assert.equal(updates.at(-1)?.percent, 100); - assert.equal(updates.at(-1)?.total, 6); + assert.equal(updates.at(-1)?.total, 201); const completed = updates.map((update) => update.completed); assert.deepEqual( completed, @@ -2341,6 +2446,100 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("resolves the submodule mode from the option, then t3.json", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + + const previousAllowedProtocol = process.env.GIT_ALLOW_PROTOCOL; + process.env.GIT_ALLOW_PROTOCOL = "file"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previousAllowedProtocol === undefined) { + delete process.env.GIT_ALLOW_PROTOCOL; + } else { + process.env.GIT_ALLOW_PROTOCOL = previousAllowedProtocol; + } + }), + ); + + // inner -> nested, so a recursive init populates nested/NESTED.md and + // a top-level init leaves it empty. + const nestedRepo = yield* makeTmpDir("git-nested-"); + yield* initRepoWithCommit(nestedRepo); + yield* writeTextFile(nestedRepo, "NESTED.md", "# nested\n"); + yield* git(nestedRepo, ["add", "."]); + yield* git(nestedRepo, ["commit", "-m", "nested"]); + const innerRepo = yield* makeTmpDir("git-inner-"); + yield* initRepoWithCommit(innerRepo); + yield* writeTextFile(innerRepo, "INNER.md", "# inner\n"); + yield* git(innerRepo, ["submodule", "add", nestedRepo, "nested"]); + yield* git(innerRepo, ["add", "."]); + yield* git(innerRepo, ["commit", "-m", "inner"]); + + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["submodule", "add", innerRepo, "inner"]); + yield* git(cwd, ["commit", "-m", "add submodule"]); + const driver = yield* GitVcsDriver.GitVcsDriver; + const worktreesDir = yield* makeTmpDir("git-worktrees-"); + + const createWithMode = Effect.fn(function* ( + fileMode: WorktreeSubmodules, + branch: string, + submodules: WorktreeSubmodules | null = null, + ) { + yield* writeTextFile(cwd, "t3.json", `{ "worktreeSubmodules": "${fileMode}" }`); + yield* git(cwd, ["add", "t3.json"]); + // Consecutive cases may reuse a file mode to test the option alone. + yield* git(cwd, ["commit", "--allow-empty", "-m", `submodules: ${fileMode}`]); + const worktreePath = pathService.join(worktreesDir, branch); + const disabled = yield* Ref.make<"settings" | "t3.json" | false>(false); + yield* driver.createWorktree( + { cwd, path: worktreePath, refName: initialBranch, newRefName: branch }, + { + submodules, + progress: { onSubmodulesDisabled: ({ source }) => Ref.set(disabled, source) }, + }, + ); + return { + disabled: yield* Ref.get(disabled), + inner: yield* fileSystem.exists(pathService.join(worktreePath, "inner", "INNER.md")), + nested: yield* fileSystem.exists( + pathService.join(worktreePath, "inner", "nested", "NESTED.md"), + ), + }; + }); + + assert.deepEqual(yield* createWithMode("recursive", "recursive"), { + disabled: false, + inner: true, + nested: true, + }); + assert.deepEqual(yield* createWithMode("top-level", "top-level"), { + disabled: false, + inner: true, + nested: false, + }); + // A resolved setting outranks the file in both directions. + assert.deepEqual(yield* createWithMode("recursive", "setting-none", "none"), { + disabled: "settings", + inner: false, + nested: false, + }); + assert.deepEqual(yield* createWithMode("none", "setting-wins", "top-level"), { + disabled: false, + inner: true, + nested: false, + }); + assert.deepEqual(yield* createWithMode("none", "none"), { + disabled: "t3.json", + inner: false, + nested: false, + }); + }), + ); + it.effect("allows worktree removal to run longer than the default command timeout", () => Effect.gen(function* () { const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -2540,6 +2739,67 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("remote operations", () => { + for (const failure of ["offline", "auth", "timeout"] as const) { + it.effect(`does not retry a scoped fetch after ${failure}`, () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["remote", "add", "origin", "https://example.invalid/repo.git"]); + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const started = yield* Deferred.make(); + const attempts: Array> = []; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) + return yield* Effect.die("unexpected command"); + if (command.args[0] !== "fetch") return yield* delegate.spawn(command); + attempts.push(command.args); + yield* Deferred.succeed(started, undefined); + return ChildProcessSpawner.makeHandle({ + ...makeNonRepositoryHandle(), + exitCode: + failure === "timeout" + ? Effect.never + : Effect.succeed(ChildProcessSpawner.ExitCode(128)), + stderr: Stream.encodeText( + Stream.make( + failure === "auth" + ? "fatal: Authentication failed" + : "fatal: Could not resolve host", + ), + ), + }); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provide(ServerConfigLayer), + ); + const fetching = yield* driver + .fetchRemote({ cwd, remoteName: "origin", refName: "main" }) + .pipe(Effect.result, Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(started); + if (failure === "timeout") { + yield* TestClock.adjust("31 seconds"); + yield* TestClock.adjust("31 seconds"); + } + const result = yield* Fiber.join(fetching); + assert.isTrue(Result.isFailure(result)); + assert.equal(attempts.length, 1); + if (Result.isFailure(result)) { + assert.equal( + result.failure.detail, + failure === "timeout" + ? "Git command timed out." + : failure === "auth" + ? "Git could not authenticate with the remote. Check Git credentials or SSH access on the server, then retry." + : "Git could not reach the remote. Check the server's network connection and remote host, then retry.", + ); + } + }), + ); + } + it.effect("explains a real fetch failure for a missing local remote", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -2579,7 +2839,15 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.notEqual(beforeFetch, remoteHead); const driver = yield* GitVcsDriver.GitVcsDriver; - yield* driver.fetchRemote({ cwd, remoteName: "origin" }); + yield* git(peer, ["push", "origin", "HEAD:refs/heads/unrelated"]); + yield* driver.fetchRemote({ + cwd, + remoteName: "origin", + refName: initialBranch, + }); + assert.isFalse( + yield* driver.remoteBranchExists({ cwd, remoteName: "origin", refName: "unrelated" }), + ); assert.equal( yield* driver.remoteBranchExists({ @@ -2641,6 +2909,46 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { const status = yield* driver.statusDetails(worktreePath); assert.equal(status.aheadCount, 0); assert.equal(status.aheadOfDefaultCount, 0); + + // Local-only bases still use the old full-fetch fallback. + yield* driver.fetchRemote({ cwd, remoteName: "origin", refName: "local-only" }); + assert.isTrue( + yield* driver.remoteBranchExists({ cwd, remoteName: "origin", refName: "unrelated" }), + ); + + // A branch literally named origin/topic must retain the old lookup at + // refs/remotes/origin/origin/topic, not get stripped to origin/topic. + yield* git(peer, ["push", "origin", "HEAD:refs/heads/origin/topic"]); + yield* driver.fetchRemote({ cwd, remoteName: "origin", refName: "origin/topic" }); + assert.isTrue( + yield* driver.remoteBranchExists({ cwd, remoteName: "origin", refName: "origin/topic" }), + ); + + // A custom no-force fetch refspec keeps its full-fetch semantics. + yield* git(peer, ["push", "origin", "HEAD:refs/heads/custom-policy"]); + yield* git(cwd, ["config", "remote.origin.fetch", "refs/heads/*:refs/remotes/origin/*"]); + yield* driver.fetchRemote({ cwd, remoteName: "origin", refName: initialBranch }); + assert.isTrue( + yield* driver.remoteBranchExists({ cwd, remoteName: "origin", refName: "custom-policy" }), + ); + + // Pruning needs the full remote refspec, not only the selected branch. + yield* git(cwd, ["config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"]); + yield* git(cwd, ["config", "fetch.prune", "true"]); + yield* git(peer, ["push", "origin", ":refs/heads/custom-policy"]); + yield* driver.fetchRemote({ cwd, remoteName: "origin", refName: initialBranch }); + assert.isFalse( + yield* driver.remoteBranchExists({ cwd, remoteName: "origin", refName: "custom-policy" }), + ); + + // A remote configured to fetch all tags also keeps its full branch fetch. + yield* git(cwd, ["config", "--unset", "fetch.prune"]); + yield* git(cwd, ["config", "remote.origin.tagOpt", "--tags"]); + yield* git(peer, ["push", "origin", "HEAD:refs/heads/tag-policy"]); + yield* driver.fetchRemote({ cwd, remoteName: "origin", refName: initialBranch }); + assert.isTrue( + yield* driver.remoteBranchExists({ cwd, remoteName: "origin", refName: "tag-policy" }), + ); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 58191a9129..d476a37ebc 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -20,6 +20,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, + T3_PROJECT_FILE_NAME, type ReviewDiffFileContentsInput, type ReviewDiffPreviewInput, type ReviewDiffFileStat, @@ -30,6 +31,7 @@ import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3 import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import { @@ -2282,13 +2284,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const readRangeContext: GitVcsDriver.GitVcsDriver["Service"]["readRangeContext"] = Effect.fn( "readRangeContext", )(function* (cwd, baseRef) { - const range = `${baseRef}..HEAD`; + const commitRange = `${baseRef}..HEAD`; + // PR diffs start at the common ancestor when the base branch has advanced. + const diffRange = `${baseRef}...HEAD`; const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( [ runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.log", cwd, - ["log", "--oneline", range], + ["log", "--oneline", commitRange], { maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2297,7 +2301,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.diffStat", cwd, - ["diff", "--stat", range], + ["diff", "--stat", diffRange], { maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2306,7 +2310,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.diffPatch", cwd, - ["diff", "--no-ext-diff", "--patch", "--minimal", range], + ["diff", "--no-ext-diff", "--patch", "--minimal", diffRange], { maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -3070,23 +3074,32 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const progress = options?.progress; const onCheckoutProgress = progress?.onCheckoutProgress; - yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { - fallbackErrorDetail: "git worktree add failed", - timeoutMs: WORKTREE_ADD_TIMEOUT_MS, - ...(onCheckoutProgress - ? { - // Git only prints checkout progress when stderr is a tty or the - // delay elapsed. GIT_PROGRESS_DELAY=0 forces it through the pipe. - env: { GIT_PROGRESS_DELAY: "0", LC_ALL: "C" }, - progress: { - onStderrLine: (line) => { - const parsed = parseGitCheckoutProgressLine(line); - return parsed ? onCheckoutProgress(parsed) : Effect.void; + // Git defaults to a single checkout worker unless the caller opts in. + // Respect an explicit checkout.workers setting while enabling Git's + // automatic worker count for the common unset case. + const checkoutWorkers = (yield* readConfigValue(input.cwd, "checkout.workers")) ?? "0"; + yield* executeGit( + "GitVcsDriver.createWorktree", + input.cwd, + ["-c", `checkout.workers=${checkoutWorkers}`, ...args], + { + fallbackErrorDetail: "git worktree add failed", + timeoutMs: WORKTREE_ADD_TIMEOUT_MS, + ...(onCheckoutProgress + ? { + // Git only prints checkout progress when stderr is a tty or the + // delay elapsed. GIT_PROGRESS_DELAY=0 forces it through the pipe. + env: { GIT_PROGRESS_DELAY: "0", LC_ALL: "C" }, + progress: { + onStderrLine: (line) => { + const parsed = parseGitCheckoutProgressLine(line); + return parsed ? onCheckoutProgress(parsed) : Effect.void; + }, }, - }, - } - : {}), - }); + } + : {}), + }, + ); if (progress?.onWorktreeClaimed) { yield* progress.onWorktreeClaimed(worktreePath); @@ -3100,7 +3113,30 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const hasSubmodules = yield* fileSystem .exists(path.join(worktreePath, ".gitmodules")) .pipe(Effect.orElseSucceed(() => false)); - if (hasSubmodules) { + const submoduleSetting = options?.submodules; + const submoduleMode = !hasSubmodules + ? "none" + : submoduleSetting != null + ? submoduleSetting + : yield* fileSystem.readFileString(path.join(worktreePath, T3_PROJECT_FILE_NAME)).pipe( + Effect.flatMap((contents) => { + const file = parseT3ProjectFile(contents); + return file === null + ? Effect.logWarning("t3.json is invalid; initializing submodules recursively", { + worktreePath, + }).pipe(Effect.as("recursive" as const)) + : Effect.succeed(file.worktreeSubmodules ?? "recursive"); + }), + Effect.orElseSucceed(() => "recursive" as const), + ); + if (hasSubmodules && submoduleMode === "none") { + if (progress?.onSubmodulesDisabled) { + yield* progress.onSubmodulesDisabled({ + source: submoduleSetting == null ? "t3.json" : "settings", + }); + } + } + if (submoduleMode !== "none") { if (progress?.onSubmodulesStarted) { yield* progress.onSubmodulesStarted(); } @@ -3108,7 +3144,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* runGit( "GitVcsDriver.createWorktree.updateSubmodules", worktreePath, - ["submodule", "update", "--init", "--recursive"], + submoduleMode === "recursive" + ? ["submodule", "update", "--init", "--recursive"] + : ["submodule", "update", "--init"], onSubmoduleLine ? { env: { LC_ALL: "C" }, @@ -3273,28 +3311,89 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* env: STATUS_UPSTREAM_REFRESH_ENV, fallbackErrorDetail: `git fetch ${input.remoteName} failed`, }; - yield* executeGitWithStableDiagnostics("GitVcsDriver.fetchRemote", input.cwd, args, { - ...options, - allowNonZeroExit: true, - }).pipe( - Effect.flatMap((result) => - result.exitCode === 0 - ? Effect.void - : Effect.fail( - new GitCommandError({ - ...gitCommandContext({ - operation: "GitVcsDriver.fetchRemote", - cwd: input.cwd, - args, - }), - detail: fetchFailureDetail(result.stderr) ?? options.fallbackErrorDetail, - ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), - stdoutLength: result.stdout.length, - stderrLength: result.stderr.length, - }), - ), - ), - ); + const runFetch = (fetchArgs: ReadonlyArray) => + executeGitWithStableDiagnostics("GitVcsDriver.fetchRemote", input.cwd, [...fetchArgs], { + ...options, + allowNonZeroExit: true, + }); + let fetchArgs: ReadonlyArray = args; + let result; + // An explicit refspec must not silently override a repository's custom + // remote.fetch mapping or its force-update policy. Scope the common + // default mapping; keep the existing full fetch for all other setups. + const defaultFetchRefspec = `+refs/heads/*:refs/remotes/${input.remoteName}/*`; + const configuredFetchRefspecs = input.refName + ? (yield* runGitStdout( + "GitVcsDriver.fetchRemote.readRefspecs", + input.cwd, + ["config", "--get-all", `remote.${input.remoteName}.fetch`], + true, + )) + .split(/\r?\n/) + .filter((line) => line.length > 0) + : []; + const pruneSettings = input.refName + ? yield* Effect.forEach( + [ + "fetch.prune", + "fetch.pruneTags", + `remote.${input.remoteName}.prune`, + `remote.${input.remoteName}.pruneTags`, + ], + (key) => + runGitStdout( + "GitVcsDriver.fetchRemote.readPruneSetting", + input.cwd, + ["config", "--type=bool", "--get", key], + true, + ).pipe(Effect.map((value) => value.trim() === "true")), + ) + : []; + const tagOption = input.refName + ? yield* readConfigValue(input.cwd, `remote.${input.remoteName}.tagOpt`) + : null; + if ( + input.refName && + parseRemoteRefWithRemoteNames(input.refName, [input.remoteName]) === null && + configuredFetchRefspecs.length === 1 && + configuredFetchRefspecs[0] === defaultFetchRefspec && + !pruneSettings.some(Boolean) && + tagOption !== "--tags" + ) { + const branch = input.refName; + const scopedArgs = [ + ...args, + `+refs/heads/${branch}:refs/remotes/${input.remoteName}/${branch}`, + ]; + fetchArgs = scopedArgs; + result = yield* runFetch(scopedArgs); + // A local-only base has no matching remote branch. Preserve the old + // full-fetch behavior so callers can still discover other remote refs. + if ( + result.exitCode !== 0 && + result.stderr + .split(/\r?\n/) + .includes(`fatal: couldn't find remote ref refs/heads/${branch}`) + ) { + fetchArgs = args; + result = yield* runFetch(args); + } + } else { + result = yield* runFetch(args); + } + if (result.exitCode !== 0) { + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.fetchRemote", + cwd: input.cwd, + args: fetchArgs, + }), + detail: fetchFailureDetail(result.stderr) ?? options.fallbackErrorDetail, + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }); + } }, ); @@ -3504,7 +3603,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ? ["checkout", localTrackingBranch] : ["checkout", input.refName]; - yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, checkoutArgs, { + // A stale ref must not turn into a path checkout that discards local edits. + yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, [...checkoutArgs, "--"], { timeoutMs: 10_000, fallbackErrorDetail: "git checkout failed", }); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9d4328a8cc..2501ad4727 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -91,6 +91,7 @@ import { type WorktreeSetupSnapshot, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { isDriveOrPosixAbsolutePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; @@ -121,6 +122,8 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ModelManifest from "./provider/ModelManifest.ts"; +import * as ProviderMaintenance from "./provider/providerMaintenance.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; @@ -150,6 +153,7 @@ import * as TerminalManager from "./terminal/Manager.ts"; import { withTerminalOutputWindow } from "./terminal/OutputProtocol.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as DeviceService from "./device/DeviceService.ts"; +import { remoteSshDeviceHosts } from "./device/localSshDeviceHost.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/AttachmentUpload.ts"; @@ -558,8 +562,12 @@ const makeWsRpcLayer = ( const terminalManager = yield* TerminalManager.TerminalManager; const previewManager = yield* PreviewManager.PreviewManager; const deviceService = yield* DeviceService.DeviceService; + const deviceHostContext = + yield* Effect.context>>(); const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; + const modelManifest = yield* ModelManifest.ModelManifest; + const providerVersionCache = yield* ProviderMaintenance.ProviderVersionCache; const providerService = yield* ProviderService.ProviderService; const relayWorkerBridge = Option.getOrUndefined( yield* Effect.serviceOption(RelayWorkerBridge), @@ -1068,6 +1076,41 @@ const makeWsRpcLayer = ( return output; }); + // The branch checked out into a new worktree may have its own t3.json, + // so a null settings value intentionally leaves that read to the driver. + const resolveBootstrapWorktreeSubmodules = Effect.fnUntraced(function* (input: { + readonly threadId: ThreadId; + readonly projectId: ProjectId | null; + }) { + const settings = yield* serverSettings.getSettings.pipe(Effect.orElseSucceed(() => null)); + if (settings === null) return null; + const projectId = + input.projectId ?? + (yield* projectionSnapshotQuery.getThreadShellById(input.threadId).pipe( + Effect.map((thread) => Option.getOrNull(thread)?.projectId ?? null), + Effect.orElseSucceed(() => null), + )); + const project = + projectId === null + ? null + : yield* projectionSnapshotQuery.getProjectShellById(projectId).pipe( + Effect.map(Option.getOrNull), + Effect.orElseSucceed(() => null), + ); + return resolveProjectSettings(settings, projectId, project).settings.worktreeSubmodules; + }); + + const resolveWorktreeSubmodulesForCwd = Effect.fnUntraced(function* (cwd: string) { + const settings = yield* serverSettings.getSettings.pipe(Effect.orElseSucceed(() => null)); + if (settings === null) return null; + const project = yield* projectionSnapshotQuery.getActiveProjectByWorkspaceRoot(cwd).pipe( + Effect.map(Option.getOrNull), + Effect.orElseSucceed(() => null), + ); + return resolveProjectSettings(settings, project?.id ?? null, project).settings + .worktreeSubmodules; + }); + const dispatchBootstrapTurnStart = ( command: Extract, ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => @@ -1342,6 +1385,7 @@ const makeWsRpcLayer = ( yield* gitWorkflow.fetchRemote({ cwd: prepareWorktree.projectCwd, remoteName: "origin", + refName: prepareWorktree.baseBranch, }); const remoteBaseExists = yield* gitWorkflow.remoteBranchExists({ cwd: prepareWorktree.projectCwd, @@ -1491,6 +1535,10 @@ const makeWsRpcLayer = ( } yield* worktreeSetupTracker.stageStatus(threadId, "checkout", "running"); let checkoutTotal: number | null = null; + const submodules = yield* resolveBootstrapWorktreeSubmodules({ + threadId, + projectId: targetProjectId ?? null, + }); const worktree = yield* gitWorkflow.createWorktree( { cwd: prepareWorktree.projectCwd, @@ -1500,6 +1548,7 @@ const makeWsRpcLayer = ( path: null, }, { + submodules, progress: { // Git has registered the directory at this point, so a // cancel during the submodule step can still remove it. @@ -1529,6 +1578,13 @@ const makeWsRpcLayer = ( worktreeSetupTracker.stageStatus(threadId, "submodules", "running"), ), ), + onSubmodulesDisabled: ({ source }) => + worktreeSetupTracker.stageStatus( + threadId, + "submodules", + "skipped", + `disabled in ${source}`, + ), onSubmoduleLine: (line) => { const submodulePath = /Submodule path '([^']+)'/.exec(line)?.[1]; return submodulePath === undefined @@ -2345,6 +2401,29 @@ const makeWsRpcLayer = ( observeRpcEffect( WS_METHODS.serverRefreshProviders, Effect.gen(function* () { + if (input.refreshModels) { + // Explicit refresh bypasses Pylon-owned caches; background probes + // keep their normal freshness windows. + yield* modelManifest.forceRefresh; + const instances = yield* providerInstances.listInstances; + yield* Effect.forEach( + instances.filter( + (instance) => + input.instanceId === undefined || input.instanceId === instance.instanceId, + ), + (instance) => + Effect.gen(function* () { + yield* instance.invalidateCaches ?? Effect.void; + const maintenance = yield* instance.snapshot.resolveMaintenance({ + fresh: true, + }); + if (maintenance.packageName) { + providerVersionCache.delete(maintenance.packageName); + } + }), + { concurrency: "unbounded", discard: true }, + ); + } let providers = yield* input.cwd !== undefined && input.instanceId !== undefined ? providerRegistry.refreshWorkspaceSnapshot({ instanceId: input.instanceId, @@ -2849,9 +2928,18 @@ const makeWsRpcLayer = ( serverSettings.getSettings.pipe( Effect.map((current) => providerSettingsMutationInstanceIds(current, patch)), ), - serverSettings - .updateSettings(patch) - .pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), + Effect.gen(function* () { + const deviceHosts = patch.deviceHosts + ? yield* remoteSshDeviceHosts(patch.deviceHosts).pipe( + Effect.provide(deviceHostContext), + ) + : undefined; + const settings = yield* serverSettings.updateSettings({ + ...patch, + ...(deviceHosts ? { deviceHosts } : {}), + }); + return ServerSettings.redactServerSettingsForClient(settings); + }), ), { "rpc.aggregate": "server", @@ -3097,7 +3185,7 @@ const makeWsRpcLayer = ( [WS_METHODS.pullRequestsInvalidate]: (input) => observeRpcEffect( WS_METHODS.pullRequestsInvalidate, - pullRequests.invalidate(input).pipe( + pullRequests.invalidate(input, { notifyReaders: true }).pipe( // A reader asking for fresh host state also wants the thread badges it feeds to // catch up, including a merged link the sweep would otherwise never revisit. Effect.andThen( @@ -3582,7 +3670,10 @@ const makeWsRpcLayer = ( [WS_METHODS.vcsCreateWorktree]: (input) => observeRpcEffect( WS_METHODS.vcsCreateWorktree, - gitWorkflow.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + resolveWorktreeSubmodulesForCwd(input.cwd).pipe( + Effect.flatMap((submodules) => gitWorkflow.createWorktree(input, { submodules })), + Effect.tap(() => refreshGitStatus(input.cwd)), + ), { "rpc.aggregate": "vcs" }, ), [WS_METHODS.vcsRemoveWorktree]: (input) => @@ -3739,13 +3830,15 @@ const makeWsRpcLayer = ( [WS_METHODS.deviceList]: (input) => observeRpcEffect( WS_METHODS.deviceList, - input.inspectOnly + input.inspectOnly && !input.updateTool ? deviceService.inspect : authorizeEffect( requiredScopeForDeviceList(input), - input.retryHostId - ? deviceService.retryHost(input.retryHostId) - : deviceService.list, + input.updateTool + ? deviceService.updateTool(input.updateTool) + : input.retryHostId + ? deviceService.retryHost(input.retryHostId) + : deviceService.list, ), { "rpc.aggregate": "device", diff --git a/apps/web/src/browser/BrowserSurfaceSlot.test.tsx b/apps/web/src/browser/BrowserSurfaceSlot.test.tsx new file mode 100644 index 0000000000..58ba8e3700 --- /dev/null +++ b/apps/web/src/browser/BrowserSurfaceSlot.test.tsx @@ -0,0 +1,55 @@ +import { act } from "react"; +import { create } from "react-test-renderer"; +import { afterEach, expect, it, vi } from "vite-plus/test"; + +import { BrowserSurfaceSlot } from "./BrowserSurfaceSlot"; + +const { present, release } = vi.hoisted(() => ({ present: vi.fn(() => true), release: vi.fn() })); +vi.mock("./browserSurfaceStore", () => ({ + acquireBrowserSurface: () => ({ present, release }), +})); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +it("tracks an inline panel moving its browser slot and cleans up the surface lease", async () => { + let panelResize: (() => void) | undefined; + const panel = {}; + const slot = { + closest: (selector: string) => + selector === '[data-preview-panel-mode="inline"]' ? panel : null, + getBoundingClientRect: () => ({ x: position, y: 8, width: 320, height: 240 }), + }; + let position = 20; + const observed: unknown[] = []; + const disconnect = vi.fn(); + vi.stubGlobal( + "ResizeObserver", + class { + constructor(private readonly callback: () => void) {} + observe(target: unknown) { + observed.push(target); + if (target === panel) panelResize = this.callback; + } + disconnect = disconnect; + }, + ); + vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); + let renderer: ReturnType; + await act(() => { + renderer = create(, { + createNodeMock: () => slot, + }); + }); + expect(observed).toEqual([slot, panel]); + expect(present).toHaveBeenCalledWith({ x: 20, y: 8, width: 320, height: 240 }, true, 0, 30); + position = 40; + // The panel observer must remeasure even though the slot's own dimensions did not change. + await act(() => panelResize?.()); + expect(present).toHaveBeenLastCalledWith({ x: 40, y: 8, width: 320, height: 240 }, true, 0, 30); + await act(() => renderer!.unmount()); + expect(disconnect).toHaveBeenCalledOnce(); + expect(release).toHaveBeenCalledOnce(); +}); diff --git a/apps/web/src/browser/BrowserSurfaceSlot.tsx b/apps/web/src/browser/BrowserSurfaceSlot.tsx index 3de3ed586c..b875e4c221 100644 --- a/apps/web/src/browser/BrowserSurfaceSlot.tsx +++ b/apps/web/src/browser/BrowserSurfaceSlot.tsx @@ -64,6 +64,10 @@ export function BrowserSurfaceSlot(props: { update(); const observer = new ResizeObserver(update); observer.observe(element); + // Inline panels animate their outer width while keeping the content at + // full width. The slot moves without resizing, so measure on shell resizes too. + const panel = element.closest('[data-preview-panel-mode="inline"]'); + if (panel) observer.observe(panel); window.addEventListener("resize", update); window.addEventListener("scroll", update, true); return () => { diff --git a/apps/web/src/browser/HostedBrowserWebview.test.tsx b/apps/web/src/browser/HostedBrowserWebview.test.tsx index 4a241befef..d27b5ec429 100644 --- a/apps/web/src/browser/HostedBrowserWebview.test.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.test.tsx @@ -48,6 +48,7 @@ import { } from "~/hooks/useSettings"; import { useBrowserSurfaceStore } from "./browserSurfaceStore"; import * as desktopTabLifetime from "./desktopTabLifetime"; +import { isGuestFocusPointerDown } from "./guestFocusPointer"; import { HostedBrowserWebview } from "./HostedBrowserWebview"; let renderer: ReactTestRenderer | undefined; @@ -99,6 +100,74 @@ afterEach(async () => { }); describe("HostedBrowserWebview settings hydration", () => { + it("dismisses host popups on guest focus and removes the listener on unmount", async () => { + mocks.getClientSettings.mockResolvedValue(DEFAULT_CLIENT_SETTINGS); + await act(async () => ensureClientSettingsHydrated()); + class TestPointerEvent extends Event { + readonly pointerType: string; + + constructor(type: string, init: EventInit & { pointerType: string }) { + super(type, init); + this.pointerType = init.pointerType; + } + } + vi.stubGlobal("PointerEvent", TestPointerEvent); + const guest = Object.assign(new EventTarget(), { getWebContentsId: () => 41 }); + const hostDocument = new EventTarget(); + const onHostOutsidePointer = vi.fn(); + hostDocument.addEventListener("pointerdown", onHostOutsidePointer); + const dispatchGuestEvent = guest.dispatchEvent.bind(guest); + vi.spyOn(guest, "dispatchEvent").mockImplementation((event) => { + const delivered = dispatchGuestEvent(event); + if (event.bubbles) hostDocument.dispatchEvent(event); + return delivered; + }); + const onPointerDown = vi.fn(); + guest.addEventListener("pointerdown", onPointerDown); + + await act(() => { + renderer = create( + , + { + createNodeMock: (element) => + element.type === "webview" + ? guest + : { scrollLeft: 0, scrollTop: 0, scrollTo: () => undefined }, + }, + ); + }); + + guest.dispatchEvent(new Event("focus")); + expect(onPointerDown).toHaveBeenCalledOnce(); + expect(onHostOutsidePointer).toHaveBeenCalledOnce(); + expect(isGuestFocusPointerDown(onHostOutsidePointer.mock.calls[0]?.[0])).toBe(true); + expect( + isGuestFocusPointerDown(new TestPointerEvent("pointerdown", { pointerType: "mouse" })), + ).toBe(false); + expect(onPointerDown.mock.calls[0]?.[0]).toMatchObject({ + type: "pointerdown", + bubbles: true, + pointerType: "mouse", + }); + + await act(() => renderer?.unmount()); + guest.dispatchEvent(new Event("focus")); + expect(onPointerDown).toHaveBeenCalledOnce(); + expect(onHostOutsidePointer).toHaveBeenCalledOnce(); + }); + it("starts a retained background tab only after a settings read succeeds on retry", async () => { const firstRead = deferred(); const retryRead = deferred(); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 521da09397..e487d46b40 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -23,6 +23,7 @@ import { import { BrowserDeviceToolbar } from "./BrowserDeviceToolbar"; import { BrowserViewportResizeHandles } from "./BrowserViewportResizeHandles"; import { acquireDesktopTab, type AcquiredDesktopTab } from "./desktopTabLifetime"; +import { dispatchGuestFocusPointerDown } from "./guestFocusPointer"; import { resolveHostedBrowserWebviewWrapperStyle } from "./hostedBrowserWebviewStyle"; import { usePreviewWebviewConfig } from "./previewWebviewConfigState"; import { useBrowserViewportResize } from "./useBrowserViewportResize"; @@ -180,7 +181,13 @@ export function HostedBrowserWebview(props: { dispatchSnapShotComposerFocus(); } }; + // Guest presses do not reach this document, so host popovers miss their + // outside-pointer dismissal when focus moves into the webview. + const dismissHostPopups = () => { + dispatchGuestFocusPointerDown(webview); + }; webview.addEventListener("focus", handleFocus); + webview.addEventListener("focus", dismissHostPopups); webview.addEventListener("did-attach", register); webview.addEventListener("dom-ready", register); webview.addEventListener("render-process-gone", recoverGuest); @@ -189,6 +196,7 @@ export function HostedBrowserWebview(props: { disposed = true; if (recoveryTimeout !== null) clearTimeout(recoveryTimeout); webview.removeEventListener("focus", handleFocus); + webview.removeEventListener("focus", dismissHostPopups); webview.removeEventListener("did-attach", register); webview.removeEventListener("dom-ready", register); webview.removeEventListener("render-process-gone", recoverGuest); diff --git a/apps/web/src/browser/guestFocusPointer.ts b/apps/web/src/browser/guestFocusPointer.ts new file mode 100644 index 0000000000..77ddeef05b --- /dev/null +++ b/apps/web/src/browser/guestFocusPointer.ts @@ -0,0 +1,13 @@ +// A guest press only surfaces in the host as webview focus. Mark the synthetic +// outside pointer so the theme inspector leaves it to host popup listeners. +const guestFocusPointers = new WeakSet(); + +export function dispatchGuestFocusPointerDown(webview: HTMLElement): void { + const event = new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" }); + guestFocusPointers.add(event); + webview.dispatchEvent(event); +} + +export function isGuestFocusPointerDown(event: Event): boolean { + return guestFocusPointers.has(event); +} diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 9b199ec872..7ffdf842d1 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -7,16 +7,28 @@ import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { useEffect, + useRef, useState, useSyncExternalStore, type CSSProperties, type ReactNode, } from "react"; -import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { isElectron } from "../env"; import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; +import { isPreviewFocused } from "../lib/previewFocus"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { isModelPickerOpen } from "../modelPickerVisibility"; +import { + handleNavigationHistoryShortcut, + observeNavigationHistory, + type ObservedNavigationHistory, +} from "../navigationHistoryShortcuts"; +import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { resolveThreadRouteRef } from "../threadRoutes"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; import { @@ -137,6 +149,75 @@ function SidebarControl() { ); } +function NavigationHistoryShortcuts() { + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const router = useRouter(); + const historyEntry = router.history.location; + const observedHistory = useRef({ + keys: new Map([ + [historyEntry.state.__TSR_index, historyEntry.state.__TSR_key ?? historyEntry.href], + ]), + minIndex: historyEntry.state.__TSR_index, + maxIndex: historyEntry.state.__TSR_index, + }); + const routeThreadRef = useParams({ + strict: false, + select: (params) => resolveThreadRouteRef(params), + }); + + useEffect(() => { + return router.history.subscribe(({ location, action }) => { + observedHistory.current = observeNavigationHistory( + observedHistory.current, + location.state.__TSR_index, + location.state.__TSR_key ?? location.href, + action.type, + ); + }); + }, [router]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + handleNavigationHistoryShortcut( + event, + keybindings, + { + terminalFocus: isTerminalFocused(), + terminalOpen: routeThreadRef + ? selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + routeThreadRef, + ).terminalOpen + : false, + previewFocus: isPreviewFocused(), + previewOpen: routeThreadRef + ? selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) === + "preview" + : false, + editableFocus: + event.target instanceof HTMLElement && + event.target.closest( + 'input, textarea, select, [contenteditable]:not([contenteditable="false"])', + ) !== null, + modelPickerOpen: isModelPickerOpen(), + isDesktop: isElectron, + isWeb: !isElectron, + }, + router.history.location.state.__TSR_index, + observedHistory.current.minIndex, + observedHistory.current.maxIndex, + router.history, + isElectron, + ); + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [keybindings, routeThreadRef, router]); + + return null; +} + // Settings swaps the thread sidebar out of the tree. Keep the lightweight // project projection subscribed so returning to a draft never renders the // zero-project state while the environment snapshot reconnects. @@ -271,6 +352,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { {children} + ); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index bd892b5a4d..5a3367c8ce 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,4 +1,10 @@ -import type { EnvironmentId, EnvironmentMachineKind, VcsRef, ProjectId } from "@t3tools/contracts"; +import type { + EnvironmentId, + EnvironmentMachineKind, + VcsRef, + ProjectId, + WorktreeSubmodules, +} from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { toSortableTimestamp } from "../lib/threadSort"; export { @@ -17,6 +23,12 @@ export interface EnvironmentOption { export const EnvMode = Schema.Literals(["local", "worktree"]); export type EnvMode = typeof EnvMode.Type; +export const WORKTREE_SUBMODULES_LABELS: Record = { + recursive: "Recursive", + "top-level": "Top level only", + none: "Do not initialize", +}; + const GENERIC_LOCAL_ENVIRONMENT_LABELS = new Set(["local", "local environment"]); function normalizeDisplayLabel(value: string | null | undefined): string | null { diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 95c7351b09..b7820cdfce 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -61,7 +61,7 @@ import { Separator } from "./ui/separator"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { MiddleTruncate } from "./ui/middle-truncate"; import { ComposerSurface } from "./chat/ComposerSurface"; -import { composerFloatingLayerProps } from "./chat/composerEventScope"; +import { useComposerMenuProps } from "./chat/composerEventScope"; import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; import { cn } from "~/lib/utils"; @@ -133,6 +133,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ previousWorktreeLabel, onUsePreviousWorktree, }: MobileRunContextSelectorProps) { + const composerFloatingLayerProps = useComposerMenuProps(); const activeEnvironment = useMemo( () => availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null, [availableEnvironments, environmentId], @@ -186,29 +187,58 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ if (isLocked) { return ( - - {triggerContent} - + + } + className="inline-flex h-7 min-w-0 max-w-[48%] flex-initial items-center justify-start gap-1 rounded-md border border-transparent px-[calc(--spacing(2)-1px)] font-normal text-muted-foreground/70 text-xs sm:h-6" + data-composer-context-control + aria-label={ + showEnvironmentIndicator + ? `Run on ${autoEnvironmentLabel ?? activeEnvironment?.label ?? "Run on"}; Workspace ${workspaceLabel}` + : `Workspace ${workspaceLabel}` + } + > + {triggerContent} + + + {showEnvironmentIndicator + ? `${autoEnvironmentLabel ?? activeEnvironment?.label ?? "Run on"} · ${workspaceLabel}` + : workspaceLabel} + + ); } return ( - } - className="min-w-0 max-w-[48%] flex-initial justify-start font-normal text-muted-foreground/70 text-xs! hover:text-foreground/80" - data-composer-context-control - data-composer-shortcut={[ - showEnvironmentPicker && !envLocked ? "composer.host" : "", - !envModeLocked ? "composer.workspace" : "", - ].join(" ")} - > - {triggerContent} - - + + } + className="min-w-0 max-w-[48%] flex-initial justify-start font-normal text-muted-foreground/70 text-xs! hover:text-foreground/80" + data-composer-context-control + aria-label={ + showEnvironmentIndicator + ? `Run on ${autoEnvironmentLabel ?? activeEnvironment?.label ?? "Run on"}; Workspace ${workspaceLabel}` + : `Workspace ${workspaceLabel}` + } + data-composer-shortcut={[ + showEnvironmentPicker && !envLocked ? "composer.host" : "", + !envModeLocked ? "composer.workspace" : "", + ].join(" ")} + /> + } + > + {triggerContent} + + + + {showEnvironmentIndicator + ? `${autoEnvironmentLabel ?? activeEnvironment?.label ?? "Run on"} · ${workspaceLabel}` + : workspaceLabel} + + {showEnvironmentPicker && availableEnvironments && onEnvironmentChange ? ( <> diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 61abc897b4..9a865565d5 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -40,7 +40,7 @@ import { vcsEnvironment } from "../state/vcs"; import { cn } from "../lib/utils"; import { parsePullRequestReference } from "../pullRequestReference"; import { getSourceControlPresentation } from "../sourceControlPresentation"; -import { composerFloatingLayerProps } from "./chat/composerEventScope"; +import { useComposerMenuProps } from "./chat/composerEventScope"; import { deriveLocalBranchNameFromRemoteRef, resolveBranchTriggerLabel, @@ -114,6 +114,7 @@ export function BranchToolbarBranchSelector({ onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { + const composerFloatingLayerProps = useComposerMenuProps(); const startFromOriginSwitchId = useId(); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); const updateThreadMetadata = useAtomCommand( diff --git a/apps/web/src/components/BranchToolbarContextTooltips.test.tsx b/apps/web/src/components/BranchToolbarContextTooltips.test.tsx new file mode 100644 index 0000000000..2d1505288c --- /dev/null +++ b/apps/web/src/components/BranchToolbarContextTooltips.test.tsx @@ -0,0 +1,39 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; +import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; + +describe("locked composer context labels", () => { + it("keeps clipped environment and workspace labels keyboard reachable", () => { + const environment = renderToStaticMarkup( + , + ); + const workspace = renderToStaticMarkup( + {}} + />, + ); + + expect(environment).toContain('tabindex="0"'); + expect(environment).toContain('aria-label="Run on Remote build host"'); + expect(workspace).toContain('tabindex="0"'); + expect(workspace).toContain('aria-label="Workspace'); + }); +}); diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index a350931fbf..be00dcbc10 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -7,7 +7,7 @@ import { resolveLockedWorkspaceLabel, type EnvMode, } from "./BranchToolbar.logic"; -import { composerFloatingLayerProps } from "./chat/composerEventScope"; +import { useComposerMenuProps } from "./chat/composerEventScope"; import { Select, SelectGroup, @@ -17,6 +17,7 @@ import { SelectTrigger, SelectValue, } from "./ui/select"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree"; @@ -37,6 +38,7 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe previousWorktreeLabel, onUsePreviousWorktree, }: BranchToolbarEnvModeSelectorProps) { + const composerFloatingLayerProps = useComposerMenuProps(); const showPreviousWorktree = Boolean(previousWorktreeLabel && onUsePreviousWorktree); const envModeItems = useMemo( () => [ @@ -51,27 +53,36 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe if (envLocked) { return ( - - {activeWorktreePath ? ( - - ) : ( - - )} - + + } + className="inline-flex h-7 min-w-0 items-center gap-1 border border-transparent px-[calc(--spacing(2)-1px)] font-normal text-muted-foreground/70 text-xs sm:h-6" + data-composer-context-control > + {activeWorktreePath ? ( + + ) : ( + + )} - {resolveLockedWorkspaceLabel(activeWorktreePath)} + + {resolveLockedWorkspaceLabel(activeWorktreePath)} + - - + + {resolveLockedWorkspaceLabel(activeWorktreePath)} + ); } @@ -88,33 +99,49 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe }} items={envModeItems} > - - {effectiveEnvMode === "worktree" ? ( - - ) : activeWorktreePath ? ( - - ) : ( - - )} - + + } > + {effectiveEnvMode === "worktree" ? ( + + ) : activeWorktreePath ? ( + + ) : ( + + )} - + + + - - + + + {effectiveEnvMode === "worktree" + ? resolveEnvModeLabel("worktree") + : resolveCurrentWorkspaceLabel(activeWorktreePath)} + + Workspace diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index e18948310b..8ee3b3dbec 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -1,5 +1,5 @@ import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; -import { composerFloatingLayerProps } from "./chat/composerEventScope"; +import { useComposerMenuProps } from "./chat/composerEventScope"; import type { EnvironmentId } from "@t3tools/contracts"; import { ScaleIcon } from "lucide-react"; import { memo, useMemo } from "react"; @@ -14,6 +14,7 @@ import { SelectTrigger, SelectValue, } from "./ui/select"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; interface BranchToolbarEnvironmentSelectorProps { autoEnvironmentLabel?: string | undefined; @@ -34,6 +35,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir availableEnvironments, onEnvironmentChange, }: BranchToolbarEnvironmentSelectorProps) { + const composerFloatingLayerProps = useComposerMenuProps(); const activeEnvironment = useMemo(() => { return availableEnvironments.find((env) => env.environmentId === environmentId) ?? null; }, [availableEnvironments, environmentId]); @@ -58,26 +60,32 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir // only thing in the strip. if (envLocked || onEnvironmentChange === undefined) { return ( - - - + + } + className="inline-flex h-7 min-w-0 max-w-full items-center gap-1 border border-transparent px-[calc(--spacing(2)-1px)] font-normal text-muted-foreground/70 text-xs sm:h-6" + data-composer-context-control > + - {activeEnvironment?.label ?? "Run on"} + + {activeEnvironment?.label ?? "Run on"} + - - + + {activeEnvironment?.label ?? "Run on"} + ); } @@ -90,34 +98,42 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir } items={environmentItems} > - - {autoEnvironmentLabel ? ( - + + {autoEnvironmentLabel ?? activeEnvironment?.label ?? "Run on"} + Run on diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 9ef4f6138d..26e4410c2f 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -3307,7 +3307,15 @@ const CHAT_MARKDOWN_COMPONENTS = { resetKeys={[codeBlock.code, language, diffThemeName, isStreaming]} fallback={
{children}
} > - {children}}> + {/* Keep the block's dimensions while highlighting loads so a + newly delivered code block does not flash as plain text. */} + + {children} + + } + > diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 025a9c2648..0e4b7920f9 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -14,7 +14,7 @@ import { type ServerProvider, type WorktreeSetupSnapshot, } from "@t3tools/contracts"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { Atom, AsyncResult } from "effect/unstable/reactivity"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadDetails } from "../state/threads"; @@ -91,7 +91,6 @@ import { observeProactivePanelUserChoice, shouldShowPlanFollowUpPrompt, shouldWriteThreadErrorToCurrentServerThread, - toolGroupConsumesUpwardNavigation, getAntigravitySendBlockReason, waitForRevertedMessage, prepareRevertedMessageAttachments, @@ -418,134 +417,6 @@ describe("proactive panels", () => { }); }); -describe("toolGroupConsumesUpwardNavigation", () => { - class ScrollElement extends EventTarget { - scrollTop = 0; - scrollHeight = 100; - clientHeight = 100; - overflowY = "visible"; - - constructor( - readonly parentElement: ScrollElement | null = null, - readonly isToolGroup = false, - ) { - super(); - } - - closest(selector: string): ScrollElement | null { - if (selector !== "[data-tool-group-scroll]") return null; - return this.isToolGroup ? this : (this.parentElement?.closest(selector) ?? null); - } - } - - beforeEach(() => { - vi.stubGlobal("Element", ScrollElement); - vi.stubGlobal("getComputedStyle", (element: ScrollElement) => ({ - overflowY: element.overflowY, - })); - }); - afterEach(() => vi.unstubAllGlobals()); - - it("releases upward navigation when an overflowing group is at the top", () => { - const group = Object.assign(new ScrollElement(null, true), { - overflowY: "auto", - scrollHeight: 300, - }); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(group))).toBe(false); - }); - - it.each([ - { overflowY: "auto", scrollTop: 1 }, - { overflowY: "auto", scrollTop: 0.25 }, - { overflowY: "scroll", scrollTop: 80 }, - ])("consumes upward navigation within a scrolled group: %j", (scroll) => { - const group = Object.assign(new ScrollElement(null, true), { - scrollHeight: 300, - ...scroll, - }); - - expect(toolGroupConsumesUpwardNavigation(group)).toBe(true); - }); - - it.each([100, 300])( - "consumes scrolling in a nested result with a group content height of %i", - (scrollHeight) => { - const group = Object.assign(new ScrollElement(null, true), { - overflowY: "auto", - scrollHeight, - }); - const result = Object.assign(new ScrollElement(group), { - overflowY: "auto", - scrollHeight: 300, - scrollTop: 0.25, - }); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(true); - }, - ); - - it("releases upward navigation when the group and nested result are both at the top", () => { - const group = Object.assign(new ScrollElement(null, true), { - overflowY: "auto", - scrollHeight: 300, - }); - const result = Object.assign(new ScrollElement(group), { - overflowY: "scroll", - scrollHeight: 300, - }); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(false); - }); - - it("ignores targets outside a tool group and non-element targets", () => { - const outside = Object.assign(new ScrollElement(), { - overflowY: "auto", - scrollHeight: 300, - scrollTop: 40, - }); - - expect(toolGroupConsumesUpwardNavigation(outside)).toBe(false); - expect(toolGroupConsumesUpwardNavigation(new EventTarget())).toBe(false); - expect(toolGroupConsumesUpwardNavigation(null)).toBe(false); - }); - - it("does not consume scrolling from an ancestor beyond the tool group", () => { - const timeline = Object.assign(new ScrollElement(), { - overflowY: "auto", - scrollHeight: 300, - scrollTop: 40, - }); - const group = new ScrollElement(timeline, true); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(group))).toBe(false); - }); - - it.each(["hidden", "clip", "visible"])( - "ignores a non-scrollable child with overflow-y %s", - (overflowY) => { - const group = new ScrollElement(null, true); - const result = Object.assign(new ScrollElement(group), { - overflowY, - scrollHeight: 300, - scrollTop: 40, - }); - - expect(toolGroupConsumesUpwardNavigation(new ScrollElement(result))).toBe(false); - }, - ); - - it("does not consume programmatic scrolling on an overflow-hidden group", () => { - const group = Object.assign(new ScrollElement(null, true), { - overflowY: "hidden", - scrollHeight: 300, - scrollTop: 40, - }); - - expect(toolGroupConsumesUpwardNavigation(group)).toBe(false); - }); -}); - const environmentId = EnvironmentId.make("environment-local"); const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 1b0ba04ce6..7885042bdc 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -225,22 +225,6 @@ export function shouldReleaseTimelineAnchorForToolActivity(input: { }); } -export function toolGroupConsumesUpwardNavigation(target: EventTarget | null): boolean { - const elementTarget = target instanceof Element ? target : null; - const group = elementTarget?.closest("[data-tool-group-scroll]"); - if (!group) return false; - - // A nested result or the group itself can consume an upward scroll. - for (let element = elementTarget; element; element = element.parentElement) { - if (element.scrollTop > 0) { - const overflowY = getComputedStyle(element).overflowY; - if (overflowY === "auto" || overflowY === "scroll") return true; - } - if (element === group) break; - } - return false; -} - export { findRecordedWorktreeSetup, resolveVisibleWorktreeSetup, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index de1de699f0..1735772920 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -61,6 +61,9 @@ import { isRollbackActive, type RollbackTarget, } from "@t3tools/client-runtime/rollback"; +import { shouldRoutePlainTextPaste } from "./composerInlineTokenPaste"; +import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste"; +import { connectedInitialConfigForState } from "@t3tools/client-runtime/state/session"; import { type CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; import { @@ -109,9 +112,10 @@ import { useState, } from "react"; import { flushSync } from "react-dom"; -import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useLocation, useNavigate, useRouter } from "@tanstack/react-router"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { assistantCitationFromLocation } from "../lib/assistantCitationNavigation"; +import { isMacPlatform } from "../lib/utils"; import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { useShallow } from "zustand/react/shallow"; import { getProviderAdmissionUnavailableReason } from "@t3tools/client-runtime/providerAvailability"; @@ -126,6 +130,7 @@ import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/unstable/reactivity"; +import * as Option from "effect/Option"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; import { useDiffPanelStore } from "../diffPanelStore"; @@ -222,7 +227,10 @@ import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, } from "../previewMiniPlayerStore"; -import { isThreadOwnPullRequest } from "./pullRequest/pullRequestDetail.logic"; +import { + pullRequestPanelContext, + pullRequestPanelHost, +} from "./pullRequest/pullRequestDetail.logic"; import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; @@ -360,6 +368,13 @@ import { } from "../lib/composerContextRecords"; import { type ReviewCommentContext } from "../reviewCommentContext"; import { environmentCatalog } from "../connection/catalog"; +import { environmentPresentations } from "../state/presentation"; +import { canManuallyDisconnectEnvironment } from "../lib/environmentDisconnectEligibility"; +import { useEnvironmentDisconnectDelay } from "../hooks/useEnvironmentDisconnectDelay"; +import { + disconnectEnvironmentAndNavigate, + disconnectIfStillEligible, +} from "../lib/environmentDisconnectNavigation"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; import { projectEnvironment } from "../state/projects"; @@ -393,6 +408,7 @@ import { import { environmentShell } from "../state/shell"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController"; +import { isTimelineScrollTarget } from "./chat/timelineScrollTarget"; import { DraftHeroHeadline } from "./chat/DraftHeroHeadline"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -453,7 +469,6 @@ import { import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, - DRAFT_HERO_TRANSITION_DURATION_MS, DRAFT_HERO_TRANSITION_EASING, MOBILE_COMPOSER_VIEW_TRANSITION_NAME, MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME, @@ -522,7 +537,6 @@ import { shouldWriteThreadErrorToCurrentServerThread, startNewThreadForProject, codexArtifactTemplatePromptToAppend, - toolGroupConsumesUpwardNavigation, waitForStartedServerThread, shouldRefocusComposerOnWindowFocus, } from "./ChatView.logic"; @@ -542,7 +556,7 @@ import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/at import { appAtomRegistry } from "../rpc/atomRegistry"; import { fileAttachmentCapabilityBlockReason } from "./chat/composerAttachmentFiles"; import { assetEnvironment } from "../state/assets"; -import { readPreparedConnection } from "../state/session"; +import { environmentSession, readPreparedConnection } from "../state/session"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { Button } from "./ui/button"; @@ -586,7 +600,11 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; -function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { +function useDraftHeroLayoutTransition( + isDraftHeroState: boolean, + animationsActive: boolean, + animationDurationMs: number, +) { const transitionGroupRef = useRef(null); const composerAnchorRef = useRef(null); const previousStateRef = useRef(isDraftHeroState); @@ -606,9 +624,6 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const transitionGroup = transitionGroupRef.current; const nextComposerRect = composerAnchorRef.current?.getBoundingClientRect() ?? null; const stateChanged = previousStateRef.current !== isDraftHeroState; - const prefersReducedMotion = - typeof window !== "undefined" && - window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; const mobileComposerTransitionActive = typeof document !== "undefined" && document.documentElement.dataset.mobileComposerRouteTransition === "true"; @@ -619,7 +634,7 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const previousComposerRect = previousComposerRectRef.current; if ( stateChanged && - !prefersReducedMotion && + animationsActive && !mobileComposerTransitionActive && transitionGroup && previousComposerRect && @@ -635,7 +650,7 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { { transform: "translate3d(0, 0, 0)" }, ], { - duration: DRAFT_HERO_TRANSITION_DURATION_MS, + duration: animationDurationMs, easing: DRAFT_HERO_TRANSITION_EASING, }, ); @@ -654,7 +669,7 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { previousStateRef.current = isDraftHeroState; previousComposerRectRef.current = nextComposerRect; - }, [isDraftHeroState]); + }, [animationDurationMs, animationsActive, isDraftHeroState]); return [attachTransitionGroupRef, attachComposerAnchorRef, captureComposerRect] as const; } @@ -758,6 +773,19 @@ function pasteTextToFocusComposer(event: ClipboardEvent): string | null { return text.length > 0 ? text : null; } +function supportsPastedTextAttachmentsNow(environmentId: EnvironmentId): boolean { + const connection = appAtomRegistry.get(environmentCatalog.stateAtom(environmentId)); + const leaseConfig = appAtomRegistry.get( + environmentSession.connectedInitialConfigAtom(environmentId), + ); + if (connection.waiting || leaseConfig.waiting) return false; + const config = connectedInitialConfigForState( + Option.getOrNull(AsyncResult.value(connection)), + Option.getOrElse(AsyncResult.value(leaseConfig), () => Option.none()), + ); + return config?.environment.capabilities.pastedTextAttachments === true; +} + function formatOutgoingPrompt(params: { provider: ProviderDriverKind; model: string | null; @@ -1634,6 +1662,10 @@ export default function ChatView(props: ChatViewProps) { const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false }); + const router = useRouter(); + const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, { + reportFailure: false, + }); const environmentById = useMemo( () => new Map(environments.map((environment) => [environment.environmentId, environment])), [environments], @@ -1750,6 +1782,7 @@ export default function ChatView(props: ChatViewProps) { const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; const branchToolbarRef = useRef(null); + const pasteAsTextShortcutUntilRef = useRef(0); const [restingComposerControlsHost, setRestingComposerControlsHost] = useState(null); const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false); @@ -2499,6 +2532,61 @@ export default function ChatView(props: ChatViewProps) { }, [retryEnvironment], ); + const disconnectDelayElapsed = useEnvironmentDisconnectDelay( + activeEnvironmentUnavailable ? activeEnvironment.environmentId : null, + ); + const canDisconnectActiveEnvironment = + disconnectDelayElapsed && canManuallyDisconnectEnvironment(activeEnvironment); + const [disconnectingEnvironmentId, setDisconnectingEnvironmentId] = + useState(null); + const disconnectingEnvironmentRef = useRef(null); + const handleDisconnectActiveEnvironment = useCallback( + async (environmentId: EnvironmentId) => { + if (disconnectingEnvironmentRef.current !== null) return; + disconnectingEnvironmentRef.current = environmentId; + setDisconnectingEnvironmentId(environmentId); + try { + const result = await disconnectIfStillEligible({ + isEligibleNow: () => + disconnectDelayElapsed && + canManuallyDisconnectEnvironment( + appAtomRegistry.get(environmentPresentations.presentationAtom(environmentId)), + ), + disconnect: () => + disconnectEnvironmentAndNavigate({ + disconnect: () => setEnvironmentEnabled({ environmentId, enabled: false }), + readLocation: () => ({ + href: router.state.location.href, + key: router.state.location.state.__TSR_key, + }), + navigateHome: () => { + void navigate({ to: "/", replace: true }); + }, + }), + }); + if (result === null) return; + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not disconnect server", + description: error instanceof Error ? error.message : "Failed to disconnect.", + }), + ); + } + return; + } + } finally { + if (disconnectingEnvironmentRef.current === environmentId) { + disconnectingEnvironmentRef.current = null; + setDisconnectingEnvironmentId(null); + } + } + }, + [disconnectDelayElapsed, navigate, router, setEnvironmentEnabled], + ); const logicalProjectEnvironments = useMemo(() => { if (!activeProject) return []; const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); @@ -2690,10 +2778,30 @@ export default function ChatView(props: ChatViewProps) { observedPullRequestProviders, ]); const pullRequestsUnavailableGitHubUrl = pullRequestsUnavailableFallbackLink?.url ?? null; - const attachmentEnvironmentConfig = environmentById.get(environmentId)?.serverConfig ?? null; + const attachmentEnvironment = environmentById.get(environmentId) ?? null; + const attachmentEnvironmentConfig = attachmentEnvironment?.serverConfig ?? null; + // The presentation config may retain a previous server's capabilities. + // Both atoms carry the exact supervisor state object: if the environment ID + // reconnects to a different server, the old config cannot match its lease. + const attachmentConnectionResult = useAtomValue(environmentCatalog.stateAtom(environmentId)); + const attachmentLeaseConfigResult = useAtomValue( + environmentSession.connectedInitialConfigAtom(environmentId), + ); + const attachmentConnectionState = attachmentConnectionResult.waiting + ? null + : Option.getOrNull(AsyncResult.value(attachmentConnectionResult)); + const attachmentLeaseConfig = attachmentLeaseConfigResult.waiting + ? Option.none() + : Option.getOrElse(AsyncResult.value(attachmentLeaseConfigResult), () => Option.none()); + const attachmentBootstrapConfig = connectedInitialConfigForState( + attachmentConnectionState, + attachmentLeaseConfig, + ); const attachmentUploadsCapabilityKnown = attachmentEnvironmentConfig !== null; const supportsQuestionAttachments = attachmentEnvironmentConfig?.environment.capabilities.questionAttachments === true; + const supportsPastedTextAttachments = + attachmentBootstrapConfig?.environment.capabilities.pastedTextAttachments === true; const supportsAttachmentUploads = attachmentEnvironmentConfig?.environment.capabilities.attachmentUploads === true; const advertisedFileAttachmentBytes = @@ -2766,6 +2874,20 @@ export default function ChatView(props: ChatViewProps) { const items: ComposerBannerStackItem[] = []; const updateRunning = serverUpdateState.status === "running"; const unavailableConnection = activeEnvironmentUnavailableState?.connection ?? null; + const disconnectAction = + canDisconnectActiveEnvironment && activeEnvironmentUnavailableState ? ( + + ) : null; const environmentReconnecting = unavailableConnection !== null && (unavailableConnection.phase === "connecting" || @@ -2799,6 +2921,7 @@ export default function ChatView(props: ChatViewProps) { ), title: `${unavailableConnection.phase === "connecting" ? "Connecting" : "Reconnecting"} to ${activeEnvironmentUnavailableState.label}`, description: "Finishing an update", + actions: disconnectAction, compact: true, }); } else { @@ -2831,6 +2954,7 @@ export default function ChatView(props: ChatViewProps) { > Connections + {disconnectAction} ), }); @@ -2885,22 +3009,24 @@ export default function ChatView(props: ChatViewProps) { (versionMismatchSelfUpdate !== "desktop-managed" || !versionMismatchDesktopAppUpdate) ? serverUpdateGuidance(versionMismatchSelfUpdate) : undefined, - actions: - updateInProgress || - !versionMismatch || + actions: updateInProgress ? ( + suppressUnavailableBanner ? ( + disconnectAction + ) : undefined + ) : !versionMismatch || (versionMismatchSelfUpdate === "desktop-managed" && !versionMismatchDesktopAppUpdate) ? undefined : ( - - ), + + ), ...(updateInProgress || (!updateFailed && !versionMismatchDismissKey) ? {} : { @@ -2928,6 +3054,9 @@ export default function ChatView(props: ChatViewProps) { activeEnvironmentUnavailableState, reconnectWarningGraceElapsed, handleReconnectActiveEnvironment, + canDisconnectActiveEnvironment, + disconnectingEnvironmentId, + handleDisconnectActiveEnvironment, navigate, setDismissedVersionMismatchKey, showVersionMismatchBanner, @@ -3778,7 +3907,11 @@ export default function ChatView(props: ChatViewProps) { attachDraftHeroTransitionGroupRef, attachDraftHeroComposerAnchorRef, captureDraftHeroComposerRect, - ] = useDraftHeroLayoutTransition(isDraftHeroState); + ] = useDraftHeroLayoutTransition( + isDraftHeroState, + panelAnimationsActive, + panelAnimationDurationMs, + ); const rollbackActive = isRollbackActive(activeThread?.rollbackStatus); const rollbackTargetIdle = activeThread?.session !== null && @@ -4911,6 +5044,8 @@ export default function ChatView(props: ChatViewProps) { ) : linkedThreadPullRequestKey; const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null; + const activeProjectHost = + activeProject?.repositoryIdentity?.canonicalKey.split("/")[0]?.trim().toLowerCase() || null; const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository; const openThreadPullRequest = useCallback( (number: number) => { @@ -4920,18 +5055,28 @@ export default function ChatView(props: ChatViewProps) { const projectId = linkedThreadPullRequest?.projectId ?? activeProject?.id; const repository = linkedThreadPullRequest?.repository ?? activeProjectRepository; if (projectId === undefined || repository === null) return; + const host = pullRequestPanelHost({ + links: visiblePullRequests, + repository, + number, + linkedUrl: linkedThreadPullRequest?.url, + projectHost: activeProjectHost, + }); useRightPanelStore.getState().openPullRequest(activeThreadRef, { projectId, + ...(host ? { host } : {}), repository, number, }); }, [ activeProject, + activeProjectHost, activeProjectRepository, activeThreadRef, linkedThreadPullRequest, supportsPullRequests, + visiblePullRequests, ], ); const openProjectPullRequest = useCallback( @@ -4946,11 +5091,18 @@ export default function ChatView(props: ChatViewProps) { } useRightPanelStore.getState().openPullRequest(activeThreadRef, { projectId: activeProject.id, + ...(activeProjectHost ? { host: activeProjectHost } : {}), repository: activeProjectRepository, number, }); }, - [activeProject, activeProjectRepository, activeThreadRef, supportsPullRequests], + [ + activeProject, + activeProjectHost, + activeProjectRepository, + activeThreadRef, + supportsPullRequests, + ], ); const proactivePanelObservationRef = useRef { + if (event.ctrlKey || !isTimelineScrollTarget(event.target, scrollNode, event.deltaY)) + return; if (event.deltaY > 0) { timelineScrollIntentRef.current = "toward-end"; if (isAtEndRef.current) { @@ -5664,11 +5818,7 @@ export default function ChatView(props: ChatViewProps) { } else if (event.deltaY < 0) { timelineScrollIntentRef.current = "away-from-end"; } - if ( - event.deltaY < 0 && - contentScrollsUp() && - !toolGroupConsumesUpwardNavigation(event.target) - ) { + if (event.deltaY < 0 && contentScrollsUp()) { handleManualNavigation(); } }; @@ -5718,12 +5868,20 @@ export default function ChatView(props: ChatViewProps) { ) { return; } + if (!["PageUp", "Home", "ArrowUp", "PageDown", "End", "ArrowDown"].includes(event.key)) + return; + const scrollDirection = ["PageUp", "Home", "ArrowUp"].includes(event.key) ? -1 : 1; + if ( + scrollNode.contains(event.target) && + !isTimelineScrollTarget(event.target, scrollNode, scrollDirection) + ) + return; switch (event.key) { case "PageUp": case "Home": case "ArrowUp": timelineScrollIntentRef.current = "away-from-end"; - if (contentScrollsUp() && !toolGroupConsumesUpwardNavigation(event.target)) { + if (contentScrollsUp()) { handleManualNavigation(); composerRef.current?.collapseForTimelineScrollKey(event.key); } @@ -7065,6 +7223,8 @@ export default function ChatView(props: ChatViewProps) { previewFocus: isPreviewFocused(), previewOpen: previewPanelOpen, modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false, + isWeb: !isElectron, + isDesktop: isElectron, }), [composerRef, previewPanelOpen, terminalUiState.terminalOpen], ); @@ -7346,24 +7506,38 @@ export default function ChatView(props: ChatViewProps) { // so a paste that follows has no editable target and would be dropped. // Route it to the composer like a typed key, which also expands it. useEffect(() => { + const keyHandler = (event: KeyboardEvent) => { + if ( + shouldRedirectInputToComposer(event) && + isPasteAsTextShortcut(event, isMacPlatform(navigator.platform)) + ) { + pasteAsTextShortcutUntilRef.current = Date.now() + 1_000; + } + }; const handler = (event: ClipboardEvent) => { if (!activeThreadId || isCommandPaletteOpen()) return; if (getTerminalFocusOwner() !== null) return; if (composerRef.current?.isModelPickerOpen()) return; const text = pasteTextToFocusComposer(event); - if (text === null) return; + const clipboardData = event.clipboardData; + if (text === null || clipboardData === null) return; + const bypassAutoAttachment = Date.now() <= pasteAsTextShortcutUntilRef.current; + pasteAsTextShortcutUntilRef.current = 0; if ( - composerRef.current?.insertTextAtEnd( - text, - event.clipboardData ? { clipboardData: event.clipboardData } : undefined, - ) + (shouldRoutePlainTextPaste(clipboardData, bypassAutoAttachment) && + composerRef.current?.pasteTextAtEnd(text, { bypassAutoAttachment })) || + composerRef.current?.insertTextAtEnd(text, { clipboardData }) ) { event.preventDefault(); event.stopPropagation(); } }; + window.addEventListener("keydown", keyHandler, true); window.addEventListener("paste", handler, true); - return () => window.removeEventListener("paste", handler, true); + return () => { + window.removeEventListener("keydown", keyHandler, true); + window.removeEventListener("paste", handler, true); + }; }, [activeThreadId, composerRef]); const [pendingRevert, setPendingRevert] = useState<{ @@ -8036,6 +8210,7 @@ export default function ChatView(props: ChatViewProps) { supportsAttachmentUploads: liveSupportsAttachmentUploads, fileBlockReason: fileAttachmentCapabilityBlockReason({ files: composerFilesSnapshot, + supportsPastedTextAttachments: supportsPastedTextAttachmentsNow(environmentId), attachmentUploadsCapabilityKnown: config !== null, supportsAttachmentUploads: liveSupportsAttachmentUploads, maxFileAttachmentBytes: @@ -8091,13 +8266,16 @@ export default function ChatView(props: ChatViewProps) { const dockStarted = new Promise((resolve) => { resolveDockStarted = resolve; }); - const dockTransition = runMobileComposerTransition(() => { - flushSync(() => { - captureDraftHeroComposerRect(); - setDockedDraftHeroThreadKey(activeThreadKey); - }); - resolveDockStarted?.(); - }); + const dockTransition = runMobileComposerTransition( + () => { + flushSync(() => { + captureDraftHeroComposerRect(); + setDockedDraftHeroThreadKey(activeThreadKey); + }); + resolveDockStarted?.(); + }, + { active: panelAnimationsActive, durationMs: panelAnimationDurationMs }, + ); void dockTransition.catch(() => resolveDockStarted?.()); await dockStarted; } @@ -8159,6 +8337,7 @@ export default function ChatView(props: ChatViewProps) { mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, downloadable: false, + ...(attachment.source ? { source: attachment.source } : {}), }, ); const shouldAnchorFirstMessage = @@ -8971,6 +9150,18 @@ export default function ChatView(props: ChatViewProps) { const draft = useComposerDraftStore.getState().getComposerDraft(target); const attachments = draft ? [...draft.images, ...draft.files] : []; if (attachments.length === 0) continue; + if ( + attachments.some( + (attachment) => attachment.type === "file" && attachment.source?._tag === "pasted-text", + ) && + !supportsPastedTextAttachmentsNow(environmentId) + ) { + setThreadError( + activeThreadId, + "This server cannot use a saved large-paste attachment. Remove it or reconnect to a server that supports large pastes.", + ); + return; + } const uploaded = getUploadedAttachments({ environmentId, images: attachments }); if (!uploaded) { setThreadError( @@ -10274,22 +10465,15 @@ export default function ChatView(props: ChatViewProps) { repository: renderedRightPanelSurface.repository, number: renderedRightPanelSurface.number, }} - context={ - isThreadOwnPullRequest( - { - projectId: linkedThreadPullRequest?.projectId ?? activeProject?.id ?? null, - repository: threadRepository, - number: activeThreadPr?.number ?? null, - }, - { - projectId: renderedRightPanelSurface.projectId, - repository: renderedRightPanelSurface.repository, - number: renderedRightPanelSurface.number, - }, - ) - ? "thread" - : "page" - } + context={pullRequestPanelContext( + { + projectId: activeThreadMetadata?.projectId ?? activeProject?.id ?? null, + pullRequests: activeThreadMetadata?.pullRequests, + linkedPullRequest: activeThreadMetadata?.linkedPullRequest, + branchPullRequest: activeThreadMetadata?.branchPullRequest, + }, + renderedRightPanelSurface, + )} composerDraftTarget={composerDraftTarget} onBack={ activeThreadRef !== null && pullRequestsSurfaceAvailable @@ -10375,6 +10559,8 @@ export default function ChatView(props: ChatViewProps) { const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({ setDragActive: setIsWorkspaceFileDragActive, addFiles: (files) => composerRef.current?.addDroppedFiles(files), + addFolders: (folders, unresolvedCount) => + composerRef.current?.addDroppedFolders(folders, unresolvedCount), }); const externalComposerDrawerAttached = composerBannerItems.length > 0 || Boolean(threadSyncPhase && !activeEnvironmentUnavailable); @@ -10691,6 +10877,7 @@ export default function ChatView(props: ChatViewProps) { attachmentUploadsCapabilityKnown={attachmentUploadsCapabilityKnown} supportsAttachmentUploads={supportsAttachmentUploads} supportsQuestionAttachments={supportsQuestionAttachments} + supportsPastedTextAttachments={supportsPastedTextAttachments} maxFileAttachmentBytes={maxFileAttachmentBytes} routeKind={routeKind} routeThreadRef={routeThreadRef} diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 5cce941f86..74219086fe 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -10,7 +10,10 @@ import { enumerateCommandPaletteItems, filterPinnedBrowseEntries, filterCommandPaletteGroups, + getExistingThreadForProjectAdd, + nextAppearanceMode, reduceCommandPaletteUiState, + type CommandPaletteActionItem, type CommandPaletteGroup, } from "./CommandPalette.logic"; @@ -250,6 +253,17 @@ describe("reduceCommandPaletteUiState", () => { mode: "command", openIntent: { kind: "new-thread-in" }, }); + expect(reduceCommandPaletteUiState(filesOpen, { _tag: "OpenChangeTheme" })).toEqual({ + open: true, + mode: "command", + openIntent: { kind: "change-theme" }, + }); + }); + + it("cycles every appearance mode back to the starting mode", () => { + expect(nextAppearanceMode("system")).toBe("light"); + expect(nextAppearanceMode("light")).toBe("dark"); + expect(nextAppearanceMode("dark")).toBe("system"); }); it("preserves the mode on close and resets it on open", () => { @@ -343,6 +357,55 @@ function makeThread(overrides: Partial = {}): Thread { }; } +describe("adding an existing project", () => { + it("starts a new thread when the latest unarchived thread is settled", () => { + const olderActive = makeThread({ + id: ThreadId.make("older-active"), + updatedAt: "2026-03-01T00:00:00.000Z", + }); + const newestSettled = makeThread({ + id: ThreadId.make("newest-settled"), + updatedAt: "2026-03-02T00:00:00.000Z", + settledOverride: "settled", + settledAt: "2026-03-02T00:00:00.000Z", + }); + expect( + getExistingThreadForProjectAdd([olderActive, newestSettled], PROJECT_ID, "updated_at"), + ).toBeNull(); + + expect( + getExistingThreadForProjectAdd( + [olderActive, { ...newestSettled, settledOverride: "active", settledAt: null }], + PROJECT_ID, + "updated_at", + )?.id, + ).toBe(newestSettled.id); + }); + + it("ignores archived threads while leaving ordinary active threads available", () => { + const active = makeThread({ id: ThreadId.make("active") }); + const archived = makeThread({ + id: ThreadId.make("archived"), + updatedAt: "2026-03-03T00:00:00.000Z", + archivedAt: "2026-03-03T00:00:00.000Z", + }); + expect(getExistingThreadForProjectAdd([active, archived], PROJECT_ID, "updated_at")?.id).toBe( + active.id, + ); + }); + + it("keeps snoozed threads eligible because snoozing does not settle them", () => { + const snoozed = makeThread({ + id: ThreadId.make("snoozed"), + snoozedAt: "2026-03-01T00:00:00.000Z", + snoozedUntil: "2026-03-04T00:00:00.000Z", + }); + expect(getExistingThreadForProjectAdd([snoozed], PROJECT_ID, "updated_at")?.id).toBe( + snoozed.id, + ); + }); +}); + describe("buildProjectActionItems", () => { it("shows the grouped display name but keeps the real title for icons", () => { const project = makeProject({ title: "fleet", workspaceRoot: "/Users/theo/Code/p/fleet" }); @@ -439,6 +502,94 @@ describe("buildThreadActionItems", () => { ]); }); + it("uses activity recency for equally relevant titles across active and remote threads", () => { + const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + const threads = [ + makeThread({ + id: ThreadId.make("older-active"), + title: "Fix search input", + createdAt: "2026-03-20T00:00:00.000Z", + updatedAt: "2026-03-21T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("newer-remote"), + environmentId: remoteEnvironmentId, + title: "Fix search results", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-23T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("older-inactive"), + title: "Fix search ranking", + createdAt: "2026-03-22T00:00:00.000Z", + updatedAt: "2026-03-20T00:00:00.000Z", + }), + ]; + const items = buildThreadActionItems({ + threads, + activeThreadId: ThreadId.make("older-active"), + projectTitleById: new Map(), + sortOrder: "created_at", + icon: null, + runThread: async () => undefined, + }); + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query: "fix search", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: items, + }); + + expect(groups[0]?.items.map((item) => item.value)).toEqual([ + "thread:newer-remote", + "thread:older-active", + "thread:older-inactive", + ]); + }); + + it("keeps stronger title matches ahead of recency and preserves stable ties", () => { + const items = buildThreadActionItems({ + threads: [ + makeThread({ + id: ThreadId.make("prefix-a"), + title: "Search alpha", + createdAt: "2026-03-03T00:00:00.000Z", + updatedAt: "2026-03-20T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("substring"), + title: "Improve search", + createdAt: "2026-03-02T00:00:00.000Z", + updatedAt: "2026-03-25T00:00:00.000Z", + }), + makeThread({ + id: ThreadId.make("prefix-b"), + title: "Search beta", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-20T00:00:00.000Z", + }), + ], + projectTitleById: new Map(), + sortOrder: "created_at", + icon: null, + runThread: async () => undefined, + }); + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query: "search", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: items, + }); + + expect(groups[0]?.items.map((item) => item.value)).toEqual([ + "thread:prefix-a", + "thread:prefix-b", + "thread:substring", + ]); + }); + it("preserves thread project-name matches when there is no stronger title match", () => { const group: CommandPaletteGroup = { value: "threads-search", @@ -583,6 +734,38 @@ describe("buildThreadActionItems", () => { expect(item?.description).toBe("Pylon · #feat/search"); }); + it("finds a thread by pasted ID while ranking a title match first", () => { + const byId = makeThread({ + id: ThreadId.make("thread-alpha-1234"), + title: "Unrelated work", + updatedAt: "2026-03-05T00:00:00.000Z", + }); + const byTitle = makeThread({ + id: ThreadId.make("thread-other-9999"), + title: "Fix thread-alpha-1234 flakes", + updatedAt: "2026-03-04T00:00:00.000Z", + }); + const items = buildThreadActionItems({ + threads: [byId, byTitle], + projectTitleById: new Map([[PROJECT_ID, "Pylon"]]), + sortOrder: "updated_at", + icon: null, + runThread: async () => undefined, + }); + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query: " THREAD-ALPHA-1234 ", + isInSubmenu: false, + projectSearchItems: [], + settingsSearchItems: [], + threadSearchItems: items, + }); + expect(groups.flatMap((group) => group.items.map((item) => item.value))).toEqual([ + `thread:${byTitle.id}`, + `thread:${byId.id}`, + ]); + }); + it("prefers renderDescription when provided", () => { const [item] = buildThreadActionItems({ threads: [makeThread({ branch: "feat/search", worktreePath: "/tmp/wt" })], @@ -738,3 +921,30 @@ it.each([ "Implementation", ]); }); + +it("ranks secondary keybinding settings after primary settings matches", () => { + const item = (value: string, title: string, secondary = false): CommandPaletteActionItem => ({ + kind: "action", + value, + title, + searchTerms: [title, "General"], + icon: null, + run: async () => undefined, + secondary, + }); + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query: "model", + isInSubmenu: false, + projectSearchItems: [], + settingsSearchItems: [ + item("setting:keybinding-modelPicker.toggle", "Model Picker: Toggle", true), + item("setting:default-model", "Default model"), + ], + threadSearchItems: [], + }); + expect(groups.flatMap((group) => group.items.map((entry) => entry.value))).toEqual([ + "setting:default-model", + "setting:keybinding-modelPicker.toggle", + ]); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 57c1711de1..11f7debf9a 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -2,6 +2,7 @@ import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests import type { CommandPaletteLinkedThreads } from "../commandPaletteBus"; import { type EnvironmentId, + type ProjectId, type FilesystemBrowseEntry, type KeybindingCommand, THREAD_JUMP_KEYBINDING_COMMANDS, @@ -11,7 +12,12 @@ import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; import { type ReactNode } from "react"; -import { sortThreads } from "../lib/threadSort"; +import { + getLatestThreadForProject, + getThreadSortTimestamp, + sortThreads, + type ThreadSortInput, +} from "../lib/threadSort"; import { normalizeSearchText } from "../lib/utils"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { type Project, type SidebarThreadSummary, type Thread } from "../types"; @@ -20,6 +26,19 @@ export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; export const ADDON_ICON_CLASS = "size-4"; +/** Adding an existing project should open its latest thread only while it remains active. */ +export function getExistingThreadForProjectAdd< + T extends { + readonly id: string; + readonly projectId: ProjectId; + readonly archivedAt: string | null; + readonly settledOverride: string | null; + } & ThreadSortInput, +>(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null { + const latestThread = getLatestThreadForProject(threads, projectId, sortOrder); + return latestThread?.settledOverride === "settled" ? null : latestThread; +} + /** A PR's relations include archived threads that normal palette search omits. */ export function buildLinkedThreadActionItems( input: CommandPaletteLinkedThreads & { @@ -60,8 +79,12 @@ export function browseInputEndPaddingClass(input: { */ export type SearchOverlayMode = "command" | "files" | "content"; +export function nextAppearanceMode(mode: "system" | "light" | "dark") { + return mode === "system" ? "light" : mode === "light" ? "dark" : "system"; +} + export type CommandPaletteOpenIntent = - | { readonly kind: "add-project" | "new-thread-in" } + | { readonly kind: "add-project" | "new-thread-in" | "change-theme" } | { readonly kind: "search"; readonly query: string; @@ -84,6 +107,7 @@ export type CommandPaletteUiAction = } | { readonly _tag: "OpenAddProject" } | { readonly _tag: "OpenNewThreadIn" } + | { readonly _tag: "OpenChangeTheme" } | { readonly _tag: "ClearOpenIntent" }; export function reduceCommandPaletteUiState( @@ -113,6 +137,8 @@ export function reduceCommandPaletteUiState( return { open: true, mode: "command", openIntent: { kind: "add-project" } }; case "OpenNewThreadIn": return { open: true, mode: "command", openIntent: { kind: "new-thread-in" } }; + case "OpenChangeTheme": + return { open: true, mode: "command", openIntent: { kind: "change-theme" } }; case "ClearOpenIntent": return state.openIntent ? { ...state, openIntent: null } : state; } @@ -132,6 +158,8 @@ export interface CommandPaletteItem { readonly description?: ReactNode; readonly threadContentMatch?: CommandPaletteThreadContentMatch; readonly timestamp?: string; + /** Activity time used only to break equally relevant search matches. */ + readonly searchRecency?: number; readonly icon: ReactNode; readonly disabled?: boolean; /** Optional content rendered inline before the title text. */ @@ -139,6 +167,8 @@ export interface CommandPaletteItem { /** Optional content rendered inline after the title text (before the timestamp). */ readonly titleTrailingContent?: ReactNode; readonly shortcutCommand?: KeybindingCommand; + /** Keep detailed settings rows after primary matches in their group. */ + readonly secondary?: boolean; } export interface CommandPaletteActionItem extends CommandPaletteItem { @@ -302,12 +332,15 @@ export function buildThreadActionItems right.rank - left.rank || left.index - right.index) + .toSorted((left, right) => { + const secondaryOrder = + Number(left.item.secondary ?? false) - Number(right.item.secondary ?? false); + if (secondaryOrder !== 0) return secondaryOrder; + if (left.rank !== right.rank) return right.rank - left.rank; + const leftRecency = left.item.searchRecency ?? Number.NEGATIVE_INFINITY; + const rightRecency = right.item.searchRecency ?? Number.NEGATIVE_INFINITY; + if (leftRecency !== rightRecency) return rightRecency > leftRecency ? 1 : -1; + return left.index - right.index; + }) .map((entry) => entry.item); if (items.length === 0) { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index ba543a8872..be7395e40f 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -45,6 +45,7 @@ import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import * as Option from "effect/Option"; import { ArrowLeftIcon, + ChartNoAxesColumnIcon, CornerLeftUpIcon, FileSearchIcon, FolderIcon, @@ -54,10 +55,13 @@ import { LibraryIcon, MessageCircleQuestionIcon, MessageSquareIcon, + MonitorIcon, + MoonIcon, PaletteIcon, CircleStopIcon, SettingsIcon, SquarePenIcon, + SunIcon, TextSearchIcon, } from "lucide-react"; import { @@ -81,6 +85,15 @@ import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl" import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { useClientSettings } from "../hooks/useSettings"; import { useTheme } from "../hooks/useTheme"; +import { useCustomThemes } from "../hooks/useCustomThemes"; +import { useEnvironmentThemeDefinitions } from "../hooks/useEnvironmentTheme"; +import { BUILT_IN_THEMES } from "@t3tools/shared/themePalettes"; +import { getThemeDefinition } from "../themePalette"; +import { + STANDARD_THEME_CARDS, + getThemeCardDefinition, + ThemePreviewCircle, +} from "./settings/ThemePreviewCircles"; import { readLocalApi } from "../localApi"; import { desktopLocalBackendId } from "../connection/desktopLocal"; import { filesystemEnvironment } from "../state/filesystem"; @@ -155,7 +168,9 @@ import { filterPinnedBrowseEntries, getCommandPaletteInputPlaceholder, getCommandPaletteMode, + getExistingThreadForProjectAdd, ITEM_ICON_CLASS, + nextAppearanceMode, RECENT_THREAD_LIMIT, reduceCommandPaletteUiState, type SearchOverlayMode, @@ -196,9 +211,26 @@ import { } from "../sidebarProjectGrouping"; import type { Project } from "../types"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; +import { readPullRequestListPreferences } from "~/components/pullRequest/pullRequestListPreferences"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; +const APPEARANCE_OPTIONS = [ + { mode: "system", label: "System", icon: MonitorIcon }, + { mode: "light", label: "Light", icon: SunIcon }, + { mode: "dark", label: "Dark", icon: MoonIcon }, +] as const; + +function notifyThemeSaveFailure(): void { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Couldn't save theme selection", + description: "Try again.", + }), + ); +} + function projectFavicon(project: Project) { return ; } @@ -460,7 +492,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const { theme, themeHalves, resolvedTheme } = useTheme(); + const { theme, themeHalves, resolvedTheme, appearanceMode, setAppearanceMode } = useTheme(); const composerHandleRef = useRef(null); const routeTarget = useParams({ strict: false, @@ -501,8 +533,32 @@ export function CommandPalette({ children }: { children: ReactNode }) { terminalOpen, previewFocus: isPreviewFocused(), previewOpen, + modelPickerOpen: composerHandleRef.current?.isModelPickerOpen() ?? false, }, }); + if (command === "appearance.cycle") { + event.preventDefault(); + event.stopPropagation(); + if (event.repeat) return; + const nextMode = nextAppearanceMode(appearanceMode); + if (!setAppearanceMode(nextMode)) { + notifyThemeSaveFailure(); + } else { + toastManager.add({ + id: "appearance-cycle", + title: `Appearance: ${APPEARANCE_OPTIONS.find((option) => option.mode === nextMode)?.label}`, + timeout: 1500, + }); + } + return; + } + if (command === "theme.select") { + event.preventDefault(); + event.stopPropagation(); + if (event.repeat) return; + dispatch({ _tag: "OpenChangeTheme" }); + return; + } if (command === "themeEditor.toggle") { event.preventDefault(); event.stopPropagation(); @@ -523,7 +579,17 @@ export function CommandPalette({ children }: { children: ReactNode }) { }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [keybindings, previewOpen, resolvedTheme, terminalOpen, theme, themeHalves, toggleMode]); + }, [ + appearanceMode, + keybindings, + previewOpen, + resolvedTheme, + setAppearanceMode, + terminalOpen, + theme, + themeHalves, + toggleMode, + ]); useEffect( () => @@ -743,7 +809,30 @@ function OpenCommandPaletteDialog(props: { const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const { theme, themeHalves, resolvedTheme } = useTheme(); + const { + theme, + themeHalves, + resolvedTheme, + appearanceMode, + setAppearanceMode, + setTheme, + setThemeHalf, + } = useTheme(); + const customThemes = useCustomThemes(); + const environmentThemes = useEnvironmentThemeDefinitions(); + const themeCards = useMemo(() => { + const seen = new Set(); + return [ + ...STANDARD_THEME_CARDS.map((card) => ({ ...card, id: null })), + ...[...BUILT_IN_THEMES, ...customThemes, ...environmentThemes] + .filter((definition) => { + if (seen.has(definition.id)) return false; + seen.add(definition.id); + return true; + }) + .map(getThemeCardDefinition), + ]; + }, [customThemes, environmentThemes]); const providers = useAtomValue(primaryServerProvidersAtom); const providerEntryByEnvironmentAndInstanceId = useMemo(() => { const map = new Map(); @@ -1914,6 +2003,99 @@ function OpenCommandPaletteDialog(props: { await navigate({ to: "/welcome" }); }, }); + const changeThemeItem: CommandPaletteSubmenuItem = { + kind: "submenu", + value: "action:change-theme", + searchTerms: ["change theme", "appearance", "colors", "palette"], + title: "Change theme", + icon: , + addonIcon: , + shortcutCommand: "theme.select", + groups: [ + { + value: "themes", + label: "Change theme", + items: themeCards.map(({ id, label, previews }) => ({ + kind: "action", + value: id === null ? "theme:standard" : `theme:palette:${id}`, + title: label, + description: previews.length === 1 ? `For ${previews[0]!.mode} mode` : undefined, + searchTerms: [label, "theme", "appearance"], + icon: , + titleTrailingContent: ( + + {(themeHalves?.[resolvedTheme] ?? getThemeDefinition(theme)?.id ?? null) === id ? ( + Current + ) : null} + + {previews.map((preview) => ( + + ))} + + + ), + run: async () => { + const saved = + previews.length === 1 && id !== null + ? setThemeHalf(previews[0]!.mode, id) + : setTheme(id ?? appearanceMode); + if (!saved) notifyThemeSaveFailure(); + }, + })), + }, + ], + }; + actionItems.push(changeThemeItem); + + const changeAppearanceItem: CommandPaletteSubmenuItem = { + kind: "submenu", + value: "action:change-appearance", + searchTerms: ["change appearance", "light", "dark", "system", "mode", "toggle"], + title: "Change appearance", + icon: , + addonIcon: , + shortcutCommand: "appearance.cycle", + groups: [ + { + value: "appearance", + label: "Change appearance", + items: APPEARANCE_OPTIONS.map(({ mode, label, icon: Icon }) => ({ + kind: "action", + value: `appearance:${mode}`, + title: label, + searchTerms: [label, "appearance", "mode"], + icon: , + titleTrailingContent: + appearanceMode === mode ? ( + Current + ) : undefined, + run: async () => { + if (!setAppearanceMode(mode)) notifyThemeSaveFailure(); + }, + })), + }, + ], + }; + actionItems.push(changeAppearanceItem); + + useLayoutEffect(() => { + if (openIntent?.kind !== "change-theme") return; + clearOpenIntent(); + browseNavigation.invalidate(); + cloneLookupGeneration.current += 1; + setIsRemoteProjectLookingUp(false); + setAddProjectCloneFlow(null); + setViewStack([]); + pushPaletteView({ + addonIcon: , + groups: [{ value: "themes", label: "Change theme", items: [] }], + }); + }, [browseNavigation, clearOpenIntent, openIntent, pushPaletteView]); actionItems.push({ kind: "action", @@ -1931,6 +2113,34 @@ function OpenCommandPaletteDialog(props: { }, }); + if ( + environments.some( + (environment) => environment.serverConfig?.environment.capabilities.pullRequests === true, + ) + ) { + actionItems.push({ + kind: "action", + value: "action:pull-requests", + searchTerms: ["pull requests", "prs", "pr", "github", "review", "merge", "branch"], + title: "Open pull requests", + icon: , + run: async () => { + await navigate({ to: "/pull-requests", search: readPullRequestListPreferences() }); + }, + }); + } + + actionItems.push({ + kind: "action", + value: "action:usage", + searchTerms: ["usage", "use", "tokens", "cost", "spend", "limits", "stats", "analytics"], + title: "Open usage", + icon: , + run: async () => { + await navigate({ to: "/usage" }); + }, + }); + actionItems.push({ kind: "action", value: "action:settings", @@ -1990,6 +2200,7 @@ function OpenCommandPaletteDialog(props: { searchTerms: [item.title, SETTINGS_SECTION_LABELS[item.to], ...(item.searchTerms ?? [])], title: item.title, description: `Settings · ${SETTINGS_SECTION_LABELS[item.to]}`, + ...(item.secondary ? { secondary: true } : {}), icon: , run: async () => { await navigate({ @@ -2010,7 +2221,11 @@ function OpenCommandPaletteDialog(props: { addProjectEnvironmentId, buildAddProjectRemoteSourceReadiness(sourceControlDiscovery.data), ) - : (currentView?.groups ?? rootGroups); + : currentView?.groups[0]?.value === "themes" + ? changeThemeItem.groups + : currentView?.groups[0]?.value === "appearance" + ? changeAppearanceItem.groups + : (currentView?.groups ?? rootGroups); const filteredGroups = filterCommandPaletteGroups({ activeGroups, @@ -2086,7 +2301,7 @@ function OpenCommandPaletteDialog(props: { cwd, ); if (existing) { - const latestThread = getLatestThreadForProject( + const latestThread = getExistingThreadForProjectAdd( threads.filter((thread) => thread.environmentId === existing.environmentId), existing.id, clientSettings.sidebarThreadSortOrder, diff --git a/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx b/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx index 98d72e246c..263c837568 100644 --- a/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx +++ b/apps/web/src/components/ComposerPromptEditor.serialization.test.tsx @@ -3,6 +3,7 @@ import { $copyNode, $getRoot, $isElementNode, PASTE_COMMAND, type LexicalEditor import { act, createRef } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { collapseExpandedComposerCursor } from "../composer-logic"; import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; @@ -84,6 +85,16 @@ afterEach(async () => { }); describe("composer mention serialization", () => { + it("turns an absolute dropped folder path into a retained path chip", async () => { + const path = "/tmp/My Project/contracts"; + const prompt = `${serializeComposerFileLink(path)} `; + await renderPrompt(prompt); + expect(editorRef.current?.readSnapshot().value).toBe(prompt); + expect(lexicalEditor.getEditorState().read(() => $firstMention().exportJSON())).toMatchObject({ + path, + }); + }); + it.each([ "@README.md control", "@terminal-1:3 Explain this output\n\n\n- Terminal 1 line 3:\n 3 | output\n", diff --git a/apps/web/src/components/ComposerPromptEditor.test.ts b/apps/web/src/components/ComposerPromptEditor.test.ts index e295c72863..98bdfcc590 100644 --- a/apps/web/src/components/ComposerPromptEditor.test.ts +++ b/apps/web/src/components/ComposerPromptEditor.test.ts @@ -19,6 +19,8 @@ import { import { importPastedComposerText, + readPastedComposerContext, + shouldRoutePlainTextPaste, registerComposerInlineTokenPaste, } from "./composerInlineTokenPaste"; import { @@ -499,95 +501,108 @@ describe("registerComposerInlineTokenPaste", () => { }); describe("context reference paste", () => { - it.each(["focused", "blurred"])("imports structured paste when %s", (focus) => { - vi.stubGlobal("ClipboardEvent", TestClipboardEvent); - const editor = createEditor({ nodes: [ComposerCitationNode] }); - editor.update( - () => { - const paragraph = $createParagraphNode(); - $getRoot().append(paragraph); - paragraph.selectEnd(); - }, - { discrete: true }, - ); - const imported: string[] = []; - const importFragment = ( - fragment: import("@t3tools/contracts").ComposerContextClipboardFragment, - ) => { - imported.push(...fragment.records.map((record) => record.contextId)); - return new Map([ - ["img-old", "img-new"], - ["ctx-t", "ctx-t"], - ]); - }; - registerComposerInlineTokenPaste(editor, { - createMentionNode: (path) => $createTextNode(``), - createCitationNode: $createComposerCitationNode, - createContextReferenceNode: (reference) => - $createTextNode(``), - getExpandedAbsoluteOffsetForPoint: () => 0, - importContextFragment: importFragment, - }); - const event = new TestClipboardEvent( - "![shot](t3-context://v1/image/img-old) and [T](t3-context://v1/terminal/ctx-t)", - { - "web application/x-t3-context-fragment+json": JSON.stringify({ - version: 1, - source: { environmentId: "env-1" }, - records: [ - { - version: 1, - contextId: "img-old", - kind: "image", - label: "shot", - attachmentId: "a", - name: "shot.png", - mimeType: "image/png", - sizeBytes: 1, - }, - { - version: 1, - contextId: "ctx-t", - kind: "terminal", - label: "T", - terminalId: "t", - terminalLabel: "T", - lineStart: 1, - lineEnd: 1, - text: "x", - }, - { - version: 1, - contextId: "img-unrelated", - kind: "image", - label: "other", - attachmentId: "b", - name: "other.png", - mimeType: "image/png", - sizeBytes: 1, - }, - ], - }), - }, - ); - if (focus === "blurred") { - expect(importPastedComposerText(event.clipboardData, importFragment)).toBe( - "![shot](t3-context://v1/image/img-new) and [T](t3-context://v1/terminal/ctx-t)", + it.each([ + { focus: "focused", prefix: "" }, + { focus: "blurred", prefix: "" }, + { focus: "focused", prefix: "log ".repeat(10_000) }, + { focus: "blurred", prefix: "log ".repeat(10_000) }, + ])( + "imports structured paste when $focus with $prefix.length extra characters", + ({ focus, prefix }) => { + vi.stubGlobal("ClipboardEvent", TestClipboardEvent); + const editor = createEditor({ nodes: [ComposerCitationNode] }); + editor.update( + () => { + const paragraph = $createParagraphNode(); + $getRoot().append(paragraph); + paragraph.selectEnd(); + }, + { discrete: true }, + ); + const imported: string[] = []; + const importFragment = ( + fragment: import("@t3tools/contracts").ComposerContextClipboardFragment, + ) => { + imported.push(...fragment.records.map((record) => record.contextId)); + return new Map([ + ["img-old", "img-new"], + ["ctx-t", "ctx-t"], + ]); + }; + registerComposerInlineTokenPaste(editor, { + createMentionNode: (path) => $createTextNode(``), + createCitationNode: $createComposerCitationNode, + createContextReferenceNode: (reference) => + $createTextNode(``), + getExpandedAbsoluteOffsetForPoint: () => 0, + importContextFragment: importFragment, + }); + const event = new TestClipboardEvent( + `${prefix}![shot](t3-context://v1/image/img-old) and [T](t3-context://v1/terminal/ctx-t)`, + { + "web application/x-t3-context-fragment+json": JSON.stringify({ + version: 1, + source: { environmentId: "env-1" }, + records: [ + { + version: 1, + contextId: "img-old", + kind: "image", + label: "shot", + attachmentId: "a", + name: "shot.png", + mimeType: "image/png", + sizeBytes: 1, + }, + { + version: 1, + contextId: "ctx-t", + kind: "terminal", + label: "T", + terminalId: "t", + terminalLabel: "T", + lineStart: 1, + lineEnd: 1, + text: "x", + }, + { + version: 1, + contextId: "img-unrelated", + kind: "image", + label: "other", + attachmentId: "b", + name: "other.png", + mimeType: "image/png", + sizeBytes: 1, + }, + ], + }), + }, + ); + expect( + readPastedComposerContext(event.clipboardData)?.records.map((record) => record.contextId), + ).toEqual(["img-old", "ctx-t"]); + expect(shouldRoutePlainTextPaste(event.clipboardData, false)).toBe(false); + expect(shouldRoutePlainTextPaste(event.clipboardData, true)).toBe(true); + if (focus === "blurred") { + expect(importPastedComposerText(event.clipboardData, importFragment)).toBe( + `${prefix}![shot](t3-context://v1/image/img-new) and [T](t3-context://v1/terminal/ctx-t)`, + ); + expect(imported).toEqual(["img-old", "ctx-t"]); + return; + } + editor.update( + () => { + editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); + }, + { discrete: true }, ); expect(imported).toEqual(["img-old", "ctx-t"]); - return; - } - editor.update( - () => { - editor.dispatchCommand(PASTE_COMMAND, event as ClipboardEvent); - }, - { discrete: true }, - ); - expect(imported).toEqual(["img-old", "ctx-t"]); - expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( - " and ", - ); - }); + expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe( + `${prefix} and `, + ); + }, + ); it("converts a copied legacy element into a sendable annotation and rewrites its link", () => { const copied = upgradeLegacyContextMessage( diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 5bbcdec50c..a899823bf0 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -839,6 +839,7 @@ export interface ComposerPromptEditorHandle { focus: () => void; focusAt: (cursor: number) => void; focusAtEnd: () => void; + readSelectionRange: () => { start: number; end: number }; requestCitationComment: (request: ComposerCitationCommentRequest) => void; readSnapshot: () => { value: string; @@ -1861,6 +1862,10 @@ function ComposerPromptEditorInner({ ), ); }, + readSelectionRange: () => { + readSnapshot(); + return selectionRangeRef.current; + }, requestCitationComment: (request) => { citationCommentRequestRef.current = request; const target = editor @@ -2028,7 +2033,7 @@ function ComposerPromptEditorInner({ }} onKeyUp={(event) => onPageScrollKeyUp?.(event.key)} onBlur={onPageScrollRelease} - onPaste={onPaste} + onPasteCapture={onPaste} /> } placeholder={ diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index d954a7ff69..a0fa23d5aa 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -26,6 +26,7 @@ import * as Schema from "effect/Schema"; import * as DateTime from "effect/DateTime"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useOpenInPreferredEditor } from "../editorPreferences"; +import { useFileContextMenuHandler } from "../fileContextMenu"; import { type DraftId } from "../composerDraftStore"; import { openDiffFilePrimaryAction } from "../diffFileActions"; import { useCheckpointDiff } from "~/lib/checkpointDiffState"; @@ -72,7 +73,8 @@ import { import { DropdownMenu, DropdownMenuContent, - DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, @@ -170,6 +172,10 @@ export default function DiffPanel({ const serverConfig = useAtomValue( serverEnvironment.configValueAtom(activeThread?.environmentId ?? null), ); + const onFileContextMenu = useFileContextMenuHandler( + activeThread?.environmentId ?? null, + activeCwd, + ); const openInPreferredEditor = useOpenInPreferredEditor( activeThread?.environmentId ?? null, serverConfig?.availableEditors ?? [], @@ -495,6 +501,29 @@ export default function DiffPanel({ }), [collapsedDiffFileKeys, renderableFileEntries, lazySource, readyFilePaths], ); + const showDiffFileContextMenu = useCallback( + (filePath: string, position: { clientX: number; clientY: number }) => { + const matching = codeViewFiles.filter((file) => file.filePath === filePath); + if (matching.length === 0) return false; + return onFileContextMenu( + { + environmentId: activeThread?.environmentId ?? null, + filePath, + fileExists: matching.some((file) => file.fileDiff.type !== "deleted"), + workspaceRoot: activeCwd, + repositoryRoot: activeRepositoryRoot, + }, + position, + ); + }, + [ + activeCwd, + activeRepositoryRoot, + activeThread?.environmentId, + codeViewFiles, + onFileContextMenu, + ], + ); const diffFileKeys = useMemo( () => renderableFileEntries.map((file) => file.fileKey), [renderableFileEntries], @@ -644,6 +673,26 @@ export default function DiffPanel({ if (!routeThreadRef) return; useDiffPanelStore.getState().selectBranchBaseRef(routeThreadRef, baseRef); }; + // The scope menu has two radio groups: the top-level one treats the latest + // turn as "latest", while the turn sub-menu keys every turn by id so the + // latest turn is also marked there. + const selectedTurnValue = selectedTurn ? `turn:${selectedTurn.turnId}` : ""; + const selectedScopeValue = + selectedTurnId === null + ? selectedGitScope + : selectedTurn?.turnId === latestTurn?.turnId + ? "latest" + : selectedTurnValue; + const selectScopeValue = (value: string) => { + if (value === "unstaged" || value === "branch") { + selectGitScope(value); + } else if (value === "latest") { + if (latestTurn) selectTurn(latestTurn.turnId); + } else { + const turn = orderedTurnDiffSummaries.find((summary) => `turn:${summary.turnId}` === value); + if (turn) selectTurn(turn.turnId); + } + }; const headerRow = ( <> @@ -657,61 +706,42 @@ export default function DiffPanel({ - selectGitScope("unstaged")} - > - Working tree - - selectGitScope("branch")} - > - Branch changes - - { - if (latestTurn) selectTurn(latestTurn.turnId); - }} - > - Latest turn - + + + Working tree + + + Branch changes + + + Latest turn + + Turn - {orderedTurnDiffSummaries.map((summary) => { - const turnCount = - summary.checkpointTurnCount ?? - inferredCheckpointTurnCountByTurnId[summary.turnId] ?? - "?"; - return ( - selectTurn(summary.turnId)} - > - Turn {turnCount} - - {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} - - - ); - })} + + {orderedTurnDiffSummaries.map((summary) => { + const turnCount = + summary.checkpointTurnCount ?? + inferredCheckpointTurnCountByTurnId[summary.turnId] ?? + "?"; + return ( + + + Turn {turnCount} + + {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} + + + + ); + })} + @@ -1071,6 +1101,53 @@ export default function DiffPanel({ ); if (file) toggleDiffFileCollapsed(file.fileKey); }} + onContextMenuCapture={(event) => { + const path = event.nativeEvent.composedPath?.() ?? []; + if ( + path.some( + (node) => + node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement, + ) + ) + return; + const title = path.find( + (node): node is HTMLElement => + node instanceof HTMLElement && node.hasAttribute("data-title"), + ); + const header = path.find( + (node): node is HTMLElement => + node instanceof HTMLElement && node.hasAttribute("data-diffs-header"), + ); + const filePath = + title?.textContent ?? header?.querySelector("[data-title]")?.textContent; + if (!filePath) return; + const rect = + title?.getBoundingClientRect() ?? header?.getBoundingClientRect(); + if ( + showDiffFileContextMenu(filePath, { + clientX: event.clientX || rect?.left || 0, + clientY: event.clientY || rect?.bottom || 0, + }) + ) + event.preventDefault(); + }} + onKeyDownCapture={(event) => { + if (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) + return; + const target = event.target; + if (!(target instanceof HTMLElement)) return; + const header = target.closest("[data-diffs-header]"); + const filePath = header?.querySelector("[data-title]")?.textContent; + if (!filePath || !header) return; + const rect = header.getBoundingClientRect(); + if ( + showDiffFileContextMenu(filePath, { + clientX: rect.left, + clientY: rect.bottom, + }) + ) + event.preventDefault(); + }} > { onFileDropThreads(threadRef, files); }, + addFolders: () => + toastManager.add({ + type: "error", + title: "Drop folders into an open local thread composer", + }), }), [onFileDropThreads, threadRef], ); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 92b4b0558f..b7b603494d 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -15,6 +15,10 @@ import type { import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { AsyncResult } from "effect/unstable/reactivity"; import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; +import { + effectiveSnoozed, + type ThreadSnoozeShell, +} from "@t3tools/client-runtime/state/thread-settled"; import { getThreadSortTimestamp, resolveSettledThreadTimestamp, @@ -27,6 +31,22 @@ import type { SidebarThreadSummary, Thread } from "../types"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; +export function shouldNavigateAfterThreadPark(input: { + readonly threadKey: string; + readonly currentThreadKey: string | null; + readonly action: "settle" | "snooze"; + readonly now: string; + readonly thread: (ThreadSnoozeShell & Pick) | null; +}): boolean { + return ( + input.threadKey === input.currentThreadKey && + input.thread !== null && + (input.action === "settle" + ? input.thread.settledOverride === "settled" + : effectiveSnoozed(input.thread, { now: input.now })) + ); +} + const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; // Visible sidebar rows are prewarmed into the thread-detail cache so opening a diff --git a/apps/web/src/components/Sidebar.motion.test.ts b/apps/web/src/components/Sidebar.motion.test.ts index 8313bed9da..9a4597822a 100644 --- a/apps/web/src/components/Sidebar.motion.test.ts +++ b/apps/web/src/components/Sidebar.motion.test.ts @@ -239,6 +239,45 @@ describe("sidebar list motion", () => { expect(a.animations[0]!.cancel).not.toHaveBeenCalled(); }); + it("gives a simple appended row its own travel when existing rows stay still", () => { + const existing = new TestRow("existing", 82); + const incoming = new TestRow("incoming", 82); + const { motion, layout } = fixture([existing]); + motion.update(true); + layout([existing, incoming]); + motion.update(true); + + expect(existing.animate).not.toHaveBeenCalled(); + expect(incoming.animate).toHaveBeenCalledWith( + [ + { opacity: 0, transform: "translateY(-40px)" }, + { opacity: 1, transform: "translateY(0px)" }, + ], + { duration: 150, easing: "ease-out" }, + ); + }); + + it("does not ride a distant moving row across a stationary neighbour", () => { + const moving = new TestRow("moving"); + const stationary = new TestRow("stationary"); + const incoming = new TestRow("incoming"); + const { motion, layout } = fixture([moving, stationary]); + motion.update(true); + layout([moving, stationary, incoming]); + moving.offsetTop -= 40; + motion.update(true); + + expectMove(moving, 40); + expect(stationary.animate).not.toHaveBeenCalled(); + expect(incoming.animate).toHaveBeenCalledWith( + [ + { opacity: 0, transform: "translateY(-40px)" }, + { opacity: 1, transform: "translateY(0px)" }, + ], + { duration: 150, easing: "ease-out" }, + ); + }); + it("cancels owned motion on suspension and never animates a disposed list", () => { const a = new TestRow("a"); const b = new TestRow("b"); @@ -281,10 +320,16 @@ describe("sidebar list motion", () => { layout([b, fresh]); motion.update(true); expect(a.animations[0]!.cancel).toHaveBeenCalledOnce(); - expect(fresh.animate).toHaveBeenLastCalledWith([{ opacity: 0 }, { opacity: 1 }], { - duration: 150, - easing: "ease-out", - }); + expect(fresh.animate).toHaveBeenLastCalledWith( + [ + { opacity: 0, transform: "translateY(83px)" }, + { opacity: 1, transform: "translateY(0px)" }, + ], + { + duration: 150, + easing: "ease-out", + }, + ); const clone = a.clones[0]!; expect(clone.style).toMatchObject({ position: "absolute", @@ -299,10 +344,16 @@ describe("sidebar list motion", () => { expect(clone.attributes).toEqual([{ name: "aria-hidden", value: "true" }]); expect(clone.children[0]!.attributes).toEqual([{ name: "data-state", value: "open" }]); expect(clone.children[1]!.attributes).toEqual(icon.attributes); - expect(clone.animate).toHaveBeenCalledWith([{ opacity: 1 }, { opacity: 0 }], { - duration: 150, - easing: "ease-out", - }); + expect(clone.animate).toHaveBeenCalledWith( + [ + { opacity: 1, transform: "translateY(0px)" }, + { opacity: 0, transform: "translateY(-83px)" }, + ], + { + duration: 150, + easing: "ease-out", + }, + ); expect(parent.children.includes(clone)).toBe(true); motion.update(true); expect(clone.animations).toHaveLength(1); @@ -311,6 +362,106 @@ describe("sidebar list motion", () => { expect(parent.children.includes(clone)).toBe(false); }); + it("rides entering rows on the shelf displacement so an opened shelf moves as one block", () => { + const a = new TestRow("a", 40); + const header = new TestRow("header", 32); + const x = new TestRow("x", 36); + const y = new TestRow("y", 36); + const z = new TestRow("z", 36); + const { motion, layout } = fixture([a, header, x]); + motion.update(true); + // The shelf is anchored below the list, so two revealed rows lift the + // header and its existing rows by the same 72px. + layout([a, header, x, y, z]); + for (const row of [header, x, y, z]) row.offsetTop -= 72; + motion.update(true); + expectMove(header, 72); + expectMove(x, 72); + expect(a.animate).not.toHaveBeenCalled(); + expect(y.animate).toHaveBeenLastCalledWith( + [ + { opacity: 0, transform: "translateY(72px)" }, + { opacity: 1, transform: "translateY(0px)" }, + ], + { duration: 150, easing: "ease-out" }, + ); + expect(z.animate).toHaveBeenLastCalledWith( + [ + { opacity: 0, transform: "translateY(72px)" }, + { opacity: 1, transform: "translateY(0px)" }, + ], + { duration: 150, easing: "ease-out" }, + ); + }); + + it("rides new rows on a retained header's unfinished travel", () => { + const header = new TestRow("header", 32); + const above = new TestRow("above", 39); + const incoming = new TestRow("incoming", 36); + const { motion, layout } = fixture([header]); + motion.update(true); + layout([above, header]); + motion.update(true); + header.animations[0]!.progress = 0.25; + layout([above, header, incoming]); + motion.update(true); + expect(incoming.animate).toHaveBeenLastCalledWith( + [ + { opacity: 0, transform: "translateY(-30px)" }, + { opacity: 1, transform: "translateY(0px)" }, + ], + { duration: 150, easing: "ease-out" }, + ); + }); + + it("cancels in-flight row travel when a bulk update skips animation", () => { + const a = new TestRow("a"); + const b = new TestRow("b"); + const { motion, layout } = fixture([a, b]); + motion.update(true); + layout([b, a]); + motion.update(true); + const first = a.animations[0]!; + first.progress = 0.4; + const incoming = Array.from({ length: 41 }, (_, index) => new TestRow(`new-${index}`)); + layout(incoming); + motion.update(true); + expect(first.cancel).toHaveBeenCalledOnce(); + expect(incoming.every((row) => row.animate.mock.calls.length === 0)).toBe(true); + }); + + it("keeps in-flight shelf entry travel when the shelf closes mid-animation", () => { + const a = new TestRow("a", 40); + const header = new TestRow("header", 32); + const x = new TestRow("x", 36); + const y = new TestRow("y", 36); + const { motion, layout } = fixture([a, header, x]); + motion.update(true); + layout([a, header, x, y]); + for (const row of [header, x, y]) row.offsetTop -= 36; + motion.update(true); + const entry = y.animations[0]!; + header.animations[0]!.progress = 0.25; + x.animations[0]!.progress = 0.25; + entry.progress = 0.25; + layout([a, header, x]); + motion.update(true); + expect(entry.cancel).toHaveBeenCalledOnce(); + const clone = y.clones[0]!; + // Remaining entry travel (36 * 0.75) is baked into the clone's box so the + // fade starts from the row's current visual top instead of jumping to 0. + expect(clone.style.top).toBe("110px"); + expect(clone.animate).toHaveBeenCalledWith( + [ + { opacity: 0.25, transform: "translateY(0px)" }, + { opacity: 0, transform: "translateY(9px)" }, + ], + { duration: 150, easing: "ease-out" }, + ); + expectMove(header, -9); + expectMove(x, -9); + }); + it("clears exit clones on pickup and does not fade the release commit", () => { const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")]; const { motion, layout, parent } = fixture([a, b]); @@ -329,10 +480,16 @@ describe("sidebar list motion", () => { expect(c.animations).toHaveLength(0); layout([c, a]); motion.update(true); - expect(a.animate).toHaveBeenCalledWith([{ opacity: 0 }, { opacity: 1 }], { - duration: 150, - easing: "ease-out", - }); + expect(a.animate).toHaveBeenCalledWith( + [ + { opacity: 0, transform: "translateY(-40px)" }, + { opacity: 1, transform: "translateY(0px)" }, + ], + { + duration: 150, + easing: "ease-out", + }, + ); motion.dispose(); expect(a.animations.at(-1)!.cancel).toHaveBeenCalledOnce(); }); @@ -349,15 +506,40 @@ describe("sidebar list motion", () => { motion.update(true); expect(marker.clones).toHaveLength(0); const clone = a.clones[0]!; - expect(clone.animate).toHaveBeenCalledWith([{ opacity: 0.4 }, { opacity: 0 }], { - duration: 150, - easing: "ease-out", - }); + expect(clone.animate).toHaveBeenCalledWith( + [ + { opacity: 0.4, transform: "translateY(0px)" }, + { opacity: 0, transform: "translateY(40px)" }, + ], + { + duration: 150, + easing: "ease-out", + }, + ); motion.update(false); expect(parent.children).toEqual([]); expect(clone.animations[0]!.cancel).toHaveBeenCalledOnce(); }); + it("retargets an in-flight entry when a later layout shift moves the row", () => { + const a = new TestRow("a", 40); + const header = new TestRow("header", 32); + const x = new TestRow("x", 36); + const y = new TestRow("y", 36); + const { motion, layout } = fixture([a, header, x]); + motion.update(true); + layout([a, header, x, y]); + for (const row of [header, x, y]) row.offsetTop -= 36; + motion.update(true); + const entry = y.animations[0]!; + entry.progress = 0.25; + for (const row of [header, x, y]) row.offsetTop += 40; + motion.update(true); + expect(entry.cancel).not.toHaveBeenCalled(); + // Remaining 27px of the 36px entry plus the new 40px shift. + expectMove(y, -13); + }); + it("skips fades when a large list change would clone too many rows", () => { const rows = Array.from({ length: 41 }, (_, index) => new TestRow(`row-${index}`)); const { motion, layout, parent } = fixture(rows); diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts index d70c839d49..7e13130354 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -1,4 +1,8 @@ const motionTiming = { duration: 150, easing: "ease-out" }; +// Rows normally ride their displaced neighbour's travel. Absent a moving +// neighbour, a row still travels on its own, clamped so a tall card does not +// slide its full height. +const rowTravel = (height: number) => Math.min(height, 40); // A project filter change or a bulk snooze swaps a large part of the list at // once. Fades are the expensive part: every removed row gets a deep clone and // every clone and entering row gets its own animation, and the layout reads @@ -23,7 +27,7 @@ export function createSidebarListMotion(parent: HTMLUListElement) { "(prefers-reduced-motion: reduce)", ); const running = new Map(); - const entering = new Map(); + const entering = new Map(); const exiting = new Map(); // Visual tops at drag release, relative to the list, so the release // commit can glide every row from where dnd-kit left it into its slot. @@ -31,15 +35,19 @@ export function createSidebarListMotion(parent: HTMLUListElement) { const remainingOffset = (node: HTMLElement) => { const current = running.get(node); - return current ? current.offset * (1 - progress(current.animation)) : 0; + const run = current ? current.offset * (1 - progress(current.animation)) : 0; + const entry = entering.get(node); + const enter = entry ? entry.travel * (1 - progress(entry.animation)) : 0; + return run + enter; }; const clearFades = () => { - for (const animation of [...entering.values(), ...exiting.values()]) animation.cancel(); + for (const entry of entering.values()) entry.animation.cancel(); + for (const animation of exiting.values()) animation.cancel(); for (const node of exiting.keys()) node.remove(); entering.clear(); exiting.clear(); }; - const fadeOut = (node: HTMLElement, position: RowPosition) => { + const fadeOut = (node: HTMLElement, position: RowPosition, travel: number) => { if (position.height === 0) return; // React owns the removed row; only a noninteractive copy stays for the fade. const clone = node.cloneNode(true) as HTMLElement; @@ -72,8 +80,12 @@ export function createSidebarListMotion(parent: HTMLUListElement) { }); parent.append(clone); const entry = entering.get(node); + const entryProgress = entry ? progress(entry.animation) : 1; const animation = clone.animate( - [{ opacity: entry ? progress(entry) : 1 }, { opacity: 0 }], + [ + { opacity: entryProgress, transform: "translateY(0px)" }, + { opacity: 0, transform: `translateY(${travel}px)` }, + ], motionTiming, ); exiting.set(clone, animation); @@ -99,7 +111,10 @@ export function createSidebarListMotion(parent: HTMLUListElement) { }; const move = (node: HTMLElement, offset: number) => { cancel(node); - if (offset === 0) return; + const entry = entering.get(node); + // The newer transform supersedes entry travel; its original opacity keeps fading. + if (entry) entry.travel = 0; + if (offset === 0 && !entry) return; const animation = node.animate( [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }], motionTiming, @@ -144,15 +159,45 @@ export function createSidebarListMotion(parent: HTMLUListElement) { positions !== null && !reducedMotion?.matches && fadeCount <= MAX_FADED_ROWS_PER_UPDATE; + const movedDelta = new Map(); + const nextOrder = [...next.keys()]; + const oldOrder = positions === null ? [] : [...positions.keys()]; + const ridingDelta = ( + order: readonly HTMLElement[], + index: number, + retained: (node: HTMLElement) => boolean, + ) => { + for (let cursor = index - 1; cursor >= 0; cursor--) { + const node = order[cursor]!; + const delta = movedDelta.get(node); + if (delta !== undefined) return delta === 0 ? undefined : delta; + if (retained(node)) { + const offset = remainingOffset(node); + return offset === 0 ? undefined : offset; + } + } + return undefined; + }; if (!shouldAnimate) clearFades(); else { + // A shelf that opens above its collapsed anchor shifts every retained + // row by the same amount. Entering rows take that same displacement so + // the shelf arrives as one moving block instead of rows popping into + // their final slots; exiting rows leave by it. + for (const [node, position] of next) { + const previousTop = positions!.get(node)?.top; + if (previousTop === undefined || previousTop === position.top) continue; + movedDelta.set(node, previousTop + remainingOffset(node) - position.top); + } for (const [node, position] of positions!) { - if (!next.has(node)) fadeOut(node, position); + if (next.has(node)) continue; + const delta = ridingDelta(oldOrder, oldOrder.indexOf(node), (n) => next.has(n)); + fadeOut(node, position, delta === undefined ? rowTravel(position.height) : -delta); } } - for (const [node, animation] of entering) { + for (const [node, entry] of entering) { if (!next.has(node)) { - animation.cancel(); + entry.animation.cancel(); entering.delete(node); } } @@ -160,26 +205,35 @@ export function createSidebarListMotion(parent: HTMLUListElement) { if (!shouldAnimate || !next.has(node)) cancel(node); } if (shouldAnimate) { - for (const [node, position] of next) { - const previousTop = positions?.get(node)?.top; + for (const [index, node] of nextOrder.entries()) { + const position = next.get(node)!; + const previousTop = positions!.get(node)?.top; if (previousTop === undefined) { if (position.height > 0) { - const animation = node.animate([{ opacity: 0 }, { opacity: 1 }], motionTiming); - entering.set(node, animation); + const delta = ridingDelta(nextOrder, index, (n) => positions!.has(n)); + const travel = delta === undefined ? -rowTravel(position.height) : delta; + const animation = node.animate( + [ + { opacity: 0, transform: `translateY(${travel}px)` }, + { opacity: 1, transform: "translateY(0px)" }, + ], + motionTiming, + ); + entering.set(node, { animation, travel }); animation.addEventListener( "finish", () => { - if (entering.get(node) === animation) entering.delete(node); + if (entering.get(node)?.animation === animation) entering.delete(node); }, { once: true }, ); } continue; } - if (previousTop === position.top) continue; + const delta = movedDelta.get(node); // Computed progress includes the effect's easing. Only our own // translate is carried forward; dnd-kit's transforms are never read. - move(node, previousTop + remainingOffset(node) - position.top); + if (delta !== undefined) move(node, delta); } } if (released !== null) { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0cce06b1d6..33e62ca175 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -179,6 +179,7 @@ import { resolveSidebarThreadStatus, searchSidebarThreads, shouldCreateNewThreadInCurrentProject, + shouldNavigateAfterThreadPark, shouldRecedeSidebarThread, resolveWorkingStartedAt, sidebarListItemId, @@ -1418,6 +1419,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { addFiles: (files) => { onFileDropThreads(threadRef, files); }, + addFolders: () => + toastManager.add({ + type: "error", + title: "Drop folders into an open local thread composer", + }), }) : null, [onFileDropThreads, threadRef], @@ -2473,6 +2479,11 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { addFiles: (files) => { props.onFileDropThreads(threadRef, files); }, + addFolders: () => + toastManager.add({ + type: "error", + title: "Drop folders into an open local thread composer", + }), }), [props.onFileDropThreads, threadRef], ); @@ -3522,7 +3533,15 @@ export default function Sidebar() { } // Only move forward if the user is still on the settled thread — // a navigation made during the await wins over ours. - if (routeThreadKeyRef.current === threadKey) { + if ( + shouldNavigateAfterThreadPark({ + threadKey, + currentThreadKey: routeThreadKeyRef.current, + action: "settle", + now: new Date().toISOString(), + thread: readThreadShell(threadRef), + }) + ) { navigateAfterSettle?.(); } } finally { @@ -4080,7 +4099,17 @@ export default function Sidebar() { const settled = await run(settleThread(threadRef), "Failed to settle thread").finally( () => settlingThreadKeysRef.current.delete(activeKey), ); - if (settled && routeThreadKeyRef.current === activeKey) navigateAfterSettle?.(); + if ( + settled && + shouldNavigateAfterThreadPark({ + threadKey: activeKey, + currentThreadKey: routeThreadKeyRef.current, + action: "settle", + now: new Date().toISOString(), + thread: readThreadShell(threadRef), + }) + ) + navigateAfterSettle?.(); return; } case "move-active": @@ -4177,7 +4206,15 @@ export default function Sidebar() { } // Only move forward if the user is still on the snoozed thread — // a navigation made during the await wins over ours. - if (routeThreadKeyRef.current === threadKey) { + if ( + shouldNavigateAfterThreadPark({ + threadKey, + currentThreadKey: routeThreadKeyRef.current, + action: "snooze", + now: new Date().toISOString(), + thread: readThreadShell(threadRef), + }) + ) { navigateAfterSnooze?.(); } return { status: "success" } as const; @@ -4508,10 +4545,24 @@ export default function Sidebar() { const isPinned = thread.pinnedAt != null; // Presets resolve at menu-open time (same as the popover). const snoozePresets = resolveSnoozePresets(new Date(), timestampFormat); + const threadProjectGroup = + projectGroupsRef.current.find((project) => + project.memberProjectRefs.some( + (projectRef) => + projectRef.environmentId === thread.environmentId && + projectRef.projectId === thread.projectId, + ), + ) ?? null; const clicked = await settlePromise(() => api.contextMenu.show( buildThreadActionMenuItems({ branch: thread.branch ?? null, + projectFilter: threadProjectGroup + ? { + label: threadProjectGroup.displayName, + isActive: projectScopeKey === threadProjectGroup.projectKey, + } + : null, isPinned, isSettled, isSnoozed, @@ -4539,17 +4590,18 @@ export default function Sidebar() { return; } switch (clicked.value) { - case "project-settings": { - const projectGroup = projectGroupsRef.current.find((group) => - group.memberProjectRefs.some( - (projectRef) => - projectRef.environmentId === thread.environmentId && - projectRef.projectId === thread.projectId, - ), - ); - if (projectGroup) openProjectSettings(projectGroup); + case "filter-by-project": + if (threadProjectGroup) { + setProjectScopeKey( + projectScopeKey === threadProjectGroup.projectKey + ? null + : threadProjectGroup.projectKey, + ); + } + return; + case "project-settings": + if (threadProjectGroup) openProjectSettings(threadProjectGroup); return; - } case "new-thread-on-branch": { // Explicit branch carry-over: reuse the thread's worktree when it // has one, otherwise its branch on the local checkout. @@ -4710,8 +4762,10 @@ export default function Sidebar() { handleMultiSelectContextMenu, markThreadUnread, openProjectSettings, + projectScopeKey, projectByKey, serverConfigs, + setProjectScopeKey, startThreadRename, updateThreadMetadata, timestampFormat, @@ -5251,7 +5305,9 @@ export default function Sidebar() { key={threadKey} id={threadKey} disabled={ - !draggableThreadKeys.has(threadKey) || optimisticDrop !== null + renamingThreadKey === threadKey || + !draggableThreadKeys.has(threadKey) || + optimisticDrop !== null } > {(bag) => diff --git a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx index 3c37fc3316..7e5dc2fc04 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx @@ -1,7 +1,13 @@ import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import type { ClientSettings } from "@t3tools/contracts/settings"; import * as Option from "effect/Option"; -import { act } from "react"; +import { + CircleAlertIcon, + CircleCheckIcon, + MessageCircleQuestionIcon, + ShieldQuestionIcon, +} from "lucide-react"; +import { act, type ReactElement } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -23,8 +29,12 @@ const state = vi.hoisted(() => ({ sessionOnly: false, notifyCompletion: true, add: vi.fn( - (_toast: { title: string; description: string; actionProps: { onClick: () => void } }) => - "toast-1", + (_toast: { + title: string; + description: string; + data?: { leadingIcon?: ReactElement }; + actionProps: { onClick: () => void }; + }) => "toast-1", ), close: vi.fn(), navigate: vi.fn(), @@ -164,6 +174,7 @@ describe("thread notifications", () => { const toast = state.add.mock.calls[0]?.[0]; expect(toast?.title).toBe("Thread completed"); expect(toast?.description).toBe("Fix the login form"); + expect(toast?.data?.leadingIcon?.type).toBe(CircleCheckIcon); toast?.actionProps.onClick(); expect(state.close).toHaveBeenCalledWith("toast-1"); expect(state.navigate).toHaveBeenCalledWith({ @@ -188,35 +199,39 @@ describe("thread notifications", () => { ); it.each([ - ["input", "Input needed"], - ["approval", "Approval needed"], - ["sessionError", "Thread failed"], - ["turnError", "Thread failed"], - ] as const)("uses the same %s event for in-app and desktop alerts", async (event, title) => { - state.mode = "notifications-and-sound"; - await render(); - state[event] = true; - await render(); - await render(); - expect(state.add).toHaveBeenCalledTimes(1); - expect(state.add).toHaveBeenLastCalledWith(expect.objectContaining({ title })); - expect(state.sound).toHaveBeenCalledWith("input", expect.any(Function)); - expect(state.notification).not.toHaveBeenCalled(); + ["input", "Input needed", MessageCircleQuestionIcon], + ["approval", "Approval needed", ShieldQuestionIcon], + ["sessionError", "Thread failed", CircleAlertIcon], + ["turnError", "Thread failed", CircleAlertIcon], + ] as const)( + "uses the same %s event for in-app and desktop alerts", + async (event, title, icon) => { + state.mode = "notifications-and-sound"; + await render(); + state[event] = true; + await render(); + await render(); + expect(state.add).toHaveBeenCalledTimes(1); + expect(state.add).toHaveBeenLastCalledWith(expect.objectContaining({ title })); + expect(state.add.mock.calls[0]?.[0].data?.leadingIcon?.type).toBe(icon); + expect(state.sound).toHaveBeenCalledWith("input", expect.any(Function)); + expect(state.notification).not.toHaveBeenCalled(); - state[event] = false; - await render(); - state.focused = false; - state[event] = true; - await render(); - await render(); - expect(state.add).toHaveBeenCalledTimes(1); - expect(state.notification).toHaveBeenCalledTimes(1); - expect(state.notification).toHaveBeenCalledWith(title, { - body: "Fix the login form", - tag: "env-1:thread-1", - silent: true, - }); - }); + state[event] = false; + await render(); + state.focused = false; + state[event] = true; + await render(); + await render(); + expect(state.add).toHaveBeenCalledTimes(1); + expect(state.notification).toHaveBeenCalledTimes(1); + expect(state.notification).toHaveBeenCalledWith(title, { + body: "Fix the login form", + tag: "env-1:thread-1", + silent: true, + }); + }, + ); it("keeps background desktop alerts when in-app notifications are disabled", async () => { state.focused = false; diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 6c3299dc8b..743d8f40f3 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -2,6 +2,12 @@ import { useAtomValue } from "@effect/atom-react"; import { useNavigate, useParams } from "@tanstack/react-router"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; +import { + CircleAlertIcon, + CircleCheckIcon, + MessageCircleQuestionIcon, + ShieldQuestionIcon, +} from "lucide-react"; import { useCallback, useEffect, useRef } from "react"; import { getClientSettings, useClientSettings } from "../hooks/useSettings"; @@ -167,7 +173,28 @@ function EnvironmentNotifications({ type: kind === "completion" ? "success" : state.phase === "failed" ? "error" : "warning", title, description: state.threadTitle, - data: { hideCopyButton: true }, + data: { + hideCopyButton: true, + leadingIcon: + state.phase === "completed" ? ( + + ) : state.phase === "waiting_for_approval" ? ( + + ) : state.phase === "failed" ? ( + + ) : ( + + ), + }, actionProps: { children: "Open thread", onClick: () => { diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index fbab4cde39..e20317e87e 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -23,6 +23,13 @@ import { MiddleTruncate } from "../ui/middle-truncate"; const EMPTY_DIRECTORY_OVERRIDES: Record = {}; +/** Opens the OS-level context menu for a changed file (reveal in file manager, open in editor). */ +export type ChangedFileContextMenuHandler = ( + filePath: string, + position: { readonly clientX: number; readonly clientY: number }, + fileExists: boolean, +) => boolean; + export const ChangedFilesCard = memo(function ChangedFilesCard(props: { turnId: TurnId; files: ReadonlyArray; @@ -30,6 +37,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { resolvedTheme: "light" | "dark"; onToggleAllDirectories: () => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; + onFileContextMenu?: ChangedFileContextMenuHandler | undefined; }) { const { turnId, @@ -38,6 +46,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { resolvedTheme, onToggleAllDirectories, onOpenTurnDiff, + onFileContextMenu, } = props; const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); const hasDirectories = files.some((file) => /[/\\]/.test(file.path)); @@ -118,6 +127,7 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { allDirectoriesExpanded={allDirectoriesExpanded} resolvedTheme={resolvedTheme} onOpenTurnDiff={onOpenTurnDiff} + onFileContextMenu={onFileContextMenu} /> ); @@ -129,9 +139,26 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { allDirectoriesExpanded: boolean; resolvedTheme: "light" | "dark"; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; + onFileContextMenu?: ChangedFileContextMenuHandler | undefined; }) { - const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props; + const { + files, + allDirectoriesExpanded, + onOpenTurnDiff, + resolvedTheme, + turnId, + onFileContextMenu, + } = props; const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]); + const deletedPaths = useMemo( + () => + new Set( + files + .filter((file) => /^(deleted|removed|d)$/iu.test(file.kind)) + .map((file) => file.path.replaceAll("\\", "/")), + ), + [files], + ); const directoryPathsKey = useMemo( () => collectDirectoryPaths(treeNodes).join("\u0000"), [treeNodes], @@ -215,6 +242,24 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { className="group flex w-full items-center gap-2 rounded-md py-1.5 pr-2 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background" style={{ paddingLeft: `${leftPadding}px` }} onClick={() => onOpenTurnDiff(turnId, node.path)} + onContextMenu={ + onFileContextMenu + ? (event) => { + const rect = event.currentTarget.getBoundingClientRect(); + if ( + onFileContextMenu( + node.path, + { + clientX: event.clientX || rect.left, + clientY: event.clientY || rect.bottom, + }, + !deletedPaths.has(node.path), + ) + ) + event.preventDefault(); + } + : undefined + } > {hasDirectoryNodes || depth > 0 ? (