Skip to content

Surface execution environment and repository identity metadata#1765

Open
juliusmarminge wants to merge 2 commits intomainfrom
t3code/remote-host-model
Open

Surface execution environment and repository identity metadata#1765
juliusmarminge wants to merge 2 commits intomainfrom
t3code/remote-host-model

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented Apr 5, 2026

prep work for multi-environments

Summary

  • Adds a persistent server environment descriptor with stable environment IDs, platform info, server version, and capability flags.
  • Resolves repository identity from Git remotes and threads that metadata into project snapshots, websocket events, and replayed orchestration data.
  • Updates desktop bootstrap plumbing so the UI can read local environment metadata from the host runtime.
  • Expands server and shared contract coverage with new tests for environment persistence, repository identity resolution, and event enrichment.

Testing

  • Not run (PR content drafted from the diff only).
  • bun fmt - Not run.
  • bun lint - Not run.
  • bun typecheck - Not run.
  • bun run test - Not run.

Note

Medium Risk
Medium risk due to cross-cutting contract/schema changes (ServerConfig + lifecycle payloads now require environment) and new event enrichment/state scoping that can affect websocket streams, store selectors, and existing clients/tests.

Overview
Surfaces execution-environment metadata end-to-end. The server now generates and persists a stable EnvironmentId and exposes an ExecutionEnvironmentDescriptor (label/platform/version/capabilities), threading it into serverGetConfig plus welcome/ready lifecycle events.

Adds repository identity resolution and propagation. A new RepositoryIdentityResolver derives a normalized RepositoryIdentity from git remotes (with caching), and the server attaches it to project snapshot data and to project.created / project.meta-updated events during replay and live subscriptions.

Updates desktop + web client bootstrapping and state for multi-environment. Desktop exposes getLocalEnvironmentBootstrap over IPC; web adds environmentBootstrap + new @t3tools/client-runtime helpers and refactors the Zustand store/sidebar to scope projects/threads by activeEnvironmentId, including snapshot syncing and event application per environment.

Reviewed by Cursor Bugbot for commit 95c2115. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Surface execution environment and repository identity metadata across server and client

  • Adds a new ServerEnvironment service that persists a stable environment ID in the state directory and exposes an ExecutionEnvironmentDescriptor (platform, server version, repositoryIdentity capability) included in welcome and ready lifecycle events and serverGetConfig responses.
  • Adds a RepositoryIdentityResolver service that inspects git remote -v output to resolve a RepositoryIdentity (normalized canonical key, optional GitHub owner/repo) for a working directory, with an in-memory cache keyed by repo top-level.
  • Enriches project.created and project.meta-updated events returned over RPC with repositoryIdentity when resolvable.
  • Refactors the client-side store and sidebar to key projects and threads by environment-scoped IDs (<environmentId>:<localId>), so snapshot syncs, event application, and selectors are scoped to the active environment.
  • Introduces a new @t3tools/client-runtime package with utilities for constructing KnownEnvironment objects, scoped refs, and environment-aware client resolution.
  • Adds getLocalEnvironmentBootstrap() to DesktopBridge (IPC + preload) so the renderer can retrieve the local environment label and WebSocket URL.
  • Risk: ServerConfig, ServerLifecycleWelcomePayload, and ServerLifecycleReadyPayload schemas now require an environment field — existing consumers that construct these objects (e.g. tests, mocks) must be updated.

Macroscope summarized 95c2115.

- Persist a stable server environment ID and descriptor
- Resolve repository identity from git remotes and enrich orchestration events
- Thread environment metadata through desktop and web startup flows
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 5, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 64d63c70-237b-4479-b7dc-3ddef0efc1fa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/remote-host-model

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions bot added size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels Apr 5, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Redundant no-op identity map in effect pipeline
    • Removed the no-op Effect.map((option) => option) line that was a leftover from refactoring.
  • ✅ Fixed: Replay events enriched twice in subscription handler
    • Moved enrichProjectEvent to only wrap the live streamDomainEvents stream so already-enriched replay events skip the redundant enrichment pass.
  • ✅ Fixed: Selector creates unstable array references every call
    • Added a per-projectScopedId reference-equality cache that returns the previous result array when the underlying scopedIds and sidebarMap references haven't changed.

Create PR

Or push these changes by commenting:

@cursor push 481ff4254f
Preview (481ff4254f)
diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
--- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
+++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
@@ -748,7 +748,6 @@
             "ProjectionSnapshotQuery.getActiveProjectByWorkspaceRoot:decodeRow",
           ),
         ),
-        Effect.map((option) => option),
         Effect.flatMap((option) =>
           Option.isNone(option)
             ? Effect.succeed(Option.none<OrchestrationProject>())

diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -503,7 +503,10 @@
               Effect.catch(() => Effect.succeed([] as Array<OrchestrationEvent>)),
             );
             const replayStream = Stream.fromIterable(replayEvents);
