From 98a84624a151065621450e9ee36d406d55d90c4f Mon Sep 17 00:00:00 2001 From: Ross Cawston Date: Tue, 1 Sep 2026 18:07:56 -0700 Subject: [PATCH 01/32] fix(server): bound orchestration replay payloads (#8992) WebSocket reconnect catch-up only checked the number of missing events, then loaded and decoded the whole replay range before sending it. A handful of events with large tool payloads could allocate gigabytes and OOM the server. Before replaying, run a SQL preflight that counts the events in the range and sums their serialized payload bytes with octet_length(). If the range is over the existing row limit or an 8 MiB byte budget, fall back to the fresh snapshot path. Applies to both thread and shell subscriptions. Authored by @rcawston. Review, octet_length fix, and merge by Claude Fable 5.1 in Claude Code on behalf of @t3dotgg. (cherry picked from commit 7e460f429b740180cd72730418262a2df971ba54) --- .../checkpointing/CheckpointDiffQuery.test.ts | 5 ++ .../Layers/OrchestrationEngine.test.ts | 1 + .../Layers/ProjectionSnapshotQuery.test.ts | 46 +++++++++++++ .../Layers/ProjectionSnapshotQuery.ts | 40 +++++++++++ .../Services/ProjectionSnapshotQuery.ts | 13 ++++ .../project/ProjectSetupScriptRunner.test.ts | 1 + .../Layers/ProviderSessionReaper.test.ts | 1 + apps/server/src/server.test.ts | 69 +++++++++++++++++++ apps/server/src/serverRuntimeStartup.test.ts | 4 ++ apps/server/src/ws.ts | 52 +++++++++++++- 10 files changed, 230 insertions(+), 2 deletions(-) diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index fe093c451..55b6dd973 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -85,6 +85,7 @@ describe("CheckpointDiffQuery.layer", () => { Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), + getEventReplayStats: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), @@ -194,6 +195,7 @@ describe("CheckpointDiffQuery.layer", () => { Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), + getEventReplayStats: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), @@ -278,6 +280,7 @@ describe("CheckpointDiffQuery.layer", () => { Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), + getEventReplayStats: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), @@ -347,6 +350,7 @@ describe("CheckpointDiffQuery.layer", () => { Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), + getEventReplayStats: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), @@ -401,6 +405,7 @@ describe("CheckpointDiffQuery.layer", () => { Effect.die("CheckpointDiffQuery should not request archived shell snapshots"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), + getEventReplayStats: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 8fc151a27..de4cc4c63 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -207,6 +207,7 @@ describe("OrchestrationEngine", () => { getSnapshotSequence: () => Effect.succeed({ snapshotSequence: projectionSnapshot.snapshotSequence }), getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }), + getEventReplayStats: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 760625517..b161d0bbf 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -962,6 +962,52 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }), ); + it.effect("measures replay payload bytes without decoding event bodies", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM orchestration_events`; + yield* sql` + INSERT INTO orchestration_events ( + event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, + command_id, causation_event_id, correlation_id, actor_kind, payload_json, metadata_json + ) + VALUES + ( + 'replay-event-1', 'thread', 'thread-replay', 1, 'thread.activity-appended', + '2026-03-01T00:00:00.000Z', NULL, NULL, NULL, 'provider', + json_object('output', printf('%.*c', 1000, 'x')), '{}' + ), + ( + 'replay-event-2', 'thread', 'thread-replay', 2, 'thread.activity-appended', + '2026-03-01T00:00:01.000Z', NULL, NULL, NULL, 'provider', + json_object('output', printf('%.*c', 2000, 'x')), '{}' + ), + ( + 'replay-event-3', 'thread', 'thread-replay', 3, 'thread.activity-appended', + '2026-03-01T00:00:02.000Z', NULL, NULL, NULL, 'provider', + json_object('output', printf('%.*c', 3000, 'x')), '{}' + ), + ( + 'replay-event-4', 'thread', 'thread-replay', 4, 'thread.activity-appended', + '2026-03-01T00:00:03.000Z', NULL, NULL, NULL, 'provider', + json_object('output', '๐Ÿ˜€'), '{}' + ) + `; + + // Bytes, not code points: the 4-byte emoji row is {"output":"๐Ÿ˜€"}, 17 bytes. + const stats = yield* snapshotQuery.getEventReplayStats({ + fromSequenceExclusive: 1, + toSequenceInclusive: 4, + }); + assert.deepStrictEqual(stats, { + eventCount: 3, + payloadBytes: 5043, + }); + }), + ); + it.effect("reads exact checkpoint availability and durable recovery state", () => Effect.gen(function* () { const snapshotQuery = yield* ProjectionSnapshotQuery; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3ae93ac88..b1862736d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -65,6 +65,7 @@ import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityRes import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { ProjectionSnapshotQuery, + type ProjectionEventReplayStats, type ProjectionFullThreadDiffContext, type ProjectionSnapshotCounts, type ProjectionPendingTurnAdmission, @@ -156,6 +157,14 @@ const ProjectionCountsRowSchema = Schema.Struct({ projectCount: Schema.Number, threadCount: Schema.Number, }); +const EventReplayStatsInput = Schema.Struct({ + fromSequenceExclusive: NonNegativeInt, + toSequenceInclusive: NonNegativeInt, +}); +const EventReplayStatsRowSchema = Schema.Struct({ + eventCount: Schema.Number, + payloadBytes: Schema.Number, +}); const ProjectionThreadSearchRequest = Schema.Struct({ pattern: Schema.String, limit: Schema.Int, @@ -1050,6 +1059,20 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const readEventReplayStats = SqlSchema.findOne({ + Request: EventReplayStatsInput, + Result: EventReplayStatsRowSchema, + execute: ({ fromSequenceExclusive, toSequenceInclusive }) => + sql` + SELECT + COUNT(*) AS "eventCount", + COALESCE(SUM(octet_length(payload_json)), 0) AS "payloadBytes" + FROM orchestration_events + WHERE sequence > ${fromSequenceExclusive} + AND sequence <= ${toSequenceInclusive} + `, + }); + const searchActiveThreadRows = SqlSchema.findAll({ Request: ProjectionThreadSearchRequest, Result: ProjectionThreadSearchRow, @@ -2836,6 +2859,22 @@ pending_approval_requests AS ( ), ); + const getEventReplayStats: ProjectionSnapshotQueryShape["getEventReplayStats"] = (input) => + readEventReplayStats(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getEventReplayStats:query", + "ProjectionSnapshotQuery.getEventReplayStats:decodeRow", + ), + ), + Effect.map( + (row): ProjectionEventReplayStats => ({ + eventCount: row.eventCount, + payloadBytes: row.payloadBytes, + }), + ), + ); + const searchThreads: ProjectionSnapshotQueryShape["searchThreads"] = Effect.fn( "ProjectionSnapshotQuery.searchThreads", )(function* (input) { @@ -3526,6 +3565,7 @@ pending_approval_requests AS ( searchThreads, getSnapshotSequence, getCounts, + getEventReplayStats, listPendingTurnAdmissions, getActiveProjectByWorkspaceRoot, getProjectShellById, diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 62d493de2..d8af534ed 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -42,6 +42,11 @@ export interface ProjectionSnapshotSequence { readonly snapshotSequence: number; } +export interface ProjectionEventReplayStats { + readonly eventCount: number; + readonly payloadBytes: number; +} + export interface ProjectionThreadCheckpointContext { readonly threadId: ThreadId; readonly projectId: ProjectId; @@ -149,6 +154,14 @@ export interface ProjectionSnapshotQueryShape { ProjectionRepositoryError >; + /** + * Measure a persisted event range without decoding its payload bodies. + */ + readonly getEventReplayStats: (input: { + readonly fromSequenceExclusive: number; + readonly toSequenceInclusive: number; + }) => Effect.Effect; + /** * Read the active project for an exact workspace root match. */ diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 5c5da4666..b0679a94c 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -32,6 +32,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }), getCounts: () => Effect.die("unused"), + getEventReplayStats: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: (workspaceRoot) => Effect.succeed( workspaceRoot === project.workspaceRoot ? Option.some(project) : Option.none(), diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index d0680f2f1..00bbbfe7e 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -229,6 +229,7 @@ describe("ProviderSessionReaper", () => { getSnapshotSequence: () => Effect.succeed({ snapshotSequence: input.readModel.snapshotSequence }), getCounts: () => Effect.die("unused"), + getEventReplayStats: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 4a43230c7..549f84d8d 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -905,6 +905,11 @@ const buildAppUnderTest = (options?: { getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), + getEventReplayStats: ({ fromSequenceExclusive, toSequenceInclusive }) => + Effect.succeed({ + eventCount: Math.max(0, toSequenceInclusive - fromSequenceExclusive), + payloadBytes: 0, + }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getThreadCheckpointContext: () => Effect.succeed(Option.none()), @@ -7648,6 +7653,70 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("subscriptions snapshot instead of decoding an oversized replay range", () => + Effect.gen(function* () { + let readEventsCalls = 0; + let replayStatsCalls = 0; + const thread = makeDefaultOrchestrationReadModel().threads[0]!; + const shell = makeDefaultOrchestrationThreadShell({ id: thread.id }); + + yield* buildAppUnderTest({ + layers: { + orchestrationEngine: { + latestSequence: Effect.succeed(5), + readEvents: () => + Stream.sync(() => { + readEventsCalls += 1; + return {} as OrchestrationEvent; + }), + }, + projectionSnapshotQuery: { + getEventReplayStats: () => + Effect.sync(() => { + replayStatsCalls += 1; + return { eventCount: 5, payloadBytes: 8 * 1024 * 1024 + 1 }; + }), + getThreadDetailSnapshot: () => + Effect.succeed(Option.some({ snapshotSequence: 5, thread })), + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 5, + projects: [], + threads: [shell], + updatedAt: "2026-01-01T00:00:00.000Z", + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const threadItems = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeThread]({ + threadId: thread.id, + afterSequence: 0, + requestCompletionMarker: true, + }).pipe(Stream.take(2), Stream.runCollect), + ), + ); + const shellItems = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.subscribeShell]({ + afterSequence: 0, + requestCompletionMarker: true, + }).pipe(Stream.take(2), Stream.runCollect), + ), + ); + + assert.equal(threadItems[0]?.kind, "snapshot"); + assert.equal(threadItems[1]?.kind, "synchronized"); + assert.equal(shellItems[0]?.kind, "snapshot"); + assert.equal(shellItems[1]?.kind, "synchronized"); + assert.equal(replayStatsCalls, 2); + assert.equal(readEventsCalls, 0); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("subscribeShell replaces a cursor ahead of the authoritative head", () => Effect.gen(function* () { let readEventsCalls = 0; diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index fc5b2c9e5..83642a956 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -83,6 +83,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa getShellSnapshot: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), + getEventReplayStats: () => Effect.die("unused"), getCounts: () => Deferred.succeed(countsStarted, undefined).pipe( Effect.andThen(Deferred.await(releaseCounts)), @@ -150,6 +151,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), + getEventReplayStats: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.succeed( Option.some({ @@ -207,6 +209,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), + getEventReplayStats: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), @@ -258,6 +261,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), + getEventReplayStats: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ac99c84c2..93da64039 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -407,6 +407,10 @@ const SHELL_RESUME_MAX_GAP = 1_000; // hundreds of thousands of events behind have OOM-killed servers on large // databases. Past this gap the client is reset with a fresh thread snapshot. const THREAD_RESUME_MAX_GAP = 1_000; +// Row count alone does not bound replay memory: a few events with large tool +// payloads can decode to gigabytes. Before replaying, sum the serialized +// payload bytes of the range in SQL and reset with a snapshot past this budget. +const ORCHESTRATION_REPLAY_PAYLOAD_BUDGET_BYTES = 8 * 1024 * 1024; function toAuthAccessStreamEvent( change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange, @@ -522,6 +526,42 @@ const makeWsRpcLayer = ( const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const canReplayPersistedRange = Effect.fnUntraced(function* ( + afterSequence: number, + headSequence: number, + maxGap: number, + ) { + const replayGap = headSequence - afterSequence; + if (replayGap < 0 || replayGap > maxGap) { + return false; + } + const stats = yield* projectionSnapshotQuery + .getEventReplayStats({ + fromSequenceExclusive: afterSequence, + toSequenceInclusive: headSequence, + }) + .pipe( + Effect.mapError( + (cause) => + new OrchestrationGetSnapshotError({ + message: "Failed to measure orchestration replay range", + cause, + }), + ), + ); + if (stats.payloadBytes > ORCHESTRATION_REPLAY_PAYLOAD_BUDGET_BYTES) { + yield* Effect.logDebug("orchestration replay replaced by snapshot", { + afterSequence, + headSequence, + replayGap, + eventCount: stats.eventCount, + payloadBytes: stats.payloadBytes, + payloadBudgetBytes: ORCHESTRATION_REPLAY_PAYLOAD_BUDGET_BYTES, + }); + return false; + } + return true; + }); const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; @@ -1450,7 +1490,13 @@ const makeWsRpcLayer = ( // is also invalid, so reset it with a snapshot. Send the snapshot // followed by the buffered live tail, exactly as the // no-afterSequence path does. - if (replayGap < 0 || replayGap > SHELL_RESUME_MAX_GAP) { + if ( + !(yield* canReplayPersistedRange( + afterSequence, + headSequence, + SHELL_RESUME_MAX_GAP, + )) + ) { const snapshot = yield* loadSnapshot; return Stream.concat( Stream.make({ kind: "snapshot" as const, snapshot }), @@ -1554,7 +1600,9 @@ const makeWsRpcLayer = ( const afterSequence = input.afterSequence; const headSequence = yield* orchestrationEngine.latestSequence; const replayGap = headSequence - afterSequence; - if (replayGap >= 0 && replayGap <= THREAD_RESUME_MAX_GAP) { + if ( + yield* canReplayPersistedRange(afterSequence, headSequence, THREAD_RESUME_MAX_GAP) + ) { const catchUpStream = orchestrationEngine .readEvents(afterSequence, replayGap) .pipe( From 50c258836bb73df99843a92a99b2066ec5d352b4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 18:18:20 -0700 Subject: [PATCH 02/32] fix(settings): sync auto-settle and other shared preferences across environments (#9147) Co-authored-by: Claude Fable 5.1 (cherry picked from commit 8efd4e95fcb08478c9c6e8eee384bfca559f62cc) --- .../features/settings/SettingsRouteScreen.tsx | 161 ++++++++++++++---- .../components/settings/SettingsPanels.tsx | 2 + .../settings/SharedSettingsMismatchAlert.tsx | 32 ++++ .../settings/SourceControlSettings.tsx | 2 + apps/web/src/hooks/useSettings.ts | 126 +++++++++++++- docs/internals/overview.md | 5 +- docs/user/thread-sidebar.md | 16 +- packages/client-runtime/package.json | 4 + .../src/state/sharedSettings.test.ts | 107 ++++++++++++ .../src/state/sharedSettings.ts | 90 ++++++++++ 10 files changed, 498 insertions(+), 47 deletions(-) create mode 100644 apps/web/src/components/settings/SharedSettingsMismatchAlert.tsx create mode 100644 packages/client-runtime/src/state/sharedSettings.test.ts create mode 100644 packages/client-runtime/src/state/sharedSettings.ts diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index c448ebd6f..28f1211a9 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -19,7 +19,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; -import { AppText as Text } from "../../components/AppText"; +import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { supportsAgentAwarenessPush } from "../agent-awareness/capabilities"; import { setLiveActivityUpdatesEnabled } from "../agent-awareness/liveActivityPreferences"; import { requestAgentNotificationPermission } from "../agent-awareness/notificationPermissions"; @@ -36,7 +36,17 @@ import { runtime } from "../../lib/runtime"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; -import type { EnvironmentId } from "@t3tools/contracts"; +import { useEnvironments } from "../../state/environments"; +import { + DEFAULT_SERVER_SETTINGS, + MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS, + type ServerSettingsPatch, +} from "@t3tools/contracts"; +import { + findSharedSettingsMismatches, + pickSharedServerSettings, +} from "@t3tools/client-runtime/state/shared-settings"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { type AppUpdateCheckState, @@ -530,51 +540,136 @@ function ConfiguredSettingsRouteScreen() { } function GeneralSettingsSection() { - const { savedConnectionsById } = useSavedRemoteConnections(); - const connections = Object.values(savedConnectionsById).sort((left, right) => - left.environmentLabel.localeCompare(right.environmentLabel), - ); - return ( - {connections.map((connection) => ( - - ))} + ); } -function EnvironmentAutoSettleSwitch(props: { - readonly environmentId: EnvironmentId; - readonly environmentLabel: string; -}) { - const settings = useAtomValue(serverEnvironment.settingsValueAtom(props.environmentId)); - const config = useAtomValue(serverEnvironment.configValueAtom(props.environmentId)); +const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays ?? 3; + +/** + * Auto-settlement is a user preference that every server has to hold. Mobile + * has no primary environment, so the first connected environment that + * supports it is the reference value. Edits fan out to every connected + * environment, and a mismatch row lets the user push the reference out. + */ +function AutoSettleSettingsRows() { + const { environments } = useEnvironments(); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { - label: "auto-settle settings update", + label: "server settings update", reportFailure: true, }); - if (config?.environment.capabilities.threadAutoSettlement !== true || settings === null) { + + const connected = environments.filter( + (environment) => + environment.connection.phase === "connected" && + environment.serverConfig?.environment.capabilities.threadAutoSettlement === true, + ); + const reference = connected[0] ?? null; + const referenceSettings = reference?.serverConfig?.settings ?? null; + + const [daysDraft, setDaysDraft] = useState(null); + + if (reference === null || referenceSettings === null) { return null; } + + const writeToAll = (patch: ServerSettingsPatch) => { + for (const environment of connected) { + void updateSettings({ environmentId: environment.environmentId, input: { patch } }); + } + }; + + const mismatches = findSharedSettingsMismatches({ + primaryEnvironmentId: reference.environmentId, + primarySettings: referenceSettings, + environments: environments.map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + connected: environment.connection.phase === "connected", + settings: environment.serverConfig?.settings ?? null, + })), + }); + + const afterDays = referenceSettings.sidebarAutoSettleAfterDays; + const commitDays = () => { + const draft = (daysDraft ?? "").trim(); + setDaysDraft(null); + // Whole-string check so "3.5" and "3days" are rejected instead of + // silently becoming 3 on every connected environment. + const parsed = /^\d+$/.test(draft) ? Number(draft) : Number.NaN; + if ( + Number.isInteger(parsed) && + parsed >= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && + parsed <= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS && + parsed !== afterDays + ) { + writeToAll({ sidebarAutoSettleAfterDays: parsed }); + } + }; + return ( - { - void updateSettings({ - environmentId: props.environmentId, - input: { patch: { sidebarAutoSettleOnMerge: value } }, - }); - }} - /> + <> + writeToAll({ sidebarAutoSettleOnMerge: value })} + /> + + writeToAll({ sidebarAutoSettleAfterDays: value ? AUTO_SETTLE_DEFAULT_DAYS : null }) + } + /> + {afterDays !== null ? ( + + Days before auto-settle + + + ) : null} + {mismatches.length > 0 ? ( + + + Settings differ + + {mismatches.map((mismatch) => mismatch.label).join(", ")} + + + { + const patch = pickSharedServerSettings(referenceSettings); + for (const mismatch of mismatches) { + void updateSettings({ + environmentId: mismatch.environmentId, + input: { patch }, + }); + } + }} + className="rounded-full bg-subtle px-4 py-2 active:opacity-70" + > + Apply to all + + + ) : null} + ); } diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 504d01eac..b801ce86e 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -112,6 +112,7 @@ import { TYPOGRAPHY_ADVANCED_STORAGE_KEY, } from "../../appearanceFonts"; import { CodeFontPreview, PromptFontPreview, TerminalFontPreview } from "./SettingsFontPreviews"; +import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert"; import { discoverInstalledFonts, FontFamilyPicker, useFontEnumeration } from "./FontFamilyPicker"; import { NumberField, @@ -1954,6 +1955,7 @@ export function GeneralSettingsPanel() { return ( + mismatch.label).join(", "); + return ( + + + + Settings differ on {labels}. Thread and source control preferences are meant to match on + every environment. + + + + + + ); +} diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 559a90f4b..f2b0578e1 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -18,6 +18,7 @@ import { } from "@t3tools/shared/backgroundActivitySettings"; import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; +import { SharedSettingsMismatchAlert } from "./SharedSettingsMismatchAlert"; import { cn } from "../../lib/utils"; import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; import { useEnvironmentQuery } from "../../state/query"; @@ -548,6 +549,7 @@ export function SourceControlSettingsPanel() { return ( + {isInitialScanPending ? ( <> diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index e43a9a490..a5f0774e1 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -26,6 +26,11 @@ import { type UnifiedSettings, } from "@t3tools/contracts/settings"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; +import { + findSharedSettingsMismatches, + pickSharedServerSettings, + splitSharedServerPatch, +} from "@t3tools/client-runtime/state/shared-settings"; import { ensureLocalApi } from "~/localApi"; import { getThemeDefinition, @@ -36,7 +41,11 @@ import { } from "~/themePalette"; import * as Struct from "effect/Struct"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; -import { usePrimaryEnvironment } from "~/state/environments"; +import { + type EnvironmentPresentation, + useEnvironments, + usePrimaryEnvironment, +} from "~/state/environments"; import { useAtomCommand } from "~/state/use-atom-command"; import { useTheme } from "./useTheme"; @@ -309,11 +318,38 @@ export function usePrimarySettings( return useMergedSettings(useAtomValue(primaryServerSettingsAtom), selector); } +/** + * Whether an environment can hold every shared key right now. Gated on the + * auto-settlement capability because it is the newest of the shared keys: a + * server that has it has all of them. Older servers drop unknown keys on + * write, so a mismatch against them could never clear, and their decoded + * defaults must not be treated as real values. + */ +function supportsSharedSettings(environment: EnvironmentPresentation): boolean { + return ( + environment.connection.phase === "connected" && + environment.serverConfig?.environment.capabilities.threadAutoSettlement === true + ); +} + +/** Environments that can receive a shared settings write right now. */ +function useConnectedEnvironmentIds(): ReadonlyArray { + const { environments } = useEnvironments(); + return useMemo( + () => + environments.filter(supportsSharedSettings).map((environment) => environment.environmentId), + [environments], + ); +} + /** * Returns an updater that routes each key to the correct backing store. * * Server keys are optimistically patched in atom-backed server state, then - * persisted via RPC. Client keys go through client persistence. + * persisted via RPC. Shared server keys (see `SHARED_SERVER_SETTING_KEYS`) + * are written to every connected environment, not only the target, so a user + * preference does not silently drift between machines. Client keys go through + * client persistence. */ function useUpdateSettingsTarget( environmentId: EnvironmentId | null, @@ -327,12 +363,16 @@ function useUpdateSettingsTarget( serverEnvironment.mutateProviderInstances, "provider instance update", ); + const connectedEnvironmentIds = useConnectedEnvironmentIds(); const updateSettings = useCallback( (patch: UnifiedSettingsPatch) => { const { serverPatch, clientPatch } = splitPatch(patch); - if (Object.keys(serverPatch).length > 0 && environmentId) { - if (serverPatch.providerInstances !== undefined) { + if (Object.keys(serverPatch).length > 0) { + // Provider-instance edits go through their own optimistic mutation so + // two writers cannot clobber each other's list. They are never shared + // keys, so they bypass the shared/local split entirely. + if (serverPatch.providerInstances !== undefined && environmentId) { void mutateProviderInstances({ environmentId, input: { @@ -344,10 +384,25 @@ function useUpdateSettingsTarget( }, }); } else { - void persistServerSettings({ - environmentId, - input: { patch: serverPatch }, - }); + const { sharedPatch, localPatch } = splitSharedServerPatch(serverPatch); + if (environmentId && Object.keys(localPatch).length > 0) { + void persistServerSettings({ + environmentId, + input: { patch: localPatch }, + }); + } + if (Object.keys(sharedPatch).length > 0) { + const targets = new Set(connectedEnvironmentIds); + if (environmentId) { + targets.add(environmentId); + } + for (const targetId of targets) { + void persistServerSettings({ + environmentId: targetId, + input: { patch: sharedPatch }, + }); + } + } } } if (Object.keys(clientPatch).length > 0) { @@ -358,6 +413,7 @@ function useUpdateSettingsTarget( } }, [ + connectedEnvironmentIds, currentSettings.providerInstances, environmentId, mutateProviderInstances, @@ -368,6 +424,60 @@ function useUpdateSettingsTarget( return updateSettings; } +/** + * Connected environments whose shared settings differ from the primary's, + * plus an action that writes the primary's values to all of them. Drift + * happens when an environment was offline during an edit or was changed by + * an older client. + */ +export function useSharedSettingsSync() { + const primaryEnvironment = usePrimaryEnvironment(); + const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; + // Read the loaded config, not `primaryServerSettingsAtom`: that atom falls + // back to defaults while the primary is disconnected, and "apply to all" + // must never push defaults over real values. Same for a primary too old to + // hold the shared keys: its decoded defaults are not a source of truth. + const primarySettings = + primaryEnvironment !== null && supportsSharedSettings(primaryEnvironment) + ? (primaryEnvironment.serverConfig?.settings ?? null) + : null; + const { environments } = useEnvironments(); + const persistServerSettings = useAtomCommand( + serverEnvironment.updateSettings, + "server settings update", + ); + + const mismatches = useMemo( + () => + findSharedSettingsMismatches({ + primaryEnvironmentId, + primarySettings, + environments: environments.map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + connected: supportsSharedSettings(environment), + settings: environment.serverConfig?.settings ?? null, + })), + }), + [environments, primaryEnvironmentId, primarySettings], + ); + + const applyToAll = useCallback(() => { + if (primarySettings === null) { + return; + } + const patch = pickSharedServerSettings(primarySettings); + for (const mismatch of mismatches) { + void persistServerSettings({ + environmentId: mismatch.environmentId, + input: { patch }, + }); + } + }, [mismatches, persistServerSettings, primarySettings]); + + return { mismatches, applyToAll }; +} + export function useUpdateEnvironmentSettings(environmentId: EnvironmentId) { const settings = useEnvironmentSettings(environmentId); return useUpdateSettingsTarget(environmentId, settings); diff --git a/docs/internals/overview.md b/docs/internals/overview.md index bfed10feb..d8e7edf68 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -88,7 +88,10 @@ A turn is complete when its session leaves `running` status, projected by `settledTurnStateForSessionStatus` in [`projector.ts`][projector]. Checkpoint work settling later does not define turn end. -Thread settlement is server-owned. Per-environment settings control PR and inactivity settlement. +Thread settlement is server-owned. Each server's own settings control PR and inactivity +settlement. Those keys are user preferences, so clients write them to every connected environment +(`SHARED_SERVER_SETTING_KEYS` in `packages/client-runtime/src/state/sharedSettings.ts`) and warn +when a connected environment drifts. [`ThreadSettlementReactor`][settlement] checks threads at startup, when those settings change, and once per minute, including when no client is connected. It dispatches the guarded internal `thread.auto-settle` command, which uses the existing settlement event lifecycle. Automatic diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 5c75441df..ea2d90804 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -11,16 +11,22 @@ shortcut. Mobile unpins immediately. Pinned threads still move to **Settled** when they become inactive. They also move when their pull request merges if **Auto-settle merged threads** is enabled. -Each environment owns its automatic settlement settings. The server checks them even when no web, -desktop, or mobile client is connected. By default, it settles threads after three days without +Each server stores its own copy of the automatic settlement settings and checks them even when no +web, desktop, or mobile client is connected. By default, it settles threads after three days without activity and when their pull request merges. An eligible idle thread also settles when its pull request closes. An open pull request blocks inactivity settlement. Active work, pending input, and live background work keep the thread active. Pylon settles from a closed or merged pull request only when its timestamp is not older than the user's latest activity. If that timestamp is not available, the inactivity rule still applies. A manual un-settle also keeps the thread active. -Change these rules in **Settings > General** for the environment. A settings change affects future -settlement and does not reopen a settled thread. Settings saved by older clients on one device no -longer control this behavior. + +Change these rules in **Settings > General**. The change is written to every environment you are +connected to at that moment. An environment that is offline keeps its old value. When a connected +environment holds a different value, **Settings > General** shows a warning that names it. Choose +**Apply to all** to write your current values to every connected environment. The same applies to +the new-thread workspace mode and the source control writing style. + +A settings change affects future settlement and does not reopen a settled thread. Settings saved +by older clients on one device no longer control this behavior. When you un-settle a thread, it returns to the top of the active list so you can find it right away. Its timestamps do not change. Other threads keep their positions. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index cd1bb7d73..152df8855 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -187,6 +187,10 @@ "types": "./src/state/session.ts", "default": "./src/state/session.ts" }, + "./state/shared-settings": { + "types": "./src/state/sharedSettings.ts", + "default": "./src/state/sharedSettings.ts" + }, "./state/shell": { "types": "./src/state/shell.ts", "default": "./src/state/shell.ts" diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts new file mode 100644 index 000000000..dbdf65118 --- /dev/null +++ b/packages/client-runtime/src/state/sharedSettings.test.ts @@ -0,0 +1,107 @@ +import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; + +import { + findSharedSettingsMismatches, + pickSharedServerSettings, + splitSharedServerPatch, +} from "./sharedSettings.ts"; + +const primaryId = EnvironmentId.make("env-primary"); +const laptopId = EnvironmentId.make("env-laptop"); +const boxId = EnvironmentId.make("env-box"); + +describe("splitSharedServerPatch", () => { + it("routes preference keys to the shared patch and machine keys to the local patch", () => { + const { sharedPatch, localPatch } = splitSharedServerPatch({ + sidebarAutoSettleAfterDays: 7, + sidebarAutoSettleOnMerge: false, + enableAgentBrowserAccess: false, + }); + expect(sharedPatch).toEqual({ sidebarAutoSettleAfterDays: 7, sidebarAutoSettleOnMerge: false }); + expect(localPatch).toEqual({ enableAgentBrowserAccess: false }); + }); +}); + +describe("pickSharedServerSettings", () => { + it("returns only the shared keys", () => { + expect(Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS)).sort()).toEqual([ + "defaultThreadEnvMode", + "newWorktreesStartFromOrigin", + "sidebarAutoSettleAfterDays", + "sidebarAutoSettleOnMerge", + "sourceControlWritingStyle", + ]); + }); +}); + +describe("findSharedSettingsMismatches", () => { + const primarySettings = { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 7 }; + + it("lists connected environments whose shared settings differ", () => { + const mismatches = findSharedSettingsMismatches({ + primaryEnvironmentId: primaryId, + primarySettings, + environments: [ + { environmentId: primaryId, label: "Desktop", connected: true, settings: primarySettings }, + { environmentId: laptopId, label: "Laptop", connected: true, settings: primarySettings }, + { + environmentId: boxId, + label: "Remote Box", + connected: true, + settings: DEFAULT_SERVER_SETTINGS, + }, + ], + }); + expect(mismatches).toEqual([{ environmentId: boxId, label: "Remote Box" }]); + }); + + it("ignores machine-only differences", () => { + const mismatches = findSharedSettingsMismatches({ + primaryEnvironmentId: primaryId, + primarySettings, + environments: [ + { + environmentId: boxId, + label: "Remote Box", + connected: true, + settings: { ...primarySettings, enableAgentBrowserAccess: false }, + }, + ], + }); + expect(mismatches).toEqual([]); + }); + + it("reports nothing until the primary environment's settings are loaded", () => { + const environments = [ + { environmentId: boxId, label: "Remote Box", connected: true, settings: primarySettings }, + ]; + expect( + findSharedSettingsMismatches({ primaryEnvironmentId: null, primarySettings, environments }), + ).toEqual([]); + expect( + findSharedSettingsMismatches({ + primaryEnvironmentId: primaryId, + primarySettings: null, + environments, + }), + ).toEqual([]); + }); + + it("skips offline environments and environments without a loaded config", () => { + const mismatches = findSharedSettingsMismatches({ + primaryEnvironmentId: primaryId, + primarySettings, + environments: [ + { + environmentId: laptopId, + label: "Laptop", + connected: false, + settings: DEFAULT_SERVER_SETTINGS, + }, + { environmentId: boxId, label: "Remote Box", connected: true, settings: null }, + ], + }); + expect(mismatches).toEqual([]); + }); +}); diff --git a/packages/client-runtime/src/state/sharedSettings.ts b/packages/client-runtime/src/state/sharedSettings.ts new file mode 100644 index 000000000..35fa4adb4 --- /dev/null +++ b/packages/client-runtime/src/state/sharedSettings.ts @@ -0,0 +1,90 @@ +/** + * Shared server settings. + * + * Every server keeps its own `settings.json`, but some keys are user + * preferences that only live on the server because the server has to act on + * them (auto-settlement runs with no client attached). A user does not want + * those to differ per machine. Clients write these keys to every connected + * environment, and warn when a connected environment still holds a different + * value so the user can push their current value out. + */ +import type { EnvironmentId, ServerSettings, ServerSettingsPatch } from "@t3tools/contracts"; +import * as Equal from "effect/Equal"; +import * as Struct from "effect/Struct"; + +/** Server keys that hold a user preference rather than machine config. */ +export const SHARED_SERVER_SETTING_KEYS = [ + "sidebarAutoSettleAfterDays", + "sidebarAutoSettleOnMerge", + "defaultThreadEnvMode", + "newWorktreesStartFromOrigin", + "sourceControlWritingStyle", +] as const satisfies ReadonlyArray; + +export type SharedServerSettingKey = (typeof SHARED_SERVER_SETTING_KEYS)[number]; + +const SHARED_KEY_SET = new Set(SHARED_SERVER_SETTING_KEYS); + +/** Split a server patch into the keys every environment should receive and the primary-only rest. */ +export function splitSharedServerPatch(patch: ServerSettingsPatch): { + sharedPatch: ServerSettingsPatch; + localPatch: ServerSettingsPatch; +} { + const sharedPatch: Record = {}; + const localPatch: Record = {}; + for (const [key, value] of Object.entries(patch)) { + if (SHARED_KEY_SET.has(key)) { + sharedPatch[key] = value; + } else { + localPatch[key] = value; + } + } + return { + sharedPatch: sharedPatch as ServerSettingsPatch, + localPatch: localPatch as ServerSettingsPatch, + }; +} + +/** The shared subset of one environment's settings, as a patch that can be written elsewhere. */ +export function pickSharedServerSettings(settings: ServerSettings): ServerSettingsPatch { + return Struct.pick(settings, SHARED_SERVER_SETTING_KEYS); +} + +export interface SharedSettingsEnvironment { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly connected: boolean; + readonly settings: ServerSettings | null; +} + +/** + * Connected environments whose shared settings differ from the primary + * environment's. Offline environments are skipped: nothing can be read from + * or written to them, and the warning would never clear. With no primary + * settings loaded there is nothing to compare against, so nothing is + * reported. Callers must pass the real loaded settings, never a default + * fallback, or "apply to all" would push defaults over real values. + */ +export function findSharedSettingsMismatches(input: { + readonly primaryEnvironmentId: EnvironmentId | null; + readonly primarySettings: ServerSettings | null; + readonly environments: ReadonlyArray; +}): ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }> { + if (input.primaryEnvironmentId === null || input.primarySettings === null) { + return []; + } + const expected = pickSharedServerSettings(input.primarySettings); + return input.environments.flatMap((environment) => { + if ( + environment.environmentId === input.primaryEnvironmentId || + !environment.connected || + environment.settings === null + ) { + return []; + } + const actual = pickSharedServerSettings(environment.settings); + return Equal.equals(actual, expected) + ? [] + : [{ environmentId: environment.environmentId, label: environment.label }]; + }); +} From 2d53d9b9b71713c0013e85fa43a63d405f5d8bfd Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 18:25:52 -0700 Subject: [PATCH 03/32] fix(server): prevent accidental service downgrades (#5302) (cherry picked from commit 0e77fbd3d0d79eec5247e75583a04590a1785133) --- apps/server/src/cli/service.test.ts | 170 +++++++++++++++- apps/server/src/cli/service.ts | 67 ++++++- apps/server/src/cloud/bootService.test.ts | 232 +++++++++++++++++----- apps/server/src/cloud/bootService.ts | 54 ++++- apps/server/src/cloud/serviceProtocol.ts | 14 ++ docs/user/background-service.md | 16 ++ 6 files changed, 482 insertions(+), 71 deletions(-) diff --git a/apps/server/src/cli/service.test.ts b/apps/server/src/cli/service.test.ts index 8c365e346..077eef33b 100644 --- a/apps/server/src/cli/service.test.ts +++ b/apps/server/src/cli/service.test.ts @@ -1,6 +1,26 @@ -import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, expect, it } from "@effect/vitest"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as NetService from "@t3tools/shared/Net"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Terminal from "effect/Terminal"; +import { Command } from "effect/unstable/cli"; +import { afterEach, vi } from "vite-plus/test"; -import { formatServiceStatus } from "./service.ts"; +import packageJson from "../../package.json" with { type: "json" }; +import * as BootService from "../cloud/bootService.ts"; +import { + formatServiceStatus, + offerServiceDuringOnboarding, + reconcileService, + recoverServiceOnboardingOffer, + serviceCommand, +} from "./service.ts"; + +afterEach(() => vi.restoreAllMocks()); const status = { supported: true, @@ -35,3 +55,149 @@ it("explains where the service is supported", () => { "Supported on: Linux with systemd, macOS with launchd", ); }); + +it("reports a newer installed service and gives an exact-version repair command", () => { + const output = formatServiceStatus( + { ...status, current: false, installedVersion: "0.0.32-nightly.1" }, + "0.0.31", + ); + + assert.include(output, "t3@0.0.32-nightly.1 (newer than this t3@0.0.31 CLI)"); + assert.include(output, "npx t3@0.0.32-nightly.1 service update"); + assert.notInclude(output, "npx t3@latest service update"); +}); + +const newerServiceStatus = { ...status, current: false, installedVersion: "999.0.0" }; + +function makeTestService(serviceStatus: BootService.BootServiceStatus) { + const installOptions: Array[0]> = []; + const service = BootService.BootService.of({ + status: Effect.succeed(serviceStatus), + install: (options) => + Effect.sync(() => { + installOptions.push(options); + return { + nodePath: "/test/node", + launcherPath: "/test/service-launcher.mjs", + baseDir: "/test/t3", + unitPath: serviceStatus.unitPath, + logPath: serviceStatus.logPath, + }; + }), + uninstall: Effect.succeed(false), + }); + return { service, installOptions }; +} + +it.layer(Layer.mergeAll(NodeServices.layer, NetService.layer))("service commands", (it) => { + it.effect.each(["install", "update"] as const)( + "%s refuses a downgrade before changing the service", + (command) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); + const { service, installOptions } = makeTestService(newerServiceStatus); + vi.spyOn(BootService, "layer").mockReturnValue( + Layer.succeed(BootService.BootService, service), + ); + + const error = yield* Command.runWith(serviceCommand, { version: packageJson.version })([ + command, + "--base-dir", + baseDir, + ]).pipe( + Effect.provideService(HostProcessEnvironment, {}), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), + Effect.flip, + ); + + expect(error).toMatchObject({ + _tag: "BootServiceDowngradeRefusedError", + installedVersion: "999.0.0", + targetVersion: packageJson.version, + }); + expect(installOptions).toEqual([]); + }), + ); + + it.effect.each(["install", "update"] as const)("%s allows an explicit downgrade", (command) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" }); + const { service, installOptions } = makeTestService(newerServiceStatus); + vi.spyOn(BootService, "layer").mockReturnValue( + Layer.succeed(BootService.BootService, service), + ); + + yield* Command.runWith(serviceCommand, { version: packageJson.version })([ + command, + "--base-dir", + baseDir, + "--allow-downgrade", + ]).pipe( + Effect.provideService(HostProcessEnvironment, {}), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))), + ); + + expect(installOptions).toEqual([{ allowDowngrade: true }]); + }), + ); +}); + +it.effect.each([ + { name: "a new service", state: { ...status, installed: false, current: false } }, + { name: "an older service", state: { ...status, current: false, installedVersion: "0.0.0" } }, + { + name: "the same version", + state: { ...status, current: false, installedVersion: packageJson.version }, + }, + { name: "an unknown version", state: { ...status, current: false } }, +])("installs or repairs $name without an override", ({ state }) => + Effect.gen(function* () { + const { service, installOptions } = makeTestService(state); + + const result = yield* reconcileService().pipe( + Effect.provideService(BootService.BootService, service), + ); + + expect(result.changed).toBe(true); + expect(installOptions).toEqual([undefined]); + }), +); + +it.effect("leaves a newer service unchanged during onboarding without prompting", () => + Effect.gen(function* () { + const { service, installOptions } = makeTestService(newerServiceStatus); + const terminal = Terminal.make({ + columns: Effect.succeed(80), + rows: Effect.succeed(24), + readInput: Effect.die("Onboarding must not prompt to replace a newer service."), + readLine: Effect.die("Onboarding must not prompt to replace a newer service."), + display: () => Effect.die("Onboarding must not prompt to replace a newer service."), + }); + + const ready = yield* offerServiceDuringOnboarding.pipe( + Effect.provideService(BootService.BootService, service), + Effect.provideService(Terminal.Terminal, terminal), + Effect.provide(NodeServices.layer), + ); + + expect(ready).toBe(false); + expect(installOptions).toEqual([]); + }), +); + +it.effect("keeps onboarding successful when a newer version appears before install", () => + Effect.gen(function* () { + const ready = yield* recoverServiceOnboardingOffer( + Effect.fail( + new BootService.BootServiceDowngradeRefusedError({ + installedVersion: "999.0.0", + targetVersion: packageJson.version, + }), + ), + ); + + expect(ready).toBe(false); + }), +); diff --git a/apps/server/src/cli/service.ts b/apps/server/src/cli/service.ts index 5a95f7d17..ee41b7d95 100644 --- a/apps/server/src/cli/service.ts +++ b/apps/server/src/cli/service.ts @@ -3,10 +3,11 @@ import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Terminal from "effect/Terminal"; -import { Command, GlobalFlag, Prompt } from "effect/unstable/cli"; +import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli"; import packageJson from "../../package.json" with { type: "json" }; import * as BootService from "../cloud/bootService.ts"; +import { compareExactServiceVersions } from "../cloud/serviceProtocol.ts"; import type * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; @@ -30,13 +31,25 @@ export type ServiceReconcileResult = }; /** Install, update, or repair the service using the CLI version running this command. */ -export const reconcileService = Effect.fn("cli.service.reconcile")(function* () { +export const reconcileService = Effect.fn("cli.service.reconcile")(function* (options?: { + readonly allowDowngrade?: boolean; +}) { const service = yield* BootService.BootService; const status = yield* service.status; if (status.installed && status.current) { return { changed: false, status } satisfies ServiceReconcileResult; } - const plan = yield* service.install; + if ( + status.installedVersion !== undefined && + options?.allowDowngrade !== true && + compareExactServiceVersions(packageJson.version, status.installedVersion) < 0 + ) { + return yield* new BootService.BootServiceDowngradeRefusedError({ + installedVersion: status.installedVersion, + targetVersion: packageJson.version, + }); + } + const plan = yield* service.install(options); return { changed: true, previouslyInstalled: status.installed, @@ -54,9 +67,23 @@ export function formatServiceStatus( if (!status.installed) { return "Pylon service\n Status: not installed\n Next: Run `t3 service install`."; } + const installedVersion = status.installedVersion ?? cliVersion; + if ( + !status.current && + status.installedVersion !== undefined && + compareExactServiceVersions(status.installedVersion, cliVersion) > 0 + ) { + return [ + "T3 Code service", + ` Status: installed ยท t3@${installedVersion} (newer than this t3@${cliVersion} CLI)`, + ` Unit: ${status.unitPath}`, + ` Logs: ${status.logPath}`, + ` Next: Use \`npx t3@${installedVersion} service update\` to repair it, or pass \`--allow-downgrade\` explicitly.`, + ].join("\n"); + } return [ "Pylon service", - ` Status: ${status.current ? `installed ยท t3@${cliVersion}` : "needs an update or repair"}`, + ` Status: ${status.current ? `installed ยท t3@${installedVersion}` : "needs an update or repair"}`, ` Unit: ${status.unitPath}`, ` Logs: ${status.logPath}`, ...(status.current ? [] : [" Next: Run `npx t3@latest service update`."]), @@ -72,13 +99,21 @@ const runServiceCommand = Effect.fn("cli.service.run")(function* ( return yield* run.pipe(Effect.provide(bootServiceLayer(config))); }); -const serviceInstallCommand = Command.make("install", projectLocationFlags).pipe( +const serviceReconcileFlags = { + ...projectLocationFlags, + allowDowngrade: Flag.boolean("allow-downgrade").pipe( + Flag.withDescription("Allow replacing a newer installed service with this older CLI version."), + Flag.withDefault(false), + ), +}; + +const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pipe( Command.withDescription("Install Pylon as a background service for this user."), Command.withHandler((flags) => runServiceCommand( flags, Effect.gen(function* () { - const result = yield* reconcileService(); + const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade }); if (!result.changed) { yield* Console.log(`Pylon service is already installed with t3@${packageJson.version}.`); return; @@ -91,7 +126,7 @@ const serviceInstallCommand = Command.make("install", projectLocationFlags).pipe ), ); -const serviceUpdateCommand = Command.make("update", projectLocationFlags).pipe( +const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe( Command.withDescription( "Update or repair the background service using this CLI version. Use `npx t3@latest service update` for the latest release.", ), @@ -99,7 +134,7 @@ const serviceUpdateCommand = Command.make("update", projectLocationFlags).pipe( runServiceCommand( flags, Effect.gen(function* () { - const result = yield* reconcileService(); + const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade }); if (!result.changed) { yield* Console.log(`Pylon service is already using t3@${packageJson.version}.`); return; @@ -143,7 +178,8 @@ const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe( export const offerServiceDuringOnboarding = Effect.gen(function* () { const service = yield* BootService.BootService; - const { supported, installed, current } = yield* service.status; + const status = yield* service.status; + const { supported, installed, current } = status; if (!supported) { return false; } @@ -151,6 +187,17 @@ export const offerServiceDuringOnboarding = Effect.gen(function* () { yield* Console.log("Pylon is already set up to run in the background on this machine."); return true; } + if ( + installed && + status.installedVersion !== undefined && + compareExactServiceVersions(status.installedVersion, packageJson.version) > 0 + ) { + yield* Console.log( + `A newer t3@${status.installedVersion} background service is installed. Leaving it unchanged.`, + ); + // This CLI cannot verify the newer service. Keep the manual fallback available. + return false; + } // A LaunchAgent starts at login and dies at logout; there is no // enable-linger equivalent on macOS. Do not promise more than that. const platform = yield* HostProcessPlatform; @@ -192,6 +239,8 @@ export const recoverServiceOnboardingOffer = ( Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), BootServiceUpdatePendingError: (error) => Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), + BootServiceDowngradeRefusedError: (error) => + Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)), }), ); diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index a999f81b2..3e31ab3f6 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -127,24 +127,34 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( const commands: string[] = []; const timeouts = new Map(); - const control: { failCommand: string | undefined } = { failCommand: undefined }; + const control: { failCommand: string | undefined; stateAfterStop?: string } = { + failCommand: undefined, + }; const runner = ProcessRunner.ProcessRunner.of({ - run: (input) => - Effect.sync(() => { - const command = `${input.command} ${input.args.join(" ")}`; - commands.push(command); - timeouts.set(command, input.timeout); - return { - stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "", - stderr: "", - code: ChildProcessSpawner.ExitCode(command === control.failCommand ? 1 : 0), - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }; - }), + run: Effect.fn("test.run_boot_service_command")(function* ( + input: ProcessRunner.ProcessRunInput, + ) { + const command = `${input.command} ${input.args.join(" ")}`; + commands.push(command); + timeouts.set(command, input.timeout); + if ( + control.stateAfterStop !== undefined && + (command === "systemctl --user stop t3code.service" || + command.startsWith("launchctl bootout --wait ")) + ) { + yield* fs.writeFileString(statePath, control.stateAfterStop).pipe(Effect.orDie); + } + return { + stdout: input.args[1] === "--version" ? "t3 v1.2.3\n" : "", + stderr: "", + code: ChildProcessSpawner.ExitCode(command === control.failCommand ? 1 : 0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + }), }); const makeService = (environmentPath = installerPath) => BootService.make({ @@ -179,14 +189,17 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("installs, reports current state, and uninstalls", () => Effect.gen(function* () { const { service, fs, statePath, commands, timeouts } = yield* makeHarness(); - const plan = yield* service.install; + const plan = yield* service.install(); expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ protocol: SERVICE_LAUNCHER_PROTOCOL, activeVersion: "1.2.3", }); expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n"); - expect((yield* service.status).current).toBe(true); + expect(yield* service.status).toMatchObject({ + current: true, + installedVersion: "1.2.3", + }); // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document. const pendingState = JSON.stringify({ protocol: SERVICE_LAUNCHER_PROTOCOL, @@ -212,10 +225,116 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); + it.effect.each(["linux", "darwin"] as const)( + "reports the installed version across launcher protocols on %s", + (platform) => + Effect.gen(function* () { + const { service, fs, statePath } = yield* makeHarness(platform); + yield* service.install(); + + for (const protocol of [SERVICE_LAUNCHER_PROTOCOL - 1, SERVICE_LAUNCHER_PROTOCOL + 1]) { + yield* fs.writeFileString( + statePath, + `{"protocol":${protocol},"activeVersion":"1.2.4-nightly.1","update":{"status":"unknown"}}`, + ); + expect(yield* service.status).toMatchObject({ + current: false, + installedVersion: "1.2.4-nightly.1", + }); + } + }), + ); + + it.effect("reports an unknown version for invalid service state", () => + Effect.gen(function* () { + const { service, fs, statePath } = yield* makeHarness(); + yield* service.install(); + + for (const stateText of [ + "{", + '{"activeVersion":"latest"}', + '{"activeVersion":"1.2"}', + '{"activeVersion":123}', + ]) { + yield* fs.writeFileString(statePath, stateText); + const status = yield* service.status; + expect(status.current).toBe(false); + expect(status.installedVersion).toBeUndefined(); + } + }), + ); + + it.effect.each(["linux", "darwin"] as const)( + "preserves a newer version that finishes updating during stop on %s", + (platform) => + Effect.gen(function* () { + const { service, fs, statePath, commands, control } = yield* makeHarness(platform); + const plan = yield* service.install(); + const launcher = yield* fs.readFileString(plan.launcherPath); + const unit = yield* fs.readFileString(plan.unitPath); + control.stateAfterStop = `{"protocol":${SERVICE_LAUNCHER_PROTOCOL + 1},"activeVersion":"1.2.4"}`; + commands.length = 0; + + const error = yield* service.install().pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "BootServiceDowngradeRefusedError", + installedVersion: "1.2.4", + targetVersion: "1.2.3", + }); + expect(yield* fs.readFileString(statePath)).toBe(control.stateAfterStop); + expect(yield* fs.readFileString(plan.launcherPath)).toBe(launcher); + expect(yield* fs.readFileString(plan.unitPath)).toBe(unit); + expect( + commands.filter((command) => + command.startsWith(platform === "linux" ? "systemctl " : "launchctl "), + ), + ).toEqual( + platform === "linux" + ? ["systemctl --user stop t3code.service", "systemctl --user restart t3code.service"] + : [ + "launchctl bootout --wait gui/501/com.t3tools.t3code.service", + `launchctl bootstrap gui/501 ${plan.unitPath}`, + ], + ); + }), + ); + + it.effect("allows an explicit downgrade", () => + Effect.gen(function* () { + const { service, fs, statePath } = yield* makeHarness(); + yield* service.install(); + yield* fs.writeFileString( + statePath, + `{"protocol":${SERVICE_LAUNCHER_PROTOCOL},"activeVersion":"1.2.4"}`, + ); + + yield* service.install({ allowDowngrade: true }); + + expect(parseServiceState(yield* fs.readFileString(statePath))?.activeVersion).toBe("1.2.3"); + expect((yield* service.status).current).toBe(true); + }), + ); + + it.effect("repairs versions with equal SemVer precedence without an override", () => + Effect.gen(function* () { + const { service, fs, statePath } = yield* makeHarness(); + yield* service.install(); + yield* fs.writeFileString( + statePath, + `{"protocol":${SERVICE_LAUNCHER_PROTOCOL},"activeVersion":"1.2.3+previous-build"}`, + ); + + yield* service.install(); + + expect((yield* service.status).current).toBe(true); + }), + ); + it.effect("copies the launcher from the prepared pinned runtime", () => Effect.gen(function* () { const { service, fs } = yield* makeHarness("linux", true); - const plan = yield* service.install; + const plan = yield* service.install(); expect(yield* fs.readFileString(plan.launcherPath)).toBe( "export const source = 'pinned runtime';\n", @@ -226,11 +345,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("restarts an installed service when repair fails", () => Effect.gen(function* () { const { service, commands, control } = yield* makeHarness(); - yield* service.install; + yield* service.install(); commands.length = 0; control.failCommand = "systemctl --user daemon-reload"; - const error = yield* service.install.pipe(Effect.flip); + const error = yield* service.install().pipe(Effect.flip); expect(error._tag).toBe("BootServiceCommandError"); expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([ "systemctl --user stop t3code.service", @@ -243,7 +362,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("restarts without overwriting a pending remote update", () => Effect.gen(function* () { const { service, fs, statePath, commands } = yield* makeHarness(); - yield* service.install; + yield* service.install(); // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document. const pendingState = JSON.stringify({ protocol: SERVICE_LAUNCHER_PROTOCOL - 1, @@ -256,14 +375,18 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }, }); yield* fs.writeFileString(statePath, pendingState); - commands.length = 0; - - expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUpdatePendingError"); - expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true); - expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([ - "systemctl --user stop t3code.service", - "systemctl --user restart t3code.service", - ]); + for (const allowDowngrade of [false, true]) { + commands.length = 0; + + expect((yield* service.install({ allowDowngrade }).pipe(Effect.flip))._tag).toBe( + "BootServiceUpdatePendingError", + ); + expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true); + expect(commands.filter((command) => command.startsWith("systemctl "))).toEqual([ + "systemctl --user stop t3code.service", + "systemctl --user restart t3code.service", + ]); + } }), ); @@ -271,14 +394,14 @@ it.layer(NodeServices.layer)("boot service install", (it) => { Effect.gen(function* () { const { service } = yield* makeHarness("win32"); expect((yield* service.status).supported).toBe(false); - expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUnsupportedError"); + expect((yield* service.install().pipe(Effect.flip))._tag).toBe("BootServiceUnsupportedError"); }), ); it.effect("installs, reports current state, and uninstalls on macOS", () => Effect.gen(function* () { const { service, fs, statePath, commands, timeouts } = yield* makeHarness("darwin"); - const plan = yield* service.install; + const plan = yield* service.install(); expect(plan.unitPath.endsWith("Library/LaunchAgents/com.t3tools.t3code.service.plist")).toBe( true, @@ -291,7 +414,10 @@ it.layer(NodeServices.layer)("boot service install", (it) => { activeVersion: "1.2.3", }); expect(yield* fs.readFileString(plan.launcherPath)).toBe("export {};\n"); - expect((yield* service.status).current).toBe(true); + expect(yield* service.status).toMatchObject({ + current: true, + installedVersion: "1.2.3", + }); expect(yield* service.uninstall).toBe(true); expect((yield* service.status).installed).toBe(false); expect(commands.some((command) => command.startsWith("npm "))).toBe(false); @@ -307,12 +433,12 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("restarts the launch agent when repair fails", () => Effect.gen(function* () { const { service, commands, control } = yield* makeHarness("darwin"); - yield* service.install; + yield* service.install(); const plistPath = (yield* service.status).unitPath; commands.length = 0; control.failCommand = `launchctl bootstrap gui/501 ${plistPath}`; - const error = yield* service.install.pipe(Effect.flip); + const error = yield* service.install().pipe(Effect.flip); expect(error._tag).toBe("BootServiceCommandError"); expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([ "launchctl bootout --wait gui/501/com.t3tools.t3code.service", @@ -326,7 +452,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("reconstructs a launch agent search path when the installer has no PATH", () => Effect.gen(function* () { const { service, fs } = yield* makeHarness("darwin", false, ""); - const plan = yield* service.install; + const plan = yield* service.install(); expect(yield* fs.readFileString(plan.unitPath)).toContain( " PATH\n /usr/bin:/opt/homebrew/bin:/usr/local/bin:/bin:/usr/sbin:/sbin", @@ -338,7 +464,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("adds missing provider directories to a minimal installer PATH", () => Effect.gen(function* () { const { service, fs } = yield* makeHarness("darwin", false, "/usr/bin:/bin"); - const plan = yield* service.install; + const plan = yield* service.install(); expect(yield* fs.readFileString(plan.unitPath)).toContain( " PATH\n /usr/bin:/bin:/opt/homebrew/bin:/usr/local/bin:/usr/sbin:/sbin", @@ -350,7 +476,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("keeps an installed launch agent current when the process PATH changes", () => Effect.gen(function* () { const { service, makeService } = yield* makeHarness("darwin"); - yield* service.install; + yield* service.install(); const restartedService = yield* makeService("/usr/local/bin:/usr/bin:/bin"); expect((yield* restartedService.status).current).toBe(true); @@ -364,7 +490,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { false, "/opt/homebrew/bin:/Users/theo/\u0001invalid:/usr/bin", ); - const plan = yield* service.install; + const plan = yield* service.install(); const plist = yield* fs.readFileString(plan.unitPath); expect(plist).toContain( @@ -378,10 +504,10 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("ignores a bootout for an agent that is not loaded", () => Effect.gen(function* () { const { service, control } = yield* makeHarness("darwin"); - yield* service.install; + yield* service.install(); control.failCommand = "launchctl bootout --wait gui/501/com.t3tools.t3code.service"; - yield* service.install; + yield* service.install(); expect((yield* service.status).current).toBe(true); }), ); @@ -389,7 +515,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { it.effect("restarts without overwriting a pending remote update on macOS", () => Effect.gen(function* () { const { service, fs, statePath, commands } = yield* makeHarness("darwin"); - yield* service.install; + yield* service.install(); const plistPath = (yield* service.status).unitPath; // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed launcher-owned test document. const pendingState = JSON.stringify({ @@ -403,14 +529,18 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }, }); yield* fs.writeFileString(statePath, pendingState); - commands.length = 0; - - expect((yield* service.install.pipe(Effect.flip))._tag).toBe("BootServiceUpdatePendingError"); - expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true); - expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([ - "launchctl bootout --wait gui/501/com.t3tools.t3code.service", - `launchctl bootstrap gui/501 ${plistPath}`, - ]); + for (const allowDowngrade of [false, true]) { + commands.length = 0; + + expect((yield* service.install({ allowDowngrade }).pipe(Effect.flip))._tag).toBe( + "BootServiceUpdatePendingError", + ); + expect(serviceStateHasPendingUpdate(yield* fs.readFileString(statePath))).toBe(true); + expect(commands.filter((command) => command.startsWith("launchctl "))).toEqual([ + "launchctl bootout --wait gui/501/com.t3tools.t3code.service", + `launchctl bootstrap gui/501 ${plistPath}`, + ]); + } }), ); }); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index e19274002..92c9e7428 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -24,7 +24,9 @@ import { SERVICE_LAUNCHER_FILE, SERVICE_LAUNCHER_PROTOCOL, SERVICE_STATE_FILE, + compareExactServiceVersions, parseServiceState, + serviceStateActiveVersion, serviceStateHasPendingUpdate, type ServiceState, } from "./serviceProtocol.ts"; @@ -416,16 +418,30 @@ export class BootServiceUpdatePendingError extends Schema.TaggedErrorClass()( + "BootServiceDowngradeRefusedError", + { + installedVersion: Schema.String, + targetVersion: Schema.String, + }, +) { + override get message(): string { + return `Refusing to replace t3@${this.installedVersion} with older t3@${this.targetVersion}. Run the command again with --allow-downgrade to continue.`; + } +} + export type BootServiceError = | BootServiceUnsupportedError | BootServiceCommandError | BootServiceInstallError - | BootServiceUpdatePendingError; + | BootServiceUpdatePendingError + | BootServiceDowngradeRefusedError; export interface BootServiceStatus { readonly supported: boolean; readonly installed: boolean; readonly current: boolean; + readonly installedVersion?: string; readonly unitPath: string; readonly logPath: string; } @@ -433,7 +449,9 @@ export interface BootServiceStatus { export class BootService extends Context.Service< BootService, { - readonly install: Effect.Effect; + readonly install: (options?: { + readonly allowDowngrade?: boolean; + }) => Effect.Effect; readonly uninstall: Effect.Effect; readonly status: Effect.Effect; } @@ -569,7 +587,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { { discard: true }, ); - const install: BootService["Service"]["install"] = Effect.gen(function* () { + const install = Effect.fn("cloud.boot_service.install")(function* (options?: { + readonly allowDowngrade?: boolean; + }) { const manager = yield* requireManager; yield* fs .makeDirectory(input.logsDir, { recursive: true }) @@ -638,11 +658,23 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { yield* Effect.gen(function* () { if (installed) { const previousStateText = yield* fs.readFileString(statePath).pipe(Effect.option); - if ( - Option.isSome(previousStateText) && - serviceStateHasPendingUpdate(previousStateText.value) - ) { - return yield* new BootServiceUpdatePendingError(); + if (Option.isSome(previousStateText)) { + if (serviceStateHasPendingUpdate(previousStateText.value)) { + return yield* new BootServiceUpdatePendingError(); + } + // A remote update can finish after the CLI checks status. Read its + // final version after the launcher stops and before changing files. + const installedVersion = serviceStateActiveVersion(previousStateText.value); + if ( + installedVersion !== undefined && + options?.allowDowngrade !== true && + compareExactServiceVersions(input.cliVersion, installedVersion) < 0 + ) { + return yield* new BootServiceDowngradeRefusedError({ + installedVersion, + targetVersion: input.cliVersion, + }); + } } } yield* fs @@ -670,7 +702,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { ), ); return plan; - }).pipe(Effect.withSpan("cloud.boot_service.install")); + }); const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () { const manager = yield* requireManager; @@ -704,6 +736,9 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { fs.readFileString(statePath).pipe(Effect.option), ]); const state = Option.isSome(stateText) ? parseServiceState(stateText.value) : undefined; + const installedVersion = Option.isSome(stateText) + ? serviceStateActiveVersion(stateText.value) + : undefined; const normalizeUnit = (contents: string) => detectedManager.kind === "launchd" ? contents.replace(/(PATH<\/key>\n\s*)[^<]*(<\/string>)/, "$1$2") @@ -711,6 +746,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { return { supported: true, installed: true, + ...(installedVersion === undefined ? {} : { installedVersion }), current: normalizeUnit(unit) === normalizeUnit(detectedManager.render(plan)) && launcherExists && diff --git a/apps/server/src/cloud/serviceProtocol.ts b/apps/server/src/cloud/serviceProtocol.ts index 0faf88948..af94c9b4b 100644 --- a/apps/server/src/cloud/serviceProtocol.ts +++ b/apps/server/src/cloud/serviceProtocol.ts @@ -184,6 +184,20 @@ export function serviceStateHasPendingUpdate(value: string): boolean { } } +/** Reads the active version across launcher protocol revisions for downgrade protection. */ +export function serviceStateActiveVersion(value: string): string | undefined { + try { + const parsed: unknown = JSON.parse(value); + return isRecord(parsed) && + typeof parsed.activeVersion === "string" && + isExactServiceVersion(parsed.activeVersion) + ? parsed.activeVersion + : undefined; + } catch { + return undefined; + } +} + export function decodeServiceLauncherContext(value: string): ServiceLauncherContext | undefined { let parsed: unknown; try { diff --git a/docs/user/background-service.md b/docs/user/background-service.md index a63159d40..697df2ab7 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -23,6 +23,22 @@ Update or repair it: npx t3@latest service update ``` +The service uses the same T3 Code version as the CLI you run. To install a nightly or an exact +version, use that version of the CLI: + +```sh +npx t3@nightly service update +npx t3@1.2.3 service update +``` + +The install and update commands refuse to replace a newer service with an older version. Setup +through T3 Connect leaves a newer service unchanged. To downgrade, select the exact older version +and pass `--allow-downgrade`: + +```sh +npx t3@1.2.3 service update --allow-downgrade +``` + Stop it and remove it from startup: ```sh From 5e243cb2c4fe5b8f318ea58c5e01dece1179c182 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 18:26:41 -0700 Subject: [PATCH 04/32] fix(claude): preview images read from the workspace (#9119) Classify Claude Read calls on image files as image_view and carry the full path through projection and both clients so the existing image renderer can load it. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit d0b19b32e01d7abc8829c01353d759fed3d304ed) --- apps/mobile/src/lib/threadActivity.test.ts | 55 ++++++ apps/mobile/src/lib/threadActivity.ts | 7 + .../ActivityPayloadProjection.test.ts | 27 +++ .../ActivityPayloadProjection.ts | 20 +++ .../src/provider/Layers/ClaudeAdapter.test.ts | 162 ++++++++++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 34 +++- apps/web/src/session-logic.test.ts | 38 ++++ apps/web/src/session-logic.ts | 7 + .../src/work-log/presentation.test.ts | 27 ++- .../src/work-log/presentation.ts | 10 ++ 10 files changed, 384 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 53a6e37bf..8907163e3 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -614,6 +614,61 @@ describe("buildThreadFeed", () => { ); }); + it("keeps viewed image metadata while collapsing a streamed Claude Read", () => { + const turnId = TurnId.make("turn-image-read"); + const imagePath = `/workspace/${"nested folder/".repeat(16)}reference image.webp`; + const thread = makeThread({ + id: ThreadId.make("thread-image-read"), + projectId: ProjectId.make("project-1"), + title: "Image read", + activities: [ + makeActivity({ + id: EventId.make("image-read-update"), + kind: "tool.updated", + tone: "tool", + summary: "Image view", + createdAt: "2026-04-01T00:00:01.000Z", + turnId, + payload: { + toolCallId: "tool-read-image", + itemType: "image_view", + status: "inProgress", + detail: `${imagePath.slice(0, 177)}...`, + data: { imagePath }, + }, + }), + makeActivity({ + id: EventId.make("image-read-completed"), + kind: "tool.completed", + tone: "tool", + summary: "Image view", + createdAt: "2026-04-01T00:00:02.000Z", + turnId, + payload: { + toolCallId: "tool-read-image", + itemType: "image_view", + status: "completed", + detail: `${imagePath.slice(0, 177)}...`, + data: {}, + }, + }), + ], + }); + + const group = buildThreadFeed(thread)[0]; + expect(group).toMatchObject({ + type: "activity-group", + activities: [ + { + workEntry: { + itemType: "image_view", + viewedImagePath: imagePath, + }, + }, + ], + }); + }); + it("keeps MCP inputs available to expanded mobile work rows", () => { const turnId = TurnId.make("turn-mcp"); const thread = makeThread({ diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 8fbf43fa7..e8b6905d0 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -94,6 +94,7 @@ export interface WorkLogEntry { turnId: TurnId | null; label: string; detail?: string; + viewedImagePath?: string; command?: string; rawCommand?: string; changedFiles?: ReadonlyArray; @@ -454,6 +455,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo } const itemType = extractWorkLogItemType(payload); const requestKind = extractWorkLogRequestKind(payload); + const viewedImagePath = asTrimmedString(asRecord(payload?.data)?.imagePath); if ( !taskDetailAsLabel && payload && @@ -465,6 +467,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo entry.detail = detail; } } + if (viewedImagePath) { + entry.viewedImagePath = viewedImagePath; + } if (commandPreview.command) { entry.command = commandPreview.command; } @@ -607,6 +612,7 @@ function mergeDerivedWorkLogEntries( ): DerivedWorkLogEntry { const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles); const detail = next.detail ?? previous.detail; + const viewedImagePath = next.viewedImagePath ?? previous.viewedImagePath; const command = next.command ?? previous.command; const rawCommand = next.rawCommand ?? previous.rawCommand; const toolTitle = next.toolTitle ?? previous.toolTitle; @@ -622,6 +628,7 @@ function mergeDerivedWorkLogEntries( id: previous.id, createdAt: previous.createdAt, ...(detail ? { detail } : {}), + ...(viewedImagePath ? { viewedImagePath } : {}), ...(command ? { command } : {}), ...(rawCommand ? { rawCommand } : {}), ...(changedFiles.length > 0 ? { changedFiles } : {}), diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index 18732fa4e..237a6f83d 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -158,6 +158,33 @@ describe("projectActivityPayload", () => { expect(JSON.stringify(openCode.payload).length).toBeLessThan(200); }); + it("keeps full Claude Read image paths through repeated projection", () => { + const imagePath = `/workspace/${"nested folder/".repeat(16)}reference image.webp`; + const projected = projectActivityPayload( + activity({ + itemType: "dynamic_tool_call", + detail: 'Read: {"file_path":"truncated..."}', + data: { + toolName: "Read", + input: { file_path: imagePath }, + result: { content: "Image Size: 1280x720." }, + }, + }), + ); + const projectedAgain = projectActivityPayload(projected); + + expect(projected.payload).toMatchObject({ data: { imagePath } }); + expect(projectedAgain.payload).toMatchObject({ data: { imagePath } }); + + const textRead = projectActivityPayload( + activity({ + itemType: "dynamic_tool_call", + data: { toolName: "Read", input: { file_path: "/workspace/src/index.ts" } }, + }), + ); + expect(textRead.payload).not.toMatchObject({ data: { imagePath: expect.anything() } }); + }); + it("slims Codex-shaped mcp_tool_call items to rendered fields plus a result summary", () => { const projected = projectActivityPayload( activity({ diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 0b1cb15d3..5a4a3d9a2 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -3,6 +3,7 @@ import type { OrchestrationThreadActivity, OrchestrationThreadDetailSnapshot, } from "@t3tools/contracts"; +import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; function asRecord(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) @@ -143,6 +144,21 @@ function projectCommandValue(data: Record): unknown { return undefined; } +function projectViewedImagePath(data: Record): string | undefined { + const directPath = asTrimmedString(data.imagePath); + if (directPath && isWorkspaceImagePreviewPath(directPath)) { + return directPath; + } + + const toolName = asTrimmedString(data.toolName)?.toLowerCase(); + if (toolName !== "read" && toolName !== "read file") { + return undefined; + } + const input = asRecord(data.input); + const inputPath = asTrimmedString(input?.file_path) ?? asTrimmedString(input?.path); + return inputPath && isWorkspaceImagePreviewPath(inputPath) ? inputPath : undefined; +} + function summarizeToolTextOutput(value: string): string | null { let meaningfulLineCount = 0; let offset = 0; @@ -374,6 +390,10 @@ export function projectActivityPayload( if (command !== undefined) { projectedData.command = command; } + const imagePath = projectViewedImagePath(data); + if (imagePath) { + projectedData.imagePath = imagePath; + } const changedFiles: string[] = []; collectChangedFiles(data, changedFiles, new Set(), 0); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 35a517219..34811d64b 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -48,6 +48,7 @@ import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../ import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); +const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); // Test-local service tag so the rest of the file can keep using `yield* ClaudeAdapter`. class ClaudeAdapter extends Context.Service()( @@ -1560,6 +1561,167 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("classifies only streamed Read image inputs as image views", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const runtimeEventsFiber = yield* Stream.takeUntil( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(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: "inspect both files", + attachments: [], + }); + + const imagePath = `/workspace/${"nested folder/".repeat(16)}reference image.webp`; + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-read-image", + uuid: "read-image-start", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "tool-read-image", + name: "Read", + input: {}, + }, + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-read-image", + uuid: "read-image-input", + parent_tool_use_id: null, + event: { + type: "content_block_delta", + index: 0, + delta: { + type: "input_json_delta", + partial_json: encodeUnknownJsonString({ file_path: imagePath }), + }, + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "user", + session_id: "sdk-session-read-image", + uuid: "read-image-result", + parent_tool_use_id: null, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-read-image", + content: "Image Size: 1280x720.", + }, + ], + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-read-image", + uuid: "read-text-start", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 1, + content_block: { + type: "tool_use", + id: "tool-read-text", + name: "Read", + input: { file_path: "/workspace/src/index.ts" }, + }, + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "user", + session_id: "sdk-session-read-image", + uuid: "read-text-result", + parent_tool_use_id: null, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-read-text", + content: "export {};", + }, + ], + }, + } as unknown as SDKMessage); + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-read-image", + uuid: "read-image-turn-result", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const imageEvents = runtimeEvents.filter( + ( + event, + ): event is Extract< + ProviderRuntimeEvent, + { type: "item.started" | "item.updated" | "item.completed" } + > => + (event.type === "item.started" || + event.type === "item.updated" || + event.type === "item.completed") && + String(event.itemId) === "tool-read-image", + ); + assert.deepEqual( + imageEvents.map((event) => [event.type, event.payload.itemType]), + [ + ["item.started", "dynamic_tool_call"], + ["item.updated", "image_view"], + ["item.updated", "image_view"], + ["item.completed", "image_view"], + ], + ); + for (const event of imageEvents.slice(1)) { + assert.equal(event.payload.detail, imagePath); + assert.equal( + (event.payload.data as { input?: { file_path?: string } } | undefined)?.input?.file_path, + imagePath, + ); + } + + const textEvents = runtimeEvents.filter( + ( + event, + ): event is Extract< + ProviderRuntimeEvent, + { type: "item.started" | "item.updated" | "item.completed" } + > => + (event.type === "item.started" || + event.type === "item.updated" || + event.type === "item.completed") && + String(event.itemId) === "tool-read-text", + ); + assert.deepEqual( + textEvents.map((event) => event.payload.itemType), + ["dynamic_tool_call", "dynamic_tool_call", "dynamic_tool_call"], + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("falls back to a default plan step label for blank TodoWrite content", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index ce560a351..1cf3d777c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -21,6 +21,7 @@ import { type ModelUsage, } from "@anthropic-ai/claude-agent-sdk"; import { parseCliArgs } from "@t3tools/shared/cliArgs"; +import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; import { ApprovalRequestId, type CanonicalItemType, @@ -750,8 +751,27 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef }; } -function classifyToolItemType(toolName: string): CanonicalItemType { +function readToolImagePath(toolName: string, input: Record): string | undefined { + const normalized = toolName.trim().toLowerCase(); + if (normalized !== "read" && normalized !== "read file") { + return undefined; + } + const pathValue = input.file_path ?? input.path; + if (typeof pathValue !== "string") { + return undefined; + } + const path = pathValue.trim(); + return path.length > 0 && isWorkspaceImagePreviewPath(path) ? path : undefined; +} + +function classifyToolItemType( + toolName: string, + input: Record = {}, +): CanonicalItemType { const normalized = toolName.toLowerCase(); + if (readToolImagePath(toolName, input)) { + return "image_view"; + } if (normalized.includes("agent")) { return "collab_agent_tool_call"; } @@ -1203,6 +1223,11 @@ function workflowAgentStatus(entry: ClaudeWorkflowAgentEntry): RuntimeTaskStatus } function summarizeToolRequest(toolName: string, input: Record): string { + const imagePath = readToolImagePath(toolName, input); + if (imagePath) { + return imagePath; + } + const commandValue = input.command ?? input.cmd; const command = typeof commandValue === "string" ? commandValue : undefined; if (command && command.trim().length > 0) { @@ -2618,9 +2643,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const partialInputJson = tool.partialInputJson + event.delta.partial_json; const parsedInput = tryParseJsonRecord(partialInputJson); + const itemType = parsedInput + ? classifyToolItemType(tool.toolName, parsedInput) + : tool.itemType; const detail = parsedInput ? summarizeToolRequest(tool.toolName, parsedInput) : tool.detail; let nextTool: ToolInFlight = { ...tool, + itemType, + title: titleForTool(itemType), partialInputJson, ...(parsedInput ? { input: parsedInput } : {}), ...(detail ? { detail } : {}), @@ -2725,11 +2755,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } const toolName = block.name; - const itemType = classifyToolItemType(toolName); const toolInput = typeof block.input === "object" && block.input !== null ? (block.input as Record) : {}; + const itemType = classifyToolItemType(toolName, toolInput); const itemId = block.id; const detail = summarizeToolRequest(toolName, toolInput); const inputFingerprint = diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 4bc45a78b..2646c31bc 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1824,6 +1824,44 @@ describe("deriveWorkLogEntries", () => { }); }); + it("keeps viewed image metadata while collapsing a streamed Claude Read", () => { + const imagePath = `/workspace/${"nested folder/".repeat(16)}reference image.webp`; + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "image-read-update", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Image view", + payload: { + toolCallId: "tool-read-image", + itemType: "image_view", + detail: `${imagePath.slice(0, 177)}...`, + data: { imagePath }, + }, + }), + makeActivity({ + id: "image-read-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Image view", + payload: { + toolCallId: "tool-read-image", + itemType: "image_view", + detail: `${imagePath.slice(0, 177)}...`, + data: {}, + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + id: "image-read-complete", + itemType: "image_view", + viewedImagePath: imagePath, + }); + }); + it("does not use command stdout as the detail when Cursor omits the command input", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 132034e8c..7934dfe64 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -85,6 +85,7 @@ export interface WorkLogEntry { toolCallId?: string; label: string; detail?: string; + viewedImagePath?: string; command?: string; rawCommand?: string; changedFiles?: ReadonlyArray; @@ -1000,9 +1001,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo }; const itemType = extractWorkLogItemType(payload); const requestKind = extractWorkLogRequestKind(payload); + const viewedImagePath = asTrimmedString(asRecord(payload?.data)?.imagePath); if (detail) { entry.detail = detail; } + if (viewedImagePath) { + entry.viewedImagePath = viewedImagePath; + } if (commandPreview.command) { entry.command = commandPreview.command; } @@ -1232,6 +1237,7 @@ function mergeDerivedWorkLogEntries( ): DerivedWorkLogEntry { const changedFiles = mergeChangedFiles(previous.changedFiles, next.changedFiles); const detail = next.detail ?? previous.detail; + const viewedImagePath = next.viewedImagePath ?? previous.viewedImagePath; const command = next.command ?? previous.command; const rawCommand = next.rawCommand ?? previous.rawCommand; const toolTitle = next.toolTitle ?? previous.toolTitle; @@ -1245,6 +1251,7 @@ function mergeDerivedWorkLogEntries( ...previous, ...next, ...(detail ? { detail } : {}), + ...(viewedImagePath ? { viewedImagePath } : {}), ...(command ? { command } : {}), ...(rawCommand ? { rawCommand } : {}), ...(changedFiles.length > 0 ? { changedFiles } : {}), diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index b4abd4c40..1d2cedee5 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from "vite-plus/test"; import { ThreadId } from "@t3tools/contracts"; -import { resolveViewedImageAsset, workEntryViewedImagePath } from "./presentation.js"; +import { + resolveViewedImageAsset, + toolGroupAction, + workEntryViewedImagePath, +} from "./presentation.js"; describe("workEntryViewedImagePath", () => { const entry = { label: "Read", tone: "tool" } as const; @@ -19,6 +23,14 @@ describe("workEntryViewedImagePath", () => { detail: "C:\\workspace\\a.webp", }), ).toBe("C:\\workspace\\a.webp"); + expect( + workEntryViewedImagePath({ + ...entry, + itemType: "dynamic_tool_call", + detail: 'Read: {"file_path":"truncated..."}', + viewedImagePath: " /workspace/reference image.webp ", + }), + ).toBe("/workspace/reference image.webp"); }); it("rejects non-image, multi-line, and non-read details", () => { @@ -32,6 +44,19 @@ describe("workEntryViewedImagePath", () => { }); }); +describe("toolGroupAction", () => { + it("groups legacy Claude image reads with other reads", () => { + expect( + toolGroupAction({ + label: "Tool call", + tone: "tool", + itemType: "dynamic_tool_call", + viewedImagePath: "/workspace/reference.png", + }), + ).toBe("read"); + }); +}); + describe("resolveViewedImageAsset", () => { const threadId = ThreadId.make("thread-1"); diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index e6b68c4ff..b69b3dd59 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -20,6 +20,7 @@ export interface WorkLogPresentationEntry { readonly detail?: string; readonly tone: "thinking" | "tool" | "info" | "error"; readonly command?: string; + readonly viewedImagePath?: string; readonly changedFiles?: ReadonlyArray; readonly itemType?: ToolLifecycleItemType; readonly requestKind?: string; @@ -72,6 +73,7 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio if ( entry.requestKind === "file-read" || entry.itemType === "image_view" || + entry.viewedImagePath !== undefined || (entry.itemType === "dynamic_tool_call" && entry.toolTitle?.trim().toLowerCase() === "read file") ) { @@ -93,6 +95,14 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio } export function workEntryViewedImagePath(entry: WorkLogPresentationEntry): string | null { + const viewedImagePath = entry.viewedImagePath?.trim(); + if ( + viewedImagePath !== undefined && + !/[\r\n]/.test(viewedImagePath) && + isWorkspaceImagePreviewPath(viewedImagePath) + ) { + return viewedImagePath; + } const detail = entry.detail?.trim(); return toolGroupAction(entry) === "read" && detail !== undefined && From c1b28f017a341db9b1d7310cded74b6fa705518e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 18:46:14 -0700 Subject: [PATCH 05/32] fix(clients): stop repeating expanded commands (#9120) Expanded command rows use a neutral Command label and keep the full command plus real output in the detail area. Claude Bash results now cross the wire as a bounded summary. Truncated Claude echoes and ACP echoes without a kind are dropped as synthetic. Co-Authored-By: Claude Fable 5.1 (cherry picked from commit 083d4de5b0fd33efa28363546b6ed89e191c5200) --- .../src/features/threads/thread-work-log.tsx | 7 +- apps/mobile/src/lib/threadActivity.test.ts | 216 ++++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 57 +++-- .../ActivityPayloadProjection.test.ts | 18 +- .../ActivityPayloadProjection.ts | 8 +- .../test/ActivityPayloadProjection.test.ts | 69 +++++- .../src/components/chat/MessagesTimeline.tsx | 10 +- .../src/session-logic.command-output.test.ts | 87 +++++++ apps/web/src/session-logic.ts | 107 ++------- .../src/work-log/presentation.test.ts | 85 +++++++ .../src/work-log/presentation.ts | 141 ++++++++++++ 11 files changed, 689 insertions(+), 116 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index f65873c0b..000ce22fe 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -17,6 +17,7 @@ import { AppText as Text } from "../../components/AppText"; import { cn } from "../../lib/cn"; import type { ThreadFeedActivity } from "../../lib/threadActivity"; import type { ToolGroupSummaryKind } from "@t3tools/client-runtime/work-log/presentation"; +import { workEntryViewedImagePath } from "@t3tools/client-runtime/work-log/presentation"; import Animated, { cancelAnimation, Easing, @@ -339,7 +340,9 @@ export function ThreadWorkLog(props: { const expanded = props.expandedRows[row.id] ?? false; const canExpand = row.canExpand; const fullDetail = expanded ? row.getFullDetail() : null; - const displayText = row.detail ? `${row.summary} ${row.detail}` : row.summary; + const viewedImagePath = workEntryViewedImagePath(row.workEntry); + const previewText = row.detail ?? row.summary; + const displayText = expanded && row.workEntry.command?.trim() ? "Command" : previewText; // Warnings are not errors. Web reserves destructive red for // runtime.error and orchestration *.failed rows and paints warnings // amber; mobile matches that split rather than colouring both rose. @@ -363,7 +366,7 @@ export function ThreadWorkLog(props: { > { + it("keeps long Claude commands expandable without repeating them in full detail", () => { + const command = `printf 'first line\nsecond line'\n&& printf done`; + const thread = makeThread({ + id: ThreadId.make("thread-long-command"), + projectId: ProjectId.make("project-1"), + title: "Long command", + activities: [ + makeActivity({ + id: EventId.make("long-command"), + kind: "tool.completed", + tone: "tool", + summary: "Command run", + createdAt: "2026-09-01T00:00:00.000Z", + payload: { + itemType: "command_execution", + title: "Command run", + detail: `Bash: ${command}`, + data: { toolName: "Bash", command }, + }, + }), + ], + }); + + const [group] = buildThreadFeed(thread); + expect(group?.type).toBe("activity-group"); + if (group?.type !== "activity-group") return; + const [row] = group.activities; + expect(row).toMatchObject({ detail: command, canExpand: true }); + expect(row?.getFullDetail()).toBe(command); + expect(row?.getCopyText()).toBe(`Command run\n${command}`); + }); + + it("keeps command output when it equals the displayed command", () => { + const command = "printf hello"; + const thread = makeThread({ + id: ThreadId.make("thread-matching-command-output"), + projectId: ProjectId.make("project-1"), + title: "Matching output", + activities: [ + makeActivity({ + id: EventId.make("matching-command-output"), + kind: "tool.completed", + tone: "tool", + summary: "Command run", + createdAt: "2026-09-01T00:00:00.000Z", + payload: { + itemType: "command_execution", + title: "Command run", + detail: `Bash: ${command}`, + data: { toolName: "Bash", command, rawOutput: { content: command } }, + }, + }), + ], + }); + + const [group] = buildThreadFeed(thread); + expect(group?.type).toBe("activity-group"); + if (group?.type !== "activity-group") return; + const [row] = group.activities; + expect(row?.detail).toBe(command); + expect(row?.getFullDetail()).toBe(`${command}\n\n${command}`); + expect(row?.getCopyText()).toBe(`Command run\n${command}\n\n${command}`); + }); + + it("keeps OpenCode detail-only output when it equals the command", () => { + const command = "printf hello"; + const thread = makeThread({ + id: ThreadId.make("thread-opencode-detail-output"), + projectId: ProjectId.make("project-1"), + title: "OpenCode detail output", + activities: [ + makeActivity({ + id: EventId.make("opencode-detail-output"), + kind: "tool.completed", + tone: "tool", + summary: "bash", + createdAt: "2026-09-01T00:00:00.000Z", + payload: { + itemType: "command_execution", + title: "bash", + detail: command, + data: { command }, + }, + }), + ], + }); + + const [group] = buildThreadFeed(thread); + expect(group?.type).toBe("activity-group"); + if (group?.type !== "activity-group") return; + const [row] = group.activities; + expect(row?.workEntry.detail).toBe(command); + expect(row?.getFullDetail()).toBe(`${command}\n\n${command}`); + }); + + it("drops a truncated Claude echo of a long command", () => { + const command = `git add -A && git commit -m "${"x".repeat(200)}"`; + const thread = makeThread({ + id: ThreadId.make("thread-truncated-echo"), + projectId: ProjectId.make("project-1"), + title: "Truncated echo", + activities: [ + makeActivity({ + id: EventId.make("truncated-echo"), + kind: "tool.completed", + tone: "tool", + summary: "Command run", + createdAt: "2026-09-01T00:00:00.000Z", + payload: { + itemType: "command_execution", + title: "Command run", + detail: `Bash: ${command}`.slice(0, 177) + "...", + data: { toolName: "Bash", command }, + }, + }), + ], + }); + + const [group] = buildThreadFeed(thread); + expect(group?.type).toBe("activity-group"); + if (group?.type !== "activity-group") return; + const [row] = group.activities; + expect(row?.workEntry.detail).toBeUndefined(); + expect(row?.getFullDetail()).toBe(command); + }); + + it("drops an ACP command echo when the update omits the tool kind", () => { + const command = "pnpm test"; + const thread = makeThread({ + id: ThreadId.make("thread-acp-no-kind"), + projectId: ProjectId.make("project-1"), + title: "ACP no kind", + activities: [ + makeActivity({ + id: EventId.make("acp-no-kind"), + kind: "tool.completed", + tone: "tool", + summary: "Terminal", + createdAt: "2026-09-01T00:00:00.000Z", + payload: { + itemType: "command_execution", + title: "Terminal", + detail: command, + data: { toolCallId: "tool-1", command }, + }, + }), + ], + }); + + const [group] = buildThreadFeed(thread); + expect(group?.type).toBe("activity-group"); + if (group?.type !== "activity-group") return; + const [row] = group.activities; + expect(row?.workEntry.detail).toBeUndefined(); + expect(row?.getFullDetail()).toBe(command); + }); + + it("drops ACP command metadata when detail only repeats the command", () => { + const command = "printf hello"; + const thread = makeThread({ + id: ThreadId.make("thread-acp-command-detail"), + projectId: ProjectId.make("project-1"), + title: "ACP command detail", + activities: [ + makeActivity({ + id: EventId.make("acp-command-detail"), + kind: "tool.completed", + tone: "tool", + summary: "Terminal", + createdAt: "2026-09-01T00:00:00.000Z", + payload: { + itemType: "command_execution", + title: "Terminal", + detail: command, + data: { kind: "execute", command }, + }, + }), + ], + }); + + const [group] = buildThreadFeed(thread); + expect(group?.type).toBe("activity-group"); + if (group?.type !== "activity-group") return; + const [row] = group.activities; + expect(row?.workEntry.detail).toBeUndefined(); + expect(row?.getFullDetail()).toBe(command); + }); + + it("does not show command output when the command input is missing", () => { + const thread = makeThread({ + id: ThreadId.make("thread-command-without-input"), + projectId: ProjectId.make("project-1"), + title: "Missing command input", + activities: [ + makeActivity({ + id: EventId.make("command-without-input"), + kind: "tool.completed", + tone: "tool", + summary: "Command run", + createdAt: "2026-09-01T00:00:00.000Z", + payload: { + itemType: "command_execution", + title: "Command run", + data: { rawOutput: { content: "output without command metadata" } }, + }, + }), + ], + }); + + const [group] = buildThreadFeed(thread); + expect(group?.type).toBe("activity-group"); + if (group?.type !== "activity-group") return; + expect(group.activities[0]?.detail).toBeNull(); + expect(group.activities[0]?.getFullDetail()).toBeNull(); + }); + it("keeps setup failures visible without routine setup notices before or after a turn", () => { const thread = makeThread({ id: ThreadId.make("thread-worktree-setup"), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index e8b6905d0..20316cfd3 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -18,6 +18,8 @@ import { } from "@t3tools/client-runtime/state/turn-costs"; import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { + commandDetailRepeatsCommand, + extractCommandOutputText, isWorktreeSetupActivity, normalizeCompactToolLabel, omitSupersededLifecycleMarkers, @@ -456,16 +458,23 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo const itemType = extractWorkLogItemType(payload); const requestKind = extractWorkLogRequestKind(payload); const viewedImagePath = asTrimmedString(asRecord(payload?.data)?.imagePath); - if ( - !taskDetailAsLabel && - payload && - typeof payload.detail === "string" && - payload.detail.length > 0 - ) { + const commandOutput = commandPreview.command ? extractCommandOutputText(payload?.data) : null; + const output = commandOutput ? stripTrailingExitCode(commandOutput).output : null; + if (!taskDetailAsLabel && output) { + entry.detail = output; + } else if (!taskDetailAsLabel && typeof payload?.detail === "string") { const detail = stripTrailingExitCode(payload.detail).output; - if (detail) { - entry.detail = detail; - } + const data = asRecord(payload.data); + const repeatsCommand = + detail !== null && + commandDetailRepeatsCommand({ + detail, + command: commandPreview.command, + rawCommand: commandPreview.rawCommand, + toolName: data?.toolName, + data, + }); + if (detail && !repeatsCommand) entry.detail = detail; } if (viewedImagePath) { entry.viewedImagePath = viewedImagePath; @@ -775,20 +784,18 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { const blocks: string[] = []; - const appendUniqueBlock = (value: string | null | undefined) => { + const appendBlock = (value: string | null | undefined) => { const trimmed = value?.trim(); - if (trimmed && !blocks.includes(trimmed)) { - blocks.push(trimmed); - } + if (trimmed && (entry.command || !blocks.includes(trimmed))) blocks.push(trimmed); }; if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) { - appendUniqueBlock(`MCP call\n${JSON.stringify(entry.toolData, null, 2)}`); + appendBlock(`MCP call\n${JSON.stringify(entry.toolData, null, 2)}`); } - appendUniqueBlock(entry.rawCommand ?? entry.command); - appendUniqueBlock(entry.detail); + appendBlock(entry.rawCommand ?? entry.command); + appendBlock(entry.detail); if ((entry.changedFiles?.length ?? 0) > 0) { - appendUniqueBlock(entry.changedFiles!.join("\n")); + appendBlock(entry.changedFiles!.join("\n")); } return blocks.length > 0 ? blocks.join("\n\n") : null; @@ -1821,13 +1828,21 @@ export function buildThreadFeed( const summary = workEntryHeading(entry); const detail = workEntryPreview(entry); const getFullDetail = memoizeValue(() => buildWorkEntryExpandedBody(entry)); - const getCopyText = memoizeValue(() => - [summary, detail, getFullDetail()] + const getCopyText = memoizeValue(() => { + const fullDetail = getFullDetail(); + if (entry.command) { + const normalizedCommand = + entry.rawCommand && summary.trim() !== entry.command.trim() ? entry.command : null; + return [summary, normalizedCommand, fullDetail ?? entry.command] + .filter((value): value is string => Boolean(value)) + .join("\n"); + } + return [summary, detail, fullDetail] .filter((value, index, values): value is string => { return Boolean(value) && values.indexOf(value) === index; }) - .join("\n"), - ); + .join("\n"); + }); return { type: "activity", id: entry.id, diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index 237a6f83d..bf09ed959 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -119,7 +119,7 @@ describe("projectActivityPayload", () => { expect(JSON.stringify(acp.payload).length).toBeLessThan(500); }); - it("normalizes Claude and OpenCode command inputs before slimming provider data", () => { + it("keeps bounded Claude command input and result summaries", () => { const claude = projectActivityPayload( activity({ itemType: "command_execution", @@ -127,7 +127,13 @@ describe("projectActivityPayload", () => { data: { toolName: "Bash", input: { command: "vp test run" }, - result: { content: "x".repeat(5_000) }, + result: { + type: "tool_result", + content: [ + { type: "text", text: "tests passed" }, + { type: "text", text: "x".repeat(5_000) }, + ], + }, }, }), ); @@ -148,13 +154,17 @@ describe("projectActivityPayload", () => { expect(claude.payload).toMatchObject({ toolCallId: "claude-call-1", - data: { command: "vp test run" }, + data: { + toolName: "Bash", + command: "vp test run", + rawOutput: { content: "tests passed" }, + }, }); expect(openCode.payload).toMatchObject({ toolCallId: "opencode-call-1", data: { command: "vp lint" }, }); - expect(JSON.stringify(claude.payload).length).toBeLessThan(200); + expect(JSON.stringify(claude.payload).length).toBeLessThan(250); expect(JSON.stringify(openCode.payload).length).toBeLessThan(200); }); diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 5a4a3d9a2..98294e63b 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -408,8 +408,14 @@ export function projectActivityPayload( if ("kind" in data) { projectedData.kind = data.kind; } + if ("toolName" in data) { + projectedData.toolName = data.toolName; + } - const rawOutput = projectRawOutput(data.rawOutput) ?? projectAcpContent(data.content); + const rawOutput = + projectRawOutput(data.rawOutput) ?? + projectAcpContent(data.content) ?? + (payload.itemType === "command_execution" ? summarizeMcpResult(data.result) : undefined); if (rawOutput) { projectedData.rawOutput = rawOutput; } diff --git a/apps/server/test/ActivityPayloadProjection.test.ts b/apps/server/test/ActivityPayloadProjection.test.ts index 4b50b8781..a77c82ddf 100644 --- a/apps/server/test/ActivityPayloadProjection.test.ts +++ b/apps/server/test/ActivityPayloadProjection.test.ts @@ -184,6 +184,52 @@ describe("projectActivityPayload", () => { }); }); + it("projects a Claude Bash result for the web and mobile expanded rows", () => { + const command = `printf 'first line\nsecond line'\n&& printf done`; + const source: OrchestrationThreadActivity = { + ...makeActivity("claude-bash", "command_execution", {}), + summary: "Command run", + payload: { + itemType: "command_execution", + title: "Command run", + detail: `Bash: ${command}`, + status: "completed", + data: { + toolName: "Bash", + input: { command }, + result: { + type: "tool_result", + tool_use_id: "toolu_1", + content: [ + { type: "text", text: "first output line" }, + { type: "text", text: "x".repeat(5_000) }, + ], + }, + }, + }, + }; + const projected = projectActivityPayload(source); + + expect(projected.payload).toMatchObject({ + data: { + toolName: "Bash", + command, + rawOutput: { content: "first output line" }, + }, + }); + + const [webEntry] = deriveWorkLogEntries([projected]); + expect(webEntry).toMatchObject({ command, detail: "first output line" }); + + const [mobileGroup] = buildThreadFeed(makeThread([projected])); + expect(mobileGroup?.type).toBe("activity-group"); + if (mobileGroup?.type !== "activity-group") return; + const [mobileRow] = mobileGroup.activities; + expect(mobileRow).toMatchObject({ detail: command, canExpand: true }); + expect(mobileRow?.getFullDetail()).toBe(`${command}\n\nfirst output line`); + expect(mobileRow?.getCopyText()).toBe(`Command run\n${command}\n\nfirst output line`); + }); + it("slims MCP tool data to the fields the expanded row renders", () => { expect(projectActivityPayload(fixtures[4]!).payload).toEqual({ itemType: "mcp_tool_call", @@ -201,9 +247,30 @@ describe("projectActivityPayload", () => { }); }); - it("keeps current web and mobile derived output identical for every tool item type", () => { + it("keeps current web and mobile derived fields for every tool item type", () => { for (const activity of fixtures) { const projected = projectActivityPayload(activity); + if (activity === fixtures[0]) { + expect(deriveWorkLogEntries([projected])).toMatchObject([ + { + command: "pnpm test", + rawCommand: 'bash -lc "pnpm test"', + detail: "first useful line", + }, + ]); + expect(comparableThreadFeed([projected])).toMatchObject([ + { + type: "activity-group", + activities: [ + { + detail: "pnpm test", + fullDetail: 'bash -lc "pnpm test"\n\nfirst useful line', + }, + ], + }, + ]); + continue; + } if (activity === fixtures[4]) { // MCP is the one deliberate difference: the expanded row's toolData // loses result bulk but keeps the rendered identity fields. diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 4731c77df..a18459b55 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -2519,8 +2519,10 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; const showFailedIndicator = workEntryDisplayIndicatesToolFailure(workEntry); - const entryIconName = showWarningIndicator ? "circle-alert" : workEntryIconName(workEntry); - const displayText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); + const entryIconName = + showWarningIndicator || showFailedIndicator ? "circle-alert" : workEntryIconName(workEntry); + const previewText = workEntryPreview(workEntry, workspaceRoot) ?? toolWorkEntryHeading(workEntry); + const displayText = expanded && workEntry.command?.trim() ? "Command" : previewText; const expandedBody = buildToolCallExpandedBody(workEntry, workspaceRoot); const viewedImagePath = workEntryViewedImagePath(workEntry); const viewedImage = @@ -2561,8 +2563,8 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { : "text-foreground/80"; const showEntryIcon = !isExpandedToolGroupEntry || showWarningIndicator || showFailedIndicator; const accessibleDisplayText = showFailedIndicator - ? `${displayText}, tool call failed` - : displayText; + ? `${previewText}, tool call failed` + : previewText; const rowToggleProps = canExpand ? { role: "button" as const, diff --git a/apps/web/src/session-logic.command-output.test.ts b/apps/web/src/session-logic.command-output.test.ts index 570629046..406b88850 100644 --- a/apps/web/src/session-logic.command-output.test.ts +++ b/apps/web/src/session-logic.command-output.test.ts @@ -66,6 +66,93 @@ describe("deriveWorkLogEntries command output", () => { }); }); + it("keeps command output that equals the command text", () => { + const [entry] = deriveWorkLogEntries([ + makeCommandActivity("matching-output", { + itemType: "command_execution", + title: "Ran command", + detail: "printf hello", + data: { + command: "printf hello", + rawOutput: { content: "printf hello" }, + }, + }), + ]); + + expect(entry).toMatchObject({ + command: "printf hello", + detail: "printf hello", + }); + }); + + it("keeps OpenCode detail-only output when it equals the command", () => { + const [entry] = deriveWorkLogEntries([ + makeCommandActivity("opencode-detail-output", { + itemType: "command_execution", + title: "bash", + detail: "printf hello", + data: { command: "printf hello" }, + }), + ]); + + expect(entry).toMatchObject({ + command: "printf hello", + detail: "printf hello", + }); + }); + + it("drops a Claude tool-name detail when there is no output", () => { + const [entry] = deriveWorkLogEntries([ + makeCommandActivity("claude-no-output", { + itemType: "command_execution", + title: "Command run", + detail: "Bash: printf hello", + data: { + toolName: "Bash", + command: "printf hello", + }, + }), + ]); + + expect(entry?.command).toBe("printf hello"); + expect(entry?.detail).toBeUndefined(); + }); + + it("drops a truncated Claude tool-name detail for a long command", () => { + const command = `git add -A && git commit -m "${"x".repeat(200)}"`; + const [entry] = deriveWorkLogEntries([ + makeCommandActivity("claude-long-command", { + itemType: "command_execution", + title: "Command run", + detail: `Bash: ${command}`.slice(0, 177) + "...", + data: { + toolName: "Bash", + command, + }, + }), + ]); + + expect(entry?.command).toBe(command); + expect(entry?.detail).toBeUndefined(); + }); + + it("drops an ACP command echo when the update omits the tool kind", () => { + const [entry] = deriveWorkLogEntries([ + makeCommandActivity("acp-no-kind", { + itemType: "command_execution", + title: "Terminal", + detail: "pnpm test", + data: { + toolCallId: "tool-1", + command: "pnpm test", + }, + }), + ]); + + expect(entry?.command).toBe("pnpm test"); + expect(entry?.detail).toBeUndefined(); + }); + it("drops duplicated command detail when the command has no output", () => { const [entry] = deriveWorkLogEntries([ makeCommandActivity("empty-command", { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 7934dfe64..e836087e7 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -6,7 +6,11 @@ import { foldSubagentActivities, isBackgroundTaskActivity, } from "@t3tools/client-runtime/state/subagentRuntime"; -import { isWorktreeSetupActivity } from "@t3tools/client-runtime/work-log/presentation"; +import { + commandDetailRepeatsCommand, + extractCommandOutputText, + isWorktreeSetupActivity, +} from "@t3tools/client-runtime/work-log/presentation"; import { ApprovalRequestId, isToolLifecycleItemType, @@ -1590,68 +1594,9 @@ function summarizeToolRawOutput(payload: Record | null): string return null; } -function extractAcpTextContent(value: unknown): string | null { - if (!Array.isArray(value)) { - return null; - } - - const chunks: string[] = []; - for (const entryValue of value) { - const entry = asRecord(entryValue); - if (entry?.type !== "content") { - continue; - } - const content = asRecord(entry.content); - if (content?.type !== "text") { - continue; - } - const text = asTrimmedString(content.text); - if (text) { - chunks.push(text); - } - } - - return chunks.length > 0 ? chunks.join("\n") : null; -} - function extractToolOutput(payload: Record | null): string | null { - const data = asRecord(payload?.data); - const item = asRecord(data?.item); - const itemResult = asRecord(item?.result); - const rawOutput = asRecord(data?.rawOutput); - - const outputStreams: string[] = []; - const stdout = asTrimmedString(rawOutput?.stdout); - const stderr = asTrimmedString(rawOutput?.stderr); - if (stdout) { - outputStreams.push(stdout); - } - if (stderr) { - outputStreams.push(stderr); - } - - const candidates: unknown[] = [ - item?.aggregatedOutput, - itemResult?.content, - data?.rawOutput, - rawOutput?.content, - outputStreams.length > 0 ? outputStreams.join("\n") : null, - rawOutput?.output, - extractAcpTextContent(data?.content), - ]; - - for (const candidate of candidates) { - const text = asTrimmedString(candidate); - if (!text) { - continue; - } - const output = stripTrailingExitCode(text).output; - if (output) { - return output; - } - } - - return null; + const output = extractCommandOutputText(payload?.data); + return output ? stripTrailingExitCode(output).output : null; } function isCommandToolDetail(payload: Record | null, heading: string): boolean { @@ -1679,32 +1624,28 @@ function extractToolDetail( ? extractToolCommand(payload) : { command: null, rawCommand: null }; const command = commandPreview.command; - const normalizedCommand = normalizePreviewForComparison(command); - const normalizedRawCommand = normalizePreviewForComparison(commandPreview.rawCommand); - if ( - detail && - normalizedHeading !== normalizedDetail && - (!commandTool || - (normalizedCommand !== normalizedDetail && normalizedRawCommand !== normalizedDetail)) - ) { + if (commandTool && command) { + const output = extractToolOutput(payload); + if (output) return output; + } + + const data = asRecord(payload?.data); + const repeatsCommand = + detail !== null && + commandDetailRepeatsCommand({ + detail, + command, + rawCommand: commandPreview.rawCommand, + toolName: data?.toolName, + data, + }); + + if (detail && normalizedHeading !== normalizedDetail && (!commandTool || !repeatsCommand)) { return detail; } if (commandTool) { - if (!command) { - return null; - } - - const output = extractToolOutput(payload); - const normalizedOutput = normalizePreviewForComparison(output); - if ( - output && - normalizedOutput !== normalizedHeading && - normalizedOutput !== normalizedCommand - ) { - return output; - } return null; } diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index 1d2cedee5..8d179c417 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -3,11 +3,96 @@ import { describe, expect, it } from "vite-plus/test"; import { ThreadId } from "@t3tools/contracts"; import { + commandDetailRepeatsCommand, + extractCommandOutputText, resolveViewedImageAsset, toolGroupAction, workEntryViewedImagePath, } from "./presentation.js"; +describe("command work-log details", () => { + it("extracts Claude result blocks and projected output", () => { + expect( + extractCommandOutputText({ + result: { + content: [ + { type: "text", text: "first" }, + { type: "text", text: "second" }, + ], + }, + }), + ).toBe("first\nsecond"); + expect(extractCommandOutputText({ rawOutput: { content: "projected summary" } })).toBe( + "projected summary", + ); + }); + + it("only removes a detail with the matching tool-name prefix", () => { + expect( + commandDetailRepeatsCommand({ + detail: "Bash: printf hello", + command: "printf hello", + rawCommand: null, + toolName: "Bash", + data: { toolName: "Bash", command: "printf hello" }, + }), + ).toBe(true); + expect( + commandDetailRepeatsCommand({ + detail: "warning: printf hello", + command: "printf hello", + rawCommand: null, + toolName: "Bash", + data: { toolName: "Bash", command: "printf hello" }, + }), + ).toBe(false); + }); + + it("treats an ingestion-truncated echo of a long command as a repeat", () => { + const command = `git add -A && git commit -m "${"x".repeat(200)}"`; + const truncated = `Bash: ${command}`.slice(0, 177) + "..."; + expect( + commandDetailRepeatsCommand({ + detail: truncated, + command, + rawCommand: null, + toolName: "Bash", + data: { toolName: "Bash", command }, + }), + ).toBe(true); + expect( + commandDetailRepeatsCommand({ + detail: "Bash: printf hello...", + command: "printf goodbye", + rawCommand: null, + toolName: "Bash", + data: { toolName: "Bash", command: "printf goodbye" }, + }), + ).toBe(false); + }); + + it("treats ACP command echoes as synthetic even without a tool kind", () => { + expect( + commandDetailRepeatsCommand({ + detail: "pnpm test", + command: "pnpm test", + rawCommand: null, + toolName: undefined, + data: { toolCallId: "tool-1", command: "pnpm test" }, + }), + ).toBe(true); + expect( + commandDetailRepeatsCommand({ + detail: "pnpm test", + command: "pnpm test", + rawCommand: null, + toolName: undefined, + data: { command: "pnpm test" }, + }), + ).toBe(false); + }); +}); + describe("workEntryViewedImagePath", () => { const entry = { label: "Read", tone: "tool" } as const; diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index b69b3dd59..1528b34d1 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -51,6 +51,147 @@ export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value : null; +} + +function commandResultContent(value: unknown): string | null { + const direct = nonEmptyString(value); + if (direct) return direct; + + const directContent = Array.isArray(value) ? value : null; + const record = asRecord(value); + const content = record?.content; + const contentText = nonEmptyString(content); + if (contentText) return contentText; + const blocks = directContent ?? (Array.isArray(content) ? content : null); + if (!blocks) return null; + + const chunks = blocks.flatMap((entry) => { + const text = nonEmptyString(entry) ?? nonEmptyString(asRecord(entry)?.text); + return text ? [text] : []; + }); + return chunks.length > 0 ? chunks.join("\n") : null; +} + +/** Returns provider command output before it is formatted for a work-log row. */ +export function extractCommandOutputText(dataValue: unknown): string | null { + const data = asRecord(dataValue); + const item = asRecord(data?.item); + const itemResult = asRecord(item?.result); + const rawOutput = asRecord(data?.rawOutput); + const outputStreams = [ + nonEmptyString(rawOutput?.stdout), + nonEmptyString(rawOutput?.stderr), + ].filter((value): value is string => value !== null); + const acpContent = Array.isArray(data?.content) + ? data.content + .flatMap((entryValue) => { + const entry = asRecord(entryValue); + const content = asRecord(entry?.content); + const text = entry?.type === "content" ? nonEmptyString(content?.text) : null; + return text ? [text] : []; + }) + .join("\n") + : null; + + const candidates = [ + item?.aggregatedOutput, + itemResult?.content, + data?.rawOutput, + rawOutput?.content, + outputStreams.length > 0 ? outputStreams.join("\n") : null, + rawOutput?.output, + acpContent, + data?.result, + ]; + for (const candidate of candidates) { + const text = commandResultContent(candidate); + if (text) return text; + } + return null; +} + +/** + * Ingestion caps tool details at 180 chars and appends "...", so a long command + * echo no longer equals the command it repeats. Treat a truncated prefix of the + * command as the same echo. + */ +function textRepeatsCommand(text: string, commands: ReadonlyArray): boolean { + const truncated = text.endsWith("...") + ? text.slice(0, -3) + : text.endsWith("\u2026") + ? text.slice(0, -1) + : null; + return commands.some((candidate) => { + const command = candidate?.trim(); + if (!command) return false; + if (command === text) return true; + return ( + truncated !== null && + truncated.length > 0 && + command.length > truncated.length && + command.startsWith(truncated) + ); + }); +} + +/** + * Decides whether a command row's `detail` is a synthetic echo of the command + * rather than real output. OpenCode stores completed output in `detail` with no + * other output channel, so plain equality is only treated as synthetic when the + * payload shape shows the detail came from the command: Codex item metadata, + * an ACP tool call (`data.toolCallId`, `kind: "execute"`), a Claude tool-name + * prefix, or no structured command at all. + */ +export function commandDetailRepeatsCommand(input: { + readonly detail: string; + readonly command: string | null; + readonly rawCommand: string | null; + readonly toolName: unknown; + readonly data: unknown; +}): boolean { + const toolName = nonEmptyString(input.toolName)?.trim(); + const detail = input.detail.trim(); + const commands = [input.command, input.rawCommand]; + if (toolName) { + const prefix = `${toolName}:`; + if (detail.toLowerCase().startsWith(prefix.toLowerCase())) { + const unprefixed = detail.slice(prefix.length).trim(); + if (textRepeatsCommand(unprefixed, commands)) return true; + } + } + + if (!textRepeatsCommand(detail, commands)) return false; + + const data = asRecord(input.data); + const item = asRecord(data?.item); + const itemInput = asRecord(item?.input); + const itemResult = asRecord(item?.result); + const hasStructuredCommand = [ + item?.command, + itemInput?.command, + itemResult?.command, + data?.command, + ].some((value) => + Array.isArray(value) + ? value.some((part) => nonEmptyString(part) !== null) + : nonEmptyString(value) !== null, + ); + return ( + !hasStructuredCommand || + item !== null || + data?.toolCallId !== undefined || + nonEmptyString(data?.kind)?.toLowerCase() === "execute" + ); +} + function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean { // A missing-response notice is not a tool call, and it arrives error-toned // from runtime.error as well as info-toned from runtime.warning. Without From f870bfd98de9abf0569c15921ed6863c67174dbb Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 18:48:19 -0700 Subject: [PATCH 06/32] fix(grok): health check, model selection, and stop all work against the real CLI (#9154) Co-authored-by: mavenskylab Co-authored-by: Claude Fable 5.1 (cherry picked from commit a434677eca737771dd64703545c13eb11ba92ce2) --- apps/server/scripts/acp-mock-agent.ts | 24 +- .../src/provider/Layers/GrokAdapter.test.ts | 315 +++++++++++++++++- .../server/src/provider/Layers/GrokAdapter.ts | 139 ++++++-- .../src/provider/Layers/GrokProvider.test.ts | 198 ++++++++++- .../src/provider/Layers/GrokProvider.ts | 229 +++++++++---- .../src/provider/acp/AcpRuntimeModel.ts | 15 +- .../src/provider/acp/AcpSessionRuntime.ts | 50 ++- .../src/provider/acp/GrokAcpSupport.test.ts | 30 ++ .../server/src/provider/acp/GrokAcpSupport.ts | 18 +- .../src/provider/acp/XAiAcpExtension.ts | 6 +- docs/internals/providers.md | 11 + packages/contracts/src/model.ts | 1 + packages/effect-acp/src/protocol.test.ts | 13 +- packages/effect-acp/src/protocol.ts | 40 ++- 14 files changed, 937 insertions(+), 152 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 7bb7d4ced..64d27de58 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -320,13 +320,22 @@ function modeState(): AcpSchema.SessionModeState { }; } +// Mirrors the real Grok ACP: it advertises versioned model ids, never the CLI's own +// "grok-build" product name, and it rejects unknown ids in session/set_model. const grokAcpModels: ReadonlyArray = [ { - modelId: "grok-build", - name: "Grok Build", - ...(initialGrokReasoningEffort - ? { _meta: { reasoningEffort: initialGrokReasoningEffort } } - : {}), + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + totalContextTokens: 500_000, + supportsReasoningEffort: true, + reasoningEffort: initialGrokReasoningEffort ?? "high", + reasoningEfforts: [ + { id: "xhigh", value: "xhigh", label: "Extra High Effort", default: false }, + { id: "high", value: "high", label: "High Effort", default: true }, + { id: "low", value: "low", label: "Low Effort", default: false }, + ], + }, }, { modelId: "grok-mock-alt", name: "Grok Mock Alt" }, ]; @@ -334,7 +343,7 @@ const grokAcpModels: ReadonlyArray = [ function modelState(): AcpSchema.SessionModelState { const modelId = grokAcpModels.some((model) => model.modelId === currentModelId) ? currentModelId - : "grok-build"; + : "grok-4.6"; return { currentModelId: modelId, availableModels: grokAcpModels, @@ -351,6 +360,9 @@ const program = Effect.gen(function* () { return { protocolVersion: 1, agentCapabilities: { loadSession: true }, + // Grok advertises model state before any session exists; the provider + // health check reads it from here without authenticating. + _meta: { modelState: modelState() }, }; }), ); diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 8f7b946a3..563b78d09 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -744,13 +744,13 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { 0, ); - yield* Fiber.interrupt(steerSendTurnFiber); yield* adapter.interruptTurn(threadId); const completed = yield* Deferred.await(turnCompleted).pipe( Effect.timeout("2 seconds"), TestClock.withLive, ); yield* Fiber.join(firstSendTurnFiber); + yield* Fiber.interrupt(steerSendTurnFiber); assert.equal(completed.payload.state, "cancelled"); yield* Fiber.interrupt(runtimeEventsFiber); @@ -1146,6 +1146,316 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }).pipe(TestClock.withLive), ); + it.effect("cancels an in-flight prompt when a mid-turn sendTurn steers", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-steer-cancels-in-flight"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-steer-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const firstTurnStarted = yield* Deferred.make(); + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) !== String(threadId)) { + return; + } + if (event.type === "turn.started" && event.turnId !== undefined) { + yield* Deferred.succeed(firstTurnStarted, event.turnId).pipe(Effect.ignore); + return; + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const firstSendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "hang until steered", attachments: [] }) + .pipe(Effect.forkChild); + const firstTurnId = yield* Deferred.await(firstTurnStarted).pipe(Effect.timeout("2 seconds")); + yield* waitForFileContent(requestLogPath, 80, '"method":"session/prompt"'); + + const steered = yield* adapter + .sendTurn({ threadId, input: "take this instead", attachments: [] }) + .pipe(Effect.timeout("3 seconds")); + yield* Fiber.join(firstSendTurnFiber).pipe(Effect.timeout("3 seconds")); + yield* Deferred.await(turnCompleted).pipe(Effect.timeout("3 seconds")); + + const requestLog = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const methods = requestLog.flatMap((entry) => + typeof entry.method === "string" ? [entry.method] : [], + ); + const turnStartedEvents = runtimeEvents.filter( + (event) => event.type === "turn.started" && String(event.threadId) === String(threadId), + ); + const turnCompletedEvents = runtimeEvents.filter( + (event): event is Extract => + event.type === "turn.completed" && String(event.threadId) === String(threadId), + ); + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + + assert.equal(String(steered.turnId), String(firstTurnId)); + assert.isTrue(methods.includes("session/cancel")); + assert.isAtLeast(methods.filter((method) => method === "session/prompt").length, 2); + assert.lengthOf(turnStartedEvents, 1); + assert.lengthOf(turnCompletedEvents, 1); + assert.equal(turnCompletedEvents[0]?.payload.state, "completed"); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + + it.effect( + "steers a prompt that has not started ACP yet instead of letting it start after cancel", + () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-steer-during-prep"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-steer-prep-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const firstTurnStarted = yield* Deferred.make(); + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) !== String(threadId)) { + return; + } + if (event.type === "turn.started" && event.turnId !== undefined) { + yield* Deferred.succeed(firstTurnStarted, event.turnId).pipe(Effect.ignore); + return; + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const firstSendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "still preparing", attachments: [] }) + .pipe(Effect.forkChild); + const firstTurnId = yield* Deferred.await(firstTurnStarted).pipe( + Effect.timeout("2 seconds"), + ); + + const steered = yield* adapter + .sendTurn({ threadId, input: "steer before first prompt starts", attachments: [] }) + .pipe(Effect.timeout("3 seconds")); + yield* Fiber.join(firstSendTurnFiber).pipe(Effect.timeout("3 seconds")); + yield* Deferred.await(turnCompleted).pipe(Effect.timeout("3 seconds")); + + const turnCompletedEvents = runtimeEvents.filter( + (event): event is Extract => + event.type === "turn.completed" && String(event.threadId) === String(threadId), + ); + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + + assert.equal(String(steered.turnId), String(firstTurnId)); + assert.lengthOf(turnCompletedEvents, 1); + assert.equal(turnCompletedEvents[0]?.payload.state, "completed"); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + + it.effect("keeps the original prompt running when a steer fails during preparation", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-failed-steer-keeps-original-prompt"); + const tempDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-failed-steer-")), + ); + const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const firstTurnStarted = yield* Deferred.make(); + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) !== String(threadId)) { + return; + } + if (event.type === "turn.started" && event.turnId !== undefined) { + yield* Deferred.succeed(firstTurnStarted, event.turnId).pipe(Effect.ignore); + return; + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const firstSendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "hang until a failed steer", attachments: [] }) + .pipe(Effect.forkChild); + const firstTurnId = yield* Deferred.await(firstTurnStarted).pipe(Effect.timeout("2 seconds")); + + const steerError = yield* Effect.flip( + adapter.sendTurn({ + threadId, + input: " ", + attachments: [], + }), + ); + yield* waitForFileContent(requestLogPath, 80, '"method":"session/prompt"'); + for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { + yield* Effect.yieldNow; + } + + const sessionsAfterFailedSteer = yield* adapter.listSessions(); + const sessionAfterFailedSteer = sessionsAfterFailedSteer.find( + (session) => session.threadId === threadId, + ); + const completedBeforeInterrupt = runtimeEvents.filter( + (event): event is Extract => + event.type === "turn.completed" && String(event.threadId) === String(threadId), + ); + + yield* adapter.interruptTurn(threadId, firstTurnId).pipe(Effect.timeout("2 seconds")); + yield* Fiber.join(firstSendTurnFiber).pipe(Effect.timeout("3 seconds")); + yield* Deferred.await(turnCompleted).pipe(Effect.timeout("3 seconds")); + + const turnCompletedEvents = runtimeEvents.filter( + (event): event is Extract => + event.type === "turn.completed" && String(event.threadId) === String(threadId), + ); + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + + assert.equal(steerError._tag, "ProviderAdapterValidationError"); + assert.equal(sessionAfterFailedSteer?.status, "running"); + assert.equal(String(sessionAfterFailedSteer?.activeTurnId), String(firstTurnId)); + assert.lengthOf(completedBeforeInterrupt, 0); + assert.lengthOf(turnCompletedEvents, 1); + assert.equal(String(turnCompletedEvents[0]?.turnId), String(firstTurnId)); + assert.equal(turnCompletedEvents[0]?.payload.state, "cancelled"); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + + it.effect("does not double-complete when interrupt wins before a prompt starts ACP", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-interrupt-before-prompt-start"); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + + const runtimeEvents: ProviderRuntimeEvent[] = []; + const firstTurnStarted = yield* Deferred.make(); + const turnCompleted = yield* Deferred.make(); + const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) !== String(threadId)) { + return; + } + if (event.type === "turn.started" && event.turnId !== undefined) { + yield* Deferred.succeed(firstTurnStarted, event.turnId).pipe(Effect.ignore); + return; + } + if (event.type === "turn.completed") { + yield* Deferred.succeed(turnCompleted, undefined).pipe(Effect.ignore); + } + }), + ).pipe(Effect.forkChild); + + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("grok"), + cwd: process.cwd(), + runtimeMode: "full-access", + }); + + const firstSendTurnFiber = yield* adapter + .sendTurn({ threadId, input: "interrupt before prompt starts", attachments: [] }) + .pipe(Effect.forkChild); + const firstTurnId = yield* Deferred.await(firstTurnStarted).pipe(Effect.timeout("2 seconds")); + yield* adapter.interruptTurn(threadId, firstTurnId).pipe(Effect.timeout("2 seconds")); + yield* Fiber.join(firstSendTurnFiber).pipe(Effect.timeout("3 seconds")); + yield* Deferred.await(turnCompleted).pipe(Effect.timeout("3 seconds")); + + const turnCompletedEvents = runtimeEvents.filter( + (event): event is Extract => + event.type === "turn.completed" && String(event.threadId) === String(threadId), + ); + const readySessions = yield* adapter.listSessions(); + const readySession = readySessions.find((session) => session.threadId === threadId); + + assert.lengthOf(turnCompletedEvents, 1); + assert.equal(String(turnCompletedEvents[0]?.turnId), String(firstTurnId)); + assert.equal(turnCompletedEvents[0]?.payload.state, "cancelled"); + assert.equal(readySession?.status, "ready"); + assert.isUndefined(readySession?.activeTurnId); + + yield* Fiber.interrupt(runtimeEventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + it.effect("drops late ACP notifications after a turn is cancelled", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-drop-late-cancelled-notifications"); @@ -1453,7 +1763,8 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { assert.equal(error._tag, "ProviderAdapterRequestError"); assert.include(error.message, "Grok usage limit reached. Try again later."); assert.equal(readySession?.status, "ready"); - assert.equal(readySession?.model, "grok-build"); + // "grok-build" resolves to the session's current model instead of going over the wire. + assert.equal(readySession?.model, "grok-4.6"); assert.isUndefined(readySession?.activeTurnId); assert.lengthOf(terminalEvents, 1); const [terminalEvent] = terminalEvents; diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 7fdea0be5..05aa83c7e 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -154,8 +154,15 @@ interface GrokSessionContext { interruptedTurnIds: Set; /** Number of sendTurn prompts currently in flight or being prepared. * >0 means a turn is actively running, so a new sendTurn is a steer that - * continues it, and only the last remaining prompt settles the turn. */ + * cancels the in-flight prompt and continues the same turn. Only the last + * remaining prompt settles the turn. */ promptsInFlight: number; + /** Monotonic id assigned to each sendTurn. Steers discard older epochs. */ + promptEpoch: number; + /** Prompt epochs below this value must not start an ACP session/prompt. */ + discardBeforeEpoch: number; + /** Serializes cancel-then-prompt so a steer cannot miss or hit the wrong RPC. */ + readonly promptLifecycle: Semaphore.Semaphore; readonly livenessSignals: Queue.Queue; livenessTurnId: TurnId | undefined; lastTurnActivityAtNanos: bigint | undefined; @@ -1307,6 +1314,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte activeTurnId: undefined, interruptedTurnIds: new Set(), promptsInFlight: 0, + promptEpoch: 0, + discardBeforeEpoch: 0, + promptLifecycle: yield* Semaphore.make(1), livenessSignals: yield* Queue.sliding(1), livenessTurnId: undefined, lastTurnActivityAtNanos: undefined, @@ -1498,15 +1508,18 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte input.threadId, Effect.gen(function* () { const ctx = yield* requireSession(input.threadId); - // A sendTurn while a prompt is in flight is a steer: the agent - // folds the new prompt into the ongoing work, so the active turn - // id is reused instead of opening a new turn. + // A sendTurn while a prompt is in flight is a steer: reuse the + // active turn and cancel the in-flight ACP prompt so Grok takes + // the new instruction immediately, matching Claude/Codex, instead + // of waiting behind serialized session/prompt. const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); // Count this prompt immediately so a superseded in-flight prompt // resolving from here on does not settle the turn; decremented on // preparation failure here, and after the prompt below otherwise. ctx.promptsInFlight += 1; + ctx.promptEpoch += 1; + const promptEpoch = ctx.promptEpoch; // Bind the turn id before cooperative yields so interruptTurn can // settle this prompt even if stop arrives during preparation. ctx.activeTurnId = turnId; @@ -1657,6 +1670,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte : {}), payload: displayModel ? { model: displayModel } : {}, }); + } else { + // Discard the previous epoch only after this replacement is + // ready. A failed steer must not skip the live prompt, which + // settles without a terminal event when emitTurnCompletion is + // false. + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); + ctx.discardBeforeEpoch = promptEpoch; } return { @@ -1666,6 +1687,9 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte displayModel, promptParts, turnId, + promptEpoch, + promptLifecycle: ctx.promptLifecycle, + steeringTurnId, }; }).pipe( Effect.tapCause(() => @@ -1692,31 +1716,92 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const promptFailureMessageRef = yield* Ref.make(undefined); return yield* Effect.gen(function* () { - const result = yield* prepared.acp - .prompt({ - prompt: prepared.promptParts, - }) - .pipe( - Effect.tap((promptResult) => - Effect.all( - [ - Ref.set(promptRpcSucceeded, true), - Ref.set(promptResultRef, promptResult), - markPromptResponseReady(input.threadId, prepared.acpSessionId, prepared.turnId), - ], - { discard: true }, - ), - ), - Effect.tapError((error) => - Ref.set( - promptFailureMessageRef, - mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error).message, - ).pipe(Effect.andThen(prepared.acp.drainEvents)), - ), - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + const promptStart = yield* prepared.promptLifecycle.withPermit( + Effect.gen(function* () { + const liveCtx = sessions.get(input.threadId); + const interrupted = liveCtx?.interruptedTurnIds.has(prepared.turnId) === true; + if ( + !liveCtx || + liveCtx.acpSessionId !== prepared.acpSessionId || + prepared.promptEpoch < liveCtx.discardBeforeEpoch || + interrupted + ) { + return { _tag: "Skipped" as const, interrupted }; + } + if (prepared.steeringTurnId !== undefined) { + yield* Effect.ignore( + liveCtx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/cancel", error), + ), + ), + ); + } + if (liveCtx.interruptedTurnIds.has(prepared.turnId)) { + return { _tag: "Skipped" as const, interrupted: true }; + } + const dispatched = yield* Deferred.make(); + const fiber = yield* liveCtx.acp + .prompt({ prompt: prepared.promptParts }, { dispatched }) + .pipe(Effect.forkChild({ startImmediately: true })); + // Hold the lifecycle permit until the runtime has registered this + // prompt's RPC fiber, so a later steer's session/cancel targets + // this prompt. Fall through if the prompt fails before that point. + yield* Effect.raceFirst( + Deferred.await(dispatched), + Fiber.await(fiber).pipe(Effect.asVoid), + ); + return { _tag: "Started" as const, fiber }; + }), + ); + if (promptStart._tag === "Skipped") { + // Settle after releasing promptLifecycle. Holding both locks + // deadlocks the next sendTurn, which takes the thread lock first. + yield* withThreadLock( + input.threadId, + settlePromptInFlight( + input.threadId, + prepared.turnId, + prepared.acpSessionId, + prepared.sessionIncarnationId, + promptStart.interrupted + ? { + completedStopReason: "cancelled", + settleAllPrompts: true, + } + : { emitTurnCompletion: false }, ), ); + yield* Ref.set(promptSettled, true); + const liveCtx = sessions.get(input.threadId); + return { + threadId: input.threadId, + turnId: prepared.turnId, + resumeCursor: liveCtx?.session.resumeCursor, + }; + } + + const result = yield* Fiber.join(promptStart.fiber).pipe( + Effect.tap((promptResult) => + Effect.all( + [ + Ref.set(promptRpcSucceeded, true), + Ref.set(promptResultRef, promptResult), + markPromptResponseReady(input.threadId, prepared.acpSessionId, prepared.turnId), + ], + { discard: true }, + ), + ), + Effect.tapError((error) => + Ref.set( + promptFailureMessageRef, + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error).message, + ).pipe(Effect.andThen(prepared.acp.drainEvents)), + ), + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); return yield* withThreadLock( input.threadId, diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 307f55eeb..e7c62f4cb 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -1,3 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off - resolves the mock ACP agent script path relative to this test file. +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -8,11 +12,74 @@ import { GrokSettings } from "@t3tools/contracts"; import { buildGrokModelCapabilities, + buildGrokModelsFromSessionModelState, buildInitialGrokProviderSnapshot, checkGrokProviderStatus, + parseGrokModelsCliOutput, } from "./GrokProvider.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); +const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); + +const LOGGED_IN_MODELS_OUTPUT = [ + "You are logged in with grok.com.", + "", + "Default model: grok-4.6", + "", + "Available models:", + " * grok-4.6 (default)", + " - grok-4.5", + "", +].join("\n"); + +const LOGGED_OUT_MODELS_OUTPUT = LOGGED_IN_MODELS_OUTPUT.replace( + "You are logged in with grok.com.", + "You are not authenticated.", +); + +describe("parseGrokModelsCliOutput", () => { + it("reads login state and model slugs, marking the default", () => { + const parsed = parseGrokModelsCliOutput(LOGGED_IN_MODELS_OUTPUT); + expect(parsed.authenticated).toBe(true); + expect(parsed.models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ + ["grok-4.6", true], + ["grok-4.5", false], + ]); + }); + + it("detects a logged-out CLI even though it exits 0", () => { + expect(parseGrokModelsCliOutput(LOGGED_OUT_MODELS_OUTPUT).authenticated).toBe(false); + }); + + it("returns unknown auth for unrecognized output", () => { + expect(parseGrokModelsCliOutput("grok 9.9.9\n").authenticated).toBeNull(); + }); +}); + +describe("buildGrokModelsFromSessionModelState", () => { + it("marks the agent's current model as default and keeps reasoning options", () => { + const models = buildGrokModelsFromSessionModelState({ + currentModelId: "grok-4.6", + availableModels: [ + { + modelId: "grok-4.6", + name: "Grok 4.6", + _meta: { + supportsReasoningEffort: true, + reasoningEffort: "high", + reasoningEfforts: [{ value: "high", label: "High", default: true }], + }, + }, + { modelId: "grok-4.5", name: "Grok 4.5" }, + ], + }); + expect(models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ + ["grok-4.6", true], + ["grok-4.5", false], + ]); + expect(models[0]?.capabilities?.optionDescriptors).toHaveLength(1); + }); +}); describe("buildGrokModelCapabilities", () => { it("preserves ACP-provided reasoning labels and the active default", () => { @@ -264,30 +331,139 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { }), ); - it.effect("reports an error when ACP model discovery is unavailable", () => + // Single-quotes a path for /bin/sh. Temp dirs and execPath never contain quotes. + const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`; + + // A shell stand-in for the Grok CLI: `--version` and `models` print canned text, + // and `agent stdio` execs the mock ACP agent so `initialize` returns model metadata. + const writeFakeGrokCli = (input: { readonly modelsOutput: string; readonly acp: boolean }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-probe-" }); + const modelsPath = path.join(dir, "models.txt"); + yield* fs.writeFileString(modelsPath, input.modelsOutput); + const grokPath = path.join(dir, "grok"); + const mockAgentPath = path.resolve(__dirname, "../../../scripts/acp-mock-agent.ts"); + yield* fs.writeFileString( + grokPath, + [ + "#!/bin/sh", + 'case "$1" in', + ' --version) printf "grok 1.0.13\\n"; exit 0;;', + ` models) cat ${shellQuote(modelsPath)}; exit 0;;`, + input.acp + ? ` agent) exec ${shellQuote(process.execPath)} ${shellQuote(mockAgentPath)};;` + : " agent) exit 3;;", + "esac", + "exit 1", + "", + ].join("\n"), + ); + yield* fs.chmod(grokPath, 0o755); + return grokPath; + }); + + it.effect("reports ready with ACP-discovered models when logged in", () => Effect.gen(function* () { const snapshot = yield* Effect.scoped( Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-grok-success-" }); - const grokPath = path.join(dir, "grok"); - yield* fs.writeFileString( - grokPath, - ["#!/bin/sh", 'printf "grok-cli 0.0.99\\n"', "exit 0", ""].join("\n"), + const grokPath = yield* writeFakeGrokCli({ + modelsOutput: LOGGED_IN_MODELS_OUTPUT, + acp: true, + }); + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { ...process.env, XAI_API_KEY: "" }, ); - yield* fs.chmod(grokPath, 0o755); + }), + ); + expect(snapshot.status).toBe("ready"); + expect(snapshot.version).toBe("1.0.13"); + expect(snapshot.auth).toEqual({ + status: "authenticated", + type: "cached_token", + label: "Grok account", + }); + // The mock agent advertises grok-4.6 with reasoning options in initialize._meta. + expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-mock-alt"]); + expect(snapshot.models[0]?.isDefault).toBe(true); + expect( + snapshot.models[0]?.capabilities?.optionDescriptors?.map((option) => option.id) ?? [], + ).toEqual(["reasoningEffort"]); + }), + ); + + it.effect("reports unauthenticated from `grok models` without starting a session", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const grokPath = yield* writeFakeGrokCli({ + modelsOutput: LOGGED_OUT_MODELS_OUTPUT, + acp: true, + }); return yield* checkGrokProviderStatus( decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { ...process.env, XAI_API_KEY: "" }, ); }), ); expect(snapshot.status).toBe("error"); + expect(snapshot.auth.status).toBe("unauthenticated"); + expect(snapshot.message).toContain("grok login"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-4.6", "grok-mock-alt"]); + }), + ); + + it.effect("falls back to CLI-listed models with a warning when ACP initialize fails", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const grokPath = yield* writeFakeGrokCli({ + modelsOutput: LOGGED_IN_MODELS_OUTPUT, + acp: false, + }); + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { ...process.env, XAI_API_KEY: "" }, + ); + }), + ); + + expect(snapshot.status).toBe("warning"); expect(snapshot.installed).toBe(true); - expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-build"]); - expect(snapshot.message).toContain("ACP startup failed"); + expect(snapshot.auth.status).toBe("authenticated"); + expect(snapshot.models.map((model) => [model.slug, model.isDefault ?? false])).toEqual([ + ["grok-4.6", true], + ["grok-4.5", false], + ]); + expect(snapshot.message).toContain("ACP initialize failed"); + }), + ); + + it.effect("treats XAI_API_KEY as authenticated regardless of CLI login state", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const grokPath = yield* writeFakeGrokCli({ + modelsOutput: LOGGED_OUT_MODELS_OUTPUT, + acp: false, + }); + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { ...process.env, XAI_API_KEY: "xai-test-key" }, + ); + }), + ); + + expect(snapshot.auth).toEqual({ + status: "authenticated", + type: "api_key", + label: "xAI API key", + }); + expect(snapshot.status).toBe("warning"); }), ); }); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 5b0f54aa0..cbb02cc0a 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -2,6 +2,7 @@ import { type GrokSettings, type ModelCapabilities, type ServerProvider, + type ServerProviderAuth, type ServerProviderModel, } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; @@ -18,6 +19,7 @@ import { createModelCapabilities } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { + AUTH_PROBE_TIMEOUT_MS, buildServerProvider, isCommandMissingCause, parseGenericCliVersion, @@ -30,10 +32,12 @@ import { type ProviderMaintenanceCapabilities, } from "../providerMaintenance.ts"; import { + GROK_DEFAULT_MODEL_SLUG, isValidGrokReasoningEffortToken, makeGrokAcpRuntime, resolveGrokAcpBaseModelId, } from "../acp/GrokAcpSupport.ts"; +import { sessionModelStateFromInitialize } from "../acp/AcpRuntimeModel.ts"; import { discoverGrokSkills } from "../Drivers/GrokSkills.ts"; const GROK_PRESENTATION = { @@ -47,11 +51,13 @@ const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ }); const VERSION_PROBE_TIMEOUT_MS = 4_000; -const GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; +// `initialize` is a single local round trip, so this is generous even on slow machines. +const GROK_ACP_INITIALIZE_TIMEOUT_MS = 8_000; +const GROK_API_KEY_ENV = "XAI_API_KEY"; const GROK_BUILT_IN_MODELS: ReadonlyArray = [ { - slug: "grok-build", + slug: GROK_DEFAULT_MODEL_SLUG, name: "Grok Build", isCustom: false, capabilities: EMPTY_CAPABILITIES, @@ -202,56 +208,94 @@ export function buildGrokModelCapabilities(model: EffectAcpSchema.ModelInfo): Mo : EMPTY_CAPABILITIES; } -function buildGrokDiscoveredModelsFromSessionModelState( +/** Models advertised by the ACP agent, with the session's current model marked as default. */ +export function buildGrokModelsFromSessionModelState( modelState: EffectAcpSchema.SessionModelState | null | undefined, ): ReadonlyArray { if (!modelState || modelState.availableModels.length === 0) { return []; } + const currentModelId = modelState.currentModelId.trim(); const seen = new Set(); - return modelState.availableModels - .map((model): ServerProviderModel | undefined => { - const slug = resolveGrokAcpBaseModelId(model.modelId); - if (!slug || seen.has(slug)) { - return undefined; - } - seen.add(slug); - return { + return modelState.availableModels.flatMap((model): ServerProviderModel[] => { + const slug = resolveGrokAcpBaseModelId(model.modelId); + if (!slug || seen.has(slug)) { + return []; + } + seen.add(slug); + return [ + { slug, name: model.name.trim() || slug, isCustom: false, + ...(model.modelId.trim() === currentModelId ? { isDefault: true } : {}), capabilities: buildGrokModelCapabilities(model), - }; - }) - .filter((model): model is ServerProviderModel => model !== undefined); + }, + ]; + }); } -const discoverGrokModelsViaAcp = ( - grokSettings: GrokSettings, - environment: NodeJS.ProcessEnv = process.env, -) => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const acp = yield* makeGrokAcpRuntime({ - grokSettings, - environment, - childProcessSpawner, - cwd: process.cwd(), - clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, +export interface GrokModelsCliOutput { + /** True or false when the CLI printed a login line, null when it printed neither. */ + readonly authenticated: boolean | null; + readonly models: ReadonlyArray; +} + +/** + * Parses `grok models`. The command exits 0 whether or not the user is logged in, so the + * text is the only signal. Current output looks like: + * + * You are logged in with grok.com. + * Default model: grok-4.6 + * Available models: + * * grok-4.6 (default) + * - grok-4.5 + */ +export function parseGrokModelsCliOutput(output: string): GrokModelsCliOutput { + const authenticated = /you are logged in/i.test(output) + ? true + : /not authenticated|not logged in/i.test(output) + ? false + : null; + + const seen = new Set(); + const models: ServerProviderModel[] = []; + for (const line of output.split(/\r?\n/)) { + const bullet = line.match(/^\s*[*-]\s+(\S+)(.*)$/); + if (!bullet?.[1]) { + continue; + } + const slug = resolveGrokAcpBaseModelId(bullet[1]); + if (seen.has(slug)) { + continue; + } + seen.add(slug); + models.push({ + slug, + name: displayNameFromGrokModelSlug(slug), + isCustom: false, + ...(/\(default\)/i.test(bullet[2] ?? "") ? { isDefault: true } : {}), + capabilities: EMPTY_CAPABILITIES, }); - const started = yield* acp.start(); - return buildGrokDiscoveredModelsFromSessionModelState(started.sessionSetupResult.models); - }).pipe(Effect.scoped); + } + return { authenticated, models }; +} + +function displayNameFromGrokModelSlug(slug: string): string { + return slug + .split(/[-_]/g) + .map((part) => (part.toLowerCase() === "grok" ? "Grok" : part)) + .join(" "); +} -const runGrokVersionCommand = ( +const runGrokCliCommand = ( grokSettings: GrokSettings, - environment: NodeJS.ProcessEnv = process.env, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv, ) => Effect.gen(function* () { const command = grokSettings.binaryPath || "grok"; - const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { - env: environment, - }); + const spawnCommand = yield* resolveSpawnCommand(command, args, { env: environment }); return yield* spawnAndCollect( command, ChildProcess.make(spawnCommand.command, spawnCommand.args, { @@ -261,6 +305,27 @@ const runGrokVersionCommand = ( ); }); +/** + * Reads model metadata from `initialize._meta.modelState`. This never calls `authenticate` + * or `session/new`, so it cannot open a browser login or boot the workspace's MCP servers. + */ +const discoverGrokModelsViaAcpInitialize = ( + grokSettings: GrokSettings, + environment: NodeJS.ProcessEnv, +) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const acp = yield* makeGrokAcpRuntime({ + grokSettings, + environment, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + }); + const initialized = yield* acp.initialize(); + return buildGrokModelsFromSessionModelState(sessionModelStateFromInitialize(initialized)); + }).pipe(Effect.scoped); + export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, @@ -289,7 +354,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func }); } - const versionResult = yield* runGrokVersionCommand(grokSettings, environment).pipe( + const versionResult = yield* runGrokCliCommand(grokSettings, ["--version"], environment).pipe( Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), Effect.result, ); @@ -355,55 +420,76 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func }); } + // `grok models` reports login state and model slugs without starting the agent. + const modelsResult = yield* runGrokCliCommand(grokSettings, ["models"], environment).pipe( + Effect.timeoutOption(AUTH_PROBE_TIMEOUT_MS), + Effect.result, + ); + // Only a clean exit is parsed. Failed invocations print help or error text that + // must not be read as model slugs or as a login verdict. + const modelsOutput = + Result.isSuccess(modelsResult) && + Option.isSome(modelsResult.success) && + modelsResult.success.value.code === 0 + ? modelsResult.success.value + : undefined; + const cliModels: GrokModelsCliOutput = modelsOutput + ? parseGrokModelsCliOutput(`${modelsOutput.stdout}\n${modelsOutput.stderr}`) + : { authenticated: null, models: [] }; + if (!modelsOutput) { + yield* Effect.logWarning("Grok CLI model listing failed or timed out.", { + errorTag: Result.isFailure(modelsResult) + ? modelsResult.failure._tag + : Option.isNone(modelsResult.success) + ? "Timeout" + : `ExitCode${modelsResult.success.value.code}`, + }); + } + + const auth: ServerProviderAuth = environment[GROK_API_KEY_ENV]?.trim() + ? { status: "authenticated", type: "api_key", label: "xAI API key" } + : cliModels.authenticated === true + ? { status: "authenticated", type: "cached_token", label: "Grok account" } + : cliModels.authenticated === false + ? { status: "unauthenticated" } + : { status: "unknown" }; + const skills = yield* discoverGrokSkills(grokSettings, environment, cwd); - const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe( - Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS), + const acpExit = yield* discoverGrokModelsViaAcpInitialize(grokSettings, environment).pipe( + Effect.timeoutOption(GROK_ACP_INITIALIZE_TIMEOUT_MS), Effect.exit, ); - if (Exit.isFailure(discoveryExit)) { - yield* Effect.logWarning("Grok ACP model discovery failed", { - errorTag: causeErrorTag(discoveryExit.cause), - }); - return buildServerProvider({ - presentation: GROK_PRESENTATION, - enabled: grokSettings.enabled, - checkedAt, - models: fallbackModels, - skills, - probe: { - installed: true, - version, - status: "error", - auth: { status: "unknown" }, - message: "Grok CLI is installed but ACP startup failed. Check server logs for details.", - }, + const acpModels = Exit.isSuccess(acpExit) ? Option.getOrElse(acpExit.value, () => []) : []; + const acpFailed = Exit.isFailure(acpExit) || Option.isNone(acpExit.value); + if (acpFailed) { + yield* Effect.logWarning("Grok ACP initialize probe failed or timed out.", { + errorTag: Exit.isFailure(acpExit) ? causeErrorTag(acpExit.cause) : "Timeout", }); } - if (Option.isNone(discoveryExit.value)) { - yield* Effect.logWarning( - `Grok ACP model discovery timed out after ${GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, - ); + + const discoveredModels = acpModels.length > 0 ? acpModels : cliModels.models; + const models = + discoveredModels.length > 0 + ? grokModelsFromSettings(grokSettings.customModels, discoveredModels) + : fallbackModels; + + if (auth.status === "unauthenticated") { return buildServerProvider({ presentation: GROK_PRESENTATION, enabled: grokSettings.enabled, checkedAt, - models: fallbackModels, + models, skills, probe: { installed: true, version, status: "error", - auth: { status: "unknown" }, - message: `Grok CLI is installed but ACP startup timed out after ${GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, + auth, + message: "Grok CLI is installed but not logged in. Run `grok login`.", }, }); } - const discoveredModels = discoveryExit.value.value; - const models = - discoveredModels.length > 0 - ? grokModelsFromSettings(grokSettings.customModels, discoveredModels) - : fallbackModels; return buildServerProvider({ presentation: GROK_PRESENTATION, @@ -414,8 +500,15 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func probe: { installed: true, version, - status: "ready", - auth: { status: "unknown" }, + // A failed metadata probe degrades the model picker, it does not make chats fail. + status: acpFailed ? "warning" : "ready", + auth, + ...(acpFailed + ? { + message: + "Grok CLI is installed but ACP initialize failed. Model options may be incomplete.", + } + : {}), }, }); }); diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index 25bae26ed..f6a80e3cb 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -715,13 +715,24 @@ export const waitForSessionLoadReplayIdle = (input: { } }); +/** + * Model state some agents (Grok) advertise in `initialize._meta.modelState`, before any + * session exists. Undefined when the agent does not advertise it or the shape is unknown. + */ +export function sessionModelStateFromInitialize( + initializeResult: EffectAcpSchema.InitializeResponse, +): EffectAcpSchema.SessionModelState | undefined { + const meta = initializeResult._meta; + const modelState = isRecord(meta) ? meta.modelState : undefined; + return isSessionModelState(modelState) ? modelState : undefined; +} + export function syntheticLoadSessionResponseFromInitialize( initializeResult: EffectAcpSchema.InitializeResponse, ): EffectAcpSchema.LoadSessionResponse { const meta = initializeResult._meta; - const modelState = isRecord(meta) ? meta.modelState : undefined; const modeState = isRecord(meta) ? meta.modeState : undefined; - const models = isSessionModelState(modelState) ? modelState : undefined; + const models = sessionModelStateFromInitialize(initializeResult); const modes = isSessionModeState(modeState) ? modeState : undefined; return { diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index b7c98577b..3c63074c7 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -195,6 +195,16 @@ export class AcpSessionRuntime extends Context.Service< * @see https://agentclientprotocol.com/protocol/extensibility */ readonly handleExtNotification: EffectAcpClient.AcpClient["Service"]["handleExtNotification"]; + /** + * Sends only `initialize` and returns the agent's response. Health probes use this to read + * advertised capabilities without authenticating or opening a session, so a probe can never + * start an interactive login or boot MCP servers. + * @see https://agentclientprotocol.com/protocol/schema#initialize + */ + readonly initialize: () => Effect.Effect< + EffectAcpSchema.InitializeResponse, + EffectAcpErrors.AcpError + >; /** * Initializes the ACP connection, optionally authenticates, and loads, resumes, or creates the session. * Concurrent calls share the same in-flight startup and a failed startup may be retried. @@ -209,11 +219,14 @@ export class AcpSessionRuntime extends Context.Service< /** Latest configuration options observed from session setup and configuration writes. */ readonly getConfigOptions: Effect.Effect>; /** - * Sends a prompt turn to the active session. + * Sends a prompt turn to the active session. `options.dispatched` settles once the + * `session/prompt` RPC is registered as the active prompt, so a caller that forks this + * effect knows when a later `cancel` will target this prompt. * @see https://agentclientprotocol.com/protocol/schema#session/prompt */ readonly prompt: ( payload: Omit, + options?: { readonly dispatched?: Deferred.Deferred }, ) => Effect.Effect; /** * Sends a real ACP `session/cancel` notification for the active session. @@ -586,18 +599,19 @@ export const make = ( ), ); - const startOnce = Effect.gen(function* () { - const initializePayload = { - protocolVersion: 1, - clientCapabilities: initializeClientCapabilities, - clientInfo: options.clientInfo, - } satisfies EffectAcpSchema.InitializeRequest; + const initializePayload = { + protocolVersion: 1, + clientCapabilities: initializeClientCapabilities, + clientInfo: options.clientInfo, + } satisfies EffectAcpSchema.InitializeRequest; + const sendInitialize = runLoggedRequest( + "initialize", + initializePayload, + runStartupRpc("initialize", acp.agent.initialize(initializePayload)), + ); - const initializeResult = yield* runLoggedRequest( - "initialize", - initializePayload, - runStartupRpc("initialize", acp.agent.initialize(initializePayload)), - ); + const startOnce = Effect.gen(function* () { + const initializeResult = yield* sendInitialize; if (options.authMethodId !== undefined) { const authenticatePayload = { @@ -764,6 +778,7 @@ export const make = ( handleUnknownExtNotification: acp.handleUnknownExtNotification, handleExtRequest: acp.handleExtRequest, handleExtNotification: acp.handleExtNotification, + initialize: () => sendInitialize, start: () => start, getEvents: () => Stream.fromQueue(eventQueue), drainEvents: Effect.gen(function* () { @@ -776,7 +791,7 @@ export const make = ( }), getModeState: Ref.get(modeStateRef), getConfigOptions: Ref.get(configOptionsRef), - prompt: (payload) => + prompt: (payload, promptOptions?) => promptSerializationSemaphore.withPermit( Effect.gen(function* () { const started = yield* getStartedState; @@ -797,6 +812,9 @@ export const make = ( acp.agent.prompt(requestPayload), ).pipe(Effect.forkIn(runtimeScope)); yield* Ref.set(activePromptFiberRef, Option.some(promptRpcFiber)); + if (promptOptions?.dispatched) { + yield* Deferred.succeed(promptOptions.dispatched, undefined); + } return yield* Fiber.join(promptRpcFiber).pipe( Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) @@ -825,9 +843,9 @@ export const make = ( if (Option.isSome(activePromptFiber)) { yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore); } - yield* acp.agent - .cancel({ sessionId: started.sessionId }) - .pipe(Effect.ignore, Effect.forkIn(runtimeScope)); + // Await the notification write so a replacement session/prompt + // cannot race ahead of session/cancel on the wire. + yield* acp.agent.cancel({ sessionId: started.sessionId }).pipe(Effect.ignore); }), ), ), diff --git a/apps/server/src/provider/acp/GrokAcpSupport.test.ts b/apps/server/src/provider/acp/GrokAcpSupport.test.ts index f8fe09a99..81958ab35 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.test.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.test.ts @@ -178,6 +178,36 @@ describe("applyGrokAcpModelSelection", () => { }), ); + it.effect("keeps the session's current model when the product slug is requested", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-4.6", + requestedModelId: "grok-build", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([]); + expect(result).toBe("grok-4.6"); + }), + ); + + it.effect("applies reasoning to the current model when the product slug is requested", () => + Effect.gen(function* () { + const { runtime, modelCalls } = makeRecordingRuntime(); + const result = yield* applyGrokAcpModelSelection({ + runtime, + currentModelId: "grok-4.6", + currentReasoningEffort: "high", + requestedModelId: "grok-build", + requestedReasoningEffort: "xhigh", + mapError: (cause) => cause.message, + }); + expect(modelCalls).toEqual([{ modelId: "grok-4.6", meta: { reasoningEffort: "xhigh" } }]); + expect(result).toBe("grok-4.6"); + }), + ); + it.effect("skips set_model when no model is requested", () => Effect.gen(function* () { const { runtime, modelCalls } = makeRecordingRuntime(); diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index 001f0adfe..e35003835 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -98,10 +98,16 @@ export const makeGrokAcpRuntime = ( return yield* makeXAiPromptCompletionRuntime(runtime); }); +/** + * T3's built-in Grok slug. It is the CLI's product name, not a model id the ACP accepts, + * so selecting it means "use whatever model the Grok session currently runs on". + */ +export const GROK_DEFAULT_MODEL_SLUG = "grok-build"; + export function resolveGrokAcpBaseModelId(model: string | null | undefined): string { const trimmed = model?.trim(); - const base = trimmed && trimmed.length > 0 ? trimmed : "grok-build"; - return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? "grok-build"; + const base = trimmed && trimmed.length > 0 ? trimmed : GROK_DEFAULT_MODEL_SLUG; + return normalizeModelSlug(base, GROK_DRIVER_KIND) ?? GROK_DEFAULT_MODEL_SLUG; } const GROK_REASONING_EFFORT_TOKEN = /^[a-z0-9][a-z0-9._-]{0,31}$/i; @@ -155,15 +161,17 @@ export function applyGrokAcpModelSelection(input: { readonly requestedReasoningEffort?: string | undefined; readonly mapError: (cause: EffectAcpErrors.AcpError) => E; }): Effect.Effect { - const modelChanged = - input.requestedModelId !== undefined && input.requestedModelId !== input.currentModelId; + // The product slug is never sent over the wire; it keeps the session's current model. + const requestedModelId = + input.requestedModelId === GROK_DEFAULT_MODEL_SLUG ? undefined : input.requestedModelId; + const modelChanged = requestedModelId !== undefined && requestedModelId !== input.currentModelId; const reasoningProvided = input.requestedReasoningEffort !== undefined; const reasoningEffort = reasoningProvided ? normalizeGrokReasoningEffort(input.requestedReasoningEffort) : undefined; const reasoningEffortChanged = reasoningProvided && reasoningEffort !== input.currentReasoningEffort; - const targetModelId = input.requestedModelId ?? input.currentModelId; + const targetModelId = requestedModelId ?? input.currentModelId; if ((!modelChanged && !reasoningEffortChanged) || targetModelId === undefined) { return Effect.succeed(input.currentModelId); } diff --git a/apps/server/src/provider/acp/XAiAcpExtension.ts b/apps/server/src/provider/acp/XAiAcpExtension.ts index 352fe3ab8..dd7527997 100644 --- a/apps/server/src/provider/acp/XAiAcpExtension.ts +++ b/apps/server/src/provider/acp/XAiAcpExtension.ts @@ -447,11 +447,11 @@ export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletion runtime .start() .pipe(Effect.tap((started) => Ref.set(activeSessionIdRef, started.sessionId))), - prompt: (payload) => + prompt: (payload, promptOptions?) => Effect.gen(function* () { const sessionId = yield* Ref.get(activeSessionIdRef); if (sessionId === undefined) { - return yield* runtime.prompt(payload); + return yield* runtime.prompt(payload, promptOptions); } const promptId = yield* allocatePromptFallbackId; @@ -470,7 +470,7 @@ export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletion } satisfies Omit; return yield* Effect.raceFirst( - runtime.prompt(requestPayload), + runtime.prompt(requestPayload, promptOptions), Deferred.await(fallback.deferred), ).pipe( Effect.tap((response) => diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 1b823d411..238ac74e7 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -189,6 +189,17 @@ directory to route session and turn operations for a thread, so callers name a t Adding a driver means writing the driver plus adapter and adding it to `BUILT_IN_DRIVERS`. No orchestration, contract, or client change is required for the common case. +### Grok health check + +`checkGrokProviderStatus` never opens an ACP session. It runs `grok --version`, then `grok models` +for login state and model slugs, then a single ACP `initialize` and reads models from +`_meta.modelState`. `authenticate` and `session/new` are skipped on purpose: `authenticate` can open +a browser login and `session/new` boots every configured MCP server, both of which made background +probes hang or surprise the user. A failed `initialize` degrades to `warning` with the CLI's model +list instead of persisting `error` over a working install. The built-in `grok-build` slug is the +CLI's product name, not an ACP model id. `applyGrokAcpModelSelection` treats it as "keep the +session's current model" and never sends it in `session/set_model`. + ## OpenCode server ownership and catalog Each OpenCode provider instance owns one lazy local server for catalog discovery and diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 4cf7d48bf..36b9c6764 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -152,6 +152,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial { yield* transport.notify("session/cancel", { sessionId: "session-1" }); + // A notification must not carry `id` or `headers`. Grok CLI drops frames that do. assert.deepEqual(events, [ { direction: "outgoing", stage: "decoded", payload: { - _tag: "Request", - id: "", + _tag: "Notification", tag: "session/cancel", payload: { sessionId: "session-1", }, - headers: [], }, }, { direction: "outgoing", stage: "raw", payload: - '{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"},"id":"","headers":[]}\n', + '{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"}}\n', }, ]); }), @@ -293,11 +292,13 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { serverRequestMethods: new Set(), }); + // Notifications encode through Schema, so the cause is the schema failure rather + // than the raw TypeError JSON.stringify throws. The ACP error shape is what callers see. const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip); assert.instanceOf(bigintError, AcpError.AcpProtocolParseError); assert.equal(bigintError.operation, "encode-message"); assert.equal(bigintError.method, "x/test"); - assert.instanceOf(bigintError.cause, TypeError); + assert.isDefined(bigintError.cause); assert.equal( bigintError.message, "ACP protocol operation 'encode-message' failed for method 'x/test'.", @@ -309,7 +310,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { assert.instanceOf(circularError, AcpError.AcpProtocolParseError); assert.equal(circularError.operation, "encode-message"); assert.equal(circularError.method, "x/test"); - assert.instanceOf(circularError.cause, TypeError); + assert.isDefined(circularError.cause); const requestError = yield* transport.request("x/request", 1n).pipe( Effect.match({ diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 44a48bd1e..9f993dedb 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -1,6 +1,7 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; +import * as Exit from "effect/Exit"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -77,6 +78,16 @@ const decodeElicitationComplete = Schema.decodeUnknownEffect( ); const parserFactory = RpcSerialization.ndJsonRpc(); const MAX_BUFFERED_RAW_NOTIFICATIONS = 32; +// Outbound JSON-RPC notification: no `id`, so peers never treat it as a request. +const encodeJsonRpcNotification = Schema.encodeUnknownExit( + Schema.fromJsonString( + Schema.Struct({ + jsonrpc: Schema.Literal("2.0"), + method: Schema.String, + params: Schema.Unknown, + }), + ), +); export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(function* ( options: AcpPatchedProtocolOptions, @@ -109,6 +120,11 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi const offerOutgoing = Effect.fn("offerOutgoing")(function* ( message: RpcMessage.FromClientEncoded | RpcMessage.FromServerEncoded, ) { + // RpcClient emits `@effect/rpc/Interrupt` when a pending request's fiber is interrupted. + // ACP has no such method; agents log it as an error and cannot act on it, so drop it. + if (message._tag === "Interrupt") { + return; + } yield* logProtocol({ direction: "outgoing", stage: "decoded", @@ -521,17 +537,29 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi supportsSpanPropagation: true, }); + // JSON-RPC notifications carry no `id`. The generic Request encoder emits `id: ""` plus + // `headers`, which real agents (Grok CLI) parse as a malformed request and silently drop. + // That made `session/cancel` a no-op against Grok while the lenient mock agent accepted it. const sendNotification = Effect.fn("sendNotification")(function* ( method: string, payload: unknown, ) { - yield* offerOutgoing({ - _tag: "Request", - id: "", - tag: method, - payload, - headers: [], + yield* logProtocol({ + direction: "outgoing", + stage: "decoded", + payload: { _tag: "Notification", tag: method, payload }, }); + const exit = encodeJsonRpcNotification({ jsonrpc: "2.0", method, params: payload }); + if (Exit.isFailure(exit)) { + return yield* AcpError.AcpProtocolParseError.fromEncodingError( + method, + undefined, + Cause.squash(exit.cause), + ); + } + const encoded = `${exit.value}\n`; + yield* logProtocol({ direction: "outgoing", stage: "raw", payload: encoded }); + yield* Queue.offer(outgoing, encoded); }); const sendRequest = Effect.fn("sendRequest")(function* (method: string, payload: unknown) { From 5af7a209e0ece48e4db7dd73325d96763a01d054 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 1 Sep 2026 19:08:38 -0700 Subject: [PATCH 07/32] feat(desktop): update the desktop app on remote Macs from the Update button (#6554) Use a prepare and commit handoff for remote desktop updates. Retry lost commits, require the prepared version after reconnect, and recover backends and windows when installation fails. Built with GPT-5.6 Sol in Codex. Co-authored-by: Claude Fable 5 Co-authored-by: Adolanium <94890352+Adolanium@users.noreply.github.com> (cherry picked from commit b2f25d390a8546e42eb6186115b673a53b8c38dc) --- apps/desktop/src/app/DesktopApp.ts | 2 + apps/desktop/src/app/DesktopLifecycle.test.ts | 11 +- apps/desktop/src/app/DesktopLifecycle.ts | 13 +- .../src/backend/DesktopBackendManager.test.ts | 4 + .../src/backend/DesktopBackendPool.test.ts | 4 + .../DesktopTelemetryPublisher.test.ts | 84 +- .../telemetry/DesktopTelemetryPublisher.ts | 50 +- .../src/updates/DesktopRemoteUpdates.test.ts | 842 ++++++++++++++++++ .../src/updates/DesktopRemoteUpdates.ts | 461 ++++++++++ .../src/updates/DesktopUpdates.test.ts | 350 +++----- apps/desktop/src/updates/DesktopUpdates.ts | 312 +++++-- .../src/updates/remoteUpdateFlow.test.ts | 218 +++++ apps/desktop/src/updates/remoteUpdateFlow.ts | 115 +++ .../desktop/src/updates/updatesTestHarness.ts | 242 +++++ .../src/window/DesktopApplicationMenu.test.ts | 4 + apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/cloud/selfUpdate.test.ts | 34 + apps/server/src/cloud/selfUpdate.ts | 16 +- .../desktopUpdate/DesktopAppUpdate.test.ts | 247 +++++ .../src/desktopUpdate/DesktopAppUpdate.ts | 219 +++++ .../src/environment/ServerEnvironment.test.ts | 37 + .../src/environment/ServerEnvironment.ts | 11 +- .../DesktopTelemetryReceiver.ts | 62 ++ apps/server/src/server.ts | 9 +- apps/server/src/ws.ts | 6 + apps/web/src/components/ChatView.tsx | 15 +- .../components/ServerUpdateAction.test.tsx | 43 + .../web/src/components/ServerUpdateAction.tsx | 28 +- .../settings/ConnectionsSettings.tsx | 7 +- apps/web/src/versionSkew.test.ts | 22 + apps/web/src/versionSkew.ts | 8 + docs/internals/server-updates.md | 12 +- docs/user/updating.md | 10 +- .../src/connection/registry.test.ts | 28 + .../client-runtime/src/state/runtime.test.ts | 51 ++ .../client-runtime/src/state/server.test.ts | 112 +++ packages/client-runtime/src/state/server.ts | 166 +++- packages/contracts/src/environment.ts | 11 +- packages/contracts/src/resourceTelemetry.ts | 58 ++ packages/contracts/src/rpc.ts | 9 + packages/contracts/src/server.ts | 7 + 41 files changed, 3597 insertions(+), 344 deletions(-) create mode 100644 apps/desktop/src/updates/DesktopRemoteUpdates.test.ts create mode 100644 apps/desktop/src/updates/DesktopRemoteUpdates.ts create mode 100644 apps/desktop/src/updates/remoteUpdateFlow.test.ts create mode 100644 apps/desktop/src/updates/remoteUpdateFlow.ts create mode 100644 apps/desktop/src/updates/updatesTestHarness.ts create mode 100644 apps/server/src/desktopUpdate/DesktopAppUpdate.test.ts create mode 100644 apps/server/src/desktopUpdate/DesktopAppUpdate.ts diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 8ff9f7b7e..6080ed3ca 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -27,6 +27,7 @@ import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopShellEnvironment from "../shell/DesktopShellEnvironment.ts"; import * as DesktopState from "./DesktopState.ts"; +import * as DesktopRemoteUpdates from "../updates/DesktopRemoteUpdates.ts"; import * as DesktopUpdates from "../updates/DesktopUpdates.ts"; import * as DesktopWslBackend from "../wsl/DesktopWslBackend.ts"; @@ -292,6 +293,7 @@ const startup = Effect.gen(function* () { yield* appIdentity.configure; yield* applicationMenu.configure; yield* updates.configure; + yield* DesktopRemoteUpdates.listen; yield* linuxUrlHandler.register; yield* bootstrap.pipe(Effect.catchCause((cause) => fatalStartupCause("bootstrap", cause))); }).pipe(Effect.withSpan("desktop.startup")); diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 422772f6f..5ca973787 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -102,6 +102,7 @@ describe("DesktopLifecycle", () => { for (const platform of ["darwin", "win32", "linux"] satisfies ReadonlyArray) { it.effect(`lets the updater's quit event proceed on ${platform}`, () => { const appListeners = new Map void>(); + let windowsDestroyed = false; const environmentLayer = Layer.succeed(DesktopEnvironment.DesktopEnvironment, { platform, isDevelopment: false, @@ -110,7 +111,13 @@ describe("DesktopLifecycle", () => { const layer = DesktopLifecycle.layer.pipe( Layer.provideMerge(makeElectronAppLayer(appListeners)), Layer.provideMerge(electronThemeLayer), - Layer.provideMerge(makeElectronWindowLayer()), + Layer.provideMerge( + makeElectronWindowLayer( + Effect.sync(() => { + windowsDestroyed = true; + }), + ), + ), Layer.provideMerge(makeDesktopWindowLayer()), Layer.provideMerge(environmentLayer), Layer.provideMerge(DesktopShutdown.layer), @@ -123,6 +130,7 @@ describe("DesktopLifecycle", () => { yield* lifecycle.register; appListeners.get("before-quit-for-update")?.(); + yield* Effect.yieldNow; let prevented = false; const event = { @@ -136,6 +144,7 @@ describe("DesktopLifecycle", () => { prevented, "cancelling this event prevents the updater from completing its relaunch", ); + assert.isTrue(windowsDestroyed); const state = yield* DesktopState.DesktopState; assert.isTrue(yield* Ref.get(state.quitting)); diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index 6a98e59eb..0a0cc6dca 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -189,6 +189,7 @@ export const make = DesktopLifecycle.of({ }), register: Effect.gen(function* () { const desktopWindow = yield* DesktopWindow.DesktopWindow; + const electronWindow = yield* ElectronWindow.ElectronWindow; const electronApp = yield* ElectronApp.ElectronApp; const electronTheme = yield* ElectronTheme.ElectronTheme; const environment = yield* DesktopEnvironment.DesktopEnvironment; @@ -206,8 +207,16 @@ export const make = DesktopLifecycle.of({ // Cancelling the following app "before-quit" event breaks that sequence, // most visibly on macOS where the native updater performs the relaunch. updaterQuitAllowed = true; - void runEffect( - logLifecycleInfo("allowing updater-controlled quit").pipe( + // This event is synchronous and the updater's quit proceeds as soon as + // the listener returns, so a forked destroyAll would race the quit + // and windows could still be open when the process exits (visible on + // macOS). Destroy them inline. + Effect.runSyncWith(context)( + electronWindow.destroyAll.pipe( + Effect.andThen(logLifecycleInfo("allowing updater-controlled quit")), + Effect.catchCause((cause) => + logLifecycleError("failed to destroy windows before updater quit", { cause }), + ), Effect.withSpan("desktop.lifecycle.beforeQuitForUpdate"), ), ); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index e6c9a2c17..53ccf5a75 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -167,6 +167,10 @@ function makeTestInstance(input: MakeInstanceInput) { handleControlForSource: (_sourceId, message) => (input.desktopTelemetryPublisher?.handleControl ?? (() => Effect.void))(message), removeControlSource: () => Effect.void, + publishUpdateReport: () => Effect.void, + updateRequests: Stream.empty, + updateCommits: Stream.empty, + updateCancellations: Stream.empty, ...input.desktopTelemetryPublisher, }), DesktopWslEnvironment.layerTest( diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 97d4359e1..5fe02c8d1 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -73,6 +73,10 @@ function makePoolLayer( handleControl: () => Effect.void, handleControlForSource: () => Effect.void, removeControlSource: () => Effect.void, + publishUpdateReport: () => Effect.void, + updateRequests: Stream.empty, + updateCommits: Stream.empty, + updateCancellations: Stream.empty, }), Layer.succeed(DesktopBackendConfiguration.DesktopBackendConfiguration, { resolvePrimary: Effect.die("unexpected primary config resolve"), diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index fa60f7131..94c8483e9 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -157,8 +157,11 @@ describe("DesktopTelemetryPublisher", () => { decodeMessage(decoder.decode(bytes).trim()), ); - assert.equal(messages[0]?.type, "desktopTelemetryHello"); - assert.equal(messages[0]?.electronPid, process.pid); + const hello = messages[0]; + if (hello?.type !== "desktopTelemetryHello") { + return assert.fail("Expected the first telemetry message to be the hello."); + } + assert.equal(hello.electronPid, process.pid); const initialSnapshot = messages[1]; if (initialSnapshot?.type !== "desktopTelemetry") { return assert.fail("Expected the second telemetry message to be a snapshot."); @@ -386,4 +389,81 @@ describe("DesktopTelemetryPublisher", () => { }).pipe(Effect.provide(layer)); }), ); + + it.effect("routes requestDesktopUpdate control messages and replays update reports", () => + Effect.gen(function* () { + const powerLayer = Layer.succeed( + ElectronPowerMonitor.ElectronPowerMonitor, + ElectronPowerMonitor.ElectronPowerMonitor.of({ + isOnBatteryPower: Effect.succeed(false), + getSystemIdleTime: Effect.succeed(0), + getSystemIdleState: () => Effect.succeed("active"), + getCurrentThermalState: Effect.succeed("nominal"), + onSimpleEvent: () => Effect.void, + onThermalStateChange: () => Effect.void, + onSpeedLimitChange: () => Effect.void, + }), + ); + const layer = DesktopTelemetryPublisher.layer.pipe( + Layer.provide(Layer.mergeAll(makeElectronAppLayer([]), powerLayer)), + ); + + yield* Effect.gen(function* () { + const publisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher; + + const requestFiber = yield* Stream.runHead(publisher.updateRequests).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* publisher.handleControlForSource("test", { + version: 1, + type: "requestDesktopUpdate", + requestId: "req-9", + }); + const received = yield* Fiber.join(requestFiber); + assert.equal(Option.getOrThrow(received).requestId, "req-9"); + + const report = { + version: 1, + type: "desktopUpdateStatus", + outcome: "up-to-date", + state: { + enabled: true, + status: "up-to-date", + channel: "latest", + currentVersion: "1.2.3", + hostArch: "arm64", + appArch: "arm64", + runningUnderArm64Translation: false, + availableVersion: null, + downloadedVersion: null, + releaseNotes: [], + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, + omittedReleaseCount: 0, + }, + } as const; + yield* publisher.publishUpdateReport(report); + + // A subscriber that attaches after the publish (the backend spawned + // by a relaunch) still sees the latest report replayed. + const decoder = new TextDecoder(); + const decodeMessage = Schema.decodeUnknownEffect( + Schema.fromJsonString(DesktopHostTelemetryMessage), + ); + const replayed = yield* publisher.encoded.pipe( + Stream.mapEffect((bytes) => decodeMessage(decoder.decode(bytes).trim())), + Stream.filter((message) => message.type === "desktopUpdateStatus"), + Stream.runHead, + ); + const replayedReport = Option.getOrThrow(replayed); + if (replayedReport.type !== "desktopUpdateStatus") { + return assert.fail("Expected a desktop update status report."); + } + assert.equal(replayedReport.outcome, "up-to-date"); + assert.equal(replayedReport.state.currentVersion, "1.2.3"); + }).pipe(Effect.provide(layer)); + }), + ); }); diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts index 9c17ae514..3f56ace15 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts @@ -2,6 +2,10 @@ import { DesktopHostTelemetryMessage, type DesktopHostTelemetrySnapshot, type DesktopTelemetryControlMessage, + type DesktopTelemetryCancelDesktopUpdate, + type DesktopTelemetryCommitDesktopUpdate, + type DesktopTelemetryRequestDesktopUpdate, + type DesktopUpdateStatusReport, type HostPowerSnapshot, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -65,6 +69,14 @@ export class DesktopTelemetryPublisher extends Context.Service< message: DesktopTelemetryControlMessage, ) => Effect.Effect; readonly removeControlSource: (sourceId: string) => Effect.Effect; + /** Sends the report to the attached backend and replays the latest one + to backends that attach later (including the one spawned after a + relaunch). */ + readonly publishUpdateReport: (report: DesktopUpdateStatusReport) => Effect.Effect; + /** Update requests received over the control channel. Single consumer. */ + readonly updateRequests: Stream.Stream; + readonly updateCommits: Stream.Stream; + readonly updateCancellations: Stream.Stream; } >()("@t3tools/desktop/telemetry/DesktopTelemetryPublisher") {} @@ -160,6 +172,11 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { const latest = yield* Ref.make(Option.none()); const changes = yield* PubSub.sliding(8); const sequence = yield* Ref.make(0); + const latestUpdateReport = yield* Ref.make(Option.none()); + const updateReportChanges = yield* PubSub.sliding(16); + const updateRequestQueue = yield* Queue.unbounded(); + const updateCommitQueue = yield* Queue.unbounded(); + const updateCancellationQueue = yield* Queue.unbounded(); const offer = (event: PowerEvent): void => { Queue.offerUnsafe(powerEvents, event); @@ -324,6 +341,12 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { active: Duration.millis(message.activeIntervalMs), idle: Duration.millis(message.idleIntervalMs), }).pipe(Effect.andThen(Queue.offer(sampleTriggers, undefined)), Effect.asVoid); + case "requestDesktopUpdate": + return Queue.offer(updateRequestQueue, message).pipe(Effect.asVoid); + case "commitDesktopUpdate": + return Queue.offer(updateCommitQueue, message).pipe(Effect.asVoid); + case "cancelDesktopUpdate": + return Queue.offer(updateCancellationQueue, message).pipe(Effect.asVoid); } }; const removeControlSource: DesktopTelemetryPublisher["Service"]["removeControlSource"] = ( @@ -357,15 +380,36 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { ); }), ); + const updateReports = Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(updateReportChanges); + const initial = yield* Ref.get(latestUpdateReport); + return Stream.concat( + Option.match(initial, { + onNone: () => Stream.empty, + onSome: Stream.make, + }), + Stream.fromSubscription(subscription), + ); + }), + ); const encoded = Stream.concat( Stream.make({ version: 1, type: "desktopTelemetryHello", electronPid: process.pid, } as const), - snapshots, + Stream.merge(snapshots, updateReports), ).pipe(Stream.map((message) => textEncoder.encode(`${encodeMessage(message)}\n`))); + const publishUpdateReport: DesktopTelemetryPublisher["Service"]["publishUpdateReport"] = ( + report, + ) => + Ref.set(latestUpdateReport, Option.some(report)).pipe( + Effect.andThen(PubSub.publish(updateReportChanges, report)), + Effect.asVoid, + ); + return DesktopTelemetryPublisher.of({ latest: Ref.get(latest), changes: Stream.fromPubSub(changes), @@ -373,6 +417,10 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { handleControl, handleControlForSource, removeControlSource, + publishUpdateReport, + updateRequests: Stream.fromQueue(updateRequestQueue), + updateCommits: Stream.fromQueue(updateCommitQueue), + updateCancellations: Stream.fromQueue(updateCancellationQueue), }); }); diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts new file mode 100644 index 000000000..0f4cb970d --- /dev/null +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts @@ -0,0 +1,842 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { + DesktopTelemetryRequestDesktopUpdate, + DesktopTelemetryCommitDesktopUpdate, + DesktopTelemetryCancelDesktopUpdate, + DesktopUpdateStatusReport, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; +import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; +import * as DesktopRemoteUpdates from "./DesktopRemoteUpdates.ts"; +import * as DesktopUpdates from "./DesktopUpdates.ts"; +import { makeHarness } from "./updatesTestHarness.ts"; + +// The remote flow hops between the test runtime's fibers and the updater's +// runPromise-driven event handlers, so settling needs real microtask turns, +// not just fiber yields. +const settle = Effect.gen(function* () { + for (let i = 0; i < 20; i += 1) { + yield* Effect.yieldNow; + yield* Effect.promise(() => Promise.resolve()); + } +}); + +const request = (requestId: string): DesktopTelemetryRequestDesktopUpdate => ({ + version: 1, + type: "requestDesktopUpdate", + requestId, +}); + +function runRemoteUpdatesTest( + harness: ReturnType, + body: (context: { + readonly reports: DesktopUpdateStatusReport[]; + readonly requests: Queue.Queue; + readonly commits: Queue.Queue; + readonly cancellations: Queue.Queue; + }) => Effect.Effect, +) { + return Effect.scoped( + Effect.gen(function* () { + const requests = yield* Queue.unbounded(); + const commits = yield* Queue.unbounded(); + const cancellations = yield* Queue.unbounded(); + const reports: DesktopUpdateStatusReport[] = []; + const publisher = DesktopTelemetryPublisher.DesktopTelemetryPublisher.of({ + latest: Effect.succeedNone, + changes: Stream.empty, + encoded: Stream.empty, + handleControl: () => Effect.void, + handleControlForSource: () => Effect.void, + removeControlSource: () => Effect.void, + publishUpdateReport: (report) => + Effect.sync(() => { + reports.push(report); + }), + updateRequests: Stream.fromQueue(requests), + updateCommits: Stream.fromQueue(commits), + updateCancellations: Stream.fromQueue(cancellations), + }); + + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + yield* DesktopRemoteUpdates.listen.pipe( + Effect.provideService(DesktopTelemetryPublisher.DesktopTelemetryPublisher, publisher), + ); + yield* settle; + yield* body({ reports, requests, commits, cancellations }); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); +} + +function terminalReports(reports: DesktopUpdateStatusReport[]): DesktopUpdateStatusReport[] { + return reports.filter((report) => report.outcome !== undefined); +} + +describe("DesktopRemoteUpdates", () => { + it.effect("drives check, download, and install with no local confirmation", () => { + const harness = makeHarness(); + + return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-1")); + yield* settle; + assert.equal(harness.checkCount(), 1); + + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + + const terminals = terminalReports(reports); + assert.equal(terminals.length, 1); + assert.equal(terminals[0]?.outcome, "ready-to-install"); + assert.equal(terminals[0]?.requestId, "req-1"); + assert.equal(harness.quitAndInstalls(), 0); + + yield* Queue.offer(commits, { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-1", + }); + yield* settle; + assert.equal(harness.quitAndInstalls(), 1); + + // The mirror stamped the in-run state changes with the request id. + const statuses = reports + .filter((report) => report.requestId === "req-1") + .map((report) => report.state.status); + assert.include(statuses, "available"); + assert.include(statuses, "downloaded"); + }), + ); + }); + + it.effect("reports a failed outcome when quitAndInstall fails", () => { + const harness = makeHarness({ + quitAndInstall: Effect.fail( + new ElectronUpdater.ElectronUpdaterQuitAndInstallError({ + channel: "latest", + isSilent: true, + isForceRunAfter: true, + cause: new Error("spawn failed"), + }), + ), + }); + + return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-4")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + + yield* Queue.offer(commits, { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-4", + }); + yield* settle; + + // Install failures reduce to status "downloaded" + errorContext + // "install"; the prepared result is followed by the commit failure. + const terminals = terminalReports(reports); + assert.deepEqual( + terminals.map((report) => report.outcome), + ["ready-to-install", "failed"], + ); + assert.equal(terminals[1]?.state.errorContext, "install"); + }), + ); + }); + + it.effect("joins an in-progress install on commit instead of failing the token", () => { + const installStarted = Deferred.makeUnsafe(); + const releaseInstall = Deferred.makeUnsafe(); + const harness = makeHarness({ + stopBackend: Deferred.succeed(installStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseInstall)), + ), + }); + + return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* Queue.offer(requests, request("req-join-commit")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + assert.deepEqual( + terminalReports(reports).map((report) => report.outcome), + ["ready-to-install"], + ); + + // A local install takes the updater reservation and starts shutdown. + const localInstall = yield* updates.install.pipe(Effect.forkChild); + yield* Deferred.await(installStarted); + yield* Queue.offer(commits, { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-join-commit", + }); + yield* settle; + + // No "failed" marker: that install relaunches the app and the + // client proves the handoff by reconnecting on the target version. + assert.deepEqual( + terminalReports(reports).map((report) => report.outcome), + ["ready-to-install"], + ); + assert.equal(harness.quitAndInstalls(), 0); + + yield* Deferred.succeed(releaseInstall, undefined); + yield* Fiber.join(localInstall); + }), + ); + }); + + it.effect("does not join a normal app quit as an update install", () => { + const harness = makeHarness(); + + return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) => + Effect.gen(function* () { + const desktopState = yield* DesktopState.DesktopState; + yield* Queue.offer(requests, request("req-normal-quit")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + + yield* Ref.set(desktopState.quitting, true); + yield* Queue.offer(commits, { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-normal-quit", + }); + yield* settle; + + assert.deepEqual( + terminalReports(reports).map((report) => report.outcome), + ["ready-to-install", "failed"], + ); + assert.equal(harness.quitAndInstalls(), 0); + }), + ); + }); + + it.effect("does not misread a lingering install error as a failed retry", () => { + let installAttempts = 0; + const harness = makeHarness({ + quitAndInstall: Effect.suspend(() => { + installAttempts += 1; + return installAttempts === 1 + ? Effect.fail( + new ElectronUpdater.ElectronUpdaterQuitAndInstallError({ + channel: "latest", + isSilent: true, + isForceRunAfter: true, + cause: new Error("first attempt failed"), + }), + ) + : Effect.void; + }), + }); + + return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-5")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + yield* Queue.offer(commits, { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-5", + }); + yield* settle; + // First run failed; state is "downloaded" with a lingering + // errorContext "install". The retry succeeds and must not report + // that leftover as a fresh failure. + yield* Queue.offer(requests, request("req-6")); + yield* settle; + yield* Queue.offer(commits, { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-6", + }); + yield* settle; + + const retryTerminals = terminalReports(reports).filter( + (report) => report.requestId === "req-6", + ); + assert.deepEqual( + retryTerminals.map((report) => report.outcome), + ["ready-to-install"], + ); + assert.equal(harness.quitAndInstalls(), 2); + }), + ); + }); + + it.effect("ignores an unrelated updater event while an install is starting", () => { + const stopStarted = Deferred.makeUnsafe(); + const releaseStop = Deferred.makeUnsafe(); + const harness = makeHarness({ + stopBackend: Deferred.succeed(stopStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseStop)), + ), + }); + + return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-late-event")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + + yield* Queue.offer(commits, { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-late-event", + }); + yield* Deferred.await(stopStarted); + harness.emit("download-progress", { percent: 90 }); + yield* settle; + yield* Deferred.succeed(releaseStop, undefined); + yield* settle; + + assert.deepEqual( + terminalReports(reports) + .filter((report) => report.requestId === "req-late-event") + .map((report) => report.outcome), + ["ready-to-install"], + ); + }), + ); + }); + + it.effect("retries a download refused while the check still holds the reservation", () => { + // electron-updater emits update-available from inside checkForUpdates, + // before the check action releases its reservation. The download the + // remote flow forks in response is refused and must be retried once the + // reservation frees up, without burning a download attempt. + const releaseCheck = Deferred.makeUnsafe(); + const harness = makeHarness({ checkForUpdates: Deferred.await(releaseCheck) }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-8")); + yield* settle; + assert.equal(harness.checkCount(), 1); + + // Fire "available" while the check reservation is still held. + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + assert.equal(harness.downloadCount(), 0); + + yield* Deferred.succeed(releaseCheck, undefined); + yield* settle; + yield* TestClock.adjust(Duration.millis(300)); + yield* settle; + assert.equal(harness.downloadCount(), 1); + + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + assert.deepEqual( + terminalReports(reports).map((report) => report.outcome), + ["ready-to-install"], + ); + assert.equal(harness.quitAndInstalls(), 0); + }), + ); + }); + + it.effect("waits for the download reservation before reporting prepared", () => { + // update-downloaded fires from inside downloadUpdate. If the flow + // reported "installing" right then, install would be refused for the + // held reservation after the irrevocable terminal already went out. + const releaseDownload = Deferred.makeUnsafe(); + const harness = makeHarness({ downloadUpdate: Deferred.await(releaseDownload) }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-10")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + assert.deepEqual(terminalReports(reports), []); + assert.equal(harness.quitAndInstalls(), 0); + + yield* Deferred.succeed(releaseDownload, undefined); + yield* settle; + yield* TestClock.adjust(Duration.millis(300)); + yield* settle; + assert.deepEqual( + terminalReports(reports).map((report) => report.outcome), + ["ready-to-install"], + ); + assert.equal(harness.quitAndInstalls(), 0); + }), + ); + }); + + it.effect("joins an install that is already tearing the app down", () => { + const harness = makeHarness(); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + const desktopState = yield* DesktopState.DesktopState; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + // Another install already owns the shutdown. + yield* Ref.set(desktopState.quitting, true); + + yield* Queue.offer(requests, request("req-9")); + yield* settle; + + // Report "installing" once, no "failed" after the refusal: that + // install will relaunch the app and this request rides along. + assert.deepEqual( + terminalReports(reports).map((report) => report.outcome), + ["ready-to-install"], + ); + assert.equal(harness.quitAndInstalls(), 0); + }), + ); + }); + + it.effect("does not run the installer twice for a repeated commit", () => { + const harness = makeHarness(); + + return runRemoteUpdatesTest(harness, ({ requests, commits }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-repeat")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + + const commit = { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-repeat", + } as const; + yield* Queue.offer(commits, commit); + yield* settle; + yield* Queue.offer(commits, commit); + yield* settle; + + assert.equal(harness.quitAndInstalls(), 1); + }), + ); + }); + + it.effect("waits for a background check before installing a prepared update", () => { + const backgroundCheckStarted = Deferred.makeUnsafe(); + const releaseBackgroundCheck = Deferred.makeUnsafe(); + let checks = 0; + const harness = makeHarness({ + checkForUpdates: Effect.suspend(() => { + checks += 1; + return checks === 2 + ? Deferred.succeed(backgroundCheckStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseBackgroundCheck)), + ) + : Effect.void; + }), + }); + + return runRemoteUpdatesTest(harness, ({ requests, commits }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* Queue.offer(requests, request("req-background-check")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + + const backgroundCheck = yield* updates.check("poll").pipe(Effect.forkChild); + yield* Deferred.await(backgroundCheckStarted); + assert.equal((yield* updates.getState).status, "checking"); + + yield* Queue.offer(commits, { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-background-check", + }); + yield* settle; + assert.equal(harness.quitAndInstalls(), 0); + + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* Deferred.succeed(releaseBackgroundCheck, undefined); + yield* Fiber.join(backgroundCheck); + yield* settle; + assert.equal(harness.quitAndInstalls(), 1); + }), + ); + }); + + it.effect("fails a prepared install when its background check stays blocked", () => { + const backgroundCheckStarted = Deferred.makeUnsafe(); + const releaseBackgroundCheck = Deferred.makeUnsafe(); + let checks = 0; + const harness = makeHarness({ + checkForUpdates: Effect.suspend(() => { + checks += 1; + return checks === 2 + ? Deferred.succeed(backgroundCheckStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseBackgroundCheck)), + ) + : Effect.void; + }), + }); + + return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) => + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* Queue.offer(requests, request("req-blocked-background-check")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + + const backgroundCheck = yield* updates.check("poll").pipe(Effect.forkChild); + yield* Deferred.await(backgroundCheckStarted); + const commit = { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-blocked-background-check", + } as const; + yield* Queue.offer(commits, commit); + yield* settle; + yield* TestClock.adjust(Duration.seconds(91)); + yield* settle; + + assert.equal(harness.quitAndInstalls(), 0); + assert.deepEqual( + terminalReports(reports) + .filter((report) => report.requestId === "req-blocked-background-check") + .map((report) => report.outcome), + ["ready-to-install", "failed"], + ); + + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* Deferred.succeed(releaseBackgroundCheck, undefined); + yield* Fiber.join(backgroundCheck); + yield* settle; + assert.equal(harness.quitAndInstalls(), 0); + + yield* Queue.offer(commits, commit); + yield* settle; + assert.deepEqual( + terminalReports(reports) + .filter((report) => report.requestId === "req-blocked-background-check") + .map((report) => report.outcome), + ["ready-to-install", "failed", "failed"], + ); + }), + ); + }); + + it.effect("rejects a new preparation while an install commit is active", () => { + const installStarted = Deferred.makeUnsafe(); + const releaseInstall = Deferred.makeUnsafe(); + const harness = makeHarness({ + quitAndInstall: Deferred.succeed(installStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseInstall)), + Effect.andThen( + Effect.fail( + new ElectronUpdater.ElectronUpdaterQuitAndInstallError({ + channel: "latest", + isSilent: true, + isForceRunAfter: true, + cause: new Error("installer refused"), + }), + ), + ), + ), + }); + + return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-active")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + yield* Queue.offer(commits, { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-active", + }); + yield* Deferred.await(installStarted); + + yield* Queue.offer(requests, request("req-overlap")); + yield* settle; + const overlap = terminalReports(reports).find( + (report) => report.requestId === "req-overlap", + ); + assert.equal(overlap?.outcome, "failed"); + assert.equal(overlap?.reason, "A prepared desktop update is already in progress."); + + yield* Deferred.succeed(releaseInstall, undefined); + yield* settle; + }), + ); + }); + + it.effect("cancels an active preparation so the next request can run", () => { + const releaseCheck = Deferred.makeUnsafe(); + const harness = makeHarness({ checkForUpdates: Deferred.await(releaseCheck) }); + + return runRemoteUpdatesTest(harness, ({ requests, cancellations }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-cancel")); + yield* settle; + yield* Queue.offer(cancellations, { + version: 1, + type: "cancelDesktopUpdate", + requestId: "req-cancel", + }); + yield* settle; + yield* Deferred.succeed(releaseCheck, undefined); + yield* Queue.offer(requests, request("req-next")); + yield* settle; + + assert.equal(harness.checkCount(), 2); + }), + ); + }); + + it.effect("remembers a cancellation that arrives before its request starts", () => { + const harness = makeHarness(); + + return runRemoteUpdatesTest(harness, ({ requests, cancellations }) => + Effect.gen(function* () { + yield* Queue.offer(cancellations, { + version: 1, + type: "cancelDesktopUpdate", + requestId: "req-early-cancel", + }); + yield* Queue.offer(requests, request("req-early-cancel")); + yield* settle; + + assert.equal(harness.checkCount(), 0); + + yield* Queue.offer(requests, request("req-after-early-cancel")); + yield* settle; + assert.equal(harness.checkCount(), 1); + }), + ); + }); + + it.effect("keeps every queued cancellation until its request starts", () => { + const harness = makeHarness(); + + return runRemoteUpdatesTest(harness, ({ requests, cancellations }) => + Effect.gen(function* () { + for (let index = 0; index < 40; index += 1) { + yield* Queue.offer(cancellations, { + version: 1, + type: "cancelDesktopUpdate", + requestId: `req-queued-cancel-${index}`, + }); + } + yield* settle; + for (let index = 0; index < 40; index += 1) { + yield* Queue.offer(requests, request(`req-queued-cancel-${index}`)); + } + yield* settle; + assert.equal(harness.checkCount(), 0); + + yield* Queue.offer(requests, request("req-after-queued-cancels")); + yield* settle; + assert.equal(harness.checkCount(), 1); + }), + ); + }); + + it.effect("does not install after cancellation wins the commit claim", () => { + const harness = makeHarness(); + + return runRemoteUpdatesTest(harness, ({ reports, requests, commits, cancellations }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-cancel-before-commit")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + + yield* Queue.offer(cancellations, { + version: 1, + type: "cancelDesktopUpdate", + requestId: "req-cancel-before-commit", + }); + yield* settle; + yield* Queue.offer(commits, { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-cancel-before-commit", + }); + yield* settle; + + assert.equal(harness.quitAndInstalls(), 0); + assert.deepEqual( + terminalReports(reports) + .filter((report) => report.requestId === "req-cancel-before-commit") + .map((report) => report.outcome), + ["ready-to-install", "failed"], + ); + }), + ); + }); + + it.effect("retains an install failure after cancellation loses the commit claim", () => { + const installStarted = Deferred.makeUnsafe(); + const releaseInstall = Deferred.makeUnsafe(); + const harness = makeHarness({ + quitAndInstall: Deferred.succeed(installStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseInstall)), + Effect.andThen( + Effect.fail( + new ElectronUpdater.ElectronUpdaterQuitAndInstallError({ + channel: "latest", + isSilent: true, + isForceRunAfter: true, + cause: new Error("installer refused"), + }), + ), + ), + ), + }); + + return runRemoteUpdatesTest(harness, ({ reports, requests, commits, cancellations }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-cancel-after-commit")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + const commit = { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-cancel-after-commit", + } as const; + yield* Queue.offer(commits, commit); + yield* Deferred.await(installStarted); + yield* Queue.offer(cancellations, { + version: 1, + type: "cancelDesktopUpdate", + requestId: "req-cancel-after-commit", + }); + yield* settle; + yield* Deferred.succeed(releaseInstall, undefined); + yield* settle; + yield* Queue.offer(commits, commit); + yield* settle; + + assert.deepEqual( + terminalReports(reports) + .filter((report) => report.requestId === "req-cancel-after-commit") + .map((report) => report.outcome), + ["ready-to-install", "failed", "failed"], + ); + }), + ); + }); + + it.effect("expires an uncommitted preparation", () => { + const harness = makeHarness(); + + return runRemoteUpdatesTest(harness, ({ reports, requests, commits }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-stale")); + yield* settle; + harness.emit("update-available", { version: "1.2.4" }); + yield* settle; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* settle; + + yield* TestClock.setTime(Duration.toMillis(Duration.minutes(6))); + yield* Queue.offer(commits, { + version: 1, + type: "commitDesktopUpdate", + requestId: "req-stale", + }); + yield* settle; + + const outcomes = terminalReports(reports) + .filter((report) => report.requestId === "req-stale") + .map((report) => report.outcome); + assert.deepEqual(outcomes, ["ready-to-install", "failed"]); + }), + ); + }); + + it.effect("reports up-to-date without installing when there is no update", () => { + const harness = makeHarness(); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-2")); + yield* settle; + harness.emit("update-not-available"); + yield* settle; + + const terminals = terminalReports(reports); + assert.equal(terminals.length, 1); + assert.equal(terminals[0]?.outcome, "up-to-date"); + assert.equal(terminals[0]?.requestId, "req-2"); + assert.equal(harness.quitAndInstalls(), 0); + }), + ); + }); + + it.effect("fails fast with the disabled reason when updates are off", () => { + const harness = makeHarness({ env: { T3CODE_DISABLE_AUTO_UPDATE: "true" } }); + + return runRemoteUpdatesTest(harness, ({ reports, requests }) => + Effect.gen(function* () { + yield* Queue.offer(requests, request("req-3")); + yield* settle; + + const terminals = terminalReports(reports); + assert.equal(terminals.length, 1); + assert.equal(terminals[0]?.outcome, "failed"); + assert.equal( + terminals[0]?.reason, + "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting.", + ); + assert.equal(harness.quitAndInstalls(), 0); + }), + ); + }); +}); diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.ts new file mode 100644 index 000000000..31e3c5af5 --- /dev/null +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.ts @@ -0,0 +1,461 @@ +import type { + DesktopTelemetryRequestDesktopUpdate, + DesktopUpdateRemoteOutcome, + DesktopUpdateState, +} from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Clock from "effect/Clock"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import * as DesktopObservability from "../app/DesktopObservability.ts"; +import * as DesktopTelemetryPublisher from "../telemetry/DesktopTelemetryPublisher.ts"; +import * as DesktopUpdates from "./DesktopUpdates.ts"; +import { + nextRemoteDesktopUpdateStep, + normalizeRemoteUpdateReason, + type RemoteDesktopUpdateAttempts, +} from "./remoteUpdateFlow.ts"; + +const { logInfo, logError } = DesktopObservability.makeComponentLogger("desktop-remote-updates"); + +/** Pause before retrying an action the updater refused for a held reservation. */ +const ACTION_RETRY_DELAY = Duration.millis(250); +const PREPARED_UPDATE_TTL = Duration.minutes(5); + +interface PreparedUpdate { + readonly requestId: string; + readonly downloadedVersion: string; + readonly status: "prepared" | "committing" | "failed"; + readonly failureReason?: string; + readonly expiresAt: number; +} + +type CommitClaim = + | { readonly _tag: "invalid" } + | { readonly _tag: "failed"; readonly prepared: PreparedUpdate } + | { readonly _tag: "committing" } + | { readonly _tag: "claimed"; readonly prepared: PreparedUpdate }; + +/** + * Server-triggered desktop updates. Mirrors updater state, prepares downloads, + * cancels abandoned preparations, and commits installs after the remote client + * confirms that it received the preparation token. + */ +export const listen: Effect.Effect< + void, + never, + DesktopUpdates.DesktopUpdates | DesktopTelemetryPublisher.DesktopTelemetryPublisher | Scope.Scope +> = Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + const publisher = yield* DesktopTelemetryPublisher.DesktopTelemetryPublisher; + const activeRequestIdRef = yield* Ref.make(Option.none()); + const requestControlRef = yield* Ref.make<{ + readonly active: Option.Option<{ + readonly requestId: string; + readonly signal: Deferred.Deferred; + }>; + readonly cancelledBeforeStart: ReadonlyArray; + }>({ active: Option.none(), cancelledBeforeStart: [] }); + const preparedUpdateRef = yield* Ref.make(Option.none()); + + const publishReport = ( + state: DesktopUpdateState, + terminal?: { readonly outcome: DesktopUpdateRemoteOutcome; readonly reason?: string }, + explicitRequestId?: string, + ): Effect.Effect => { + const reason = normalizeRemoteUpdateReason(terminal?.reason); + return Ref.get(activeRequestIdRef).pipe( + Effect.flatMap((requestId) => + publisher.publishUpdateReport({ + version: 1, + type: "desktopUpdateStatus", + ...(explicitRequestId !== undefined + ? { requestId: explicitRequestId } + : Option.isSome(requestId) + ? { requestId: requestId.value } + : {}), + ...(terminal === undefined ? {} : { outcome: terminal.outcome }), + ...(reason ? { reason } : {}), + state, + }), + ), + ); + }; + + const recordPreparedFailure = (requestId: string, reason: string) => + Ref.modify(preparedUpdateRef, (prepared) => { + if ( + Option.isNone(prepared) || + prepared.value.requestId !== requestId || + prepared.value.status === "failed" + ) { + return [false, prepared] as const; + } + return [ + true, + Option.some({ ...prepared.value, status: "failed" as const, failureReason: reason }), + ] as const; + }); + + const clearActiveRequest = (requestId: string) => + Ref.update(activeRequestIdRef, (active) => + Option.isSome(active) && active.value === requestId ? Option.none() : active, + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const { latest, changes } = yield* updates.subscribe; + yield* publishReport(latest); + yield* Stream.runForEach(changes, (state) => + Effect.gen(function* () { + yield* publishReport(state); + const prepared = yield* Ref.get(preparedUpdateRef); + if ( + Option.isSome(prepared) && + prepared.value.status === "committing" && + state.errorContext === "install" + ) { + const reason = state.message ?? "The desktop app failed to install the update."; + if (yield* recordPreparedFailure(prepared.value.requestId, reason)) { + yield* publishReport(state, { outcome: "failed", reason }, prepared.value.requestId); + } + } + }), + ); + }), + ).pipe(Effect.forkScoped); + + const handleRequest = (request: DesktopTelemetryRequestDesktopUpdate): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + const cancellation = yield* Deferred.make(); + const shouldStart = yield* Ref.modify(requestControlRef, (control) => { + if (control.cancelledBeforeStart.includes(request.requestId)) { + return [ + false, + { + ...control, + cancelledBeforeStart: control.cancelledBeforeStart.filter( + (requestId) => requestId !== request.requestId, + ), + }, + ] as const; + } + return [ + true, + { + ...control, + active: Option.some({ requestId: request.requestId, signal: cancellation }), + }, + ] as const; + }); + if (!shouldStart) return; + + const now = yield* Clock.currentTimeMillis; + const prepared = yield* Ref.modify(preparedUpdateRef, (current) => + Option.isSome(current) && + current.value.status === "prepared" && + current.value.expiresAt <= now + ? ([Option.none(), Option.none()] as const) + : ([current, current] as const), + ); + if (Option.isSome(prepared) && prepared.value.status !== "failed") { + yield* publishReport( + yield* updates.getState, + { + outcome: "failed", + reason: "A prepared desktop update is already in progress.", + }, + request.requestId, + ); + return; + } + yield* Ref.set(activeRequestIdRef, Option.some(request.requestId)); + yield* logInfo("remote update requested", { requestId: request.requestId }); + const { latest, changes } = yield* updates.subscribe; + const disabledReason = Option.getOrNull(yield* updates.disabledReason); + let attempts: RemoteDesktopUpdateAttempts = { checks: 0, downloads: 0 }; + // The updater admits one action at a time. A state event can land + // while the action that produced it still holds the reservation + // (e.g. "available" before the check releases), so a forked action + // can be refused with no later state event to retry on. Rejected + // actions re-enqueue their state here after a short pause so the + // step runs again once the reservation is free. + const retries = yield* Queue.unbounded(); + const retryLater = (state: DesktopUpdateState) => + Effect.sleep(ACTION_RETRY_DELAY).pipe( + Effect.andThen(Queue.offer(retries, state)), + Effect.asVoid, + Effect.forkScoped, + ); + + // Returns true when the run reached a terminal outcome. + const step = (state: DesktopUpdateState): Effect.Effect => + Effect.gen(function* () { + const next = nextRemoteDesktopUpdateStep(state, attempts, disabledReason); + switch (next.action) { + case "wait": + return false; + // Counters increment before the action so the state event the + // action produces already sees it; a refusal for a held + // reservation rolls the count back, since it was not a try. + case "check": + attempts = { ...attempts, checks: attempts.checks + 1 }; + yield* updates.check("remote-update").pipe( + Effect.flatMap((result) => { + if (result.checked) return Effect.void; + attempts = { ...attempts, checks: attempts.checks - 1 }; + return retryLater(state); + }), + Effect.forkScoped, + ); + return false; + case "download": + attempts = { ...attempts, downloads: attempts.downloads + 1 }; + yield* updates.download.pipe( + Effect.flatMap((result) => { + if (result.accepted) return Effect.void; + attempts = { ...attempts, downloads: attempts.downloads - 1 }; + return retryLater(state); + }), + Effect.forkScoped, + ); + return false; + case "install": { + // The download event fires before its action releases the + // updater reservation. Wait until the prepared install can + // be committed by the client in a separate RPC. + if (yield* updates.isActionActive) { + yield* retryLater(state); + return false; + } + if (state.downloadedVersion === null) { + yield* publishReport( + state, + { + outcome: "failed", + reason: "The desktop app lost the downloaded update.", + }, + request.requestId, + ); + return true; + } + yield* Ref.set( + preparedUpdateRef, + Option.some({ + requestId: request.requestId, + downloadedVersion: state.downloadedVersion, + status: "prepared", + expiresAt: + (yield* Clock.currentTimeMillis) + Duration.toMillis(PREPARED_UPDATE_TTL), + }), + ); + yield* publishReport(state, { outcome: "ready-to-install" }, request.requestId); + yield* logInfo("remote update prepared", { requestId: request.requestId }); + return true; + } + case "done": + yield* publishReport( + state, + { + outcome: next.outcome, + ...(next.reason === undefined ? {} : { reason: next.reason }), + }, + request.requestId, + ); + yield* logInfo("remote update finished", { + requestId: request.requestId, + outcome: next.outcome, + reason: next.reason ?? null, + }); + return true; + } + }); + + yield* Effect.raceFirst( + Effect.gen(function* () { + if (yield* step(latest)) return; + yield* Stream.merge(changes, Stream.fromQueue(retries)).pipe( + Stream.mapEffect(step), + Stream.takeUntil((done) => done), + Stream.runDrain, + ); + }), + Deferred.await(cancellation), + ); + }), + ).pipe( + Effect.ensuring( + Effect.all( + [ + clearActiveRequest(request.requestId), + Ref.update(requestControlRef, (control) => ({ + ...control, + active: + Option.isSome(control.active) && + control.active.value.requestId === request.requestId + ? Option.none() + : control.active, + })), + ], + { discard: true }, + ), + ), + Effect.catchCause((cause) => + logError("remote update request failed unexpectedly", { + requestId: request.requestId, + cause: String(cause), + }), + ), + ); + + // Sequential by construction: a second remote request queued mid-run is + // handled after the current one, when the state machine resolves it fast. + yield* Stream.runForEach(publisher.updateRequests, handleRequest).pipe(Effect.forkScoped); + + yield* Stream.runForEach(publisher.updateCancellations, (cancellation) => + Effect.gen(function* () { + const activeSignal = yield* Ref.modify(requestControlRef, (control) => { + if ( + Option.isSome(control.active) && + control.active.value.requestId === cancellation.requestId + ) { + return [Option.some(control.active.value.signal), control] as const; + } + return [ + Option.none>(), + { + ...control, + cancelledBeforeStart: [ + ...control.cancelledBeforeStart.filter( + (requestId) => requestId !== cancellation.requestId, + ), + cancellation.requestId, + ], + }, + ] as const; + }); + if (Option.isSome(activeSignal)) { + yield* Deferred.succeed(activeSignal.value, undefined); + } + const matchedPrepared = yield* Ref.modify(preparedUpdateRef, (prepared) => { + if (Option.isNone(prepared) || prepared.value.requestId !== cancellation.requestId) { + return [false, prepared] as const; + } + return [true, prepared.value.status === "prepared" ? Option.none() : prepared] as const; + }); + if (matchedPrepared) { + yield* Ref.update(requestControlRef, (control) => { + return { + ...control, + cancelledBeforeStart: control.cancelledBeforeStart.filter( + (requestId) => requestId !== cancellation.requestId, + ), + }; + }); + } + }), + ).pipe(Effect.forkScoped); + + yield* Stream.runForEach(publisher.updateCommits, (commit) => + Effect.gen(function* () { + const current = yield* updates.getState; + const now = yield* Clock.currentTimeMillis; + const claim = yield* Ref.modify( + preparedUpdateRef, + (prepared): readonly [CommitClaim, Option.Option] => { + if ( + Option.isNone(prepared) || + prepared.value.requestId !== commit.requestId || + (prepared.value.status === "prepared" && prepared.value.expiresAt <= now) + ) { + return [{ _tag: "invalid" as const }, prepared] as const; + } + if (prepared.value.status === "failed") { + return [{ _tag: "failed" as const, prepared: prepared.value }, prepared] as const; + } + if (prepared.value.status === "committing") { + return [{ _tag: "committing" as const }, prepared] as const; + } + return [ + { _tag: "claimed" as const, prepared: prepared.value }, + Option.some({ ...prepared.value, status: "committing" as const }), + ] as const; + }, + ); + if (claim._tag === "invalid") { + yield* publishReport( + yield* updates.getState, + { + outcome: "failed", + reason: "This desktop update is no longer prepared.", + }, + commit.requestId, + ); + return; + } + if (claim._tag === "failed") { + yield* publishReport( + current, + { + outcome: "failed", + reason: claim.prepared.failureReason ?? "The desktop app failed to install the update.", + }, + commit.requestId, + ); + return; + } + if (claim._tag === "committing") { + return; + } + yield* Ref.set(activeRequestIdRef, Option.some(commit.requestId)); + if (current.downloadedVersion !== claim.prepared.downloadedVersion) { + const reason = "This desktop update is no longer prepared."; + if (yield* recordPreparedFailure(commit.requestId, reason)) { + yield* publishReport(current, { outcome: "failed", reason }, commit.requestId); + } + return; + } + const result = yield* updates.installPrepared(claim.prepared.downloadedVersion); + if ( + !result.accepted && + result.state.downloadedVersion === claim.prepared.downloadedVersion && + (yield* updates.isInstallActive) + ) { + // Another install (local, or an earlier remote request) already owns + // the shutdown and will relaunch the app on the same downloaded + // version. This commit joins it: no failure marker, and the client + // proves the handoff the same way, by transport loss then the + // target version on reconnect. + yield* logInfo("remote update commit joining an in-progress install", { + requestId: commit.requestId, + }); + return; + } + if (!result.accepted || result.failed) { + const reason = result.state.message ?? "The desktop app could not start the install."; + if (yield* recordPreparedFailure(commit.requestId, reason)) { + yield* publishReport(result.state, { outcome: "failed", reason }, commit.requestId); + } + return; + } + // A successful install tears down this backend. Do not send a success + // marker from the old process: transport loss followed by the target + // version is the only proof that the handoff succeeded. + }).pipe( + Effect.ensuring(clearActiveRequest(commit.requestId)), + Effect.catchCause((cause) => + logError("remote update commit failed unexpectedly", { + requestId: commit.requestId, + cause: String(cause), + }), + ), + ), + ).pipe(Effect.forkScoped); +}); diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index b8c4e185e..1978337df 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -1,6 +1,4 @@ import { assert, describe, it } from "@effect/vitest"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import type { DesktopUpdateState } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -11,221 +9,14 @@ import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as References from "effect/References"; import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; -import * as DesktopConfig from "../app/DesktopConfig.ts"; -import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; -import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopState from "../app/DesktopState.ts"; import * as DesktopUpdates from "./DesktopUpdates.ts"; - -interface UpdatesHarnessOptions { - readonly checkForUpdates?: Effect.Effect< - void, - ElectronUpdater.ElectronUpdaterCheckForUpdatesError - >; - readonly beforeSetUpdateChannel?: Effect.Effect; - readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError; - readonly setDisableDifferentialDownload?: Effect.Effect; - readonly stopBackend?: Effect.Effect; - readonly env?: Record; -} - -const flushCallbacks = Effect.yieldNow; - -function makeHarness(options: UpdatesHarnessOptions = {}) { - let checkCount = 0; - let allowDowngrade = false; - let fullChangelog = false; - const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; - const listeners = new Map void>>(); - const sentStates: DesktopUpdateState[] = []; - - const addListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => { - const eventListeners = listeners.get(eventName) ?? new Set(); - eventListeners.add(listener); - listeners.set(eventName, eventListeners); - }; - - const removeListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => { - const eventListeners = listeners.get(eventName); - if (!eventListeners) { - return; - } - eventListeners.delete(listener); - if (eventListeners.size === 0) { - listeners.delete(eventName); - } - }; - - const updaterLayer = Layer.succeed(ElectronUpdater.ElectronUpdater, { - setFeedURL: (options) => - Effect.sync(() => { - feedUrls.push(options); - }), - setAutoDownload: () => Effect.void, - setAutoInstallOnAppQuit: () => Effect.void, - setChannel: () => Effect.void, - setAllowPrerelease: () => Effect.void, - allowDowngrade: Effect.sync(() => allowDowngrade), - setAllowDowngrade: (value) => - Effect.sync(() => { - allowDowngrade = value; - }), - setFullChangelog: (value) => - Effect.sync(() => { - fullChangelog = value; - }), - setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void, - checkForUpdates: Effect.sync(() => { - checkCount += 1; - }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), - downloadUpdate: Effect.void, - quitAndInstall: () => Effect.void, - on: (eventName, listener) => - Effect.acquireRelease( - Effect.sync(() => { - addListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); - }), - () => - Effect.sync(() => { - removeListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); - }), - ).pipe(Effect.asVoid), - } satisfies ElectronUpdater.ElectronUpdater["Service"]); - - const windowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { - create: () => Effect.die("unexpected BrowserWindow creation"), - main: Effect.succeed(Option.none()), - currentMainOrFirst: Effect.succeed(Option.none()), - focusedMainOrFirst: Effect.succeed(Option.none()), - setMain: () => Effect.void, - clearMain: () => Effect.void, - reveal: () => Effect.void, - sendAll: (_channel, state) => - Effect.sync(() => { - sentStates.push(state as DesktopUpdateState); - }), - destroyAll: Effect.void, - syncAllAppearance: () => Effect.void, - } satisfies ElectronWindow.ElectronWindow["Service"]); - - const stubBackendInstance: DesktopBackendPool.DesktopBackendInstance = { - id: DesktopBackendPool.PRIMARY_INSTANCE_ID, - label: Effect.succeed("Windows"), - start: Effect.void, - stop: () => options.stopBackend ?? Effect.void, - currentConfig: Effect.succeed(Option.none()), - snapshot: Effect.succeed({ - desiredRunning: false, - ready: false, - activePid: Option.none(), - restartAttempt: 0, - restartScheduled: false, - }), - waitForReady: () => Effect.succeed(true), - }; - const backendLayer = DesktopBackendPool.layerTest([stubBackendInstance]); - - const environmentLayer = DesktopEnvironment.layer({ - dirname: "/repo/apps/desktop/src", - homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`, - platform: "darwin", - processArch: "x64", - appVersion: "1.2.3", - appPath: "/repo", - isPackaged: true, - resourcesPath: "/missing/resources", - runningUnderArm64Translation: false, - }).pipe( - Layer.provide( - Layer.mergeAll( - NodeServices.layer, - DesktopConfig.layerTest({ - T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, - T3CODE_DESKTOP_MOCK_UPDATES: "true", - T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", - ...options.env, - }), - ), - ), - ); - - let testSettings: DesktopAppSettings.DesktopSettings = { - ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, - }; - const setUpdateChannelError = options.setUpdateChannelError; - const settingsLayer = - setUpdateChannelError || options.beforeSetUpdateChannel - ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, { - get: Effect.sync(() => testSettings), - load: Effect.sync(() => testSettings), - setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), - setServerExposureMode: () => Effect.die("unexpected server exposure update"), - setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), - setUpdateChannel: (channel) => - setUpdateChannelError - ? Effect.fail(setUpdateChannelError) - : (options.beforeSetUpdateChannel ?? Effect.void).pipe( - Effect.andThen( - Effect.sync(() => { - const changed = testSettings.updateChannel !== channel; - testSettings = { - ...testSettings, - updateChannel: channel, - updateChannelConfiguredByUser: true, - }; - return { settings: testSettings, changed }; - }), - ), - ), - setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), - setWslDistro: () => Effect.die("unexpected WSL distro change"), - setWslOnly: () => Effect.die("unexpected WSL-only toggle"), - applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), - applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), - } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) - : DesktopAppSettings.layer; - - const layer = DesktopUpdates.layer.pipe( - Layer.provideMerge(updaterLayer), - Layer.provideMerge(windowLayer), - Layer.provideMerge(backendLayer), - Layer.provideMerge(DesktopState.layer), - Layer.provideMerge(settingsLayer), - Layer.provideMerge( - DesktopConfig.layerTest({ - T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, - T3CODE_DESKTOP_MOCK_UPDATES: "true", - T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", - ...options.env, - }), - ), - Layer.provideMerge(environmentLayer), - Layer.provideMerge(NodeServices.layer), - ); - - return { - layer, - checkCount: () => checkCount, - feedUrls: () => feedUrls, - fullChangelog: () => fullChangelog, - listenerCount: () => - Array.from(listeners.values()).reduce( - (total, eventListeners) => total + eventListeners.size, - 0, - ), - sentStates, - emit: (eventName: string, payload?: unknown) => { - for (const listener of listeners.get(eventName) ?? []) { - listener(payload); - } - }, - }; -} +import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts"; describe("DesktopUpdates", () => { it("preserves complete causes for update poller and event failures", () => { @@ -294,6 +85,29 @@ describe("DesktopUpdates", () => { }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("subscribe delivers the latest state plus subsequent changes", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + const { latest, changes } = yield* updates.subscribe; + assert.equal(latest.status, "idle"); + + const nextState = yield* Stream.runHead(changes).pipe(Effect.forkChild); + yield* flushCallbacks; + harness.emit("update-available", { version: "1.2.4" }); + yield* flushCallbacks; + + const observed = yield* Fiber.join(nextState); + assert.equal(Option.getOrThrow(observed).status, "available"); + assert.equal(Option.getOrThrow(observed).availableVersion, "1.2.4"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("updates and broadcasts state from updater events", () => { const harness = makeHarness(); @@ -745,6 +559,120 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("keeps windows and restarts backends when quitAndInstall fails", () => { + const harness = makeHarness({ + quitAndInstall: Effect.fail( + new ElectronUpdater.ElectronUpdaterQuitAndInstallError({ + channel: "latest", + isSilent: true, + isForceRunAfter: true, + cause: new Error("installer refused"), + }), + ), + }); + + return Effect.scoped( + Effect.gen(function* () { + const desktopState = yield* DesktopState.DesktopState; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + + const result = yield* updates.install; + assert.isTrue(result.accepted); + assert.isFalse(yield* Ref.get(desktopState.quitting)); + assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("holds the install reservation until failed-install recovery finishes", () => { + const recoveryStarted = Deferred.makeUnsafe(); + const releaseRecovery = Deferred.makeUnsafe(); + const harness = makeHarness({ + quitAndInstall: Effect.fail( + new ElectronUpdater.ElectronUpdaterQuitAndInstallError({ + channel: "latest", + isSilent: true, + isForceRunAfter: true, + cause: new Error("installer refused"), + }), + ), + startBackend: Deferred.succeed(recoveryStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseRecovery)), + ), + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + + const failedInstall = yield* updates.install.pipe(Effect.forkChild); + yield* Deferred.await(recoveryStarted); + assert.isFalse(yield* updates.isInstallActive); + + const overlappingInstall = yield* updates.install; + assert.isFalse(overlappingInstall.accepted); + assert.equal(harness.quitAndInstalls(), 1); + harness.emit("error", new Error("duplicate native installer error")); + yield* flushCallbacks; + assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]); + + yield* Deferred.succeed(releaseRecovery, undefined); + const failedResult = yield* Fiber.join(failedInstall); + assert.equal(failedResult.state.errorContext, "install"); + + const retry = yield* updates.install; + assert.isTrue(retry.accepted); + assert.equal(harness.quitAndInstalls(), 2); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("recovers when quitAndInstall reports failure through an updater event", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const desktopState = yield* DesktopState.DesktopState; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + + yield* updates.install; + assert.deepEqual(harness.installSteps, ["quitAndInstall"]); + harness.emit("error", new Error("native installer refused")); + yield* flushCallbacks; + + assert.isFalse(yield* Ref.get(desktopState.quitting)); + assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]); + assert.equal((yield* updates.getState).errorContext, "install"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("rejects a prepared install when the downloaded version changed", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.5" }); + yield* flushCallbacks; + + const result = yield* updates.installPrepared("1.2.4"); + assert.isFalse(result.accepted); + assert.equal(harness.quitAndInstalls(), 0); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("persists channel changes through the settings service", () => { const harness = makeHarness(); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index a3d7da17b..20f005f2a 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -11,12 +11,16 @@ import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopConfig from "../app/DesktopConfig.ts"; @@ -44,8 +48,13 @@ import { const AUTO_UPDATE_STARTUP_DELAY = "15 seconds"; const AUTO_UPDATE_POLL_INTERVAL = "4 minutes"; +const PREPARED_INSTALL_CHECK_WAIT = Duration.seconds(90); -type UpdateAction = "check" | "download" | "install" | "channel"; +type UpdateAction = "check" | "download" | "install" | "install-recovery" | "channel"; + +interface DesktopPreparedUpdateInstallResult extends DesktopUpdateActionResult { + readonly failed: boolean; +} const AppUpdateYmlConfig = Schema.Record(Schema.String, Schema.String); type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; @@ -152,6 +161,20 @@ export class DesktopUpdates extends Context.Service< DesktopUpdates, { readonly getState: Effect.Effect; + /** True while a check, download, install, or channel change holds the + updater's single action reservation. */ + readonly isActionActive: Effect.Effect; + /** True only while an install owns the updater action reservation. */ + readonly isInstallActive: Effect.Effect; + /** Current state plus a stream of every later state change. */ + readonly subscribe: Effect.Effect< + { + readonly latest: DesktopUpdateState; + readonly changes: Stream.Stream; + }, + never, + Scope.Scope + >; readonly emitState: Effect.Effect; readonly disabledReason: Effect.Effect>; readonly configure: Effect.Effect; @@ -161,6 +184,9 @@ export class DesktopUpdates extends Context.Service< readonly check: (reason: string) => Effect.Effect; readonly download: Effect.Effect; readonly install: Effect.Effect; + readonly installPrepared: ( + expectedVersion: string, + ) => Effect.Effect; } >()("@t3tools/desktop/updates/DesktopUpdates") {} @@ -258,6 +284,7 @@ export const make = Effect.gen(function* () { const appUpdateYmlConfigRef = yield* Ref.make>(Option.none()); const activeUpdateActionRef = yield* Ref.make>(Option.none()); + const finishedUpdateActions = yield* PubSub.unbounded(); const updaterConfiguredRef = yield* Ref.make(false); const lastLoggedDownloadMilestoneRef = yield* Ref.make(-1); const updateStateRef = yield* Ref.make( @@ -268,12 +295,21 @@ export const make = Effect.gen(function* () { ), ); + const stateChanges = yield* PubSub.sliding(16); + // Makes ref writes + publishes atomic against subscribe, so a snapshot + // never overlaps with the first change a subscriber receives. + const stateMutex = yield* Semaphore.make(1); + const emitState = Ref.get(updateStateRef).pipe( Effect.flatMap((state) => electronWindow.sendAll(IpcChannels.UPDATE_STATE_CHANNEL, state)), ); const setState = (state: DesktopUpdateState): Effect.Effect => - Ref.set(updateStateRef, state).pipe(Effect.andThen(emitState)); + stateMutex + .withPermits(1)( + Ref.set(updateStateRef, state).pipe(Effect.andThen(PubSub.publish(stateChanges, state))), + ) + .pipe(Effect.andThen(emitState)); const updateState = ( f: (state: DesktopUpdateState) => DesktopUpdateState, @@ -327,8 +363,13 @@ export const make = Effect.gen(function* () { ); const finishUpdateAction = (action: UpdateAction): Effect.Effect => - Ref.update(activeUpdateActionRef, (activeAction) => - Option.isSome(activeAction) && activeAction.value === action ? Option.none() : activeAction, + Ref.modify(activeUpdateActionRef, (activeAction) => { + const finished = Option.isSome(activeAction) && activeAction.value === action; + return [finished, finished ? Option.none() : activeAction] as const; + }).pipe( + Effect.flatMap((finished) => + finished ? PubSub.publish(finishedUpdateActions, action).pipe(Effect.asVoid) : Effect.void, + ), ); const applyAutoUpdaterChannel = Effect.fn("desktop.updates.applyAutoUpdaterChannel")(function* ( @@ -396,7 +437,10 @@ export const make = Effect.gen(function* () { return yield* actionReservation === "held" ? check - : check.pipe(Effect.ensuring(finishUpdateAction("check"))); + : check.pipe( + Effect.onInterrupt(() => setState(state)), + Effect.ensuring(finishUpdateAction("check")), + ); }); const downloadAvailableUpdate = Effect.gen(function* () { @@ -462,85 +506,156 @@ export const make = Effect.gen(function* () { { discard: true }, ); - const installDownloadedUpdate = Effect.gen(function* () { - const state = yield* Ref.get(updateStateRef); - const hasInstallableDownload = - state.downloadedVersion !== null && - (state.status === "downloaded" || - (state.status === "error" && - (state.errorContext === null || state.errorContext === "install"))); - if ( - (yield* Ref.get(desktopState.quitting)) || - !(yield* Ref.get(updaterConfiguredRef)) || - !hasInstallableDownload - ) { - return { accepted: false, completed: false }; - } - - if (!(yield* tryStartUpdateAction("install"))) { - return { accepted: false, completed: false }; - } - - yield* Ref.set(desktopState.quitting, true); + const recoverFailedInstall = Effect.fn("desktop.updates.recoverFailedInstall")(function* ( + message: string, + ) { + const ownsRecovery = yield* Ref.modify(activeUpdateActionRef, (activeAction) => + Option.isSome(activeAction) && activeAction.value === "install" + ? ([true, Option.some("install-recovery")] as const) + : ([false, activeAction] as const), + ); + if (!ownsRecovery) return; - return yield* Effect.gen(function* () { - // Stop every backend in the pool, not just the primary. With - // parallel WSL + Windows backends, leaving the WSL instance up - // means quitAndInstall's app.quit() exits before the pool's - // scope cascade has a chance to run its stop finalizer, so the - // WSL child gets hard-killed by the OS instead of receiving - // SIGTERM + grace. Stops run concurrently with the same 5s - // budget the primary had on its own. + yield* Ref.set(desktopState.quitting, false); + yield* Effect.gen(function* () { const instances = yield* pool.list; - yield* Effect.forEach( - instances, - (instance) => instance.stop({ timeout: Duration.seconds(5) }), - { concurrency: "unbounded" }, - ); - yield* electronWindow.destroyAll; - yield* electronUpdater.quitAndInstall({ - isSilent: true, - isForceRunAfter: true, - }); - return { accepted: true, completed: false }; + const restartExit = yield* Effect.forEach(instances, (instance) => instance.start, { + concurrency: "unbounded", + discard: true, + }).pipe(Effect.exit); + yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, message)); + if (Exit.isFailure(restartExit)) { + yield* logUpdaterError("Desktop update install recovery could not restart every backend."); + } }).pipe( - Effect.catchTags({ - ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")( - function* (error) { - yield* resetInstallAction; - yield* updateState((current) => - reduceDesktopUpdateStateOnInstallFailure(current, error.message), + Effect.catchCause(() => + logUpdaterError("Desktop update install recovery failed unexpectedly."), + ), + Effect.ensuring(finishUpdateAction("install-recovery")), + ); + }); + + const installDownloadedUpdate = (expectedVersion?: string) => + Effect.scoped( + Effect.gen(function* () { + const actionCompletions = yield* PubSub.subscribe(finishedUpdateActions); + let admission: "admitted" | "refused" | "wait-for-check" = "wait-for-check"; + while (admission === "wait-for-check") { + admission = yield* stateMutex.withPermits(1)( + Effect.gen(function* () { + const state = yield* Ref.get(updateStateRef); + const activeAction = yield* Ref.get(activeUpdateActionRef); + const hasExpectedDownload = + state.downloadedVersion !== null && + (expectedVersion === undefined || state.downloadedVersion === expectedVersion); + if ( + (yield* Ref.get(desktopState.quitting)) || + !(yield* Ref.get(updaterConfiguredRef)) || + !hasExpectedDownload + ) { + return "refused" as const; + } + if (Option.isSome(activeAction)) { + return activeAction.value === "check" && expectedVersion !== undefined + ? ("wait-for-check" as const) + : ("refused" as const); + } + const hasInstallableDownload = + state.status === "downloaded" || + (state.status === "error" && + (state.errorContext === null || state.errorContext === "install")); + if (!hasInstallableDownload) return "refused" as const; + return (yield* tryStartUpdateAction("install")) + ? ("admitted" as const) + : ("refused" as const); + }), + ); + if (admission === "wait-for-check") { + const finishedAction = yield* PubSub.take(actionCompletions).pipe( + Effect.timeoutOption(PREPARED_INSTALL_CHECK_WAIT), ); - yield* logUpdaterError(error.message, { - errorTag: error._tag, - channel: error.channel, - isSilent: error.isSilent, - isForceRunAfter: error.isForceRunAfter, - }); - return { accepted: true, completed: false }; - }, - ), - }), - Effect.onInterrupt(() => resetInstallAction), - Effect.catchCause((cause) => - Effect.gen(function* () { - if (Cause.hasInterruptsOnly(cause)) { - return yield* Effect.failCause(cause); + if (Option.isNone(finishedAction)) { + admission = "refused"; + } } - yield* resetInstallAction; - const error = new DesktopUpdateUnexpectedActionError({ action: "install", cause }); - yield* updateState((current) => - reduceDesktopUpdateStateOnInstallFailure(current, error.message), + } + if (admission === "refused") { + return { accepted: false, completed: false, failed: false }; + } + + yield* Ref.set(desktopState.quitting, true); + + return yield* Effect.gen(function* () { + // Stop every backend in the pool, not just the primary. With + // parallel WSL + Windows backends, leaving the WSL instance up + // means quitAndInstall's app.quit() exits before the pool's + // scope cascade has a chance to run its stop finalizer, so the + // WSL child gets hard-killed by the OS instead of receiving + // SIGTERM + grace. Stops run concurrently with the same 5s + // budget the primary had on its own. + const instances = yield* pool.list; + yield* Effect.forEach( + instances, + (instance) => instance.stop({ timeout: Duration.seconds(5) }), + { concurrency: "unbounded" }, ); - yield* logUpdaterError(error.message, { - errorTag: error._tag, - action: error.action, + yield* electronUpdater.quitAndInstall({ + isSilent: true, + isForceRunAfter: true, }); - return { accepted: true, completed: false }; - }), - ), - ); - }).pipe(Effect.withSpan("desktop.updates.installDownloadedUpdate")); + return { accepted: true, completed: false, failed: false }; + }).pipe( + Effect.catchTags({ + ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")( + function* (error) { + yield* recoverFailedInstall(error.message); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + channel: error.channel, + isSilent: error.isSilent, + isForceRunAfter: error.isForceRunAfter, + }); + return { accepted: true, completed: false, failed: true }; + }, + ), + }), + Effect.onInterrupt(() => resetInstallAction), + Effect.catchCause((cause) => + Effect.gen(function* () { + if (Cause.hasInterruptsOnly(cause)) { + return yield* Effect.failCause(cause); + } + const error = new DesktopUpdateUnexpectedActionError({ action: "install", cause }); + yield* recoverFailedInstall(error.message); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + action: error.action, + }); + return { accepted: true, completed: false, failed: true }; + }), + ), + ); + }), + ).pipe(Effect.withSpan("desktop.updates.installDownloadedUpdate")); + + const installWithExpectedVersion = (expectedVersion?: string) => + Effect.gen(function* () { + if (yield* Ref.get(desktopState.quitting)) { + return { + accepted: false, + completed: false, + failed: false, + state: yield* Ref.get(updateStateRef), + }; + } + const result = yield* installDownloadedUpdate(expectedVersion); + return { + accepted: result.accepted, + completed: result.completed, + failed: result.failed, + state: yield* Ref.get(updateStateRef), + }; + }).pipe(Effect.withSpan("desktop.updates.install")); const startUpdatePollers: Effect.Effect = Effect.gen(function* () { yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe( @@ -640,15 +755,14 @@ export const make = Effect.gen(function* () { ) { const activeAction = yield* activeUpdateAction; const error = new DesktopUpdaterReportedError({ - operation: Option.getOrElse(activeAction, () => "background" as const), + operation: Option.match(activeAction, { + onNone: () => "background" as const, + onSome: (action) => (action === "install-recovery" ? "install" : action), + }), cause, }); if (Option.isSome(activeAction) && activeAction.value === "install") { - yield* finishUpdateAction("install"); - yield* Ref.set(desktopState.quitting, false); - yield* updateState((current) => - reduceDesktopUpdateStateOnInstallFailure(current, error.message), - ); + yield* recoverFailedInstall(error.message); yield* logUpdaterError(error.message, { errorTag: error._tag, operation: error.operation, @@ -733,6 +847,17 @@ export const make = Effect.gen(function* () { return DesktopUpdates.of({ getState: Ref.get(updateStateRef), + isActionActive: activeUpdateAction.pipe(Effect.map(Option.isSome)), + isInstallActive: activeUpdateAction.pipe( + Effect.map((action) => Option.isSome(action) && action.value === "install"), + ), + subscribe: stateMutex.withPermits(1)( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(stateChanges); + const latest = yield* Ref.get(updateStateRef); + return { latest, changes: Stream.fromSubscription(subscription) }; + }), + ), emitState, disabledReason: resolveDisabledReason, configure: Effect.gen(function* () { @@ -804,7 +929,7 @@ export const make = Effect.gen(function* () { const activeAction = yield* tryStartChannelChange; if (Option.isSome(activeAction)) { return yield* new DesktopUpdateActionInProgressError({ - action: activeAction.value, + action: activeAction.value === "install-recovery" ? "install" : activeAction.value, requestedChannel: nextChannel, }); } @@ -861,21 +986,10 @@ export const make = Effect.gen(function* () { state: yield* Ref.get(updateStateRef), }; }).pipe(Effect.withSpan("desktop.updates.download")), - install: Effect.gen(function* () { - if (yield* Ref.get(desktopState.quitting)) { - return { - accepted: false, - completed: false, - state: yield* Ref.get(updateStateRef), - }; - } - const result = yield* installDownloadedUpdate; - return { - accepted: result.accepted, - completed: result.completed, - state: yield* Ref.get(updateStateRef), - }; - }).pipe(Effect.withSpan("desktop.updates.install")), + install: installWithExpectedVersion().pipe( + Effect.map(({ accepted, completed, state }) => ({ accepted, completed, state })), + ), + installPrepared: (expectedVersion) => installWithExpectedVersion(expectedVersion), }); }); diff --git a/apps/desktop/src/updates/remoteUpdateFlow.test.ts b/apps/desktop/src/updates/remoteUpdateFlow.test.ts new file mode 100644 index 000000000..7fd50a82f --- /dev/null +++ b/apps/desktop/src/updates/remoteUpdateFlow.test.ts @@ -0,0 +1,218 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { DesktopUpdateState } from "@t3tools/contracts"; + +import { + MAX_REMOTE_UPDATE_CHECKS, + MAX_REMOTE_UPDATE_DOWNLOADS, + nextRemoteDesktopUpdateStep, + normalizeRemoteUpdateReason, + type RemoteDesktopUpdateAttempts, +} from "./remoteUpdateFlow.ts"; + +const NO_ATTEMPTS: RemoteDesktopUpdateAttempts = { checks: 0, downloads: 0 }; + +function makeState(overrides: Partial = {}): DesktopUpdateState { + return { + enabled: true, + status: "idle", + channel: "latest", + currentVersion: "1.2.3", + hostArch: "arm64", + appArch: "arm64", + runningUnderArm64Translation: false, + availableVersion: null, + downloadedVersion: null, + releaseNotes: [], + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, + omittedReleaseCount: 0, + ...overrides, + }; +} + +describe("nextRemoteDesktopUpdateStep", () => { + it("fails immediately when updates are disabled, preferring the known reason", () => { + const state = makeState({ enabled: false, status: "disabled" }); + assert.deepEqual(nextRemoteDesktopUpdateStep(state, NO_ATTEMPTS, "dev build"), { + action: "done", + outcome: "failed", + reason: "dev build", + }); + assert.deepEqual(nextRemoteDesktopUpdateStep(state, NO_ATTEMPTS, null), { + action: "done", + outcome: "failed", + reason: "Automatic updates are disabled on this machine.", + }); + }); + + it("installs only from the downloaded status the updater will accept", () => { + assert.deepEqual( + nextRemoteDesktopUpdateStep( + makeState({ status: "downloaded", downloadedVersion: "1.2.4" }), + NO_ATTEMPTS, + null, + ), + { action: "install" }, + ); + // A previous install failure keeps status "downloaded", so a remote run + // retries the install. + assert.deepEqual( + nextRemoteDesktopUpdateStep( + makeState({ + status: "downloaded", + downloadedVersion: "1.2.4", + errorContext: "install", + message: "quitAndInstall failed", + }), + NO_ATTEMPTS, + null, + ), + { action: "install" }, + ); + // A download survives an unrelated background updater error, so a run + // installs it instead of replaying that error. + assert.deepEqual( + nextRemoteDesktopUpdateStep( + makeState({ + status: "error", + downloadedVersion: "1.2.4", + errorContext: null, + message: "background updater error", + }), + NO_ATTEMPTS, + null, + ), + { action: "install" }, + ); + // A leftover download behind a check error is not installable: fresh + // runs re-check, and post-check the error is terminal. + const staleError = makeState({ + status: "error", + downloadedVersion: "1.2.4", + errorContext: "check", + message: "feed unreachable", + }); + assert.deepEqual(nextRemoteDesktopUpdateStep(staleError, NO_ATTEMPTS, null), { + action: "check", + }); + assert.deepEqual(nextRemoteDesktopUpdateStep(staleError, { checks: 1, downloads: 0 }, null), { + action: "done", + outcome: "failed", + reason: "feed unreachable", + }); + }); + + it("rides along while a check or download is already in flight", () => { + assert.deepEqual( + nextRemoteDesktopUpdateStep(makeState({ status: "checking" }), NO_ATTEMPTS, null), + { + action: "wait", + }, + ); + assert.deepEqual( + nextRemoteDesktopUpdateStep( + makeState({ status: "downloading", availableVersion: "1.2.4", downloadPercent: 40 }), + NO_ATTEMPTS, + null, + ), + { action: "wait" }, + ); + }); + + it("downloads an available update until the attempt cap, then fails", () => { + const available = makeState({ status: "available", availableVersion: "1.2.4" }); + assert.deepEqual(nextRemoteDesktopUpdateStep(available, NO_ATTEMPTS, null), { + action: "download", + }); + assert.deepEqual( + nextRemoteDesktopUpdateStep( + available, + { checks: 1, downloads: MAX_REMOTE_UPDATE_DOWNLOADS }, + null, + ), + { + action: "done", + outcome: "failed", + reason: "The desktop app failed to download the update.", + }, + ); + assert.deepEqual( + nextRemoteDesktopUpdateStep( + makeState({ + status: "available", + availableVersion: "1.2.4", + message: "network blipped", + }), + { checks: 1, downloads: MAX_REMOTE_UPDATE_DOWNLOADS }, + null, + ), + { action: "done", outcome: "failed", reason: "network blipped" }, + ); + }); + + it("re-checks stale up-to-date and error states before trusting them", () => { + // These states are retained from earlier/background checks; a remote + // request must look again instead of replaying them. + assert.deepEqual( + nextRemoteDesktopUpdateStep(makeState({ status: "up-to-date" }), NO_ATTEMPTS, null), + { action: "check" }, + ); + assert.deepEqual( + nextRemoteDesktopUpdateStep( + makeState({ status: "error", message: "feed unreachable" }), + NO_ATTEMPTS, + null, + ), + { action: "check" }, + ); + }); + + it("reports up-to-date and error states as terminal after this run's check", () => { + const checked = { checks: 1, downloads: 0 }; + assert.deepEqual( + nextRemoteDesktopUpdateStep(makeState({ status: "up-to-date" }), checked, null), + { action: "done", outcome: "up-to-date" }, + ); + assert.deepEqual( + nextRemoteDesktopUpdateStep( + makeState({ status: "error", message: "feed unreachable" }), + checked, + null, + ), + { action: "done", outcome: "failed", reason: "feed unreachable" }, + ); + assert.deepEqual(nextRemoteDesktopUpdateStep(makeState({ status: "error" }), checked, null), { + action: "done", + outcome: "failed", + reason: "The desktop app update failed.", + }); + }); + + it("checks from idle until the attempt cap, then fails", () => { + assert.deepEqual(nextRemoteDesktopUpdateStep(makeState(), NO_ATTEMPTS, null), { + action: "check", + }); + assert.deepEqual( + nextRemoteDesktopUpdateStep( + makeState(), + { checks: MAX_REMOTE_UPDATE_CHECKS, downloads: 0 }, + null, + ), + { + action: "done", + outcome: "failed", + reason: "The desktop app did not report an update result.", + }, + ); + }); + + it("drops blank reasons so the wire report still encodes", () => { + assert.equal(normalizeRemoteUpdateReason(undefined), undefined); + assert.equal(normalizeRemoteUpdateReason(""), undefined); + assert.equal(normalizeRemoteUpdateReason(" "), undefined); + assert.equal(normalizeRemoteUpdateReason(" feed unreachable "), "feed unreachable"); + }); +}); diff --git a/apps/desktop/src/updates/remoteUpdateFlow.ts b/apps/desktop/src/updates/remoteUpdateFlow.ts new file mode 100644 index 000000000..50b077313 --- /dev/null +++ b/apps/desktop/src/updates/remoteUpdateFlow.ts @@ -0,0 +1,115 @@ +import type { DesktopUpdateRemoteOutcome, DesktopUpdateState } from "@t3tools/contracts"; + +/** + * What a server-triggered update run should do next, given the updater's + * current state. "wait" means an action (possibly started locally) is in + * flight and the run should ride along until the next state change. + */ +export type RemoteDesktopUpdateStep = + | { readonly action: "check" } + | { readonly action: "download" } + | { readonly action: "install" } + | { readonly action: "wait" } + | { + readonly action: "done"; + readonly outcome: DesktopUpdateRemoteOutcome; + readonly reason?: string; + }; + +/** + * How many times this run already issued each action. The caps are what stop + * a check -> up-to-date -> check loop and endless download retries; they are + * counts rather than booleans because a state event raced by the local + * 4-minute poller can re-show an already-handled status once. + */ +export interface RemoteDesktopUpdateAttempts { + readonly checks: number; + readonly downloads: number; +} + +export const MAX_REMOTE_UPDATE_CHECKS = 2; +export const MAX_REMOTE_UPDATE_DOWNLOADS = 3; + +/** Same predicate DesktopUpdates.installDownloadedUpdate uses for admission. */ +export function isInstallableDesktopUpdateState(state: DesktopUpdateState): boolean { + return ( + state.downloadedVersion !== null && + (state.status === "downloaded" || + (state.status === "error" && + (state.errorContext === null || state.errorContext === "install"))) + ); +} + +export function nextRemoteDesktopUpdateStep( + state: DesktopUpdateState, + attempts: RemoteDesktopUpdateAttempts, + disabledReason: string | null, +): RemoteDesktopUpdateStep { + if (!state.enabled || state.status === "disabled") { + return { + action: "done", + outcome: "failed", + reason: disabledReason ?? "Automatic updates are disabled on this machine.", + }; + } + // Mirror installDownloadedUpdate's own admission rule exactly, so the run + // never reports an install that the updater then refuses. A download + // survives an unrelated background updater error (errorContext null) and + // a previous failed install (errorContext "install"); it does not survive + // a check or download error, which fall through to the error branch. + if (isInstallableDesktopUpdateState(state)) { + return { action: "install" }; + } + if (state.status === "downloading" || state.status === "checking") { + return { action: "wait" }; + } + if (state.status === "available") { + if (attempts.downloads >= MAX_REMOTE_UPDATE_DOWNLOADS) { + return { + action: "done", + outcome: "failed", + reason: state.message ?? "The desktop app failed to download the update.", + }; + } + return { action: "download" }; + } + // "up-to-date" and "error" are retained from earlier/background checks, + // so before this run has issued its own check they are stale, not + // terminal: the whole point of a remote request is to look again. + if (state.status === "up-to-date") { + if (attempts.checks === 0) { + return { action: "check" }; + } + return { action: "done", outcome: "up-to-date" }; + } + if (state.status === "error") { + if (attempts.checks === 0) { + return { action: "check" }; + } + return { + action: "done", + outcome: "failed", + reason: state.message ?? "The desktop app update failed.", + }; + } + // status === "idle" + if (attempts.checks >= MAX_REMOTE_UPDATE_CHECKS) { + return { + action: "done", + outcome: "failed", + reason: "The desktop app did not report an update result.", + }; + } + return { action: "check" }; +} + +/** + * Normalizes an updater message for the `reason` wire field, which is a + * TrimmedNonEmptyString. Updater messages are plain strings and may be blank; + * a blank reason would fail encoding and silently drop the terminal report, + * leaving the server to wait for its timeout. + */ +export function normalizeRemoteUpdateReason(reason: string | undefined): string | undefined { + const trimmed = reason?.trim(); + return trimmed ? trimmed : undefined; +} diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts new file mode 100644 index 000000000..53a6dc97f --- /dev/null +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -0,0 +1,242 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import type { DesktopUpdateState } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; +import * as DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import * as DesktopState from "../app/DesktopState.ts"; +import * as DesktopUpdates from "./DesktopUpdates.ts"; + +/** Shared DesktopUpdates test harness: a fully stubbed updater layer whose + electron-updater events are driven by hand via `emit`. Used by + DesktopUpdates.test.ts and DesktopRemoteUpdates.test.ts. */ + +export const flushCallbacks = Effect.yieldNow; + +export interface UpdatesHarnessOptions { + readonly checkForUpdates?: Effect.Effect< + void, + ElectronUpdater.ElectronUpdaterCheckForUpdatesError + >; + readonly beforeSetUpdateChannel?: Effect.Effect; + readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError; + readonly setDisableDifferentialDownload?: Effect.Effect; + readonly downloadUpdate?: Effect.Effect; + readonly quitAndInstall?: Effect.Effect; + readonly stopBackend?: Effect.Effect; + readonly startBackend?: Effect.Effect; + readonly env?: Record; +} + +export function makeHarness(options: UpdatesHarnessOptions = {}) { + let checkCount = 0; + let quitAndInstallCount = 0; + let downloadCount = 0; + let allowDowngrade = false; + let fullChangelog = false; + const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; + const listeners = new Map void>>(); + const sentStates: DesktopUpdateState[] = []; + const installSteps: string[] = []; + + const addListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => { + const eventListeners = listeners.get(eventName) ?? new Set(); + eventListeners.add(listener); + listeners.set(eventName, eventListeners); + }; + + const removeListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => { + const eventListeners = listeners.get(eventName); + if (!eventListeners) { + return; + } + eventListeners.delete(listener); + if (eventListeners.size === 0) { + listeners.delete(eventName); + } + }; + + const updaterLayer = Layer.succeed(ElectronUpdater.ElectronUpdater, { + setFeedURL: (options) => + Effect.sync(() => { + feedUrls.push(options); + }), + setAutoDownload: () => Effect.void, + setAutoInstallOnAppQuit: () => Effect.void, + setChannel: () => Effect.void, + setAllowPrerelease: () => Effect.void, + allowDowngrade: Effect.sync(() => allowDowngrade), + setAllowDowngrade: (value) => + Effect.sync(() => { + allowDowngrade = value; + }), + setFullChangelog: (value) => + Effect.sync(() => { + fullChangelog = value; + }), + setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void, + checkForUpdates: Effect.sync(() => { + checkCount += 1; + }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), + downloadUpdate: Effect.sync(() => { + downloadCount += 1; + }).pipe(Effect.andThen(options.downloadUpdate ?? Effect.void)), + quitAndInstall: () => + Effect.sync(() => { + quitAndInstallCount += 1; + installSteps.push("quitAndInstall"); + }).pipe(Effect.andThen(options.quitAndInstall ?? Effect.void)), + on: (eventName, listener) => + Effect.acquireRelease( + Effect.sync(() => { + addListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); + }), + () => + Effect.sync(() => { + removeListener(eventName, listener as unknown as (...args: readonly unknown[]) => void); + }), + ).pipe(Effect.asVoid), + } satisfies ElectronUpdater.ElectronUpdater["Service"]); + + const windowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { + create: () => Effect.die("unexpected BrowserWindow creation"), + main: Effect.succeed(Option.none()), + currentMainOrFirst: Effect.succeed(Option.none()), + focusedMainOrFirst: Effect.succeed(Option.none()), + setMain: () => Effect.void, + clearMain: () => Effect.void, + reveal: () => Effect.void, + sendAll: (_channel, state) => + Effect.sync(() => { + sentStates.push(state as DesktopUpdateState); + }), + destroyAll: Effect.sync(() => { + installSteps.push("destroyAll"); + }), + syncAllAppearance: () => Effect.void, + } satisfies ElectronWindow.ElectronWindow["Service"]); + + const stubBackendInstance: DesktopBackendPool.DesktopBackendInstance = { + id: DesktopBackendPool.PRIMARY_INSTANCE_ID, + label: Effect.succeed("Windows"), + start: Effect.sync(() => { + installSteps.push("startBackend"); + }).pipe(Effect.andThen(options.startBackend ?? Effect.void)), + stop: () => options.stopBackend ?? Effect.void, + currentConfig: Effect.succeed(Option.none()), + snapshot: Effect.succeed({ + desiredRunning: false, + ready: false, + activePid: Option.none(), + restartAttempt: 0, + restartScheduled: false, + }), + waitForReady: () => Effect.succeed(true), + }; + const backendLayer = DesktopBackendPool.layerTest([stubBackendInstance]); + + const environmentLayer = DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`, + platform: "darwin", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + DesktopConfig.layerTest({ + T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, + T3CODE_DESKTOP_MOCK_UPDATES: "true", + T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", + ...options.env, + }), + ), + ), + ); + + let testSettings: DesktopAppSettings.DesktopSettings = { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + }; + const setUpdateChannelError = options.setUpdateChannelError; + const settingsLayer = + setUpdateChannelError || options.beforeSetUpdateChannel + ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, { + get: Effect.sync(() => testSettings), + load: Effect.sync(() => testSettings), + setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), + setServerExposureMode: () => Effect.die("unexpected server exposure update"), + setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), + setUpdateChannel: (channel) => + setUpdateChannelError + ? Effect.fail(setUpdateChannelError) + : (options.beforeSetUpdateChannel ?? Effect.void).pipe( + Effect.andThen( + Effect.sync(() => { + const changed = testSettings.updateChannel !== channel; + testSettings = { + ...testSettings, + updateChannel: channel, + updateChannelConfiguredByUser: true, + }; + return { settings: testSettings, changed }; + }), + ), + ), + setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), + setWslDistro: () => Effect.die("unexpected WSL distro change"), + setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), + applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), + } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) + : DesktopAppSettings.layer; + + const layer = DesktopUpdates.layer.pipe( + Layer.provideMerge(updaterLayer), + Layer.provideMerge(windowLayer), + Layer.provideMerge(backendLayer), + Layer.provideMerge(DesktopState.layer), + Layer.provideMerge(settingsLayer), + Layer.provideMerge( + DesktopConfig.layerTest({ + T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, + T3CODE_DESKTOP_MOCK_UPDATES: "true", + T3CODE_DESKTOP_MOCK_UPDATE_SERVER_PORT: "4141", + ...options.env, + }), + ), + Layer.provideMerge(environmentLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return { + layer, + checkCount: () => checkCount, + quitAndInstalls: () => quitAndInstallCount, + installSteps, + downloadCount: () => downloadCount, + feedUrls: () => feedUrls, + fullChangelog: () => fullChangelog, + listenerCount: () => + Array.from(listeners.values()).reduce( + (total, eventListeners) => total + eventListeners.size, + 0, + ), + sentStates, + emit: (eventName: string, payload?: unknown) => { + for (const listener of listeners.get(eventName) ?? []) { + listener(payload); + } + }, + }; +} diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 2890eec1b..525545570 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -60,6 +60,9 @@ const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, { getState: Effect.die("unexpected getState"), + isActionActive: Effect.succeed(false), + isInstallActive: Effect.succeed(false), + subscribe: Effect.die("unexpected subscribe"), emitState: Effect.void, disabledReason: Effect.succeed(Option.none()), configure: Effect.void, @@ -67,6 +70,7 @@ const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, { check: () => Effect.die("unexpected check"), download: Effect.die("unexpected download"), install: Effect.die("unexpected install"), + installPrepared: () => Effect.die("unexpected installPrepared"), } satisfies DesktopUpdates.DesktopUpdates["Service"]); const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 5fdf4d264..d52c6fb62 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -59,6 +59,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverCancelProviderLogin]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope, + [WS_METHODS.serverCommitDesktopUpdate]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpsertKeybinding]: AuthOrchestrationOperateScope, [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index 71880ec4e..d7c383140 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -9,6 +9,7 @@ import * as Path from "effect/Path"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ServerConfig from "../config.ts"; +import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ServiceLauncherClient from "./serviceLauncherClient.ts"; import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts"; @@ -19,6 +20,7 @@ interface HarnessOptions { readonly managed?: boolean; readonly preflight?: "ready" | "blocked"; readonly requestUpdate?: ServiceLauncherClient.ServiceLauncherClient["Service"]["requestUpdate"]; + readonly desktopAppUpdate?: DesktopAppUpdate.DesktopAppUpdate["Service"]; } const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( @@ -88,6 +90,13 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( const selfUpdate = yield* ServerSelfUpdate.make().pipe( Effect.provideService(ProcessRunner.ProcessRunner, runner), Effect.provideService(ServiceLauncherClient.ServiceLauncherClient, launcher), + Effect.provideService( + DesktopAppUpdate.DesktopAppUpdate, + options.desktopAppUpdate ?? { + available: false, + run: () => Effect.die("unexpected desktop app update run"), + }, + ), Effect.provideService(HostProcessExecutablePath, "/usr/bin/node"), Effect.provide(ServerConfig.layer({ ...config, mode: options.mode ?? "web" })), ); @@ -121,6 +130,31 @@ it.layer(NodeServices.layer)("server self update", (it) => { }), ); + it.effect("delegates desktop-managed updates to the desktop app when available", () => + Effect.gen(function* () { + const stages: string[] = []; + const { selfUpdate, order } = yield* makeHarness({ + mode: "desktop", + desktopAppUpdate: { + available: true, + run: (reportProgress) => + reportProgress("downloading").pipe( + Effect.andThen(reportProgress("installing")), + Effect.as({ targetVersion: "1.2.0", method: "desktop-app" as const }), + ), + commit: () => Effect.never, + }, + }); + const result = yield* selfUpdate.update({ targetVersion: "1.1.0" }, (stage) => + Effect.sync(() => void stages.push(stage)), + ); + expect(result).toEqual({ targetVersion: "1.2.0", method: "desktop-app" }); + expect(stages).toEqual(["downloading", "installing"]); + // The launcher staging path must not run on the desktop path. + expect(order).toEqual([]); + }), + ); + it.effect("preserves the preflight refusal reason", () => Effect.gen(function* () { const { selfUpdate } = yield* makeHarness({ preflight: "blocked" }); diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index ac1001966..be39833b9 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -15,6 +15,7 @@ import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as ServerConfig from "../config.ts"; +import * as DesktopAppUpdate from "../desktopUpdate/DesktopAppUpdate.ts"; import * as ProcessRunner from "../processRunner.ts"; import { ensurePinnedRuntimeInstalled, @@ -42,11 +43,15 @@ export class ServerSelfUpdate extends Context.Service< input: ServerSelfUpdateInput, reportProgress?: (stage: ServerSelfUpdateProgressStage) => Effect.Effect, ) => Effect.Effect; + readonly commitDesktopUpdate: ( + requestId: string, + ) => Effect.Effect; } >()("t3/cloud/selfUpdate/ServerSelfUpdate") {} export const make = Effect.fn("cloud.server_self_update.make")(function* () { const serverConfig = yield* ServerConfig.ServerConfig; + const desktopAppUpdate = yield* DesktopAppUpdate.DesktopAppUpdate; const launcher = yield* ServiceLauncherClient.ServiceLauncherClient; const runner = yield* ProcessRunner.ProcessRunner; const fs = yield* FileSystem.FileSystem; @@ -65,6 +70,12 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { "cloud.server_self_update.update", )(function* (input, reportProgress = () => Effect.void) { if (capability === "desktop-managed") { + // input.targetVersion is meaningless here: the desktop app's own + // update feed decides what it downloads, and the result carries what + // it actually got. + if (desktopAppUpdate.available) { + return yield* desktopAppUpdate.run(reportProgress); + } return yield* failWith( "This server is managed by the Pylon desktop app on its machine; update the desktop app to update it.", ); @@ -191,7 +202,10 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* () { }).pipe(Effect.onError(() => Ref.set(inFlight, false))); }); - return ServerSelfUpdate.of({ update }); + return ServerSelfUpdate.of({ + update, + commitDesktopUpdate: (requestId) => desktopAppUpdate.commit(requestId), + }); }); export const layer = Layer.effect(ServerSelfUpdate, make()).pipe( diff --git a/apps/server/src/desktopUpdate/DesktopAppUpdate.test.ts b/apps/server/src/desktopUpdate/DesktopAppUpdate.test.ts new file mode 100644 index 000000000..ea89f45e6 --- /dev/null +++ b/apps/server/src/desktopUpdate/DesktopAppUpdate.test.ts @@ -0,0 +1,247 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import type { DesktopUpdateState, DesktopUpdateStatusReport } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import * as ServerConfig from "../config.ts"; +import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts"; +import * as DesktopAppUpdate from "./DesktopAppUpdate.ts"; + +function makeState(overrides: Partial = {}): DesktopUpdateState { + return { + enabled: true, + status: "idle", + channel: "latest", + currentVersion: "1.2.3", + hostArch: "arm64", + appArch: "arm64", + runningUnderArm64Translation: false, + availableVersion: null, + downloadedVersion: null, + releaseNotes: [], + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, + omittedReleaseCount: 0, + ...overrides, + }; +} + +function report( + requestId: string, + state: DesktopUpdateState, + terminal?: { + readonly outcome: DesktopUpdateStatusReport["outcome"]; + readonly reason?: string; + }, +): DesktopUpdateStatusReport { + return { + version: 1, + type: "desktopUpdateStatus", + requestId, + ...(terminal?.outcome === undefined ? {} : { outcome: terminal.outcome }), + ...(terminal?.reason === undefined ? {} : { reason: terminal.reason }), + state, + }; +} + +interface HarnessOptions { + readonly mode?: "web" | "desktop"; + readonly controlFd?: number | undefined; + /** Reports emitted for the run, given the requestId the service generated. + The stream ends after the last one unless `keepOpen` is set. */ + readonly reports?: (requestId: string) => readonly DesktopUpdateStatusReport[]; + readonly keepOpen?: boolean; +} + +const makeHarness = Effect.fn("test.make_desktop_app_update_harness")(function* ( + options: HarnessOptions = {}, +) { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-desktop-app-update-test-" }); + const requestIdDeferred = yield* Deferred.make(); + const baseConfig = yield* ServerConfig.ServerConfig.pipe( + Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), + ); + const config: ServerConfig.ServerConfig["Service"] = { + ...baseConfig, + mode: options.mode ?? "desktop", + ...("controlFd" in options + ? { desktopTelemetryControlFd: options.controlFd } + : { desktopTelemetryControlFd: 5 }), + }; + const reportsForRun = options.reports ?? (() => []); + const changes = Stream.unwrap( + Deferred.await(requestIdDeferred).pipe( + Effect.map((requestId) => { + const emitted = Stream.fromIterable(reportsForRun(requestId)); + return options.keepOpen ? Stream.concat(emitted, Stream.never) : emitted; + }), + ), + ); + + const service = yield* DesktopAppUpdate.make().pipe( + Effect.provide( + Layer.mergeAll( + DesktopTelemetryReceiver.layerTest({ + requestDesktopUpdate: (requestId) => + Deferred.succeed(requestIdDeferred, requestId).pipe(Effect.asVoid), + desktopUpdates: Effect.succeed({ + latest: Option.none(), + changes, + }), + }), + ServerConfig.layer(config), + ), + ), + ); + return { service }; +}); + +it.layer(NodeServices.layer)("desktop app update", (it) => { + it.effect("is unavailable without desktop mode or the control fd", () => + Effect.gen(function* () { + const web = yield* makeHarness({ mode: "web" }); + expect(web.service.available).toBe(false); + const noFd = yield* makeHarness({ controlFd: undefined }); + expect(noFd.service.available).toBe(false); + expect((yield* noFd.service.run(() => Effect.void).pipe(Effect.flip)).reason).toContain( + "not started by the T3 Code desktop app", + ); + const desktop = yield* makeHarness(); + expect(desktop.service.available).toBe(true); + }), + ); + + it.effect("collapses state reports into progress stages and succeeds on installing", () => + Effect.gen(function* () { + const { service } = yield* makeHarness({ + reports: (requestId) => [ + report(requestId, makeState({ status: "checking" })), + report(requestId, makeState({ status: "available", availableVersion: "1.2.4" })), + report(requestId, makeState({ status: "downloading", downloadPercent: 40 })), + // Reports from another run must be ignored. + report("other-run", makeState({ status: "error", message: "unrelated" })), + report(requestId, makeState({ status: "downloaded", downloadedVersion: "1.2.4" }), { + outcome: "ready-to-install", + }), + ], + }); + const stages: string[] = []; + const result = yield* service.run((stage) => Effect.sync(() => void stages.push(stage))); + expect(result).toEqual({ + targetVersion: "1.2.4", + method: "desktop-app", + desktopUpdateToken: expect.any(String), + }); + // "downloading" is not repeated for every download report. + expect(stages).toEqual(["downloading", "installing"]); + + // Success releases the in-flight guard: if the desktop rejected the + // install after reporting, the server must accept a retry instead of + // refusing until restart. (The second run fails differently because + // the stub report stream is exhausted.) + const retry = yield* service.run(() => Effect.void).pipe(Effect.flip); + expect(retry.reason).not.toBe("A desktop app update is already in progress."); + }), + ); + + it.effect("maps up-to-date and failed outcomes to readable errors", () => + Effect.gen(function* () { + const upToDate = yield* makeHarness({ + reports: (requestId) => [ + report(requestId, makeState({ status: "up-to-date" }), { outcome: "up-to-date" }), + ], + }); + expect((yield* upToDate.service.run(() => Effect.void).pipe(Effect.flip)).reason).toBe( + "The T3 Code desktop app on this machine is already up to date on 1.2.3.", + ); + + const failed = yield* makeHarness({ + reports: (requestId) => [ + report(requestId, makeState({ status: "error", message: "feed unreachable" }), { + outcome: "failed", + reason: "feed unreachable", + }), + ], + }); + expect((yield* failed.service.run(() => Effect.void).pipe(Effect.flip)).reason).toBe( + "feed unreachable", + ); + }), + ); + + it.effect("replays a retained commit failure for the preparation token", () => + Effect.gen(function* () { + const { service } = yield* makeHarness({ + reports: (requestId) => [ + report(requestId, makeState({ status: "downloaded", downloadedVersion: "1.2.4" }), { + outcome: "ready-to-install", + }), + report( + requestId, + makeState({ + status: "downloaded", + downloadedVersion: "1.2.4", + errorContext: "install", + message: "installer refused", + }), + { outcome: "failed", reason: "installer refused" }, + ), + ], + }); + const prepared = yield* service.run(() => Effect.void); + + expect( + (yield* service.commit(prepared.desktopUpdateToken ?? "missing").pipe(Effect.flip)).reason, + ).toBe("installer refused"); + }), + ); + + it.effect("fails when the desktop stops reporting before a terminal outcome", () => + Effect.gen(function* () { + const { service } = yield* makeHarness({ + reports: (requestId) => [report(requestId, makeState({ status: "checking" }))], + }); + expect((yield* service.run(() => Effect.void).pipe(Effect.flip)).reason).toBe( + "The desktop app stopped reporting its update.", + ); + }), + ); + + it.effect("allows only one desktop update at a time", () => + Effect.gen(function* () { + const { service } = yield* makeHarness({ reports: () => [], keepOpen: true }); + const first = yield* Effect.forkChild( + service.run(() => Effect.void), + { + startImmediately: true, + }, + ); + expect((yield* service.run(() => Effect.void).pipe(Effect.flip)).reason).toBe( + "A desktop app update is already in progress.", + ); + yield* Fiber.interrupt(first); + + const retry = yield* Effect.forkChild( + service.run(() => Effect.void), + { + startImmediately: true, + }, + ); + yield* Effect.yieldNow; + expect((yield* service.run(() => Effect.void).pipe(Effect.flip)).reason).toBe( + "A desktop app update is already in progress.", + ); + yield* Fiber.interrupt(retry); + }), + ); +}); diff --git a/apps/server/src/desktopUpdate/DesktopAppUpdate.ts b/apps/server/src/desktopUpdate/DesktopAppUpdate.ts new file mode 100644 index 000000000..9770aa083 --- /dev/null +++ b/apps/server/src/desktopUpdate/DesktopAppUpdate.ts @@ -0,0 +1,219 @@ +import { + ServerSelfUpdateError, + type DesktopUpdateState, + type DesktopUpdateStatusReport, + type ServerSelfUpdateProgressStage, + type ServerSelfUpdateResult, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Stream from "effect/Stream"; + +import { ServerConfig } from "../config.ts"; +import * as DesktopTelemetryReceiver from "../resourceTelemetry/DesktopTelemetryReceiver.ts"; + +/** Backstop for a desktop updater that hangs without ever reporting a + terminal outcome. Generous: it covers a slow download of a full build. */ +const DESKTOP_UPDATE_TIMEOUT = Duration.minutes(20); +const DESKTOP_INSTALL_TIMEOUT = Duration.minutes(2); + +/** Progress stage a desktop update state maps to, or null when the state + carries no progress worth streaming. */ +export function desktopUpdateProgressStage( + state: DesktopUpdateState, +): ServerSelfUpdateProgressStage | null { + switch (state.status) { + case "checking": + case "available": + case "downloading": + return "downloading"; + case "downloaded": + return "installing"; + default: + return null; + } +} + +export class DesktopAppUpdate extends Context.Service< + DesktopAppUpdate, + { + /** True when this server was spawned by a desktop app that can be + driven over the telemetry control channel. */ + readonly available: boolean; + /** Checks and downloads through the desktop app, then returns a token + while this server is still connected. `commit` starts installation. */ + readonly run: ( + reportProgress: (stage: ServerSelfUpdateProgressStage) => Effect.Effect, + ) => Effect.Effect; + /** Starts the prepared install. Success stops this server, so this effect + returns only when installation fails or times out. */ + readonly commit: (requestId: string) => Effect.Effect; + } +>()("t3/desktopUpdate/DesktopAppUpdate") {} + +export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* () { + const config = yield* ServerConfig; + const crypto = yield* Crypto.Crypto; + const receiver = yield* DesktopTelemetryReceiver.DesktopTelemetryReceiver; + const inFlight = yield* Ref.make(false); + + const available = config.mode === "desktop" && config.desktopTelemetryControlFd !== undefined; + const failWith = (reason: string, cause?: unknown) => + cause === undefined + ? new ServerSelfUpdateError({ reason }) + : new ServerSelfUpdateError({ reason, cause }); + + const consumeReports = ( + requestId: string, + changes: Stream.Stream, + reportProgress: (stage: ServerSelfUpdateProgressStage) => Effect.Effect, + ) => + Effect.gen(function* () { + const lastStage = yield* Ref.make(null); + const emitStage = (stage: ServerSelfUpdateProgressStage | null): Effect.Effect => + stage === null + ? Effect.void + : Ref.get(lastStage).pipe( + Effect.flatMap((previous) => + previous === stage + ? Effect.void + : Ref.set(lastStage, stage).pipe(Effect.andThen(reportProgress(stage))), + ), + ); + + const terminal = yield* changes.pipe( + Stream.filter((report) => report.requestId === requestId), + Stream.mapEffect( + (report): Effect.Effect> => + report.outcome === undefined + ? emitStage(desktopUpdateProgressStage(report.state)).pipe( + Effect.as(Option.none()), + ) + : Effect.succeed(Option.some(report)), + ), + Stream.filterMap( + Option.match({ + onNone: () => Result.failVoid, + onSome: Result.succeed, + }), + ), + Stream.runHead, + ); + if (Option.isNone(terminal)) { + return yield* failWith("The desktop app stopped reporting its update."); + } + + const report = terminal.value; + if (report.outcome === "ready-to-install") { + yield* emitStage("installing"); + const targetVersion = + report.state.downloadedVersion ?? + report.state.availableVersion ?? + report.state.currentVersion; + yield* Effect.logInfo("Desktop app update prepared for install.", { + targetVersion, + }); + yield* Ref.set(inFlight, false); + return { targetVersion, method: "desktop-app" as const, desktopUpdateToken: requestId }; + } + if (report.outcome === "up-to-date") { + return yield* failWith( + `The T3 Code desktop app on this machine is already up to date on ${report.state.currentVersion}.`, + ); + } + return yield* failWith( + report.reason ?? report.state.message ?? "The desktop app update failed.", + ); + }); + + const run: DesktopAppUpdate["Service"]["run"] = Effect.fn("desktopUpdate.desktopAppUpdate.run")( + function* (reportProgress) { + if (!available) { + return yield* failWith( + "This server was not started by the T3 Code desktop app, so it cannot drive a desktop update.", + ); + } + if (yield* Ref.getAndSet(inFlight, true)) { + return yield* failWith("A desktop app update is already in progress."); + } + + return yield* Effect.scoped( + Effect.gen(function* () { + const requestId = yield* crypto.randomUUIDv4.pipe( + Effect.mapError((error) => + failWith("Could not generate a desktop update request id.", error), + ), + ); + // Subscribe before sending the request so a fast first report + // cannot be missed. + const { changes } = yield* receiver.desktopUpdates; + yield* receiver + .requestDesktopUpdate(requestId) + .pipe( + Effect.mapError((error) => + failWith("Could not reach the T3 Code desktop app on this machine.", error), + ), + ); + return yield* consumeReports(requestId, changes, reportProgress).pipe( + Effect.onInterrupt(() => receiver.cancelDesktopUpdate(requestId).pipe(Effect.ignore)), + ); + }), + ).pipe( + Effect.timeout(DESKTOP_UPDATE_TIMEOUT), + Effect.catchTags({ + TimeoutError: () => failWith("The desktop app did not finish the update in time."), + }), + Effect.onError(() => Ref.set(inFlight, false)), + ); + }, + ); + + const commit: DesktopAppUpdate["Service"]["commit"] = Effect.fn( + "desktopUpdate.desktopAppUpdate.commit", + )(function* (requestId) { + if (!available) { + return yield* failWith("This server cannot commit a desktop app update."); + } + const terminal = yield* Effect.scoped( + Effect.gen(function* () { + const { latest, changes } = yield* receiver.desktopUpdates; + const reports = Option.match(latest, { + onNone: () => changes, + onSome: (report) => Stream.concat(Stream.make(report), changes), + }); + yield* receiver + .commitDesktopUpdate(requestId) + .pipe( + Effect.mapError((error) => failWith("Could not reach the T3 Code desktop app.", error)), + ); + return yield* reports.pipe( + Stream.filter((report) => report.requestId === requestId && report.outcome === "failed"), + Stream.runHead, + Effect.timeout(DESKTOP_INSTALL_TIMEOUT), + Effect.catchTags({ + TimeoutError: () => + failWith("The desktop app did not report an install result in time."), + }), + ); + }), + ); + if (Option.isNone(terminal)) { + return yield* failWith("The desktop app stopped reporting the install."); + } + return yield* failWith( + terminal.value.reason ?? + terminal.value.state.message ?? + "The desktop app failed to install the update.", + ); + }); + + return DesktopAppUpdate.of({ available, run, commit }); +}); + +export const layer = Layer.effect(DesktopAppUpdate, make()); diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 9dc1a8eb5..f8344f122 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -145,6 +145,43 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }), ); + it.effect("advertises desktopAppUpdate only with desktop mode and the control fd", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-desktop-update-test-", + }); + const serverConfig = yield* makeServerConfig(baseDir); + yield* fileSystem.makeDirectory(serverConfig.stateDir, { recursive: true }); + + const describeWith = (overrides: Partial) => + Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + return yield* serverEnvironment.getDescriptor; + }).pipe( + Effect.provide( + ServerEnvironment.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(ServerConfig.layer({ ...serverConfig, ...overrides })), + ), + ), + ); + + const withFd = yield* describeWith({ mode: "desktop", desktopTelemetryControlFd: 5 }); + expect(withFd.capabilities.serverSelfUpdate).toBe("desktop-managed"); + expect(withFd.capabilities.desktopAppUpdate).toBe(true); + expect(withFd.capabilities.serverSelfUpdateProgress).toBe(true); + + const withoutFd = yield* describeWith({ mode: "desktop" }); + expect(withoutFd.capabilities.serverSelfUpdate).toBe("desktop-managed"); + expect(withoutFd.capabilities.desktopAppUpdate).toBeUndefined(); + expect(withoutFd.capabilities.serverSelfUpdateProgress).toBeUndefined(); + + const web = yield* describeWith({ mode: "web", desktopTelemetryControlFd: 5 }); + expect(web.capabilities.desktopAppUpdate).toBeUndefined(); + }), + ); + it.effect("structures persisted environment id filesystem failures", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index a65564507..f3da60e98 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -138,6 +138,12 @@ export const make = Effect.gen(function* () { desktopManaged: serverConfig.mode === "desktop", launcherManaged: launcher.managed, }); + // Static is correct: the control fd is known at bootstrap, and the desktop + // app and its bundled server ship in one artifact, so a present fd means + // the app speaks the requestDesktopUpdate protocol. WSL backends never get + // the fd and correctly do not advertise. + const desktopAppUpdate = + serverSelfUpdate === "desktop-managed" && serverConfig.desktopTelemetryControlFd !== undefined; const descriptor: ExecutionEnvironmentDescriptor = { environmentId, @@ -162,7 +168,10 @@ export const make = Effect.gen(function* () { threadTitleRegeneration: true, threadPullRequestLinking: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), - ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), + ...(serverSelfUpdate === "boot-service" || desktopAppUpdate + ? { serverSelfUpdateProgress: true } + : {}), + ...(desktopAppUpdate ? { desktopAppUpdate: true } : {}), }, }; diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts index 1fca5696a..0e4b99f9f 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts @@ -7,6 +7,7 @@ import { type DesktopHostTelemetryMessage as DesktopHostTelemetryMessageValue, type DesktopHostTelemetrySnapshot, DesktopTelemetryControlMessage, + type DesktopUpdateStatusReport, type ResourceTelemetrySourceStatus, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; @@ -171,6 +172,29 @@ export class DesktopTelemetryReceiver extends Context.Service< readonly setDiagnosticsDemand: ( enabled: boolean, ) => Effect.Effect; + /** Asks the desktop app supervising this server to update itself. The + desktop answers with desktopUpdateStatus reports carrying the same + requestId. */ + readonly requestDesktopUpdate: ( + requestId: string, + ) => Effect.Effect; + readonly commitDesktopUpdate: ( + requestId: string, + ) => Effect.Effect; + readonly cancelDesktopUpdate: ( + requestId: string, + ) => Effect.Effect; + /** Latest desktop update state report plus subsequent reports. The + desktop replays its latest report when the backend attaches, so this + is populated shortly after startup on desktop-managed servers. */ + readonly desktopUpdates: Effect.Effect< + { + readonly latest: Option.Option; + readonly changes: Stream.Stream; + }, + never, + Scope.Scope + >; } >()("t3/resourceTelemetry/DesktopTelemetryReceiver") {} @@ -322,6 +346,8 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") ); const changes = yield* PubSub.sliding(8); const healthChanges = yield* PubSub.sliding(4); + const latestUpdateReport = yield* Ref.make(Option.none()); + const updateReportChanges = yield* PubSub.sliding(16); const controlMutex = yield* Semaphore.make(1); const snapshotMutex = yield* Semaphore.make(1); const health = yield* Ref.make({ @@ -503,6 +529,15 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") ); } + // Not a resource sample: do not touch `latest` or sample health. + if (message.type === "desktopUpdateStatus") { + return recordContact.pipe( + Effect.andThen(Ref.set(latestUpdateReport, Option.some(message))), + Effect.andThen(PubSub.publish(updateReportChanges, message)), + Effect.asVoid, + ); + } + const sampledAt = DateTime.makeUnsafe(message.sampledAtUnixMs); return snapshotMutex.withPermits(1)( recordContact.pipe( @@ -616,6 +651,24 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") health: Ref.get(health), subscribeHealth: subscribeBeforeSnapshotWithoutMutex(healthChanges, Ref.get(health)), setDiagnosticsDemand, + requestDesktopUpdate: (requestId) => + sendControlMessage({ + version: 1, + type: "requestDesktopUpdate", + requestId, + }), + commitDesktopUpdate: (requestId) => + sendControlMessage({ version: 1, type: "commitDesktopUpdate", requestId }), + cancelDesktopUpdate: (requestId) => + sendControlMessage({ version: 1, type: "cancelDesktopUpdate", requestId }), + desktopUpdates: Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(updateReportChanges); + const initial = yield* Ref.get(latestUpdateReport); + return { + latest: initial, + changes: Stream.fromSubscription(subscription), + }; + }), }); }); @@ -656,6 +709,15 @@ export const layerTest = ( })), ), setDiagnosticsDemand: () => Effect.void, + requestDesktopUpdate: () => Effect.void, + commitDesktopUpdate: () => Effect.void, + cancelDesktopUpdate: () => Effect.void, + desktopUpdates: + overrides.desktopUpdates ?? + Effect.succeed({ + latest: Option.none(), + changes: Stream.empty, + }), ...overrides, }), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0c98911c3..f22388e4c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -105,6 +105,7 @@ import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts" import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as CloudCliState from "./cloud/CliState.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; +import * as DesktopAppUpdate from "./desktopUpdate/DesktopAppUpdate.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; @@ -178,6 +179,12 @@ const HostPowerMonitorLayerLive = HostPowerMonitor.layer.pipe( Layer.provide(DesktopTelemetryReceiverLayerLive), ); +// Reuses DesktopTelemetryReceiverLayerLive: a fresh receiver layer here +// would open a second reader on the desktop telemetry fd. +const DesktopAppUpdateLayerLive = DesktopAppUpdate.layer.pipe( + Layer.provide(DesktopTelemetryReceiverLayerLive), +); + const BackgroundLayerLive = BackgroundPolicy.layer.pipe( Layer.provide(HostPowerMonitorLayerLive), Layer.provideMerge(ServerSettingsLayerLive), @@ -511,7 +518,7 @@ export const makeRoutesLayer = Layer.mergeAll( // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. Layer.provide(PullRequestServiceLive), Layer.provide(PreviewAutomationBroker.layer), - Layer.provide(ServerSelfUpdate.layer), + Layer.provide(ServerSelfUpdate.layer.pipe(Layer.provide(DesktopAppUpdateLayerLive))), Layer.provide(commandReadinessLayer), Layer.provide(browserApiCorsLayer), Layer.provide(httpCompressionLayer), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 93da64039..2c997679f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2028,6 +2028,12 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "server" }, ), + [WS_METHODS.serverCommitDesktopUpdate]: (input) => + observeRpcEffect( + WS_METHODS.serverCommitDesktopUpdate, + serverSelfUpdate.commitDesktopUpdate(input.requestId), + { "rpc.aggregate": "server" }, + ), [WS_METHODS.serverUpsertKeybinding]: (rule) => observeRpcEffect( WS_METHODS.serverUpsertKeybinding, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index be2786d1e..9aeb3a42f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -473,6 +473,7 @@ import { resolveServerConfigVersionMismatch, resolveServerSelfUpdateCapability, serverUpdateGuidance, + supportsDesktopAppUpdate, } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; import { @@ -2321,6 +2322,7 @@ function ChatViewContent(props: ChatViewProps) { : "server"; const serverUpdateEnvironmentId = activeThread?.environmentId ?? null; const versionMismatchSelfUpdate = resolveServerSelfUpdateCapability(serverConfig); + const versionMismatchDesktopAppUpdate = supportsDesktopAppUpdate(serverConfig); const serverUpdateState = useAtomValue( serverEnvironment.updateStateAtom(serverUpdateEnvironmentId), ); @@ -2441,19 +2443,25 @@ function ChatViewContent(props: ChatViewProps) { "Server update available" ), description: - !updateInProgress && !updateFailed && versionMismatchSelfUpdate === "desktop-managed" + !updateInProgress && + !updateFailed && + versionMismatchSelfUpdate === "desktop-managed" && + !versionMismatchDesktopAppUpdate ? serverUpdateGuidance(versionMismatchSelfUpdate, versionMismatchServerLabel) : undefined, // The desktop-managed guidance is already the description; the action - // slot would only repeat it. + // slot would only repeat it. When the desktop app accepts remote + // update requests, the action button takes over instead. actions: updateInProgress || !versionMismatch || - versionMismatchSelfUpdate === "desktop-managed" ? undefined : ( + (versionMismatchSelfUpdate === "desktop-managed" && + !versionMismatchDesktopAppUpdate) ? undefined : ( @@ -2491,6 +2499,7 @@ function ChatViewContent(props: ChatViewProps) { versionMismatchDismissKey, serverUpdateEnvironmentId, versionMismatchSelfUpdate, + versionMismatchDesktopAppUpdate, versionMismatchServerLabel, ]); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; diff --git a/apps/web/src/components/ServerUpdateAction.test.tsx b/apps/web/src/components/ServerUpdateAction.test.tsx index 32a457d41..794ff1cd8 100644 --- a/apps/web/src/components/ServerUpdateAction.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.test.tsx @@ -98,6 +98,49 @@ describe("ServerUpdateAction", () => { expect(testState.toast).not.toHaveBeenCalled(); }); + + it("keeps the manual instruction for desktop servers without remote update support", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Update the desktop app on that machine to update this server."); + expect(markup).not.toContain(" { + testState.updateServer.mockResolvedValue( + AsyncResult.success({ targetVersion: "0.0.34", method: "desktop-app" as const }), + ); + + const action = ServerUpdateAction({ + environmentId: "env-test" as EnvironmentId, + serverLabel: "Test server", + selfUpdate: "desktop-managed", + desktopAppUpdate: true, + targetVersion: "0.0.31", + }) as ActionElement; + + // No confirm-dialog host is mounted in this test, which the component + // treats as consent: the click itself was the request. + action.props.onClick?.(); + await flushPromises(); + + expect(testState.updateServer).toHaveBeenCalledWith({ + environmentId: "env-test", + input: { targetVersion: "0.0.31" }, + }); + expect(testState.toast).toHaveBeenCalledWith({ + type: "success", + title: "Test server updated", + description: "Desktop app relaunched on 0.0.34.", + }); + }); }); describe("ServerUpdateProgress", () => { diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 22a71b7dd..f85a11cc4 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -4,6 +4,8 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; + +import { requestConfirmDialog } from "~/confirmDialog"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { serverEnvironment } from "~/state/server"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -74,15 +76,20 @@ export function ServerUpdateAction({ environmentId, serverLabel, selfUpdate, + desktopAppUpdate = false, targetVersion, label = "Update", }: { readonly environmentId: EnvironmentId; readonly serverLabel: string; readonly selfUpdate: ServerSelfUpdateCapability | null; + /** The desktop app supervising this server accepts remote update + requests (capabilities.desktopAppUpdate). */ + readonly desktopAppUpdate?: boolean; readonly targetVersion: string; readonly label?: string; }) { + const isDesktopAppUpdate = selfUpdate === "desktop-managed"; const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false, }); @@ -105,6 +112,21 @@ export function ServerUpdateAction({ }); const handleUpdate = async () => { + if (pendingUpdateEnvironmentIds.has(environmentId)) { + return; + } + if (isDesktopAppUpdate) { + // No themed host mounted (undefined) means proceed: the click itself + // was the request. This is the only confirmation in the flow; the + // remote machine installs without asking anyone there. + const confirmed = + (await requestConfirmDialog( + `Update the T3 Code desktop app that runs the ${serverLabel}? It will close and relaunch on that machine.`, + )) ?? true; + if (!confirmed) { + return; + } + } if (pendingUpdateEnvironmentIds.has(environmentId)) { return; } @@ -128,14 +150,16 @@ export function ServerUpdateAction({ toastManager.add({ type: "success", title: `${serverLabel} updated`, - description: `Reconnected on t3@${result.value.targetVersion}.`, + description: isDesktopAppUpdate + ? `Desktop app relaunched on ${result.value.targetVersion}.` + : `Reconnected on t3@${result.value.targetVersion}.`, }); } finally { pendingUpdateEnvironmentIds.delete(environmentId); } }; - if (selfUpdate === "desktop-managed") { + if (selfUpdate === "desktop-managed" && !desktopAppUpdate) { return ( Update the desktop app on that machine to update this server. diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index bccad8c9f..063532932 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -117,6 +117,7 @@ import { useUiStateStore } from "~/uiStateStore"; import { resolveServerConfigVersionMismatch, resolveServerSelfUpdateCapability, + supportsDesktopAppUpdate, } from "~/versionSkew"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; @@ -1506,6 +1507,7 @@ function SavedBackendListRow({ environmentId={environmentId} serverLabel={`${environment.label} server`} selfUpdate={resolveServerSelfUpdateCapability(environment.serverConfig)} + desktopAppUpdate={supportsDesktopAppUpdate(environment.serverConfig)} targetVersion={versionMismatch.clientVersion} label={serverUpdateState.status === "failed" ? "Retry" : "Update"} /> @@ -3112,8 +3114,11 @@ export function ConnectionsSettings() { primaryServerUpdateState.status !== "running" ? ( diff --git a/apps/web/src/versionSkew.test.ts b/apps/web/src/versionSkew.test.ts index 32724f759..1827ff84c 100644 --- a/apps/web/src/versionSkew.test.ts +++ b/apps/web/src/versionSkew.test.ts @@ -18,6 +18,7 @@ import { resolveServerSelfUpdateCapability, resolveVersionMismatch, serverUpdateGuidance, + supportsDesktopAppUpdate, } from "./versionSkew"; const MISMATCH_HINT = @@ -192,6 +193,27 @@ describe("versionSkew", () => { expect(resolveServerSelfUpdateCapability(null)).toBeNull(); }); + it("detects remote desktop-app update support from config descriptors", () => { + const descriptor = (desktopAppUpdate?: boolean) => ({ + environment: { + environmentId: EnvironmentId.make("environment-desktop"), + label: "Desktop", + platform: { os: "darwin", arch: "arm64" } as const, + serverVersion: "9.9.9", + capabilities: { + repositoryIdentity: true, + serverSelfUpdate: "desktop-managed" as const, + ...(desktopAppUpdate === undefined ? {} : { desktopAppUpdate }), + }, + }, + }); + + expect(supportsDesktopAppUpdate(descriptor(true))).toBe(true); + expect(supportsDesktopAppUpdate(descriptor(false))).toBe(false); + expect(supportsDesktopAppUpdate(descriptor())).toBe(false); + expect(supportsDesktopAppUpdate(null)).toBe(false); + }); + it("matches version-drift guidance to the advertised update path", () => { expect(serverUpdateGuidance("respawn", "Remote server")).toBe( "Update the Remote server so they stay in sync.", diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts index ac0f10ad6..91b7caa97 100644 --- a/apps/web/src/versionSkew.ts +++ b/apps/web/src/versionSkew.ts @@ -98,6 +98,14 @@ export function resolveServerSelfUpdateCapability( return serverConfig?.environment.capabilities.serverSelfUpdate ?? null; } +/** True when the desktop app supervising this server can be told to update + itself over RPC. Older desktop servers only get the manual instruction. */ +export function supportsDesktopAppUpdate( + serverConfig: Pick | null | undefined, +): boolean { + return serverConfig?.environment.capabilities.desktopAppUpdate === true; +} + /** The command to hand users whose server cannot update itself. */ export function manualServerUpdateCommand(targetVersion: string): string { return `npx t3@${targetVersion}`; diff --git a/docs/internals/server-updates.md b/docs/internals/server-updates.md index 645312880..05990d3b0 100644 --- a/docs/internals/server-updates.md +++ b/docs/internals/server-updates.md @@ -85,8 +85,16 @@ recorded reason. Older servers without an ID retain version-only reconnect behav The existing additive RPC and lifecycle schemas remain compatible with older clients. New servers advertise remote self-update only when they have valid launcher context and a live IPC channel. -Desktop-managed servers direct the user to update the desktop app. Other process shapes provide a -manual command; the old detached foreground respawn path no longer exists. +Desktop-managed servers advertise `desktopAppUpdate` when the desktop telemetry control fd is +attached. The progress RPC asks the desktop app to check and download, then returns a preparation +token while the backend is still connected. Only after the client receives that result does it send +`server.commitDesktopUpdate`. A successful commit closes the connection and must reconnect at the +prepared version. The desktop app and bundled server versions stay equal because +`scripts/update-release-package-versions.ts` bumps them together. If install fails, the desktop keeps its windows, restarts stopped backends, and +replays the failure for the same token. This two-phase handoff prevents backend shutdown from +dropping the only successful RPC result. Desktop servers without the capability direct the user to +update the desktop app locally. Other process shapes provide a manual command; the old detached +foreground respawn path no longer exists. ## Source Map diff --git a/docs/user/updating.md b/docs/user/updating.md index f4b94473c..e083aa019 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -22,11 +22,11 @@ The update does not remove saved threads, settings, or project files. ## Choose the Action You See -| Action | What to do | -| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Update server** | Available for the Pylon Linux background service. Select the button and leave Pylon open while it prepares, tests, restarts, and reconnects. | -| **Update the desktop app** | Open the Pylon desktop app on the machine that runs the server and install the app update there. Reopen it if needed. | -| **Copy update command** | Copy the command, open a terminal on the server machine, stop the current Pylon server, and relaunch it with the copied command and any startup options you normally use. | +| Action | What to do | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Update server** | Available for the Pylon Linux background service and for servers run by a current Pylon desktop app. Select the button and leave Pylon open while it downloads, installs, restarts, and reconnects. For desktop-app servers this closes and relaunches the desktop app on that machine. If installation fails, the desktop app stays open and reconnects to its server. | +| **Update the desktop app** | Shown for desktop apps that predate remote updates. Open the Pylon desktop app on the machine that runs the server and install the app update there. Reopen it if needed. | +| **Copy update command** | Copy the command, open a terminal on the server machine, stop the current Pylon server, and relaunch it with the copied command and any startup options you normally use. | The available action depends on how that server was started. Pylon does not update connected servers silently in the background. diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 9354db9c9..aec3d5d67 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -46,6 +46,7 @@ import * as EnvironmentRegistry from "./registry.ts"; import * as RpcSession from "../rpc/session.ts"; import * as EnvironmentSupervisor from "./supervisor.ts"; import * as ConnectionWakeups from "./wakeups.ts"; +import { runDesktopCommitWithReconnectObserver } from "../state/server.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -420,6 +421,33 @@ function awaitConnectionState( } describe("EnvironmentRegistry", () => { + it.effect("replays connected state when arming a desktop commit observer", () => + Effect.gen(function* () { + const harness = yield* makeHarness([TARGET]); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + yield* awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "connected", + ); + + const commits = yield* Ref.make(0); + const result = yield* runDesktopCommitWithReconnectObserver( + registry.stateChanges(TARGET.environmentId), + Ref.update(commits, (count) => count + 1).pipe( + Effect.andThen(Effect.fail("commit refused")), + ), + ).pipe(Effect.flip, Effect.timeout("1 second")); + + expect(result).toBe("commit refused"); + expect(yield* Ref.get(commits)).toBe(1); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + it.effect("hydrates connection profiles into catalog entries", () => Effect.gen(function* () { const harness = yield* makeHarness([SSH_CONNECTION], [SSH_PROFILE]); diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index 745db2b99..3f12f44d5 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from "@effect/vitest"; import { EnvironmentId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; import * as Latch from "effect/Latch"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -833,6 +835,55 @@ describe("runtime command runner", () => { }), ); + it.effect("keeps an outer reconnect observer alive after a scheduled handoff returns", () => + Effect.gen(function* () { + const commitStarting = yield* Deferred.make(); + const observerArmed = yield* Deferred.make(); + const reconnected = yield* Deferred.make(); + const states = yield* Queue.unbounded<{ readonly phase: string }>(); + yield* Queue.offer(states, { phase: "connected" }); + const runtime = Atom.runtime(Layer.empty); + const scheduler = createAtomCommandScheduler(); + const concurrency = { mode: "serial" as const, key: () => "shared" }; + const command = createRuntimeCommand(runtime, { + label: "test.desktop-update-handoff", + execute: (_input: void, registry) => + Effect.gen(function* () { + yield* Deferred.await(commitStarting).pipe( + Effect.andThen( + Stream.fromQueue(states).pipe( + Stream.tap(() => Deferred.succeed(observerArmed, undefined)), + Stream.dropWhile((state) => state.phase === "connected"), + Stream.filter((state) => state.phase === "connected"), + Stream.runHead, + ), + ), + Effect.andThen(Deferred.succeed(reconnected, undefined)), + Effect.forkChild, + ); + yield* scheduleAtomCommandEffect( + registry, + scheduler, + concurrency, + undefined, + Deferred.succeed(commitStarting, undefined).pipe( + Effect.andThen(Deferred.await(observerArmed)), + ), + ); + yield* Queue.offer(states, { phase: "backoff" }); + yield* Queue.offer(states, { phase: "connected" }); + yield* Deferred.await(reconnected); + }), + }); + const registry = AtomRegistry.make(); + + expect(yield* Effect.promise(() => command.run(registry, undefined))).toMatchObject({ + _tag: "Success", + }); + registry.dispose(); + }), + ); + it("deduplicates single-flight commands by key", async () => { const latch = Latch.makeUnsafe(); let executions = 0; diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 17dc09998..8574a4c74 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -8,6 +8,7 @@ import { import { describe, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -34,6 +35,7 @@ import { makeEnvironmentServerConfigState, isLegacyUpdateHandoffLoss, matchesServerUpdateReadyEvent, + matchesServerUpdateResumeEvent, nudgeReconnectDuringUpdateRestart, projectServerWelcome, resolveServerConfigValue, @@ -41,6 +43,9 @@ import { serverUpdateStateForProgressEvent, serverUpdateStateForServerVersion, validateServerUpdateReadyEvent, + waitForNextEnvironmentReconnect, + waitForDesktopUpdateTarget, + runDesktopCommitWithReconnectObserver, } from "./server.ts"; const CONFIG = { @@ -80,6 +85,83 @@ function session(client: WsRpcProtocolClient): RpcSession { } describe("update restart reconnect nudges", () => { + it.effect("retries a desktop commit that was lost before delivery", () => + Effect.gen(function* () { + const readyEvents = + yield* Queue.unbounded[1]>(); + const ready = (serverVersion: string) => + ({ + version: 1 as const, + sequence: 1, + type: "ready" as const, + payload: { + at: "2026-09-01T00:00:00.000Z", + environment: { serverVersion }, + }, + }) as Parameters[1]; + yield* Queue.offerAll(readyEvents, [ready("0.0.30"), ready("0.0.31")]); + const retries = yield* Ref.make(0); + const disconnect = new RpcClientError.RpcClientError({ + reason: new Socket.SocketCloseError({ code: 1006 }), + }); + + const result = yield* waitForDesktopUpdateTarget( + "0.0.31", + Queue.take(readyEvents), + Ref.update(retries, (count) => count + 1).pipe(Effect.andThen(Effect.fail(disconnect))), + ); + + expect(result.payload.environment.serverVersion).toBe("0.0.31"); + expect(yield* Ref.get(retries)).toBe(1); + }), + ); + it.effect("observes a fast reconnect even when the caller awaits it later", () => + Effect.gen(function* () { + const states = yield* Queue.unbounded<{ readonly phase: string }>(); + const reconnected = yield* waitForNextEnvironmentReconnect(Stream.fromQueue(states)).pipe( + Effect.forkChild, + ); + yield* Queue.offerAll(states, [ + { phase: "connected" }, + { phase: "backoff" }, + { phase: "connected" }, + ]); + + yield* Fiber.join(reconnected); + }), + ); + it.effect("arms the retry observer before a commit can disconnect", () => + Effect.gen(function* () { + const allowSubscription = yield* Deferred.make(); + const subscriptionStarted = yield* Deferred.make(); + const states = yield* Queue.unbounded<{ readonly phase: string }>(); + const commits = yield* Ref.make(0); + const disconnect = new RpcClientError.RpcClientError({ + reason: new Socket.SocketCloseError({ code: 1006 }), + }); + const stateChanges = Stream.unwrap( + Deferred.succeed(subscriptionStarted, undefined).pipe( + Effect.andThen(Deferred.await(allowSubscription)), + Effect.as(Stream.fromQueue(states)), + ), + ); + const retry = yield* runDesktopCommitWithReconnectObserver( + stateChanges, + Ref.update(commits, (count) => count + 1).pipe( + Effect.andThen(Queue.offerAll(states, [{ phase: "backoff" }, { phase: "connected" }])), + Effect.andThen(Effect.fail(disconnect)), + ), + ).pipe(Effect.flip, Effect.forkChild); + + yield* Deferred.await(subscriptionStarted); + expect(yield* Ref.get(commits)).toBe(0); + yield* Deferred.succeed(allowSubscription, undefined); + yield* Queue.offer(states, { phase: "connected" }); + + expect(yield* Fiber.join(retry)).toBe(disconnect); + expect(yield* Ref.get(commits)).toBe(1); + }), + ); it.effect("retries once per backoff entry instead of only the first", () => Effect.gen(function* () { const retries = yield* Ref.make(0); @@ -291,6 +373,36 @@ describe("server state projection", () => { }), ); + it("requires tokenless desktop updates to reach the target version", () => { + const ready = (serverVersion: string) => + ({ + version: 1 as const, + sequence: 1, + type: "ready" as const, + payload: { + at: "2026-09-01T00:00:00.000Z", + environment: { serverVersion }, + }, + }) as Parameters[1]; + + expect( + matchesServerUpdateResumeEvent( + { targetVersion: "0.0.31", method: "desktop-app" }, + ready("0.0.30"), + ), + ).toBe(false); + expect( + matchesServerUpdateResumeEvent( + { + targetVersion: "0.0.31", + method: "desktop-app", + desktopUpdateToken: "update-1", + }, + ready("0.0.30"), + ), + ).toBe(true); + }); + it("applies every config category to the projected snapshot", () => { const snapshot = applyServerConfigProjection(Option.none(), { version: 1, diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 34d40e87f..84158f4df 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -10,8 +10,10 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -124,6 +126,16 @@ export function matchesServerUpdateReadyEvent( : event.payload.updateOutcome?.id === result.updateId; } +export function matchesServerUpdateResumeEvent( + result: ServerSelfUpdateResult, + event: ServerLifecycleStreamReadyEvent, +): boolean { + return ( + (result.method === "desktop-app" && result.desktopUpdateToken !== undefined) || + matchesServerUpdateReadyEvent(result, event) + ); +} + export function validateServerUpdateReadyEvent( result: ServerSelfUpdateResult, event: ServerLifecycleStreamReadyEvent, @@ -192,6 +204,71 @@ export function nudgeReconnectDuringUpdateRestart(input: { ); } +export function waitForNextEnvironmentReconnect( + stateChanges: Stream.Stream<{ readonly phase: string }, E>, +): Effect.Effect { + return stateChanges.pipe( + Stream.dropWhile((state) => state.phase === "connected"), + Stream.filter((state) => state.phase === "connected"), + Stream.runHead, + Effect.asVoid, + ); +} + +export const runDesktopCommitWithReconnectObserver = Effect.fn( + "runDesktopCommitWithReconnectObserver", +)(function* ( + stateChanges: Stream.Stream<{ readonly phase: string }, EState>, + commit: Effect.Effect, +) { + const armed = yield* Deferred.make(); + const reconnected = yield* Deferred.make(); + const observer = yield* stateChanges.pipe( + Stream.tap(() => Deferred.succeed(armed, undefined)), + waitForNextEnvironmentReconnect, + Effect.andThen(Deferred.succeed(reconnected, undefined)), + Effect.forkChild, + ); + yield* Deferred.await(armed); + const commitExit = yield* commit.pipe(Effect.exit); + if (Exit.isSuccess(commitExit)) { + yield* Fiber.interrupt(observer); + return; + } + if (!isLegacyUpdateHandoffLoss(commitExit.cause)) { + yield* Fiber.interrupt(observer); + return yield* Effect.failCause(commitExit.cause); + } + yield* Deferred.await(reconnected).pipe(Effect.timeout(SERVER_UPDATE_RESUME_TIMEOUT)); + return yield* Effect.failCause(commitExit.cause); +}); + +export const waitForDesktopUpdateTarget = Effect.fn("waitForDesktopUpdateTarget")(function* < + EReady, + ECommit, +>( + targetVersion: string, + nextReady: Effect.Effect, + retryCommit: Effect.Effect, + maxCommitAttempts = 3, +): Effect.fn.Return { + for (let attempt = 1; attempt <= maxCommitAttempts; attempt += 1) { + const ready = yield* nextReady; + if (ready.payload.environment.serverVersion === targetVersion) return ready; + if (attempt === maxCommitAttempts) break; + const retryExit = yield* retryCommit.pipe(Effect.exit); + if (Exit.isSuccess(retryExit)) break; + if (!isLegacyUpdateHandoffLoss(retryExit.cause)) { + return yield* Effect.failCause(retryExit.cause); + } + } + return yield* new ServerUpdateTerminalError({ + targetVersion, + status: "failed", + reason: "The desktop app resumed without installing the prepared update.", + }); +}); + export function serverUpdateStateForProgressEvent( fromVersion: string, targetVersion: string, @@ -574,11 +651,12 @@ export function createServerEnvironmentAtoms( concurrency: configConcurrency, execute: (target, atomRegistry) => { const stateAtom = serverUpdateStateAtom(target.environmentId); - const targetVersion = target.input.targetVersion; + let targetVersion = target.input.targetVersion; let fromVersion = atomRegistry.get(configValueAtom(target.environmentId))?.environment.serverVersion ?? targetVersion; let currentStage: ServerUpdateStage = "downloading"; + let desktopCommitLostTransport = false; atomRegistry.set(stateAtom, { status: "running", stage: currentStage, @@ -588,6 +666,21 @@ export function createServerEnvironmentAtoms( return Effect.gen(function* () { const environmentRegistry = yield* EnvironmentRegistry; + const desktopCommitStarting = yield* Deferred.make(); + const desktopReconnectObserverArmed = yield* Deferred.make(); + const desktopReconnected = yield* Deferred.make(); + yield* Deferred.await(desktopCommitStarting).pipe( + Effect.andThen( + environmentRegistry.stateChanges(target.environmentId).pipe( + Stream.tap(() => Deferred.succeed(desktopReconnectObserverArmed, undefined)), + Stream.dropWhile((state) => state.phase === "connected"), + Stream.filter((state) => state.phase === "connected"), + Stream.runHead, + ), + ), + Effect.andThen(Deferred.succeed(desktopReconnected, undefined)), + Effect.forkChild, + ); const result = yield* scheduleAtomCommandEffect( atomRegistry, configScheduler, @@ -659,6 +752,28 @@ export function createServerEnvironmentAtoms( return yield* Effect.failCause(exit.cause); }); + if ( + updateResult.method === "desktop-app" && + updateResult.desktopUpdateToken !== undefined + ) { + yield* Deferred.succeed(desktopCommitStarting, undefined); + yield* Deferred.await(desktopReconnectObserverArmed); + const commitExit = yield* environmentRegistry + .run( + target.environmentId, + request(WS_METHODS.serverCommitDesktopUpdate, { + requestId: updateResult.desktopUpdateToken, + }), + ) + .pipe(Effect.exit); + if (Exit.isFailure(commitExit) && !isLegacyUpdateHandoffLoss(commitExit.cause)) { + return yield* Effect.failCause(commitExit.cause); + } + desktopCommitLostTransport = Exit.isFailure(commitExit); + } + + targetVersion = updateResult.targetVersion; + currentStage = "resuming"; atomRegistry.set(stateAtom, { status: "running", @@ -678,24 +793,55 @@ export function createServerEnvironmentAtoms( retryNow: environmentRegistry.retryNow(target.environmentId), }).pipe(Effect.forkChild); - const resumed = yield* environmentRegistry + if (result.method === "desktop-app" && desktopCommitLostTransport) { + yield* Deferred.await(desktopReconnected).pipe( + Effect.timeout(SERVER_UPDATE_RESUME_TIMEOUT), + ); + } + + const waitForReady = environmentRegistry .followStream(target.environmentId, subscribe(WS_METHODS.subscribeServerLifecycle, {})) .pipe( Stream.filter( (event): event is ServerLifecycleStreamReadyEvent => - event.type === "ready" && matchesServerUpdateReadyEvent(result, event), + event.type === "ready" && matchesServerUpdateResumeEvent(result, event), ), Stream.runHead, Effect.timeoutOption(SERVER_UPDATE_RESUME_TIMEOUT), Effect.map(Option.flatten), ); - if (Option.isNone(resumed)) { - return yield* new ServerUpdateResumeTimeoutError({ - environmentId: target.environmentId, - targetVersion, - }); - } - yield* validateServerUpdateReadyEvent(result, resumed.value); + const nextReady = waitForReady.pipe( + Effect.flatMap( + Option.match({ + onNone: () => + new ServerUpdateResumeTimeoutError({ + environmentId: target.environmentId, + targetVersion, + }), + onSome: Effect.succeed, + }), + ), + ); + const desktopUpdateToken = result.desktopUpdateToken; + const resumed = + result.method === "desktop-app" && desktopUpdateToken !== undefined + ? yield* waitForDesktopUpdateTarget( + result.targetVersion, + nextReady, + runDesktopCommitWithReconnectObserver( + environmentRegistry.stateChanges(target.environmentId), + environmentRegistry + .run( + target.environmentId, + request(WS_METHODS.serverCommitDesktopUpdate, { + requestId: desktopUpdateToken, + }), + ) + .pipe(Effect.asVoid), + ), + ) + : yield* nextReady; + yield* validateServerUpdateReadyEvent(result, resumed); atomRegistry.set(stateAtom, IDLE_SERVER_UPDATE_STATE); return result; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 82e50d3a5..40c12b590 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -30,8 +30,10 @@ export type ExecutionEnvironmentPlatform = typeof ExecutionEnvironmentPlatform.T /** How a server can replace itself with another version when asked over RPC. New servers only advertise the stable launcher-backed "boot-service" path; - "respawn" remains decodable for compatibility with older servers. */ -export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn"]); + "respawn" remains decodable for compatibility with older servers. + "desktop-app" means the supervising desktop app updated and relaunched + itself, bringing the server back with it. */ +export const ServerSelfUpdateMethod = Schema.Literals(["boot-service", "respawn", "desktop-app"]); export type ServerSelfUpdateMethod = typeof ServerSelfUpdateMethod.Type; /** What update path a client should offer for a server: one of the RPC @@ -97,6 +99,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ this is false โ€” no update would ever repaint it. Absent on older servers, which may still publish, so only an explicit false skips. */ agentActivityPublishing: Schema.optionalKey(Schema.Boolean), + /** The desktop app supervising this server can be driven over RPC: + server.updateServer runs its check -> download -> relaunch. Absent on + desktop servers whose app predates the remote trigger, where clients + must keep telling the user to update the app on that machine. */ + desktopAppUpdate: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/resourceTelemetry.ts b/packages/contracts/src/resourceTelemetry.ts index 2c1b2ffec..3ec1e4de3 100644 --- a/packages/contracts/src/resourceTelemetry.ts +++ b/packages/contracts/src/resourceTelemetry.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { HostPowerSnapshot } from "./background.ts"; +import { DesktopUpdateStateSchema } from "./ipc.ts"; export const RESOURCE_MONITOR_PROTOCOL_VERSION = 2 as const; @@ -244,9 +245,37 @@ export const DesktopHostTelemetryHello = Schema.Struct({ }); export type DesktopHostTelemetryHello = typeof DesktopHostTelemetryHello.Type; +/** Terminal marker for a server-triggered desktop update run. */ +export const DesktopUpdateRemoteOutcome = Schema.Literals([ + "ready-to-install", + "up-to-date", + "failed", +]); +export type DesktopUpdateRemoteOutcome = typeof DesktopUpdateRemoteOutcome.Type; + +/** + * Desktop main -> server: the desktop app's update state. Sent once when the + * backend attaches and again on every state change, so the server always + * knows whether the app on its machine can be updated and how a + * server-triggered run is progressing. + */ +export const DesktopUpdateStatusReport = Schema.Struct({ + version: Schema.Literal(1), + type: Schema.Literal("desktopUpdateStatus"), + // Set while a server-triggered run owns the flow; absent for the attach + // snapshot and for locally driven update activity. + requestId: Schema.optionalKey(TrimmedNonEmptyString), + // Terminal marker for a server-triggered run; absent while it is working. + outcome: Schema.optionalKey(DesktopUpdateRemoteOutcome), + reason: Schema.optionalKey(TrimmedNonEmptyString), + state: DesktopUpdateStateSchema, +}); +export type DesktopUpdateStatusReport = typeof DesktopUpdateStatusReport.Type; + export const DesktopHostTelemetryMessage = Schema.Union([ DesktopHostTelemetryHello, DesktopHostTelemetrySnapshot, + DesktopUpdateStatusReport, ]); export type DesktopHostTelemetryMessage = typeof DesktopHostTelemetryMessage.Type; @@ -266,9 +295,38 @@ export const DesktopTelemetrySetHostPowerIntervals = Schema.Struct({ export type DesktopTelemetrySetHostPowerIntervals = typeof DesktopTelemetrySetHostPowerIntervals.Type; +/** + * Server -> desktop main: run the app's own update flow now (check -> + * download -> quit-and-install) with no local confirmation. The remote click + * on the machine that sent the RPC is the consent. + */ +export const DesktopTelemetryRequestDesktopUpdate = Schema.Struct({ + version: Schema.Literal(1), + type: Schema.Literal("requestDesktopUpdate"), + requestId: TrimmedNonEmptyString, +}); +export type DesktopTelemetryRequestDesktopUpdate = typeof DesktopTelemetryRequestDesktopUpdate.Type; + +export const DesktopTelemetryCommitDesktopUpdate = Schema.Struct({ + version: Schema.Literal(1), + type: Schema.Literal("commitDesktopUpdate"), + requestId: TrimmedNonEmptyString, +}); +export type DesktopTelemetryCommitDesktopUpdate = typeof DesktopTelemetryCommitDesktopUpdate.Type; + +export const DesktopTelemetryCancelDesktopUpdate = Schema.Struct({ + version: Schema.Literal(1), + type: Schema.Literal("cancelDesktopUpdate"), + requestId: TrimmedNonEmptyString, +}); +export type DesktopTelemetryCancelDesktopUpdate = typeof DesktopTelemetryCancelDesktopUpdate.Type; + export const DesktopTelemetryControlMessage = Schema.Union([ DesktopTelemetrySetDiagnosticsDemand, DesktopTelemetrySetHostPowerIntervals, + DesktopTelemetryRequestDesktopUpdate, + DesktopTelemetryCommitDesktopUpdate, + DesktopTelemetryCancelDesktopUpdate, ]); export type DesktopTelemetryControlMessage = typeof DesktopTelemetryControlMessage.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 774117c47..cab59b104 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -210,6 +210,7 @@ import { } from "./previewAutomation.ts"; import { ServerConfigStreamEvent, + DesktopUpdateCommitInput, ServerConfig, ServerProviderUpdateError, ServerProviderMutationBusyError, @@ -367,6 +368,7 @@ export const WS_METHODS = { serverCancelProviderLogin: "server.cancelProviderLogin", serverUpdateServer: "server.updateServer", serverUpdateServerWithProgress: "server.updateServerWithProgress", + serverCommitDesktopUpdate: "server.commitDesktopUpdate", serverUpsertKeybinding: "server.upsertKeybinding", serverRemoveKeybinding: "server.removeKeybinding", serverGetSettings: "server.getSettings", @@ -677,6 +679,12 @@ export const WsServerUpdateServerWithProgressRpc = Rpc.make( }, ); +export const WsServerCommitDesktopUpdateRpc = Rpc.make(WS_METHODS.serverCommitDesktopUpdate, { + payload: DesktopUpdateCommitInput, + success: ServerSelfUpdateResult, + error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), +}); + export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { payload: Schema.Struct({}), success: ServerSettings, @@ -1365,6 +1373,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerCancelProviderLoginRpc, WsServerUpdateServerRpc, WsServerUpdateServerWithProgressRpc, + WsServerCommitDesktopUpdateRpc, WsServerUpsertKeybindingRpc, WsServerRemoveKeybindingRpc, WsServerGetSettingsRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 6c244eee8..7aefa2b25 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1125,9 +1125,16 @@ export const ServerSelfUpdateResult = Schema.Struct({ method: ServerSelfUpdateMethod, /** Launcher-generated correlation ID. Absent when talking to older servers. */ updateId: Schema.optionalKey(TrimmedNonEmptyString), + /** Desktop preparation token. Present only for the desktop-app method. */ + desktopUpdateToken: Schema.optionalKey(TrimmedNonEmptyString), }); export type ServerSelfUpdateResult = typeof ServerSelfUpdateResult.Type; +export const DesktopUpdateCommitInput = Schema.Struct({ + requestId: TrimmedNonEmptyString, +}); +export type DesktopUpdateCommitInput = typeof DesktopUpdateCommitInput.Type; + export const ServerSelfUpdateProgressStage = Schema.Literals(["downloading", "installing"]); export type ServerSelfUpdateProgressStage = typeof ServerSelfUpdateProgressStage.Type; From 5e4755d93ffc5dc7a4603e34c574b34bfcbe7c96 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 1 Sep 2026 19:26:07 -0700 Subject: [PATCH 08/32] fix(web): project default model works on the hosted app (#9142) Co-authored-by: Claude Code (cherry picked from commit 0e1570bde5b64458449a45989f1b56d6fd651883) --- .../settings/IntegrationsSettings.tsx | 6 ++- .../settings/ProjectSettingsPanel.tsx | 37 +++++++++---- .../components/settings/SettingsPanels.tsx | 10 ++++ .../settings/SourceControlSettings.tsx | 4 +- .../settings/SourceControlWritingSettings.tsx | 3 ++ .../components/settings/settingsLayout.tsx | 52 +++++++++++++++++-- apps/web/src/hooks/useSettings.ts | 35 +++++++++++-- apps/web/src/hostedPairing.ts | 9 +++- 8 files changed, 132 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index bed2973b9..1d6cf700c 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -408,6 +408,7 @@ function AgentBrowserAccessSetting() { return ( - {/* Server-authoritative, so it stays editable on every client and sits - outside the block covering the desktop-only defaults. */} + {/* Server-authoritative, so it stays editable on any client anchored to + a server; `serverScoped` covers the hosted app, which has none. It + sits outside the block covering the desktop-only defaults. */} {previewDefaultsDisabled ? ( {previewDefaults} diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 3a9d9c9e1..b6ebc6e2e 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -39,6 +39,7 @@ import { useComposerDraftStore } from "../../composerDraftStore"; import { isElectron } from "../../env"; import { useClientSettings, + useEnvironmentSettings, useUpdateClientSettings, usePrimarySettings, } from "../../hooks/useSettings"; @@ -69,7 +70,7 @@ import { import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; import { useProjects, useThreadShells } from "../../state/entities"; import { projectEnvironment } from "../../state/projects"; -import { primaryServerProvidersAtom, serverEnvironment } from "../../state/server"; +import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; @@ -292,10 +293,20 @@ export function ProjectSettingsPanel({ projectKey }: { projectKey: string }) { function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const navigate = useNavigate(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const representative = + group.memberProjects.find( + (member) => member.environmentId === group.environmentId && member.id === group.id, + ) ?? group.memberProjects[0]!; const settings = usePrimarySettings(); + // Provider instances and model options belong to the environment that runs + // the project's threads. The hosted app has no primary environment, so + // reading them from there would show "No providers available" everywhere. + const projectSettings = useEnvironmentSettings(representative.environmentId); + const serverProviders = + useAtomValue(serverEnvironment.providersValueAtom(representative.environmentId)) ?? + EMPTY_SERVER_PROVIDERS; const updateClientSettings = useUpdateClientSettings(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const serverProviders = useAtomValue(primaryServerProvidersAtom); const threads = useThreadShells(); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); @@ -321,10 +332,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { }, }); - const representative = - group.memberProjects.find( - (member) => member.environmentId === group.environmentId && member.id === group.id, - ) ?? group.memberProjects[0]!; const faviconPath = representative.faviconPath ?? null; const pickProjectFavicon = typeof window !== "undefined" && @@ -422,14 +429,22 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const instanceEntries = useMemo( () => sortProviderInstanceEntries( - applyProviderInstanceSettings(deriveProviderInstanceEntries(serverProviders), settings), + applyProviderInstanceSettings( + deriveProviderInstanceEntries(serverProviders), + projectSettings, + ), ), - [serverProviders, settings], + [serverProviders, projectSettings], ); const modelOptionsByInstance = useMemo( () => - getCustomModelOptionsByInstance(settings, serverProviders, resolvedInstanceId, resolvedModel), - [resolvedInstanceId, resolvedModel, serverProviders, settings], + getCustomModelOptionsByInstance( + projectSettings, + serverProviders, + resolvedInstanceId, + resolvedModel, + ), + [resolvedInstanceId, resolvedModel, serverProviders, projectSettings], ); const activeEntry = instanceEntries.find((entry) => entry.instanceId === resolvedInstanceId); const setDefaultModel = useCallback( @@ -863,7 +878,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { onPromptChange={() => {}} modelOptions={resolvedSelection.options ?? []} allowPromptInjectedEffort={false} - planModeEnabled={settings.planModeEnabled} + planModeEnabled={projectSettings.planModeEnabled} triggerVariant="outline" triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" onModelOptionsChange={(nextOptions) => { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index b801ce86e..4c88606e9 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1849,6 +1849,7 @@ function LegacyFeaturesSection() { } /> {settings.sidebarAutoSettleAfterDays !== null ? ( @@ -2299,6 +2305,7 @@ export function GeneralSettingsPanel() { /> )} - {isPrimaryEnvironment ? : null} + {/* Its rows are serverScoped: without a primary they render inert with + an explanation, which beats disappearing. */} + ); } diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index 5726990d1..dc7e1d56e 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -102,6 +102,7 @@ export function SourceControlWritingSettingsSection() { return ( (rowProps.id); + const primarySettingsAvailable = usePrimarySettingsAvailable(); + const unavailable = serverScoped && !primarySettingsAvailable; + const renderedReset = unavailable ? null : resetAction; + const renderedControl = + unavailable && control ? ( + + + } + > +
+ {control} +
+
+ + {PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE} + +
+ ) : ( + control + ); return (

{title}

- {resetAction} + {renderedReset}
{description ? ( @@ -200,13 +238,19 @@ export function SettingsRow({ ) : null} {status ?
{status}
: null} - {control ? ( + {renderedControl ? (
- {control} + {renderedControl}
) : null} - {children} + {unavailable && children ? ( +
+ {children} +
+ ) : ( + children + )} ); } diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index a5f0774e1..ed4338c81 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -40,6 +40,8 @@ import { themeAllowsSidebarArtwork, } from "~/themePalette"; import * as Struct from "effect/Struct"; +import { toastManager } from "~/components/ui/toast"; +import { isHostedStaticApp } from "~/hostedPairing"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; import { type EnvironmentPresentation, @@ -318,6 +320,20 @@ export function usePrimarySettings( return useMergedSettings(useAtomValue(primaryServerSettingsAtom), selector); } +export const PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE = + "This setting is saved on a server, and the hosted app is not anchored to one. Change it from the desktop app or from the server's own address."; + +/** + * Whether primary-scoped server settings have a server to live on. The + * hosted app connects to every environment as a remote, so it has no primary: + * `usePrimarySettings` reads schema defaults there and writes have nowhere + * to go. Desktop and server-served web always have one. + */ +export function usePrimarySettingsAvailable(): boolean { + const primaryEnvironment = usePrimaryEnvironment(); + return primaryEnvironment !== null || !isHostedStaticApp(); +} + /** * Whether an environment can hold every shared key right now. Gated on the * auto-settlement capability because it is the newest of the shared keys: a @@ -385,11 +401,22 @@ function useUpdateSettingsTarget( }); } else { const { sharedPatch, localPatch } = splitSharedServerPatch(serverPatch); - if (environmentId && Object.keys(localPatch).length > 0) { - void persistServerSettings({ - environmentId, - input: { patch: localPatch }, + // Dropping the write silently leaves the control looking saved. + const warnUnsaved = () => + toastManager.add({ + type: "warning", + title: "Setting not saved", + description: PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE, }); + if (Object.keys(localPatch).length > 0) { + if (environmentId) { + void persistServerSettings({ + environmentId, + input: { patch: localPatch }, + }); + } else { + warnUnsaved(); + } } if (Object.keys(sharedPatch).length > 0) { const targets = new Set(connectedEnvironmentIds); diff --git a/apps/web/src/hostedPairing.ts b/apps/web/src/hostedPairing.ts index 6a9815b4b..6c5ac58cb 100644 --- a/apps/web/src/hostedPairing.ts +++ b/apps/web/src/hostedPairing.ts @@ -31,7 +31,7 @@ function originFromUrl(value: string): string | null { } } -export function isHostedStaticApp(url: URL = new URL(window.location.href)): boolean { +export function isHostedStaticApp(url?: URL): boolean { if (configuredBackendUrl()) { return false; } @@ -40,8 +40,13 @@ export function isHostedStaticApp(url: URL = new URL(window.location.href)): boo return true; } + // No window (tests, static render) means no origin to be hosted at. + if (url === undefined && typeof window === "undefined") { + return false; + } + const hostedOrigin = originFromUrl(configuredHostedAppUrl()); - return hostedOrigin !== null && url.origin === hostedOrigin; + return hostedOrigin !== null && (url ?? new URL(window.location.href)).origin === hostedOrigin; } export function readHostedPairingRequest(url: URL = new URL(window.location.href)) { From 31df0a3f1c4bcc700475b221184375888ab6f28b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 1 Sep 2026 19:31:55 -0700 Subject: [PATCH 09/32] feat(files): open markdown, HTML, and PDF files outside the workspace (#9140) Co-authored-by: Claude Code (cherry picked from commit f46a709ee487e1e6e04423fcb7032a26eb103129) --- .../features/files/ThreadFilesRouteScreen.tsx | 11 +++- .../src/features/files/filePath.test.ts | 15 ++++- apps/mobile/src/features/files/filePath.ts | 7 ++ .../features/files/workspaceFileAssetUrl.ts | 14 ++-- .../src/features/threads/ThreadFeed.tsx | 38 ++++++++++- apps/server/src/assets/AssetAccess.test.ts | 10 +-- apps/server/src/assets/AssetAccess.ts | 6 +- apps/server/src/http.test.ts | 16 ++--- apps/server/src/http.ts | 9 ++- .../src/workspace/WorkspaceFileSystem.test.ts | 66 +++++++++++++++++++ .../src/workspace/WorkspaceFileSystem.ts | 54 +++++++++++++-- apps/web/src/browser/openFileInPreview.ts | 10 ++- apps/web/src/components/ChatMarkdown.tsx | 56 +++++++++------- apps/web/src/components/RightPanelTabs.tsx | 4 +- .../src/components/files/FilePreviewPanel.tsx | 34 ++++++---- .../web/src/components/files/filePath.test.ts | 20 +++++- apps/web/src/components/files/filePath.ts | 17 ++++- apps/web/src/markdown-links.test.ts | 16 +++++ apps/web/src/markdown-links.ts | 20 ++++-- apps/web/src/rightPanelStore.ts | 1 + apps/web/src/terminal-links.ts | 2 +- docs/internals/environment-auth.md | 23 +++++-- docs/user/composer.md | 9 +++ packages/contracts/src/assets.ts | 5 +- packages/contracts/src/project.ts | 2 + packages/shared/src/filePreview.ts | 16 +++++ 26 files changed, 393 insertions(+), 88 deletions(-) diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 95177ed3e..c682436ca 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -60,6 +60,7 @@ import { WorkspaceFileVideoPreview } from "./WorkspaceFileVideoPreview"; import { WorkspaceFileWebPreview } from "./WorkspaceFileWebPreview"; import { basename, + isAbsolutePath, isMarkdownPreviewFile, isSvgImagePreviewFile, isVideoPreviewFile, @@ -782,8 +783,14 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { ); } - const parentDir = relativePath.split("/").slice(0, -1).join("/"); - const headerSubtitle = [projectName, parentDir].filter(Boolean).join(" ยท "); + const parentDir = relativePath.slice( + 0, + Math.max(relativePath.lastIndexOf("/"), relativePath.lastIndexOf("\\"), 0), + ); + // A host file outside the workspace is not under the project name. + const headerSubtitle = isAbsolutePath(relativePath) + ? parentDir + : [projectName, parentDir].filter(Boolean).join(" ยท "); return ( diff --git a/apps/mobile/src/features/files/filePath.test.ts b/apps/mobile/src/features/files/filePath.test.ts index eaa32df28..898cc4a16 100644 --- a/apps/mobile/src/features/files/filePath.test.ts +++ b/apps/mobile/src/features/files/filePath.test.ts @@ -1,6 +1,19 @@ import { describe, expect, it } from "vite-plus/test"; -import { isSvgImagePreviewFile, resolveWorkspaceRelativeFilePath } from "./filePath"; +import { + fileRoutePathSegments, + isSvgImagePreviewFile, + resolveWorkspaceRelativeFilePath, +} from "./filePath"; + +describe("fileRoutePathSegments", () => { + it("round-trips workspace-relative and host paths through the route", () => { + expect(fileRoutePathSegments("src/main.ts")).toEqual(["src", "main.ts"]); + expect(fileRoutePathSegments("/tmp/t3-cleanup/report.md").join("/")).toBe( + "/tmp/t3-cleanup/report.md", + ); + }); +}); describe("resolveWorkspaceRelativeFilePath", () => { it("keeps normalized workspace-relative paths", () => { diff --git a/apps/mobile/src/features/files/filePath.ts b/apps/mobile/src/features/files/filePath.ts index b6f351cff..2598b58d7 100644 --- a/apps/mobile/src/features/files/filePath.ts +++ b/apps/mobile/src/features/files/filePath.ts @@ -10,10 +10,17 @@ function isWindowsAbsolutePath(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); } +/** A file route holding an absolute path shows a host file outside the workspace. */ export function isAbsolutePath(value: string): boolean { return value.startsWith("/") || isWindowsAbsolutePath(value); } +/** Route segments that `normalizeRoutePath` joins back into the same path, root included. */ +export function fileRoutePathSegments(path: string): string[] { + const segments = path.split("/").filter((segment) => segment.length > 0); + return path.startsWith("/") ? ["", ...segments] : segments; +} + function isWindowsPathStyle(value: string): boolean { return isWindowsAbsolutePath(value) || /^[A-Za-z]:\\/.test(value); } diff --git a/apps/mobile/src/features/files/workspaceFileAssetUrl.ts b/apps/mobile/src/features/files/workspaceFileAssetUrl.ts index eb2ba93b4..8ea903f83 100644 --- a/apps/mobile/src/features/files/workspaceFileAssetUrl.ts +++ b/apps/mobile/src/features/files/workspaceFileAssetUrl.ts @@ -2,7 +2,7 @@ import type { AssetResource, EnvironmentId, ThreadId } from "@t3tools/contracts" import { useMemo } from "react"; import { useAssetUrlState, useRefreshAssetUrl } from "../../state/assets"; -import { isVideoPreviewFile, resolveWorkspaceFilePath } from "./filePath"; +import { isAbsolutePath, isVideoPreviewFile, resolveWorkspaceFilePath } from "./filePath"; export function useWorkspaceFileAssetUrlState(props: { readonly cwd: string | null; @@ -18,16 +18,22 @@ export function useWorkspaceFileAssetUrlState(props: { [props.cwd, props.relativePath], ); + // Videos stream from an exact-file URL, and so does anything outside the + // workspace, where no workspace-scoped URL can exist. + const relativePath = props.relativePath; const resource = useMemo( () => - absolutePath !== null && props.threadId !== null + absolutePath !== null && relativePath !== null && props.threadId !== null ? { - _tag: isVideoPreviewFile(absolutePath) ? "media-file" : "workspace-file", + _tag: + isVideoPreviewFile(absolutePath) || isAbsolutePath(relativePath) + ? "media-file" + : "workspace-file", threadId: props.threadId, path: absolutePath, } : null, - [absolutePath, props.threadId], + [absolutePath, relativePath, props.threadId], ); const state = useAssetUrlState(props.environmentId, resource); const refresh = useRefreshAssetUrl(props.environmentId, resource); diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index dbe235877..a25204907 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -169,7 +169,12 @@ import { import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; import { usePreparedConnection } from "../../state/session"; import * as Option from "effect/Option"; -import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; +import { + basename, + fileRoutePathSegments, + isAbsolutePath, + resolveWorkspaceRelativeFilePath, +} from "../files/filePath"; import { MARKDOWN_IMAGE_MAX_WIDTH, resolveMarkdownImageDisplaySize } from "./markdownImageSize"; import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu"; @@ -2129,7 +2134,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { navigation.navigate("ThreadFile", { environmentId: String(props.environmentId), threadId: String(props.threadId), - path: relativePath.split("/").filter((segment) => segment.length > 0), + path: fileRoutePathSegments(relativePath), ...(presentation.line ? { line: String(presentation.line) } : {}), }); return; @@ -2151,6 +2156,35 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { return; } + // A host file outside the workspace, such as a report an agent wrote to + // a temp directory, opens read-only in the file screen. + if (presentation.kind === "file" && isAbsolutePath(presentation.path)) { + void Haptics.selectionAsync(); + if (isPdfFile({ name: presentation.path })) { + setExpandedFile( + (current) => + current ?? { + kind: "pdf", + name: basename(presentation.path), + environmentId: props.environmentId, + resource: { + _tag: "media-file", + threadId: props.threadId, + path: presentation.path, + }, + }, + ); + return; + } + navigation.navigate("ThreadFile", { + environmentId: String(props.environmentId), + threadId: String(props.threadId), + path: fileRoutePathSegments(presentation.path), + ...(presentation.line ? { line: String(presentation.line) } : {}), + }); + return; + } + if (presentation.kind !== "file" && presentation.href) { if (/^https?:\/\//i.test(presentation.href) && isPdfFile({ name: presentation.href })) { setExpandedFile( diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 7452ca091..0ca146935 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -44,7 +44,7 @@ const testLayer = Layer.mergeAll( ).pipe(Layer.provideMerge(NodeServices.layer)); describe("AssetAccess", () => { - it.effect("issues exact URLs for images and videos outside the workspace", () => + it.effect("issues exact URLs for media and browser documents outside the workspace", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -54,6 +54,8 @@ describe("AssetAccess", () => { ["screenshot.png", "image/png"], ["recording.mp4", "video/mp4"], ["recording.webm", "video/webm"], + ["report.html", "text/html"], + ["report.pdf", "application/pdf"], ] as const) { const filePath = path.join(outside, name); yield* fs.writeFileString(filePath, "media"); @@ -104,12 +106,12 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); - it.effect("rejects non-media files, disguised targets, and directories", () => + it.effect("rejects non-previewable files, disguised targets, and directories", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-media-validation-" }); - for (const name of ["report.html", "secret.txt", "secret.%70ng", "secret.png#private.txt"]) { + for (const name of ["report.md", "secret.txt", "secret.%70ng", "secret.png#private.txt"]) { const filePath = path.join(root, name); yield* fs.writeFileString(filePath, "not media"); const error = yield* issueAssetUrl({ @@ -118,7 +120,7 @@ describe("AssetAccess", () => { expect(error).toBeInstanceOf(AssetPreviewTypeValidationError); } const disguisedPath = path.join(root, "disguised.png"); - yield* fs.symlink(path.join(root, "report.html"), disguisedPath); + yield* fs.symlink(path.join(root, "secret.txt"), disguisedPath); const disguisedError = yield* issueAssetUrl({ resource: { _tag: "media-file", threadId: ThreadId.make("thread-1"), path: disguisedPath }, }).pipe(Effect.flip); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index e71a93a5c..5a370d48d 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -14,9 +14,9 @@ import { AssetWorkspaceRootNormalizationError, } from "@t3tools/contracts"; import { + hostPreviewMimeTypeFromExtension, isWorkspaceImagePreviewPath, isWorkspacePreviewEntryPath, - mediaMimeTypeFromExtension, WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, } from "@t3tools/shared/filePreview"; @@ -257,7 +257,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i if (!canonicalFile) { return yield* new AssetWorkspaceAssetNotFoundError({ resource: input.resource }); } - if (mediaMimeTypeFromExtension(path.extname(canonicalFile)) === null) { + if (hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)) === null) { return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); } const identity = yield* openMediaFile(canonicalFile).pipe( @@ -614,7 +614,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( Effect.orElseSucceed(() => null), ); if (canonicalFile !== claims.filePath) return null; - const mimeType = mediaMimeTypeFromExtension(path.extname(canonicalFile)); + const mimeType = hostPreviewMimeTypeFromExtension(path.extname(canonicalFile)); if (!mimeType) return null; const file = yield* openMediaFile(canonicalFile, claims).pipe( Effect.tapError((cause) => diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 9d54adef8..6b0940856 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -324,15 +324,13 @@ describe("assetResponseHeaders", () => { "X-Content-Type-Options": "nosniff", }); }); - it("declares utf-8 for HTML assets so non-ASCII content renders correctly", () => { - expect(assetResponseHeaders("/workspace/page.html")).toHaveProperty( - "Content-Type", - "text/html; charset=utf-8", - ); - expect(assetResponseHeaders("/workspace/PAGE.HTM")).toHaveProperty( - "Content-Type", - "text/html; charset=utf-8", - ); + it("serves HTML assets as utf-8 inside a sandboxed origin", () => { + for (const path of ["/workspace/page.html", "/workspace/PAGE.HTM", "/tmp/report.html"]) { + expect(assetResponseHeaders(path)).toMatchObject({ + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": "sandbox allow-scripts allow-forms allow-popups allow-modals", + }); + } }); it("downloads uploaded documents without executing their content", () => { diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 3cc22faa0..e20477c58 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -56,6 +56,10 @@ const DESKTOP_RENDERER_ORIGINS = [ "t3code-dev://app", ]; const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; +// HTML previews are agent output, not the app. The sandbox gives the document an +// opaque origin: scripts run, but same-origin cookies, storage, and API calls are +// out of reach. Relative sibling assets still load through their signed URLs. +const HTML_CONTENT_SECURITY_POLICY = "sandbox allow-scripts allow-forms allow-popups allow-modals"; // Types a browser may render as a document if a proxy strips the disposition // header. Downloads of these fall back to octet-stream. @@ -110,7 +114,10 @@ export function assetResponseHeaders( : inlineVideoMimeType !== undefined && isSafeInlineVideoMimeType(inlineVideoMimeType) ? { "Content-Type": inlineVideoMimeType } : lowerPath.endsWith(".html") || lowerPath.endsWith(".htm") - ? { "Content-Type": "text/html; charset=utf-8" } + ? { + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": HTML_CONTENT_SECURITY_POLICY, + } : {}), ...(!options?.download && lowerPath.endsWith(".svg") ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY } diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index cecffbc19..17c7f0bb0 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -1,3 +1,6 @@ +// @effect-diagnostics nodeBuiltinImport:off - FileSystem cannot create a FIFO. +import * as NodeChildProcess from "node:child_process"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { it, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -73,6 +76,53 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); + it.effect("reads host files outside the workspace root by absolute path", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + yield* writeTextFile(outsideDir, "cleanup-report.md", "# Report\n"); + const absolutePath = path.join(outsideDir, "cleanup-report.md"); + + const result = yield* workspaceFileSystem.readFile({ + cwd, + relativePath: absolutePath, + }); + + expect(result).toEqual({ + relativePath: absolutePath, + contents: "# Report\n", + byteLength: 9, + truncated: false, + }); + }), + ); + + it.effect("rejects a FIFO without blocking on open", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + const fifoPath = path.join(outsideDir, "pipe"); + yield* Effect.promise( + () => + new Promise((resolve, reject) => + NodeChildProcess.execFile("mkfifo", [fifoPath], (error) => + error ? reject(error) : resolve(), + ), + ), + ); + + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: fifoPath }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError); + }), + ); + it.effect("rejects reads outside the workspace root", () => Effect.gen(function* () { const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; @@ -212,6 +262,22 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); + it.effect("rejects writes by absolute path", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + const absolutePath = path.join(outsideDir, "cleanup-report.md"); + + const error = yield* workspaceFileSystem + .writeFile({ cwd, relativePath: absolutePath, contents: "# Edited\n" }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError); + }), + ); + it.effect("invalidates workspace entry search cache after writes", () => Effect.gen(function* () { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index e2dc9cbbb..055ab51fe 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -3,10 +3,13 @@ * WorkspaceFileSystem - Effect service contract for workspace file mutations. * * Owns workspace-root-relative file read/write operations and their associated - * safety checks and cache invalidation hooks. + * safety checks and cache invalidation hooks. Reads also accept absolute host + * paths so clients can show files an agent left outside the workspace; writes + * never leave the root. * * @module WorkspaceFileSystem */ +import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import type { @@ -104,7 +107,10 @@ export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type; export class WorkspaceFileSystem extends Context.Service< WorkspaceFileSystem, { - /** Read a UTF-8 text file relative to the workspace root. */ + /** + * Read a UTF-8 text file relative to the workspace root, or any host file by + * absolute path. + */ readonly readFile: ( input: ProjectReadFileInput, ) => Effect.Effect< @@ -132,9 +138,31 @@ export const make = Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn( - "WorkspaceFileSystem.readFile", - )(function* (input) { + /** + * Resolves the file a read targets. Workspace-relative paths must stay inside the + * root, symlinks included. An absolute path reads a host file in place, such as a + * report an agent wrote to a temp directory; it gets no root check. + */ + const resolveReadTarget = Effect.fn("WorkspaceFileSystem.resolveReadTarget")(function* ( + input: ProjectReadFileInput, + ) { + const requestedPath = input.relativePath.trim(); + if (path.isAbsolute(requestedPath)) { + const realTargetPath = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(requestedPath), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: requestedPath, + operationPath: requestedPath, + operation: "realpath-target", + cause, + }), + }); + return { relativePath: requestedPath, realTargetPath }; + } + const target = yield* workspacePaths.resolveRelativePathWithinRoot({ workspaceRoot: input.cwd, relativePath: input.relativePath, @@ -177,10 +205,24 @@ export const make = Effect.gen(function* () { resolvedPath: realTargetPath, }); } + return { relativePath: target.relativePath, realTargetPath }; + }); + + const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn( + "WorkspaceFileSystem.readFile", + )(function* (input) { + const target = yield* resolveReadTarget(input); + const realTargetPath = target.realTargetPath; return yield* Effect.acquireUseRelease( Effect.tryPromise({ - try: () => NodeFSP.open(realTargetPath, "r"), + // Non-blocking so a FIFO cannot hang the open; the stat below rejects + // it. Regular files ignore the flag. Windows lacks it. + try: () => + NodeFSP.open( + realTargetPath, + NodeFS.constants.O_RDONLY | (NodeFS.constants.O_NONBLOCK ?? 0), + ), catch: (cause) => new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index b89b87c92..4e540a9a0 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -6,6 +6,7 @@ import type { PreviewSessionSnapshot, ScopedThreadRef, } from "@t3tools/contracts"; +import { mediaFileReference } from "@t3tools/client-runtime/media-reference"; import { type AtomCommandResult, mapAtomCommandResult, @@ -52,9 +53,14 @@ export async function openUrlInPreview(input: { }); } +/** + * Opens a browser document in the integrated browser. Inside the workspace the + * page may load sibling assets; a file outside it is served on its own. + */ export async function openFileInPreview(input: { readonly threadRef: ScopedThreadRef; readonly filePath: string; + readonly workspaceRoot: string | undefined; readonly httpBaseUrl: string; readonly createAssetUrl: (input: { readonly environmentId: EnvironmentId; @@ -71,11 +77,13 @@ export async function openFileInPreview(input: { ), ); } + const insideWorkspace = + mediaFileReference(input.filePath, input.workspaceRoot).relativePath !== undefined; const assetResult = await input.createAssetUrl({ environmentId: input.threadRef.environmentId, input: { resource: { - _tag: "workspace-file", + _tag: insideWorkspace ? "workspace-file" : "media-file", threadId: input.threadRef.threadId, path: input.filePath, }, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 7f0625f14..249dd9089 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -165,7 +165,7 @@ import { } from "~/lib/openPullRequestLink"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { isPreviewSupportedInRuntime } from "../previewStateStore"; -import { resolvePathLinkTarget } from "../terminal-links"; +import { isAbsolutePath, resolvePathLinkTarget } from "../terminal-links"; import { isBrowserPreviewFile, openFileInPreview, @@ -189,6 +189,8 @@ interface ChatMarkdownProps { parseRawHtml?: boolean; /** Append a prompt that invokes a newly created artifact-template skill. */ onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined; + /** Directory that anchors relative links and images; defaults to `cwd`. Set + to the file's own directory when rendering a markdown file. */ imageBaseDir?: string | undefined; onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; extraRemarkPlugins?: NonNullable; @@ -1032,7 +1034,9 @@ interface MarkdownFileLinkProps { targetPath: string; iconPath: string; displayPath: string; - workspaceRelativePath: string | null; + /** What the files panel opens: workspace-relative inside the workspace, the + absolute host path outside it, null when the panel cannot show the file. */ + panelPath: string | null; line?: number | undefined; label: string; copyMarkdown: string; @@ -1044,7 +1048,7 @@ interface MarkdownFileLinkProps { fileManagerTargetPath?: string, ) => Promise>) | undefined; - onOpenInPanel: (workspaceRelativePath: string, line: number | undefined) => void; + onOpenInPanel: (panelPath: string, line: number | undefined) => void; getOpenInEditorMenuLabel: () => string; onOpenInBrowser?: (() => Promise>) | undefined; onOpenMedia?: (() => void) | undefined; @@ -1623,7 +1627,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ targetPath, iconPath, displayPath, - workspaceRelativePath, + panelPath, line, label, copyMarkdown, @@ -1677,8 +1681,8 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ }, [iconPath, onOpen, targetPath]); const handleOpenInFilePreview = useCallback(() => { - if (threadRef && workspaceRelativePath) { - onOpenInPanel(workspaceRelativePath, line); + if (threadRef && panelPath) { + onOpenInPanel(panelPath, line); return; } if (onOpenMedia) { @@ -1686,7 +1690,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ return; } handleOpenInEditor(); - }, [handleOpenInEditor, line, onOpenInPanel, onOpenMedia, threadRef, workspaceRelativePath]); + }, [handleOpenInEditor, line, onOpenInPanel, onOpenMedia, panelPath, threadRef]); const handleOpenInBrowser = useCallback(() => { if (!onOpenInBrowser) { @@ -1887,7 +1891,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ const canOpenInEditor = onOpen !== undefined; const canOpenInBrowser = onOpenInBrowser !== undefined; - const canOpenInPanel = threadRef !== undefined && Boolean(workspaceRelativePath); + const canOpenInPanel = threadRef !== undefined && Boolean(panelPath); const hasPrimaryAction = hasMarkdownFilePrimaryAction({ canOpenInEditor, canOpenInBrowser, @@ -1974,7 +1978,7 @@ function areMarkdownFileLinkPropsEqual( previous.targetPath === next.targetPath && previous.iconPath === next.iconPath && previous.displayPath === next.displayPath && - previous.workspaceRelativePath === next.workspaceRelativePath && + previous.panelPath === next.panelPath && previous.line === next.line && previous.label === next.label && previous.copyMarkdown === next.copyMarkdown && @@ -2124,24 +2128,24 @@ function ChatMarkdown({ for (const href of extractMarkdownLinkHrefs(renderCodexFileCitationsAsMarkdown(text))) { const normalizedHref = normalizeMarkdownLinkHrefKey(href); if (metaByHref.has(normalizedHref)) continue; - const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd); + const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd); if (meta) { metaByHref.set(normalizedHref, meta); } } return metaByHref; - }, [cwd, text]); + }, [cwd, imageBaseDir, text]); const inlineCodeFileLinkMetaByText = useMemo(() => { const metaByText = new Map(); for (const span of extractInlineCodeSpans(text)) { if (metaByText.has(span)) continue; - const meta = resolveInlineCodeFileLinkMeta(span, cwd); + const meta = resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd); if (meta) { metaByText.set(span, meta); } } return metaByText; - }, [cwd, text]); + }, [cwd, imageBaseDir, text]); const fileLinkParentSuffixByPath = useMemo(() => { const filePaths = [ ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath), @@ -2249,12 +2253,13 @@ function ChatMarkdown({ return openFileInPreview({ threadRef, filePath: path, + workspaceRoot: cwd, httpBaseUrl: preparedConnection.value.httpBaseUrl, createAssetUrl, openPreview, }); }, - [createAssetUrl, openPreview, preparedConnection, threadRef], + [createAssetUrl, cwd, openPreview, preparedConnection, threadRef], ); const findWorkspaceBasenameMatch = useCallback( async (workspaceRelativePath: string) => { @@ -2277,23 +2282,23 @@ function ChatMarkdown({ [cwd, environmentId, searchProjectEntries], ); // A bare filename resolves to the workspace root, which is rarely where the - // file is, so ask the index before opening. + // file is, so ask the index before opening. Absolute host paths open as-is. const openFileInPanel = useCallback( - (workspaceRelativePath: string, line: number | undefined) => { + (panelPath: string, line: number | undefined) => { if (!threadRef) return; // Claimed on every open so a synchronous one supersedes a lookup already // in flight. const isLatestLookup = claimWorkspaceBasenameLookup(); const openAt = (path: string) => useRightPanelStore.getState().openFile(threadRef, path, line); - if (!cwd || !needsWorkspaceBasenameLookup(workspaceRelativePath)) { - openAt(workspaceRelativePath); + if (!cwd || !needsWorkspaceBasenameLookup(panelPath)) { + openAt(panelPath); return; } void (async () => { - const match = await findWorkspaceBasenameMatch(workspaceRelativePath); + const match = await findWorkspaceBasenameMatch(panelPath); if (!isLatestLookup()) return; - openAt(match ?? workspaceRelativePath); + openAt(match ?? panelPath); })(); }, [cwd, findWorkspaceBasenameMatch, threadRef], @@ -2341,6 +2346,11 @@ function ChatMarkdown({ mediaMimeTypeFromExtension( fileLinkMeta.basename.slice(fileLinkMeta.basename.lastIndexOf(".")), ) !== null; + // Media outside the workspace keeps the expanded preview; other host + // files (a report in a temp dir) open read-only in the files panel. + const panelPath = + fileLinkMeta.workspaceRelativePath ?? + (!canPreviewMedia && isAbsolutePath(fileLinkMeta.filePath) ? fileLinkMeta.filePath : null); return ( & { threadRef: ScopedThreadRef; + readOnly: boolean; }) { const saveCoordinator = useFileSaveCoordinator({ environmentId, @@ -819,15 +821,19 @@ function RenderedMarkdownSurface({ cwd={cwd} relativePath={relativePath} threadRef={threadRef} - onTaskListChange={({ markerOffset, checked }) => { - const currentContents = - getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ?? - contents; - const nextContents = setMarkdownTaskChecked(currentContents, markerOffset, checked); - if (nextContents === currentContents) return; - setProjectFileQueryData(environmentId, cwd, relativePath, nextContents); - saveCoordinator.change(nextContents); - }} + onTaskListChange={ + readOnly + ? undefined + : ({ markerOffset, checked }) => { + const currentContents = + getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ?? + contents; + const nextContents = setMarkdownTaskChecked(currentContents, markerOffset, checked); + if (nextContents === currentContents) return; + setProjectFileQueryData(environmentId, cwd, relativePath, nextContents); + saveCoordinator.change(nextContents); + } + } /> ); @@ -872,6 +878,8 @@ export default function FilePreviewPanel({ const isVideo = relativePath !== null && isWorkspaceVideoPreviewPath(relativePath); const isImage = relativePath !== null && !isVideo && isWorkspaceImagePreviewPath(relativePath); const isMedia = isImage || isVideo; + // A file outside the workspace (an absolute path) is shown, never edited. + const isHostFile = relativePath !== null && isAbsolutePath(relativePath); const file = useProjectFileQuery(environmentId, cwd, relativePath, !isMedia); const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); // Reading markdown rendered is a preference, not a property of one file. Keeping @@ -938,6 +946,7 @@ export default function FilePreviewPanel({ const result = await openFileInPreview({ threadRef, filePath: absolutePath, + workspaceRoot: cwd, httpBaseUrl: environmentHttpBaseUrl, createAssetUrl, openPreview, @@ -954,7 +963,7 @@ export default function FilePreviewPanel({ }), ); })(); - }, [absolutePath, createAssetUrl, environmentHttpBaseUrl, openPreview, threadRef]); + }, [absolutePath, createAssetUrl, cwd, environmentHttpBaseUrl, openPreview, threadRef]); return (
@@ -1130,9 +1139,10 @@ export default function FilePreviewPanel({ relativePath={relativePath} threadRef={threadRef} contents={file.data.contents} + readOnly={isHostFile} onPendingChange={onPendingChange} /> - ) : file.data.truncated ? ( + ) : file.data.truncated || isHostFile ? ( { }); it("normalizes repeated separators", () => { - expect(fileBreadcrumbs("workspace", "/src//index.ts").map((crumb) => crumb.label)).toEqual([ + expect(fileBreadcrumbs("workspace", "src//index.ts").map((crumb) => crumb.label)).toEqual([ "workspace", "src", "index.ts", ]); }); + + it("starts host paths outside the workspace at the filesystem root", () => { + expect(fileBreadcrumbs("t3code", "/tmp/t3-cleanup/report.md")).toEqual([ + { label: "tmp", path: "/tmp", kind: "directory" }, + { label: "t3-cleanup", path: "/tmp/t3-cleanup", kind: "directory" }, + { label: "report.md", path: "/tmp/t3-cleanup/report.md", kind: "file" }, + ]); + expect(fileBreadcrumbs("t3code", "C:\\Temp\\report.md")).toEqual([ + { label: "C:", path: "C:", kind: "directory" }, + { label: "Temp", path: "C:\\Temp", kind: "directory" }, + { label: "report.md", path: "C:\\Temp\\report.md", kind: "file" }, + ]); + expect(fileBreadcrumbs("t3code", "\\\\server\\share\\report.md").map((c) => c.path)).toEqual([ + "\\\\server", + "\\\\server\\share", + "\\\\server\\share\\report.md", + ]); + }); }); diff --git a/apps/web/src/components/files/filePath.ts b/apps/web/src/components/files/filePath.ts index 66ede5811..0598239cf 100644 --- a/apps/web/src/components/files/filePath.ts +++ b/apps/web/src/components/files/filePath.ts @@ -1,16 +1,27 @@ +import { isWindowsAbsolutePath } from "@t3tools/shared/path"; + +import { isAbsolutePath } from "~/terminal-links"; + export interface FileBreadcrumb { label: string; path: string; kind: "project" | "directory" | "file"; } +/** + * Crumbs for a workspace-relative path start at the project. An absolute host + * path is outside the workspace, so its crumbs start at the filesystem root. + */ export function fileBreadcrumbs(projectName: string, relativePath: string): FileBreadcrumb[] { - const parts = relativePath.split("/").filter(Boolean); + const hostPath = isAbsolutePath(relativePath); + const separator = isWindowsAbsolutePath(relativePath) ? "\\" : "/"; + const parts = relativePath.split(/[\\/]/).filter(Boolean); + const root = relativePath.startsWith("\\\\") ? "\\\\" : hostPath && separator === "/" ? "/" : ""; return [ - { label: projectName, path: "", kind: "project" }, + ...(hostPath ? [] : [{ label: projectName, path: "", kind: "project" as const }]), ...parts.map((part, index) => ({ label: part, - path: parts.slice(0, index + 1).join("/"), + path: root + parts.slice(0, index + 1).join(separator), kind: index === parts.length - 1 ? ("file" as const) : ("directory" as const), })), ]; diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 93d410ff1..2cc72ad3a 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -287,6 +287,22 @@ describe("resolveMarkdownFileLinkTarget", () => { }); }); +describe("relative links inside a rendered host file", () => { + it("anchor to the file's directory while workspace membership follows cwd", () => { + const meta = resolveMarkdownFileLinkMeta("appendix.md", "/repo", "/tmp/report"); + expect(meta).toMatchObject({ + filePath: "/tmp/report/appendix.md", + workspaceRelativePath: null, + }); + const inline = resolveInlineCodeFileLinkMeta("Makefile:12", "/repo", "/tmp/report"); + expect(inline).toMatchObject({ filePath: "/tmp/report/Makefile", line: 12 }); + expect(resolveMarkdownFileLinkMeta("src/main.ts", "/repo", "/repo/docs")).toMatchObject({ + filePath: "/repo/docs/src/main.ts", + workspaceRelativePath: "docs/src/main.ts", + }); + }); +}); + describe("resolveInlineCodeFileLinkMeta", () => { it("links relative paths with file extensions", () => { expect( diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index f7ac2627e..ddf52c93d 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -189,9 +189,15 @@ function hasExternalScheme(path: string): boolean { return !POSITION_ONLY_PATTERN.test(rest); } +/** + * `baseDir` anchors relative links; it defaults to the workspace root and is the + * file's own directory when rendering a markdown file. `cwd` stays the workspace + * root so the result still knows whether the target is inside it. + */ export function resolveMarkdownFileLinkTarget( href: string | undefined, cwd?: string, + baseDir: string | undefined = cwd, ): string | null { if (!href) return null; const rawHref = normalizeMarkdownLinkDestination(href); @@ -222,8 +228,8 @@ export function resolveMarkdownFileLinkTarget( return pathWithPosition; } - if (!cwd) return null; - return resolvePathLinkTarget(pathWithPosition, cwd); + if (!baseDir) return null; + return resolvePathLinkTarget(pathWithPosition, baseDir); } /** @@ -235,18 +241,19 @@ export function resolveMarkdownFileLinkTarget( export function resolveInlineCodeFileLinkMeta( codeText: string, cwd?: string, + baseDir: string | undefined = cwd, ): MarkdownFileLinkMeta | null { const candidate = inlineCodeFilePathCandidate(codeText); if (candidate === null) return null; - const resolved = resolveMarkdownFileLinkMeta(candidate, cwd); + const resolved = resolveMarkdownFileLinkMeta(candidate, cwd, baseDir); if (resolved) return resolved; // `Makefile:12` โ€” conventional extensionless names fail the generic // markdown-link candidate patterns, but here the :line suffix already // marked the span as a file reference. - if (cwd && isConventionalFilePosition(candidate)) { - return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, cwd), cwd); + if (baseDir && isConventionalFilePosition(candidate)) { + return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, baseDir), cwd); } return null; } @@ -284,8 +291,9 @@ function workspaceRelativePath(path: string, workspaceRoot: string | undefined): export function resolveMarkdownFileLinkMeta( href: string | undefined, cwd?: string, + baseDir: string | undefined = cwd, ): MarkdownFileLinkMeta | null { - const targetPath = resolveMarkdownFileLinkTarget(href, cwd); + const targetPath = resolveMarkdownFileLinkTarget(href, cwd, baseDir); if (!targetPath) return null; return buildFileLinkMetaFromTarget(targetPath, cwd); } diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 27d5ded5d..40038a2f0 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -41,6 +41,7 @@ export type RightPanelSurface = | { id: `file:${string}`; kind: "file"; + /** Workspace-relative, or absolute for a host file outside the workspace. */ relativePath: string; revealLine: number | null; revealRequestId: number; diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index d84d8712c..6c09ab150 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -102,7 +102,7 @@ function isWindowsAbsolutePath(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\"); } -function isAbsolutePath(value: string): boolean { +export function isAbsolutePath(value: string): boolean { return value.startsWith("/") || isWindowsAbsolutePath(value); } diff --git a/docs/internals/environment-auth.md b/docs/internals/environment-auth.md index da9303a54..f313a649b 100644 --- a/docs/internals/environment-auth.md +++ b/docs/internals/environment-auth.md @@ -28,22 +28,31 @@ managed relay connectivity: The desktop bootstrap credential and command-line administrative bootstrap credentials additionally grant `access:read access:write relay:write`. +## Host file access + +Clients with `orchestration:read` can read files anywhere the environment's server account can +read, following the environment-wide authorization model rather than introducing per-project +filesystem permissions. `projects.readFile` accepts an absolute path and returns the text of that +host file; only workspace-relative paths pass its root check, and `projects.writeFile` never +accepts an absolute path. Clients use this to show files an agent wrote outside the workspace, such +as a report in a temp directory, read-only. + ## Media preview access Clients with `orchestration:read` can request a `media-file` URL through `assets.createUrl` for -supported images and videos anywhere the environment's server account can read. A thread ID -supplies the workspace for relative paths; absolute paths refer to the environment host, not the -client. This follows the environment-wide authorization model rather than introducing per-project -filesystem permissions. +supported images, videos, HTML, and PDF files anywhere the environment's server account can read. +A thread ID supplies the workspace for relative paths; absolute paths refer to the environment +host, not the client. [`AssetAccess.ts`](../../apps/server/src/assets/AssetAccess.ts) resolves symlinks, requires a regular file, and validates the resolved file's literal extension. It opens the file and signs its canonical path and device/inode identity for one hour. The token grants access to that exact file, not adjacent files or its containing directory. Serving rechecks the canonical path, media type, and opened descriptor's identity, then streams full or partial responses from that descriptor. Replacing a -file atomically requires a freshly signed URL; editing it in place does not. The existing workspace -boundary still applies to HTML, PDF, and other workspace previews. Uploaded attachments keep their -separate asset resource. +file atomically requires a freshly signed URL; editing it in place does not. Because the token names +one file, an HTML document served this way cannot load sibling assets; the directory-scoped +`workspace-file` resource remains the route for HTML inside the workspace. Uploaded attachments keep +their separate asset resource. Signed asset URLs are bearer credentials. Anyone who obtains a URL and can reach the environment can fetch that file until it expires. Clients should copy the authored reference, not the temporary diff --git a/docs/user/composer.md b/docs/user/composer.md index ac4e82cfa..8690a2dd3 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -95,6 +95,15 @@ have a cached copy. Supported video formats and codecs depend on the browser or Bare paths in ordinary prose and paths inside code blocks stay text. Raw HTML `