-            const source = Stream.merge(replayStream, orchestrationEngine.streamDomainEvents);
+            const liveStream = orchestrationEngine.streamDomainEvents.pipe(
+              Stream.mapEffect(enrichProjectEvent),
+            );
+            const source = Stream.merge(replayStream, liveStream);
             type SequenceState = {
               readonly nextSequence: number;
               readonly pendingBySequence: Map<number, OrchestrationEvent>;
@@ -515,43 +518,33 @@
 
             return source.pipe(
               Stream.mapEffect((event) =>
-                enrichProjectEvent(event).pipe(
-                  Effect.flatMap((enrichedEvent) =>
-                    Ref.modify(
-                      state,
-                      ({
-                        nextSequence,
-                        pendingBySequence,
-                      }): [Array<OrchestrationEvent>, SequenceState] => {
-                        if (
-                          enrichedEvent.sequence < nextSequence ||
-                          pendingBySequence.has(enrichedEvent.sequence)
-                        ) {
-                          return [[], { nextSequence, pendingBySequence }];
-                        }
+                Ref.modify(
+                  state,
+                  ({
+                    nextSequence,
+                    pendingBySequence,
+                  }): [Array<OrchestrationEvent>, SequenceState] => {
+                    if (event.sequence < nextSequence || pendingBySequence.has(event.sequence)) {
+                      return [[], { nextSequence, pendingBySequence }];
+                    }
 
-                        const updatedPending = new Map(pendingBySequence);
-                        updatedPending.set(enrichedEvent.sequence, enrichedEvent);
+                    const updatedPending = new Map(pendingBySequence);
+                    updatedPending.set(event.sequence, event);
 
-                        const emit: Array<OrchestrationEvent> = [];
-                        let expected = nextSequence;
-                        for (;;) {
-                          const expectedEvent = updatedPending.get(expected);
-                          if (!expectedEvent) {
-                            break;
-                          }
-                          emit.push(expectedEvent);
-                          updatedPending.delete(expected);
-                          expected += 1;
-                        }
+                    const emit: Array<OrchestrationEvent> = [];
+                    let expected = nextSequence;
+                    for (;;) {
+                      const expectedEvent = updatedPending.get(expected);
+                      if (!expectedEvent) {
+                        break;
+                      }
+                      emit.push(expectedEvent);
+                      updatedPending.delete(expected);
+                      expected += 1;
+                    }
 
-                        return [
-                          emit,
-                          { nextSequence: expected, pendingBySequence: updatedPending },
-                        ];
-                      },
-                    ),
-                  ),
+                    return [emit, { nextSequence: expected, pendingBySequence: updatedPending }];
+                  },
                 ),
               ),
               Stream.flatMap((events) => Stream.fromIterable(events)),

diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts
--- a/apps/web/src/store.ts
+++ b/apps/web/src/store.ts
@@ -1305,22 +1305,36 @@
         ]
       : undefined;
 
+const _threadIdsByProjectCache = new Map<
+  string,
+  { scopedIds: string[]; sidebarMap: Record<string, SidebarThreadSummary>; result: ThreadId[] }
+>();
+
 export const selectThreadIdsByProjectId =
   (projectId: ProjectId | null | undefined) =>
-  (state: AppState): ThreadId[] =>
-    projectId
-      ? (
-          state.threadScopedIdsByProjectScopedId[
-            getProjectScopedId({
-              environmentId: state.activeEnvironmentId,
-              id: projectId,
-            })
-          ] ?? EMPTY_SCOPED_IDS
-        )
-          .map((scopedId) => state.sidebarThreadsByScopedId[scopedId]?.id ?? null)
-          .filter((threadId): threadId is ThreadId => threadId !== null)
-      : EMPTY_THREAD_IDS;
+  (state: AppState): ThreadId[] => {
+    if (!projectId) return EMPTY_THREAD_IDS;
 
+    const projectScopedId = getProjectScopedId({
+      environmentId: state.activeEnvironmentId,
+      id: projectId,
+    });
+    const scopedIds = state.threadScopedIdsByProjectScopedId[projectScopedId] ?? EMPTY_SCOPED_IDS;
+    const sidebarMap = state.sidebarThreadsByScopedId;
+
+    const cached = _threadIdsByProjectCache.get(projectScopedId);
+    if (cached && cached.scopedIds === scopedIds && cached.sidebarMap === sidebarMap) {
+      return cached.result;
+    }
+
+    const result = scopedIds
+      .map((scopedId) => sidebarMap[scopedId]?.id ?? null)
+      .filter((threadId): threadId is ThreadId => threadId !== null);
+
+    _threadIdsByProjectCache.set(projectScopedId, { scopedIds, sidebarMap, result });
+    return result;
+  };
+
 export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {
   return updateThreadState(state, state.activeEnvironmentId, threadId, (t) => {
     if (t.error === error) return t;

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 410dbe8. Configure here.

deletedAt: row.deletedAt,
}),
),
Effect.map((option) => option),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant no-op identity map in effect pipeline

Low Severity

Effect.map((option) => option) is a no-op identity transformation that does nothing. This appears to be a leftover from refactoring where Effect.map(Option.map(...)) was replaced — the Effect.map wrapper wasn't removed when the inner logic moved into the subsequent Effect.flatMap.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 410dbe8. Configure here.

orchestrationEngine.readEvents(fromSequenceExclusive),
).pipe(
Effect.map((events) => Array.from(events)),
Effect.flatMap(enrichOrchestrationEvents),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replay events enriched twice in subscription handler

Low Severity

In subscribeOrchestrationDomainEvents, replay events are enriched with repository identity twice — first via enrichOrchestrationEvents at collection time, then again individually via enrichProjectEvent inside the Stream.mapEffect. The per-event enrichment in the stream is needed for live events from streamDomainEvents, but replay events have already been enriched. The caching in RepositoryIdentityResolver mitigates the cost, but the double resolution (including a potential getReadModel() call for project.meta-updated) is unnecessary work.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 410dbe8. Configure here.

)
.map((scopedId) => state.sidebarThreadsByScopedId[scopedId]?.id ?? null)
.filter((threadId): threadId is ThreadId => threadId !== null)
: EMPTY_THREAD_IDS;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Selector creates unstable array references every call

Medium Severity

selectThreadIdsByProjectId now performs .map().filter() on every invocation, always returning a new array reference even when the underlying data hasn't changed. The previous implementation returned a stable stored reference from threadIdsByProjectId. When used as a Zustand selector (e.g., useStore(selectThreadIdsByProjectId(id))), the new behavior defeats Object.is equality checks and causes unnecessary React re-renders on every unrelated store update.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 410dbe8. Configure here.

Comment on lines +645 to +650
function resolveTargetEnvironmentId(
state: AppState,
environmentId?: EnvironmentId | null,
): EnvironmentId | null {
return environmentId ?? state.activeEnvironmentId ?? null;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low src/store.ts:645

In resolveTargetEnvironmentId, using environmentId ?? state.activeEnvironmentId treats null as a missing value. Since null is a valid environment identifier used throughout the codebase (e.g., environmentId: null in threads/projects), explicitly passing null incorrectly resolves to activeEnvironmentId when set, causing operations to target the wrong environment when the "null environment" was intended.

Suggested change
function resolveTargetEnvironmentId(
state: AppState,
environmentId?: EnvironmentId | null,
): EnvironmentId | null {
return environmentId ?? state.activeEnvironmentId ?? null;
}
function resolveTargetEnvironmentId(
state: AppState,
environmentId?: EnvironmentId | null,
): EnvironmentId | null {
return environmentId !== undefined ? environmentId : state.activeEnvironmentId ?? null;
}
Also found in 1 other location(s)

apps/web/src/components/Sidebar.tsx:1106

The getActiveEnvironmentThread function uses activeEnvironmentId to build the scoped lookup key, but threads rendered in the sidebar may belong to projects with a different environmentId. When the user right-clicks on such a thread, handleThreadContextMenu at line 1106 calls getActiveEnvironmentThread(threadId) which returns undefined, causing the context menu to silently fail to appear. The same issue affects handleMultiSelectContextMenu at line 1198 where the "Mark unread" action will silently skip threads from non-active environments.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/store.ts around lines 645-650:

In `resolveTargetEnvironmentId`, using `environmentId ?? state.activeEnvironmentId` treats `null` as a missing value. Since `null` is a valid environment identifier used throughout the codebase (e.g., `environmentId: null` in threads/projects), explicitly passing `null` incorrectly resolves to `activeEnvironmentId` when set, causing operations to target the wrong environment when the "null environment" was intended.

Evidence trail:
apps/web/src/store.ts lines 645-649 (resolveTargetEnvironmentId function using `??`); apps/web/src/store.ts lines 192-240 (mapThread and mapProject using environmentId parameter directly); apps/web/src/store.ts lines 656-673 (syncServerReadModel using targetEnvironmentId for mapping); packages/contracts/src/baseSchemas.ts line 23 (EnvironmentId is a branded string type, distinct from null)

Also found in 1 other location(s):
- apps/web/src/components/Sidebar.tsx:1106 -- The `getActiveEnvironmentThread` function uses `activeEnvironmentId` to build the scoped lookup key, but threads rendered in the sidebar may belong to projects with a different `environmentId`. When the user right-clicks on such a thread, `handleThreadContextMenu` at line 1106 calls `getActiveEnvironmentThread(threadId)` which returns `undefined`, causing the context menu to silently fail to appear. The same issue affects `handleMultiSelectContextMenu` at line 1198 where the "Mark unread" action will silently skip threads from non-active environments.

@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp bot commented Apr 5, 2026

Approvability

Verdict: Needs human review

This PR introduces a substantial new feature for environment and repository identity tracking with new services, a new package, and significant state management refactoring. The complexity of environment-scoped entity handling plus unresolved comments about selector stability and null-handling semantics warrant human review.

You can customize Macroscope's approvability policy. Learn more.

- Add packages/client-runtime/package.json to the release smoke workspace list
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant