diff --git a/.github/workflows/web-preview.yml b/.github/workflows/web-preview.yml index 3def8478bbdc2..4b3f8952d503d 100644 --- a/.github/workflows/web-preview.yml +++ b/.github/workflows/web-preview.yml @@ -82,7 +82,8 @@ jobs: --archive=tgz \ --yes \ --token "$VERCEL_TOKEN" \ - --scope "$vercel_scope" + --scope "$vercel_scope" \ + --build-env "VITE_RELEASE_CHANNEL=preview" )" printf '%s\n' "$deployment_output" # Vercel can print lockfile warnings to stdout before the deployment URL. diff --git a/.gitignore b/.gitignore index b33f3469c5345..26b8965ecab8b 100644 --- a/.gitignore +++ b/.gitignore @@ -36,7 +36,6 @@ apps/mobile/.generated/ apps/mobile/.android-cache/ /.generated/ artifacts/app-store/screenshots/ -artifacts/mobile-e2e/ .github/pr-assets/ native/**/target/ node_modules/ diff --git a/AGENTS.md b/AGENTS.md index 3a6e2a957d7d2..a0a1f949faa51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,6 +194,7 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/ options, and pass combinators as additional function-builder arguments. - Inferred types over annotations. `any` is the enemy. - Default to no comment. Write one only for a constraint, an external quirk, or an invariant the code can't show, in one line where you can and four at most. Don't restate names or signatures, narrate steps, record history, or explain the fix you just made; the PR carries that. `t3code/no-low-value-comments` enforces the length cap and the mechanical cases. +- React effects are a last resort: derive in render, act in handlers, reset with `key`, or use a ref callback or store. The `t3code/react-effect-budget` lint rule holds each file to its entry in `scripts/effect-budget.json`; raise an entry only in the PR that needs the effect, and lower it when you remove one. - Our users drive agents all day and notice a dropped frame, a lying spinner, and a stale label. No continuously repainting animations; they peg the GPU on high-refresh displays. - If a rule here fights the task in front of you, say so loudly and get a human sign-off before breaking it. diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 6baf2c4117f9d..1d1e79a4fe9b0 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -2,11 +2,13 @@ import { PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import { makeLocalFileTracer, makeTraceSink, + processServiceInstanceId, type SignalExport, } from "@t3tools/shared/observability"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as SharedObservability from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; +import { releaseChannelOf, releaseResourceAttributes } from "@t3tools/shared/releaseResource"; import { parsePersistedServerObservabilitySettings, type PersistedServerObservabilitySettings, @@ -595,14 +597,22 @@ const layerTelemetry = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const endpoints = yield* resolveOtlpEndpoints; + // Unpackaged runs launch a script, so Electron reports its own version. + const serviceVersion = environment.isPackaged + ? environment.appVersion + : desktopPackageJson.version; const resource = { serviceName: "t3code-desktop", - // Unpackaged runs launch a script, so Electron reports its own version. - serviceVersion: environment.isPackaged ? environment.appVersion : desktopPackageJson.version, + serviceVersion, attributes: { "service.namespace": "t3code", "service.runtime": "desktop", "service.mode": environment.isDevelopment ? "development" : "packaged", + "service.instance.id": processServiceInstanceId(endpoints.resourceAttributes), + ...releaseResourceAttributes( + environment.isDevelopment ? "dev" : releaseChannelOf(serviceVersion), + endpoints.resourceAttributes, + ), "host.name": endpoints.resourceAttributes["host.name"] || (yield* HostProcess.Hostname), }, }; diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 8d70ab4c4791f..7464d8fbe9e2f 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -678,7 +678,7 @@ describe("DesktopBackendConfiguration", () => { ), ); - it.effect("resolveWsl carries the kill switch into the distro", () => + it.effect("resolveWsl carries the kill switch and resource attributes into the distro", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const baseDir = yield* fileSystem.makeTempDirectoryScoped({ @@ -688,10 +688,12 @@ describe("DesktopBackendConfiguration", () => { const previousWslEnv = process.env.WSLENV; const previousDisabled = process.env.OTEL_SDK_DISABLED; const previousTelemetry = process.env.T3CODE_TELEMETRY_ENABLED; + const previousResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES; try { delete process.env.WSLENV; process.env.OTEL_SDK_DISABLED = "true"; process.env.T3CODE_TELEMETRY_ENABLED = "false"; + process.env.OTEL_RESOURCE_ATTRIBUTES = "deployment.environment.name=staging"; yield* Effect.gen(function* () { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; @@ -701,6 +703,8 @@ describe("DesktopBackendConfiguration", () => { assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_SDK_DISABLED"); assert.equal(config.env.T3CODE_TELEMETRY_ENABLED, "false"); assert.include((config.env.WSLENV ?? "").split(":"), "T3CODE_TELEMETRY_ENABLED"); + assert.equal(config.env.OTEL_RESOURCE_ATTRIBUTES, "deployment.environment.name=staging"); + assert.include((config.env.WSLENV ?? "").split(":"), "OTEL_RESOURCE_ATTRIBUTES"); }).pipe( Effect.provide( layerBackendConfiguration().pipe( @@ -722,6 +726,7 @@ describe("DesktopBackendConfiguration", () => { restoreEnv("WSLENV", previousWslEnv); restoreEnv("OTEL_SDK_DISABLED", previousDisabled); restoreEnv("T3CODE_TELEMETRY_ENABLED", previousTelemetry); + restoreEnv("OTEL_RESOURCE_ATTRIBUTES", previousResourceAttributes); } }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 06b4c6b59816d..ca69f5d025899 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -110,6 +110,7 @@ const WSL_FORWARDED_ENV_NAMES = [ "OTEL_TRACES_EXPORTER", "OTEL_METRICS_EXPORTER", "OTEL_LOGS_EXPORTER", + "OTEL_RESOURCE_ATTRIBUTES", ] as const; const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; diff --git a/apps/mobile/src/features/observability/tracing.test.ts b/apps/mobile/src/features/observability/tracing.test.ts index 3ca4bf9a3d61c..a0fa9e85f6de9 100644 --- a/apps/mobile/src/features/observability/tracing.test.ts +++ b/apps/mobile/src/features/observability/tracing.test.ts @@ -27,6 +27,7 @@ it.effect("does not let OTLP serialization failures alter application effects", }, { appVariant: "test", + serviceInstanceId: "test-instance", serviceVersion: "1.2.3", }, ).pipe(Layer.provide(layerRemoteHttpClient(fetchFn))); diff --git a/apps/mobile/src/features/observability/tracing.ts b/apps/mobile/src/features/observability/tracing.ts index e2b8ee0f16883..fbdb14fc78509 100644 --- a/apps/mobile/src/features/observability/tracing.ts +++ b/apps/mobile/src/features/observability/tracing.ts @@ -1,4 +1,5 @@ import Constants from "expo-constants"; +import { type ReleaseChannel, releaseResourceAttributes } from "@t3tools/shared/releaseResource"; import * as RelayTracing from "@t3tools/shared/relayTracing"; import { hasTracingPublicConfig, resolveCloudPublicConfig } from "../cloud/publicConfig"; @@ -11,9 +12,16 @@ export interface TracingConfig { export interface TracingResource { readonly serviceVersion?: string; + readonly serviceInstanceId: string; readonly appVariant: string; } +const RELEASE_CHANNEL_BY_VARIANT: Readonly> = { + development: "dev", + preview: "preview", + production: "stable", +}; + export function resolveTracingConfig(): TracingConfig | null { const config = resolveCloudPublicConfig(); if (!hasTracingPublicConfig(config)) { @@ -24,16 +32,23 @@ export function resolveTracingConfig(): TracingConfig | null { } export function layerFromConfig(config: TracingConfig | null, resource: TracingResource) { + const channel = + typeof __DEV__ !== "undefined" && __DEV__ + ? "dev" + : RELEASE_CHANNEL_BY_VARIANT[resource.appVariant]; return RelayTracing.layer(config, { serviceName: "t3code-mobile", serviceVersion: resource.serviceVersion, runtime: "react-native", + serviceInstanceId: resource.serviceInstanceId, client: `mobile-${resource.appVariant}`, + attributes: channel && releaseResourceAttributes(channel), }); } export const layer = layerFromConfig(resolveTracingConfig(), { serviceVersion: Constants.expoConfig?.version, + serviceInstanceId: Constants.sessionId, appVariant: typeof Constants.expoConfig?.extra?.appVariant === "string" ? Constants.expoConfig.extra.appVariant diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 8498d907f6901..992a5838a66ef 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -59,6 +59,8 @@ import type { ComposerCommandItem } from "./ComposerCommandPopover"; import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; +// A cwd that never scans, such as a deleted worktree, would otherwise retry forever. +const WORKSPACE_SNAPSHOT_MAX_ATTEMPTS = 3; function composerSelectionAtEnd(draftMessage: string): ComposerEditorSelection { return { start: draftMessage.length, end: draftMessage.length }; @@ -252,6 +254,7 @@ export function useComposerCommandMenu({ ); // Record client request time to enforce TTL even when the client clock runs ahead of the server. const workspaceRefreshKeyRef = useRef<{ key: string; requestedAt: number } | null>(null); + const failedRefreshesRef = useRef<{ key: string; count: number } | null>(null); const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ key: string; notBefore: number; @@ -307,6 +310,11 @@ export function useComposerCommandMenu({ workspaceRefreshKeyRef.current = request; const retryLater = () => { if (workspaceRefreshKeyRef.current !== request) return; + const failed = failedRefreshesRef.current; + const count = failed?.key === key ? failed.count + 1 : 1; + failedRefreshesRef.current = { key, count }; + // Keeping the request marker lets the TTL hold off the next scan. + if (count >= WORKSPACE_SNAPSHOT_MAX_ATTEMPTS) return; workspaceRefreshKeyRef.current = null; setWorkspaceRefreshRetry({ key, @@ -326,6 +334,7 @@ export function useComposerCommandMenu({ projectCwd, ); if (!refreshed) retryLater(); + else failedRefreshesRef.current = null; }, retryLater); }, [ draftMessage, diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 51e89523d031e..63b2b644b77d4 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -52,6 +52,7 @@ const emitGrokMonitorPostTurnPoll = process.env.T3_ACP_EMIT_GROK_MONITOR_POST_TU const emitGrokBackgroundTaskStarted = process.env.T3_ACP_EMIT_GROK_BACKGROUND_TASK_STARTED === "1"; const emitGrokBackgroundTaskPoll = process.env.T3_ACP_GROK_BACKGROUND_TASK_COMPLETION === "poll"; const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; +const omitInitializeModelState = process.env.T3_ACP_OMIT_INITIALIZE_MODEL_STATE === "1"; const waitForResumeRelease = process.env.T3_ACP_WAIT_FOR_RESUME_RELEASE === "1"; const completeFirstPromptOnCancel = process.env.T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL === "1"; const floodStderr = process.env.T3_ACP_FLOOD_STDERR === "1"; @@ -575,7 +576,7 @@ const program = Effect.gen(function* () { return { protocolVersion: 2, info: { name: "t3-acp-mock-agent", version: "0.0.0" }, - _meta: { modelState: modelState() }, + ...(omitInitializeModelState ? {} : { _meta: { modelState: modelState() } }), capabilities: { session: { ...(supportsSessionLifecycle ? { fork: {}, additionalDirectories: {} } : {}), diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index 9d536f471e763..82230858891c4 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -487,14 +487,15 @@ it.layer(NodeServices.layer)("prunePinnedRuntimes", (it) => { const path = yield* Path.Path; const { baseDir, versionsDir } = yield* setup(["1.0.0", "1.1.0"]); const entry = path.join(versionsDir, "1.0.0", "t3"); - yield* fs.copyFile("/bin/sleep", entry); + // Multicall coreutils (Ubuntu's uutils) refuse to run renamed, so copy Node instead. + yield* fs.copyFile(process.execPath, entry); yield* fs.chmod(entry, 0o755); const link = path.join(baseDir, "t3"); yield* fs.symlink(entry, link); // Its argv names only the link, as with `t3 serve` run from PATH. Removing the link after // spawning leaves the process's executable as the only reference. const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - yield* spawner.spawn(ChildProcess.make(link, ["30"])); + yield* spawner.spawn(ChildProcess.make(link, ["-e", "setTimeout(() => {}, 30_000)"])); yield* fs.remove(link); const removed = yield* prunePinnedRuntimes({ baseDir, keep: ["2.0.0"] }).pipe( diff --git a/apps/server/src/cloud/relayTracing.ts b/apps/server/src/cloud/relayTracing.ts index 1b76f0a228f65..d5f1380f966eb 100644 --- a/apps/server/src/cloud/relayTracing.ts +++ b/apps/server/src/cloud/relayTracing.ts @@ -1,16 +1,30 @@ +import { processServiceInstanceId } from "@t3tools/shared/observability"; import * as RelayTracing from "@t3tools/shared/relayTracing"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as ServerConfig from "../config.ts"; import { resolveRelayClientTracingConfig } from "./publicConfig.ts"; const relayClientTracingConfig = resolveRelayClientTracingConfig(); -export const layerHeadlessRelayClient = RelayTracing.layer(relayClientTracingConfig, { +const relayLayer = (resource: Omit) => + Layer.unwrap( + Effect.map(ServerConfig.ServerConfig, (config) => + RelayTracing.layer(relayClientTracingConfig, { + ...resource, + serviceInstanceId: processServiceInstanceId(config.otelEnvironment.resourceAttributes), + }), + ), + ); + +export const layerHeadlessRelayClient = relayLayer({ serviceName: "t3code-server", runtime: "node", client: "headless-cli", }); -export const layerServerRelayBroker = RelayTracing.layer(relayClientTracingConfig, { +export const layerServerRelayBroker = relayLayer({ serviceName: "t3code-server", runtime: "node", client: "environment-server", diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 11628ae95c3c9..47a38b4020a1e 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -10,10 +10,21 @@ import * as Schema from "effect/Schema"; import type { OtlpEndpointSource } from "@t3tools/contracts"; import { sweepStalePendingAttachments } from "./attachmentStore.ts"; -import { DEFAULT_SIGNAL_EXPORT, type SignalExport } from "@t3tools/shared/observability"; +import { + DEFAULT_SIGNAL_EXPORT, + processServiceInstanceId, + type SignalExport, +} from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; +import { type ReleaseChannel, releaseResourceAttributes } from "@t3tools/shared/releaseResource"; import packageJson from "../package.json" with { type: "json" }; +// Official release workflows set this telemetry channel; other builds report dev. +// Unlike __T3CODE_BUILD_CHANNEL__, this is not the sign-in page's latest/nightly channel. +declare const __T3CODE_TELEMETRY_CHANNEL__: ReleaseChannel | undefined; +export const telemetryChannel: ReleaseChannel = + typeof __T3CODE_TELEMETRY_CHANNEL__ === "undefined" ? "dev" : __T3CODE_TELEMETRY_CHANNEL__; + export const DEFAULT_PORT = 3773; export const RuntimeMode = Schema.Literals(["web", "desktop"]); @@ -114,6 +125,8 @@ export const otlpResource = ( "service.namespace": "t3code", "service.runtime": "t3-server", "t3code.server.managed_by": config.mode === "desktop" ? "desktop" : "standalone", + "service.instance.id": processServiceInstanceId(config.otelEnvironment.resourceAttributes), + ...releaseResourceAttributes(telemetryChannel, config.otelEnvironment.resourceAttributes), "host.name": config.otelEnvironment.resourceAttributes["host.name"] || hostName, }, }); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 2bd56e30e9dcb..4678aff95b736 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -239,6 +239,7 @@ export const make = Effect.gen(function* () { threadSnooze: true, environmentThemes: true, usageLimitSources: true, + providerWorkspaceSnapshotsOnDemand: true, usagePriceOverrides: true, usageModelAliases: true, threadPinning: true, diff --git a/apps/server/src/observability/Metrics.ts b/apps/server/src/observability/Metrics.ts index be92b47a5bdee..a16a364cc4b70 100644 --- a/apps/server/src/observability/Metrics.ts +++ b/apps/server/src/observability/Metrics.ts @@ -15,6 +15,14 @@ export const rpcRequestDuration = Metric.timer("t3_rpc_request_duration", { description: "RPC request handling duration.", }); +export const orchestrationCommandsTotal = Metric.counter("t3_orchestration_commands_total", { + description: "Total orchestration commands dispatched, by outcome.", +}); + +export const orchestrationCommandDuration = Metric.timer("t3_orchestration_command_duration", { + description: "Orchestration command handling duration, excluding thread dispatch lock wait.", +}); + export const orchestrationEffectClaimsTotal = Metric.counter( "t3_orchestration_effect_claims_total", { @@ -186,3 +194,14 @@ export const withMetrics: { ): (effect: Effect.Effect) => Effect.Effect; (effect: Effect.Effect, options: WithMetricsOptions): Effect.Effect; } = dual(2, withMetricsImpl); + +// Shaped as an `Effect.fn` combinator: the command is the instrumented function's argument. +export const withCommandMetrics = ( + effect: Effect.Effect, + command: { readonly type: string }, +): Effect.Effect => + withMetrics(effect, { + counter: orchestrationCommandsTotal, + timer: orchestrationCommandDuration, + attributes: { commandType: command.type }, + }); diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index 3b1a8ec1cecaa..c36dfc365af60 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -21,10 +21,15 @@ import { import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Metric from "effect/Metric"; import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/sql/SqlClient"; +import { + orchestrationCommandDuration, + orchestrationCommandsTotal, +} from "../observability/Metrics.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as DelegatedTaskQuestionReactor from "./DelegatedTaskQuestionReactor.ts"; @@ -1368,3 +1373,38 @@ it.effect("explicit settle clears snooze atomically and stays idempotent after r assert.deepInclude((yield* projections.getThreadProjection(threadId)).thread, active); }).pipe(Effect.provide(layerTest)), ); + +it.effect("dispatch records the command outcome and its duration", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const successes = Metric.value( + Metric.withAttributes(orchestrationCommandsTotal, { + commandType: "thread.create", + outcome: "success", + }), + ); + const durations = Metric.value( + Metric.withAttributes(orchestrationCommandDuration, { commandType: "thread.create" }), + ); + const successesBefore = (yield* successes).count; + const durationsBefore = (yield* durations).count; + + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-dispatch-metrics"), + threadId: ThreadId.make("thread:dispatch-metrics"), + projectId: ProjectId.make("project:dispatch-metrics"), + title: "Dispatch metrics", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + + assert.strictEqual((yield* successes).count - successesBefore, 1); + assert.strictEqual((yield* durations).count - durationsBefore, 1); + }).pipe(Effect.provide(layerTest)), +); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 0670eb23e451e..aae5d1b68db83 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -118,6 +118,7 @@ import { type GuestShareCommand, } from "./GuestShareDecider.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; +import { withCommandMetrics } from "../observability/Metrics.ts"; import type { OrchestrationEffectRequestV2, PendingOrchestrationEffectV2 } from "./EffectOutbox.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; @@ -12116,7 +12117,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio storedEvents: committed.storedEvents, ...(committed.committed ? {} : { replayed: true as const }), } satisfies OrchestratorV2DispatchResult; - }); + }, withCommandMetrics); // dispatchWithReceiptEffect owns the dispatch span; this wrapper selects the thread locks. const dispatchWithReceipt = Effect.fnUntraced(function* (command: OrchestrationV2ServerCommand) { diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index 9347acef12c8f..5ea7871e94e2c 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -5,12 +5,17 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Metric from "effect/Metric"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import { TestClock } from "effect/testing"; import * as SqlClient from "effect/sql/SqlClient"; import * as ServerConfig from "../config.ts"; +import { + orchestrationCommandDuration, + orchestrationCommandsTotal, +} from "../observability/Metrics.ts"; import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; @@ -203,6 +208,33 @@ it.layer(layerTest)("ProjectService", (it) => { }), ); + it.effect("records the outcome and duration of a committed project command", () => + Effect.gen(function* () { + const service = yield* ProjectService.ProjectService; + const successes = Metric.value( + Metric.withAttributes(orchestrationCommandsTotal, { + commandType: "project.create", + outcome: "success", + }), + ); + const durations = Metric.value( + Metric.withAttributes(orchestrationCommandDuration, { commandType: "project.create" }), + ); + const successesBefore = (yield* successes).count; + const durationsBefore = (yield* durations).count; + + yield* service.create({ + commandId: CommandId.make("command:metrics:create"), + projectId: ProjectId.make("project:metrics"), + title: "Metrics", + workspaceRoot: "/work/metrics", + }); + + assert.strictEqual((yield* successes).count - successesBefore, 1); + assert.strictEqual((yield* durations).count - durationsBefore, 1); + }), + ); + it.effect("auto-bootstraps a workspace exactly once", () => Effect.gen(function* () { const service = yield* ProjectService.ProjectService; diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index b2c65dd17ae11..5c646e1ea7be1 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -17,6 +17,7 @@ import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; +import { withCommandMetrics } from "../observability/Metrics.ts"; import * as EventSink from "../orchestration-v2/EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; @@ -283,7 +284,7 @@ export const make = Effect.gen(function* () { rejection ?? receipt.error ?? "The command was previously rejected.", ); } - }); + }, withCommandMetrics); const readCommitted = Effect.fn("ProjectService.readCommitted")(function* (projectId: ProjectId) { const row = yield* readRow(projectId, { includeDeleted: true }); diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts index 80740b0a21d49..60a4371cb133d 100644 --- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts @@ -1,8 +1,15 @@ // @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose. +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ClaudeSettings, type ServerProvider } from "@t3tools/contracts"; import { vi } from "vite-plus/test"; import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; -import { buildClaudeCapabilitiesProbeQueryOptions } from "./ClaudeProvider.ts"; +import { + buildClaudeCapabilitiesProbeQueryOptions, + probeClaudeWorkspaceSnapshot, +} from "./ClaudeProvider.ts"; vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true }); @@ -20,3 +27,15 @@ it("drops strict MCP mode, and only that, next to an administrator's managed-mcp assert.deepEqual(options.mcpServers, {}); assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false"); }); + +it.effect("reports a deleted worktree's scan as finished, not pending", () => + Effect.gen(function* () { + const snapshot = yield* probeClaudeWorkspaceSnapshot( + yield* Schema.decodeEffect(ClaudeSettings)({}), + { skills: [], slashCommands: [] } as unknown as ServerProvider, + "/nonexistent/t3-deleted-worktree", + { HOME: "/nonexistent/t3-home" }, + ); + assert.strictEqual(snapshot.slashCommandsPending, false); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 4c6a86dbc3815..5bc82a41d4eda 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -431,10 +431,14 @@ export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnaps ): Effect.fn.Return { if (!claudeSettings.enabled) return machineSnapshot; const skills = yield* discoverClaudeSkills(claudeSettings, cwd, environment); - const probeResult = yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false).pipe( - Effect.result, - ); - const capabilities = Result.isSuccess(probeResult) ? probeResult.success : undefined; + const fileSystem = yield* FileSystem.FileSystem; + // A deleted worktree can never answer the probe. Pending would make clients rescan it forever. + const cwdExists = yield* fileSystem.exists(cwd).pipe(Effect.orElseSucceed(() => true)); + const probeResult = cwdExists + ? yield* probeClaudeCapabilities(claudeSettings, environment, cwd, false).pipe(Effect.result) + : undefined; + const capabilities = + probeResult && Result.isSuccess(probeResult) ? probeResult.success : undefined; return { ...machineSnapshot, skills, @@ -442,7 +446,7 @@ export const probeClaudeWorkspaceSnapshot = Effect.fn("probeClaudeWorkspaceSnaps COMPACT_SLASH_COMMAND, ...(capabilities?.slashCommands ?? []), ]), - slashCommandsPending: !capabilities, + slashCommandsPending: cwdExists && !capabilities, }; }); diff --git a/apps/server/src/provider/GrokProvider.test.ts b/apps/server/src/provider/GrokProvider.test.ts index a6945eb902a64..4ecc0ad28971b 100644 --- a/apps/server/src/provider/GrokProvider.test.ts +++ b/apps/server/src/provider/GrokProvider.test.ts @@ -352,6 +352,27 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { }), ); + it.effect("warns instead of reporting ready when no models are discovered", () => + Effect.gen(function* () { + const snapshot = yield* Effect.scoped( + Effect.gen(function* () { + const grokPath = yield* writeFakeGrokCli({ + modelsOutput: "You are logged in with grok.com.\n", + acp: true, + }); + return yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + { ...process.env, XAI_API_KEY: "", T3_ACP_OMIT_INITIALIZE_MODEL_STATE: "1" }, + ); + }), + ); + + expect(snapshot.status).toBe("warning"); + expect(snapshot.message).toContain("did not list any models"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["grok-build"]); + }), + ); + it.effect("treats XAI_API_KEY as authenticated regardless of CLI login state", () => Effect.gen(function* () { const snapshot = yield* Effect.scoped( diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index f819fd64ca305..1c47a2f31c3fe 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -693,6 +693,44 @@ it.layer( ); }); + it("drops Grok models a completed probe no longer advertises", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("grok"), + driver: ProviderDriverKind.make("grok"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-09-30T00:00:00.000Z", + version: "1.0.44", + models: [ + { slug: "grok-4.7", name: "Grok 4.7", isCustom: false, capabilities: null }, + { slug: "codexhub-removed", name: "Removed", isCustom: false, capabilities: null }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-09-30T00:01:00.000Z", + models: [previousProvider.models[0]], + } satisfies ServerProvider; + const acpFailedProvider = { + ...refreshedProvider, + status: "warning", + message: "Grok CLI is installed but ACP initialize failed.", + } satisfies ServerProvider; + + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, + [previousProvider.models[0]], + ); + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, acpFailedProvider).models, + [...previousProvider.models], + ); + }); + it("retains stale OpenCode models when a refresh fails", () => { const previousProvider = { instanceId: ProviderInstanceId.make("opencode"), @@ -1246,6 +1284,98 @@ it.layer( ); }); + it.effect( + "a second request for a cwd being scanned gets its result, even if the first leaves", + () => + Effect.gen(function* () { + const driver = ProviderDriverKind.make("codex"); + const instanceId = ProviderInstanceId.make("codex"); + const provider = { + instanceId, + driver, + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-06-10T00:00:00.000Z", + version: "1.0.0", + models: [], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const scanStarted = yield* Deferred.make(); + const releaseScan = yield* Deferred.make(); + const scans = yield* Ref.make(0); + const instance: ProviderInstance = { + instanceId, + driverKind: driver, + continuationIdentity: { driverKind: driver, continuationKey: "codex:instance:codex" }, + displayName: undefined, + enabled: true, + snapshot: { + resolveMaintenance: () => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ + provider: driver, + packageName: null, + }), + ), + getSnapshot: Effect.succeed(provider), + refresh: Effect.succeed(provider), + streamChanges: Stream.empty, + applyUsageLimits: () => Effect.void, + }, + snapshotForCwd: () => + Ref.update(scans, (count) => count + 1).pipe( + Effect.andThen(Deferred.succeed(scanStarted, undefined)), + Effect.andThen(Deferred.await(releaseScan)), + Effect.as(provider), + ), + orchestrationAdapter: {} as ProviderInstance["orchestrationAdapter"], + textGeneration: {} as ProviderInstance["textGeneration"], + }; + const layerInstanceRegistry = Layer.succeed( + ProviderInstanceRegistry.ProviderInstanceRegistry, + { + getInstance: (requestedId) => + Effect.succeed(requestedId === instanceId ? instance : undefined), + listInstances: Effect.succeed([instance]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.flatMap(PubSub.unbounded(), PubSub.subscribe), + }, + ); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const runtimeServices = yield* Layer.build( + ProviderRegistry.layer.pipe( + Layer.provideMerge(layerInstanceRegistry), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-provider-registry-shared-workspace-scan-", + }), + ), + Layer.provideMerge(NodeServices.layer), + ), + ).pipe(Scope.provide(scope)); + + yield* Effect.gen(function* () { + const registry = yield* ProviderRegistry.ProviderRegistry; + const refresh = registry.refreshWorkspaceSnapshot({ instanceId, cwd: "/workspace" }); + const first = yield* Effect.forkChild(refresh); + yield* Deferred.await(scanStarted); + const second = yield* Effect.forkChild(refresh, { startImmediately: true }); + yield* Fiber.interrupt(first); + yield* Deferred.succeed(releaseScan, undefined); + assert.deepStrictEqual( + (yield* Fiber.join(second))[0]?.workspaceSnapshots?.map((snapshot) => snapshot.cwd), + ["/workspace"], + ); + assert.strictEqual(yield* Ref.get(scans), 1); + }).pipe(Effect.provide(runtimeServices)); + }), + ); + it.effect("publishes workspace skills while machine health is in error", () => Effect.gen(function* () { const driver = ProviderDriverKind.make("codex"); diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 0096ca23c0370..78b11306c5b6d 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -12,6 +12,7 @@ import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -167,6 +168,12 @@ const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => ); } + if (provider.driver === ProviderDriverKind.make("grok")) { + // Grok reports ready only after it discovers a model list; the pending, + // ACP-failed, empty-inventory, and logged-out probes are warning/error. + return !(provider.installed && provider.status === "ready"); + } + const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity"); const isCodex = provider.driver === ProviderDriverKind.make("codex"); if (!isAntigravity && !isCodex && provider.driver !== ProviderDriverKind.make("opencode")) { @@ -405,7 +412,7 @@ export const layer = Layer.effect( cachedProviders.map((provider) => classifyCompatibility(provider, initialManifest)), ); const workspaceRefreshesRef = yield* Ref.make< - ReadonlyMap> + ReadonlyMap>> >(new Map()); const maintenanceActionStatesRef = yield* Ref.make< ReadonlyMap @@ -922,21 +929,13 @@ export const layer = Layer.effect( const scannedAt = DateTime.formatIso(now); const instance = yield* instanceRegistry.getInstance(input.instanceId); if (!instance?.snapshotForCwd) return providers; - const token = Symbol(); - const claimed = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { - const current = refreshes.get(instance); - if (!input.fresh && current?.has(input.cwd)) return [false, refreshes] as const; - const next = new Map(refreshes); - next.set(instance, new Map(current).set(input.cwd, token)); - return [true, next] as const; - }); - if (!claimed) return yield* Ref.get(providersRef); + const token = yield* Deferred.make(); const refreshMachineSnapshot = input.fresh ? (instance.invalidateCaches ?? Effect.void).pipe( Effect.andThen(refreshInstance(input.instanceId)), ) : Effect.void; - return yield* refreshMachineSnapshot.pipe( + const runScan = refreshMachineSnapshot.pipe( Effect.andThen(instance.snapshotForCwd(input.cwd)), // Scans fail on error; returned inventory remains valid even when provider health reports an error. Effect.flatMap((scopedSnapshot) => @@ -974,9 +973,33 @@ export const layer = Layer.effect( if (nextCwds.size) next.set(instance, nextCwds); else next.delete(instance); return next; - }), + }).pipe(Effect.andThen(Deferred.succeed(token, undefined))), ), ); + // The claim and the fork happen together, so the token is always completed. The scan is + // detached so a disconnecting owner cannot cancel the result its waiters depend on. + const claim = yield* Effect.uninterruptible( + Effect.gen(function* () { + const inFlight = yield* Ref.modify(workspaceRefreshesRef, (refreshes) => { + const current = refreshes.get(instance); + const running = current?.get(input.cwd); + if (!input.fresh && running) return [running, refreshes] as const; + const next = new Map(refreshes); + next.set(instance, new Map(current).set(input.cwd, token)); + return [undefined, next] as const; + }); + if (inFlight) return { inFlight } as const; + return { + // Starting now attaches the scan's `ensuring` before anything can close the scope. + scan: yield* Effect.forkIn(runScan, serviceScope, { startImmediately: true }), + } as const; + }), + ); + // Wait for the running scan: on-demand clients get its result only in this reply. + if ("inFlight" in claim) { + return yield* Deferred.await(claim.inFlight).pipe(Effect.andThen(Ref.get(providersRef))); + } + return yield* Fiber.join(claim.scan); }); return { diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts index 7ee010b6f76d5..c71d414ccdfab 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts @@ -165,7 +165,7 @@ describe("terminatePosixOwnedProcessTree", () => { AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { PATH: undefined }), ).toBeUndefined(); expect( - AcpSessionRuntime.resolveLinuxCgroupTargetCommand("node", scratch, { PATH: undefined }), + AcpSessionRuntime.resolveLinuxCgroupTargetCommand("sh", scratch, { PATH: undefined }), ).toBeDefined(); expect( AcpSessionRuntime.resolveLinuxCgroupTargetCommand("grok", scratch, { diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index fec4db2541517..9a4e71149a496 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -151,6 +151,21 @@ const printThroughConsole = (fields: object) => }); describe("ServerLoggerLive", () => { + it.effect("exports the operator's service instance id instead of a generated one", () => + Effect.gen(function* () { + const { requests } = yield* logInSpanThrough({ + otlpLogsUrl: "https://collector.example.com/v1/logs", + otelEnvironment: { + ...OtelEnvironment.none, + resourceAttributes: { "service.instance.id": "pod-7" }, + }, + }); + + const body = requests.map((request) => request.body).join("\n"); + assert.include(body, '"key":"service.instance.id","value":{"stringValue":"pod-7"}'); + }), + ); + it.effect("exports a resource that names whether the desktop app launched the server", () => Effect.gen(function* () { const { requests } = yield* logInSpanThrough({ diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index bc0a0ad562a4f..54e3a3e6105d3 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -50,10 +50,6 @@ const RETRY_MAX_DELAY_MS = 300_000; // These statuses reject the batch itself; unlike 408/429, retrying cannot help. const REJECTED_BATCH_STATUSES = new Set([400, 401, 413]); -// Official release workflows set this telemetry channel; other builds report dev. -// Unlike __T3CODE_BUILD_CHANNEL__, this is not the sign-in page's latest/nightly channel. -declare const __T3CODE_TELEMETRY_CHANNEL__: "stable" | "nightly" | "preview" | "dev" | undefined; - /** `random` is in [0, 1). */ function retryDelayMs(failures: number, random: number): number { const ceiling = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * 2 ** (failures - 1)); @@ -142,8 +138,6 @@ export const make = Effect.gen(function* () { // The background flush and the shutdown flush must not send the same batch at once. const flushLock = yield* Semaphore.make(1); const clientType = serverConfig.mode === "desktop" ? "desktop-app" : "cli-web-client"; - const telemetryChannel = - typeof __T3CODE_TELEMETRY_CHANNEL__ === "undefined" ? "dev" : __T3CODE_TELEMETRY_CHANNEL__; const hostPlatform = yield* HostProcess.Platform; const hostArchitecture = yield* HostProcess.Architecture; @@ -198,7 +192,7 @@ export const make = Effect.gen(function* () { wsl: Option.getOrUndefined(telemetryConfig.wslDistroName), arch: hostArchitecture, t3CodeVersion: packageJson.version, - telemetryChannel, + telemetryChannel: ServerConfig.telemetryChannel, clientType, serverOs: serverOsFromNodePlatform(hostPlatform), serverArch: hostArchitecture, diff --git a/apps/server/src/testUtils/managedServiceEnv.setup.ts b/apps/server/src/testUtils/managedServiceEnv.setup.ts new file mode 100644 index 0000000000000..a6581817e14bb --- /dev/null +++ b/apps/server/src/testUtils/managedServiceEnv.setup.ts @@ -0,0 +1,6 @@ +import { SERVICE_LAUNCHER_CONTEXT_ENV } from "../cloud/serviceProtocol.ts"; + +// Agents inside a managed T3 service inherit its home and launcher context; CI has neither. +for (const name of ["T3CODE_HOME", "T3_BOOT_SERVICE_UNIT", SERVICE_LAUNCHER_CONTEXT_ENV]) { + delete process.env[name]; +} diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index a2c3910d4da5f..49425668cd77b 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -5,13 +5,17 @@ import { EnvironmentId, ProjectId, ThreadId, + ProviderDriverKind, + ProviderInstanceId, WS_METHODS, + WsServerRefreshProvidersRpc, WsSubscribeServerConfigRpc, type OrchestrationV2StoredEvent, type OrchestrationV2ThreadShell, type OrchestrationProjectShell, type ServerConfig, type ServerConfigStreamEvent, + type ServerProvider, } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as DateTime from "effect/DateTime"; @@ -20,6 +24,7 @@ import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { Headers } from "effect/http"; import { RpcGroup, RpcMessage, RpcServer } from "effect/rpc"; import * as SqlClient from "effect/sql/SqlClient"; @@ -632,6 +637,162 @@ it.effect( ), ); +it.effect("on-demand clients get provider config without workspace snapshots", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("claudeAgent"); + const snapshotFor = (cwd: string, checkedAt: string) => ({ + cwd, + checkedAt, + slashCommands: [{ name: `${cwd.slice(1)}-command` }], + skills: [], + }); + const provider: ServerProvider = { + instanceId, + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-25T00:00:00.000Z", + models: [], + slashCommands: [{ name: "machine-command" }], + skills: [], + workspaceSnapshots: [ + snapshotFor("/a", "2026-10-25T00:00:00.000Z"), + snapshotFor("/b", "2026-10-25T00:00:00.000Z"), + ], + }; + const providerChanges = yield* Queue.unbounded>(); + const pulled = yield* Queue.unbounded(); + const responses = yield* Queue.unbounded(); + const configRpcServices = Layer.mergeAll( + layerUnusedConfigRpcServices, + Layer.mock(ExternalLauncher.ExternalLauncher)({ + resolveAvailableEditors: () => Effect.succeed([]), + resolveFileManagerRevealKind: () => Effect.succeed(undefined), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([provider]), + streamChanges: Stream.fromQueue(providerChanges).pipe( + Stream.tap(() => Queue.offer(pulled, undefined)), + ), + refreshWorkspaceSnapshot: () => Effect.succeed([provider]), + }), + Layer.mock(UsageLimitSources.UsageLimitSources)({ + current: Effect.succeed([]), + streamChanges: Stream.make([]), + }), + Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ + resolveTargets: () => Effect.succeed([]), + }), + Layer.mock(DirectEndpoints.DirectEndpoints)({ resolve: () => Effect.succeed([]) }), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + scratchRoot: Effect.succeedNone, + namedProjectsRoot: process.cwd(), + }), + Layer.mock(ServerEnvironment.ServerEnvironment)({ + getDescriptor: Effect.succeed({ + environmentId: EnvironmentId.make("config-test"), + label: "Config test", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0", + capabilities: { repositoryIdentity: false }, + }), + }), + Layer.mock(EnvironmentAuth.EnvironmentAuth)({ + getDescriptor: () => + Effect.succeed({ + policy: "loopback-browser", + bootstrapMethods: [], + sessionMethods: ["browser-session-cookie"], + sessionCookieName: "config-test-session", + }), + }), + ); + const server = yield* RpcServer.makeNoSerialization( + RpcGroup.make(WsSubscribeServerConfigRpc, WsServerRefreshProvidersRpc), + { + ...WS_RPC_SERVER_OPTIONS, + onFromServer: (response) => + response._tag === "Chunk" + ? Effect.forEach(response.values, (value) => Queue.offer(responses, value), { + discard: true, + }) + : response._tag === "Exit" && response.exit._tag === "Success" + ? Queue.offer(responses, response.exit.value) + : Effect.void, + }, + ).pipe( + Effect.provide( + layerWsRpc( + { + sessionId: AuthSessionId.make("config-test"), + subject: "config-test", + method: "browser-session-cookie", + scopes: [], + }, + {}, + {}, + yield* PreviewAutomationBroker.PreviewAutomationBroker, + yield* ServerBrowser.ServerBrowser, + ).pipe(Layer.provide(configRpcServices)), + ), + ); + const configRequest = RpcMessage.RequestId("config"); + yield* server.write(0, { + _tag: "Request", + id: configRequest, + tag: WS_METHODS.subscribeServerConfig, + payload: { workspaceSnapshotsOnDemand: true }, + headers: Headers.empty, + }); + const snapshot = (yield* Queue.take(responses)) as ServerConfigStreamEvent; + if (snapshot.type !== "snapshot") return yield* Effect.die("Missing initial snapshot"); + assert.isUndefined(snapshot.config.providers[0]?.workspaceSnapshots); + yield* server.write(0, { _tag: "Ack", requestId: configRequest }); + + // A rescan that only touches a snapshot sends nothing; the next real change does. + yield* Queue.offer(providerChanges, [ + { ...provider, workspaceSnapshots: [snapshotFor("/a", "2026-10-25T00:05:00.000Z")] }, + ]); + yield* Queue.take(pulled); + yield* TestClock.adjust("1 second"); + yield* Queue.offer(providerChanges, [{ ...provider, status: "warning" }]); + yield* Queue.take(pulled); + yield* TestClock.adjust("1 second"); + const update = (yield* Queue.take(responses)) as ServerConfigStreamEvent; + if (update.type !== "providerStatuses") return yield* Effect.die("Expected provider update"); + assert.equal(update.payload.providers[0]?.status, "warning"); + assert.isUndefined(update.payload.providers[0]?.workspaceSnapshots); + + yield* server.write(0, { + _tag: "Request", + id: RpcMessage.RequestId("refresh"), + tag: WS_METHODS.serverRefreshProviders, + payload: { instanceId, cwd: "/b", workspaceSnapshotsOnDemand: true }, + headers: Headers.empty, + }); + const refreshed = (yield* Queue.take(responses)) as { + readonly providers: ReadonlyArray; + }; + assert.deepEqual( + refreshed.providers.map((entry) => [entry.workspaceSnapshots, entry.slashCommands]), + [[[snapshotFor("/b", "2026-10-25T00:00:00.000Z")], []]], + ); + yield* server.write(0, { _tag: "Interrupt", requestId: configRequest, interruptors: [] }); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.fresh(layerConfigSources), + Layer.mock(PreviewAutomationBroker.PreviewAutomationBroker)({}), + Layer.mock(ServerBrowser.ServerBrowser)({}), + ), + ), + Effect.scoped, + ), +); + // Compaction keeps the oldest and newest event per message, so a cursor older // than the deleted rows still replays each message where the client would // first have seen it (reducers append on first sight) and reaches the same diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a03176a455eaf..e5ef646a615af 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -82,6 +82,7 @@ import { type ServerConfig as ClientServerConfig, type ServerConfigStreamEvent, type ServerLifecycleStreamEvent, + type ServerProvider, type FilesystemBrowseFailure, FilesystemBrowseError, AssetWorkspaceContextNotFoundError, @@ -363,6 +364,10 @@ export const withLateEditorConfig = ( ); }; +function withoutWorkspaceSnapshots(providers: ReadonlyArray): ServerProvider[] { + return providers.map(({ workspaceSnapshots: _workspaceSnapshots, ...provider }) => provider); +} + function unexpectedCompatibilityError(error: never): never { throw new Error(`Unhandled compatibility error: ${String(error)}`); } @@ -1721,12 +1726,16 @@ export const layerWsRpc = ( const loadServerConfig = Effect.fn("ws.loadServerConfig")(function* (options: { readonly usageLimitsCommand: boolean; + readonly workspaceSnapshotsOnDemand: boolean; }) { const keybindingsConfig = yield* keybindings.loadConfigState; const currentProviders = yield* providerRegistry.getProviders; - const providers = options.usageLimitsCommand + const commandProviders = options.usageLimitsCommand ? withUsageLimitsCommands(currentProviders, yield* usageLimitSources.current) : currentProviders; + const providers = options.workspaceSnapshotsOnDemand + ? withoutWorkspaceSnapshots(commandProviders) + : commandProviders; const settings = ServerSettings.redactServerSettingsForClient( yield* serverSettings.getSettings, ); @@ -2207,7 +2216,8 @@ export const layerWsRpc = ( Effect.andThen(scheduledTasks.getWebhookDelivery(input)), ), [WS_METHODS.serverProbe]: (_input) => Effect.succeed({}), - [WS_METHODS.serverGetConfig]: (_input) => loadServerConfig({ usageLimitsCommand: false }), + [WS_METHODS.serverGetConfig]: (_input) => + loadServerConfig({ usageLimitsCommand: false, workspaceSnapshotsOnDemand: false }), [WS_METHODS.serverSearchAcpRegistry]: (input) => acpRegistryCatalog .search(input) @@ -2376,7 +2386,29 @@ export const layerWsRpc = ( providers = yield* providerRegistry.refreshInstance(instance.instanceId); } } - return { providers }; + if (input.workspaceSnapshotsOnDemand !== true) return { providers }; + const commandProviders = + input.usageLimitsCommand === true + ? withUsageLimitsCommands(providers, yield* usageLimitSources.current) + : providers; + const { instanceId, cwd } = input; + if (instanceId === undefined || cwd === undefined) { + return { providers: withoutWorkspaceSnapshots(commandProviders) }; + } + return { + providers: commandProviders + .filter((provider) => provider.instanceId === instanceId) + .map((provider) => ({ + ...provider, + // Callers read only the cwd snapshot; the config stream carries the rest. + models: [], + slashCommands: [], + skills: [], + workspaceSnapshots: (provider.workspaceSnapshots ?? []).filter( + (snapshot) => snapshot.cwd === cwd, + ), + })), + }; }), [WS_METHODS.mcpAppsCallTool]: (input) => mcpAppRequests.callTool(input), [WS_METHODS.mcpAppsToolInfo]: (input) => mcpAppRequests.toolInfo(input), @@ -3093,10 +3125,14 @@ export const layerWsRpc = ( Stream.unwrap( Effect.gen(function* () { const usageLimitsCommand = input.usageLimitsCommand === true; + const workspaceSnapshotsOnDemand = input.workspaceSnapshotsOnDemand === true; // Buffer changes through discovery and the initial snapshot's RPC ACK wait. const keybindingsChanges = yield* keybindings.subscribeChanges; const settingsChanges = yield* serverSettings.subscribeChanges; - const config = yield* loadServerConfig({ usageLimitsCommand }); + const config = yield* loadServerConfig({ + usageLimitsCommand, + workspaceSnapshotsOnDemand, + }); const keybindingsUpdates = keybindingsChanges.pipe( Stream.map((event) => ({ version: 1 as const, @@ -3118,8 +3154,15 @@ export const layerWsRpc = ( usageLimitsCommand ? sameUsageLimitCommandCoverage : () => true, ), ), - (providers, sources) => - usageLimitsCommand ? withUsageLimitsCommands(providers, sources) : providers, + (providers, sources) => { + const commandProviders = usageLimitsCommand + ? withUsageLimitsCommands(providers, sources) + : providers; + // Stripped before the de-dupe below, so snapshot-only changes send nothing. + return workspaceSnapshotsOnDemand + ? withoutWorkspaceSnapshots(commandProviders) + : commandProviders; + }, ).pipe( // Compare initial paired values with the snapshot; blindly dropping them loses changes between snapshot and subscription. (updates) => Stream.concat(rpcInitialItems([config.providers]), updates), diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 5d36d0867c597..f68d1cc733c6a 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -128,7 +128,10 @@ export default mergeConfig( // Parallel sqlite/git/worktree/orchestration tests introduce load-sensitive flakes. fileParallelism: false, sequence: { sequencer: WeightedShardSequencer }, - setupFiles: ["./src/testUtils/gitConfig.setup.ts"], + setupFiles: [ + "./src/testUtils/gitConfig.setup.ts", + "./src/testUtils/managedServiceEnv.setup.ts", + ], hookTimeout: 120_000, testTimeout: 120_000, }, diff --git a/apps/web/src/components/chat/ChatMarkdownMedia.tsx b/apps/web/src/components/chat/ChatMarkdownMedia.tsx index 564ea71a3d567..18aadf4b76ddc 100644 --- a/apps/web/src/components/chat/ChatMarkdownMedia.tsx +++ b/apps/web/src/components/chat/ChatMarkdownMedia.tsx @@ -207,6 +207,7 @@ export function ChatMarkdownImage(props: { alt={props.alt} data-markdown-copy={props.copyMarkdown} decoding="async" + loading="lazy" draggable={false} className={cn( CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, @@ -263,6 +264,7 @@ export function ChatMarkdownImage(props: { src={src} alt={props.alt} decoding="async" + loading="lazy" draggable={false} className="invisible absolute inset-0 size-full" {...imageEvents(src)} diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index 31018d9250d01..6d2a7776091c1 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -508,6 +508,7 @@ function UserTimelineRow({ row }: { row: Extract diff --git a/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts b/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts index bda0641e90917..7256052cebbbc 100644 --- a/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts +++ b/apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts @@ -14,6 +14,8 @@ import { useEffect, useEffectEvent, useRef, useState } from "react"; import { useStableCallback } from "../../hooks/useStableCallback"; const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; +// A cwd that never scans, such as a deleted worktree, would otherwise retry forever. +const WORKSPACE_SNAPSHOT_MAX_ATTEMPTS = 3; /** * Keeps the selected provider's workspace snapshot fresh for the composer's @@ -32,6 +34,7 @@ export function useProviderWorkspaceSnapshotRefresh(input: { // The last scan this composer asked for. A request inside the TTL is not // repeated, so a client clock ahead of the server's cannot loop rescans. const workspaceRefreshKeyRef = useRef<{ key: string; requestedAt: number } | null>(null); + const failedRefreshesRef = useRef<{ key: string; count: number } | null>(null); const [workspaceRefreshRetry, setWorkspaceRefreshRetry] = useState<{ key: string; notBefore: number; @@ -96,6 +99,11 @@ export function useProviderWorkspaceSnapshotRefresh(input: { workspaceRefreshKeyRef.current = request; const retryLater = () => { if (workspaceRefreshKeyRef.current !== request) return; + const failed = failedRefreshesRef.current; + const count = failed?.key === key ? failed.count + 1 : 1; + failedRefreshesRef.current = { key, count }; + // Keeping the request marker lets the TTL hold off the next scan. + if (count >= WORKSPACE_SNAPSHOT_MAX_ATTEMPTS) return; workspaceRefreshKeyRef.current = null; setWorkspaceRefreshRetry({ key, @@ -115,6 +123,7 @@ export function useProviderWorkspaceSnapshotRefresh(input: { gitCwd, ); if (!refreshedSnapshot) retryLater(); + else failedRefreshesRef.current = null; }, retryLater); }; const refreshWorkspaceSnapshotOnChange = useEffectEvent(refreshWorkspaceSnapshotIfStale); diff --git a/apps/web/src/lib/runtime.ts b/apps/web/src/lib/runtime.ts index d983c716e526a..4795655be0260 100644 --- a/apps/web/src/lib/runtime.ts +++ b/apps/web/src/lib/runtime.ts @@ -22,6 +22,7 @@ const layerRelayTracing = RelayTracing.layer(resolveRelayTracingConfig(), { serviceName: "t3code-web", serviceVersion: import.meta.env.APP_VERSION, runtime: "browser", + serviceInstanceId: ClientTracer.serviceInstanceId, client: typeof window !== "undefined" && window.desktopBridge ? "desktop" : "web", }).pipe(Layer.provide(layerHttpClient)); diff --git a/apps/web/src/observability/clientTracer.ts b/apps/web/src/observability/clientTracer.ts index 73d3885f8800c..4625ec7af4b82 100644 --- a/apps/web/src/observability/clientTracer.ts +++ b/apps/web/src/observability/clientTracer.ts @@ -1,6 +1,11 @@ import * as Layer from "effect/Layer"; import * as Tracer from "effect/Tracer"; +import { randomUUID } from "../lib/utils"; + +/** `service.instance.id` for this page load, shared by every resource the UI exports. */ +export const serviceInstanceId = randomUUID(); + let delegate: Tracer.Tracer | null = null; export function setDelegate(next: Tracer.Tracer | null): void { diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index 73a2611c7d4db..43a9ebd1f5b9c 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -8,17 +8,26 @@ import { OtlpExporter, OtlpSerialization, OtlpTracer } from "effect/observabilit import { settleAsyncResult, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary"; +import { releaseChannelOf, releaseResourceAttributes } from "@t3tools/shared/releaseResource"; import * as ClientTracer from "./clientTracer"; import * as PrimaryEnvironmentHttpLayer from "../environments/primary/httpLayer"; import { isElectron } from "../env"; import { APP_VERSION } from "~/branding"; const DEFAULT_EXPORT_INTERVAL_MS = 1_000; +// PR previews can't mark themselves in APP_VERSION without breaking the version-skew check against the server. +const RELEASE_CHANNEL = + import.meta.env.DEV || import.meta.env.VITE_RELEASE_CHANNEL === "dev" + ? "dev" + : import.meta.env.VITE_RELEASE_CHANNEL === "preview" + ? "preview" + : releaseChannelOf(APP_VERSION); const CLIENT_TRACING_RESOURCE = { serviceName: "t3code-web", attributes: { "service.namespace": "t3code", - "deployment.environment.name": import.meta.env.DEV ? "development" : "production", + "service.instance.id": ClientTracer.serviceInstanceId, + ...releaseResourceAttributes(RELEASE_CHANNEL), "service.runtime": "t3-web", "t3code.client.surface": isElectron ? "desktop" : "web", "service.version": APP_VERSION, diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index acc73a399a3cc..37a133afafeee 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -7,6 +7,7 @@ interface ImportMetaEnv { readonly VITE_WS_URL: string; readonly VITE_HOSTED_APP_URL: string; readonly VITE_HOSTED_APP_CHANNEL: string; + readonly VITE_RELEASE_CHANNEL: string; readonly VITE_CLERK_PUBLISHABLE_KEY: string; readonly VITE_CLERK_JWT_TEMPLATE: string; readonly VITE_CLERK_CLI_OAUTH_CLIENT_ID: string; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index ede07403975c8..577dda09c9b89 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -39,6 +39,8 @@ const configuredRelayTracingUrl = repoEnv.VITE_RELAY_OTLP_TRACES_URL?.trim() || const configuredRelayTracingDataset = repoEnv.VITE_RELAY_OTLP_TRACES_DATASET?.trim() || ""; const configuredRelayTracingToken = repoEnv.VITE_RELAY_OTLP_TRACES_TOKEN?.trim() || ""; const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || ""; +const configuredReleaseChannel = + process.env.T3CODE_BUILT_DEV === "1" ? "dev" : process.env.VITE_RELEASE_CHANNEL?.trim() || ""; const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; const configuredDesktopUpdateRepository = process.env.T3CODE_DESKTOP_UPDATE_REPOSITORY?.trim() || @@ -237,6 +239,7 @@ export default defineConfig(() => { "import.meta.env.T3CODE_DESKTOP_UPDATE_REPOSITORY": JSON.stringify( configuredDesktopUpdateRepository, ), + "import.meta.env.VITE_RELEASE_CHANNEL": JSON.stringify(configuredReleaseChannel), "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion), }, resolve: { diff --git a/docs/operations/observability.md b/docs/operations/observability.md index a9d03758ebfdc..51413e205da6e 100644 --- a/docs/operations/observability.md +++ b/docs/operations/observability.md @@ -393,12 +393,14 @@ Traces are best for one request. Metrics are best for trends. Good metric families to watch: - `t3_rpc_request_duration` +- `t3_orchestration_command_duration` - `t3_provider_turn_duration` (how long the provider adapter takes to start a turn, not the turn's run time) - `t3_git_command_duration` Counters tell you volume and failure rate: - `t3_rpc_requests_total` +- `t3_orchestration_commands_total` - `t3_provider_turns_total` - `t3_git_commands_total` @@ -625,15 +627,27 @@ with a startup warning, rather than sending it to the Settings endpoint. Service names are fixed: `t3code-server` for the backend and `t3code-desktop` for the desktop main process, both in `service.namespace` `t3code`. `OTEL_SERVICE_NAME` and a `service.name` or `service.namespace` in `OTEL_RESOURCE_ATTRIBUTES` are ignored. Tell installations apart with other -resource attributes, such as `OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=development`. -The UI reports `deployment.environment.name` from its build: `development` under the Vite dev server -and `production` otherwise. +resource attributes, such as `OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=staging`. + +Each process reports its own build, not the one it talks to: the server, the desktop main process, +the UI, and the mobile app each carry `t3code.release.channel` (`dev`, `stable`, `nightly`, or +`preview`, from their own version; desktop PR builds count as `preview`, and a server not built by a +release workflow reports `dev`) and `deployment.environment.name` (`development`, `production`, or +`staging` for `preview`). On the server and the desktop main process either key in +`OTEL_RESOURCE_ATTRIBUTES` wins. The UI and mobile app never read that variable: Vercel PR previews +set `VITE_RELEASE_CHANNEL=preview` because their `APP_VERSION` must stay comparable with the +server's, and the mobile app maps its build variant (`development`, `preview`, `production`), or +reports `dev` from a development bundle. Attributes specific to T3 Code use the `t3code.` prefix. The UI runs as `t3code-web` in both a browser and the desktop window, since it is the same code, and `t3code.client.surface` is `desktop` or `web`. The server's `t3code.server.managed_by` is `desktop` when the desktop app launched it and `standalone` otherwise. +Every resource also carries a `service.instance.id`: random per process for the server and desktop, +per page load for the UI, and the Expo session on mobile. A `service.instance.id` in +`OTEL_RESOURCE_ATTRIBUTES` replaces the generated one on the server and desktop. + If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay on stdout only. diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index acae1e75258e3..cf9c614c403c7 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -15,6 +15,7 @@ import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-e import noAmbiguousDetailNavigation from "./rules/no-ambiguous-detail-navigation.ts"; import noMobilePullRequestGlyphLiterals from "./rules/no-mobile-pull-request-glyph-literals.ts"; import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts"; +import reactEffectBudget from "./rules/react-effect-budget.ts"; import requireRegisteredListScroll from "./rules/require-registered-list-scroll.ts"; import noOutsetStateIndicators from "./rules/no-outset-state-indicators.ts"; import noPollingLoopInTests from "./rules/no-polling-loop-in-tests.ts"; @@ -52,6 +53,7 @@ export default definePlugin({ "no-ambiguous-detail-navigation": noAmbiguousDetailNavigation, "no-mobile-pull-request-glyph-literals": noMobilePullRequestGlyphLiterals, "no-native-title-tooltip": noNativeTitleTooltip, + "react-effect-budget": reactEffectBudget, "require-registered-list-scroll": requireRegisteredListScroll, "no-outset-state-indicators": noOutsetStateIndicators, "no-polling-loop-in-tests": noPollingLoopInTests, diff --git a/oxlint-plugin-t3code/rules/react-effect-budget.test.ts b/oxlint-plugin-t3code/rules/react-effect-budget.test.ts new file mode 100644 index 0000000000000..b1cba759d188a --- /dev/null +++ b/oxlint-plugin-t3code/rules/react-effect-budget.test.ts @@ -0,0 +1,50 @@ +import { assert, describe } from "@effect/vitest"; + +import budget from "../../scripts/effect-budget.json" with { type: "json" }; +import { createOxlintRuleHarness } from "../test/utils.ts"; + +const [budgetedFile, allowed] = Object.entries(budget)[0]!; + +const component = (effects: number) => `import React, { useEffect } from "react"; +export function Panel() { +${Array.from({ length: effects }, (_, index) => (index % 2 === 0 ? " useEffect(() => {}, []);" : " React.useLayoutEffect(() => {});")).join("\n")} + return null; +} +`; + +const reports = (output: string) => output.match(/t3code\(react-effect-budget\)/g)?.length ?? 0; +const lint = (filename: string) => + createOxlintRuleHarness("t3code/react-effect-budget", { filename }); + +const budgeted = lint(budgetedFile); +const unbudgeted = lint("apps/web/src/NotInTheBudget.tsx"); + +describe("t3code/react-effect-budget", () => { + budgeted.valid("passes a file at its budget", component(allowed)); + + budgeted.invalid("reports only the calls past the budget", component(allowed + 2), (output) => { + assert.strictEqual(reports(output), 2); + assert.include(output, `has ${allowed + 2} React effects against a budget of ${allowed}`); + }); + + unbudgeted.invalid( + "gives a file without an entry a budget of 0, counting member calls", + `export function New() { React.useEffect(() => {}); return null; }`, + (output) => { + assert.strictEqual(reports(output), 1); + assert.include(output, "has 1 React effect against a budget of 0"); + }, + ); + + unbudgeted.valid( + "ignores comments, strings and lookalike names", + `// useEffect(() => {}) in a comment +/* React.useLayoutEffect(() => {}) */ +const label = "useEffect(() => {})"; +const pipeline = Effect.matchCauseEffect(effect, {}); +const alias = useEffect; +`, + ); + + lint("apps/web/src/Panel.test.tsx").valid("skips test files", component(3)); +}); diff --git a/oxlint-plugin-t3code/rules/react-effect-budget.ts b/oxlint-plugin-t3code/rules/react-effect-budget.ts new file mode 100644 index 0000000000000..3974eb6e7b9ab --- /dev/null +++ b/oxlint-plugin-t3code/rules/react-effect-budget.ts @@ -0,0 +1,58 @@ +import { defineRule, type ESTree } from "@oxlint/plugins"; +import * as Option from "effect/Option"; + +import budget from "../../scripts/effect-budget.json" with { type: "json" }; +import { getPropertyName, unwrapExpression } from "../utils.ts"; + +const BUDGET_FILE = "scripts/effect-budget.json"; +const EFFECT_HOOKS = new Set(["useEffect", "useLayoutEffect", "useInsertionEffect"]); +const EFFECT_HOOK_TEXT = /use(?:Layout|Insertion)?Effect/; +const UNBUDGETED_FILE = /\.test\.[cm]?[jt]sx?$|[\\/](?:test|__tests__)[\\/]/; +const budgets: Readonly> = budget; + +const isEffectHookCall = (node: ESTree.CallExpression): boolean => { + const callee = unwrapExpression(node.callee); + if (Option.isNone(callee)) return false; + const expression = callee.value; + if (expression.type === "Identifier") return EFFECT_HOOKS.has(expression.name); + if (expression.type === "MemberExpression" && !expression.computed) { + return Option.exists(getPropertyName(expression.property), (name) => EFFECT_HOOKS.has(name)); + } + return false; +}; + +// Budget keys are repo-relative; matching on the path's end also works for files linted elsewhere. +const budgetFor = (filename: string): number => { + const normalized = filename.replaceAll("\\", "/"); + const key = Object.keys(budgets).find((file) => normalized.endsWith(`/${file}`)); + return key === undefined ? 0 : budgets[key]!; +}; + +export default defineRule({ + meta: { + type: "suggestion", + docs: { + description: `Hold each file to the React effect hooks it is allowed in ${BUDGET_FILE}; a missing entry allows none.`, + }, + }, + create(context) { + if (UNBUDGETED_FILE.test(context.filename) || !EFFECT_HOOK_TEXT.test(context.sourceCode.text)) { + return {}; + } + const calls: Array = []; + return { + CallExpression(node) { + if (isEffectHookCall(node)) calls.push(node); + }, + "Program:exit"() { + const allowed = budgetFor(context.filename); + if (calls.length <= allowed) return; + const message = + `This file has ${calls.length} React ${calls.length === 1 ? "effect" : "effects"} against a budget of ${allowed}. ` + + "Prefer deriving in render, event handlers, a `key` reset, an element-owned ref callback, or a store. " + + `If an effect is genuinely right, raise this file's entry in ${BUDGET_FILE} in the same PR.`; + for (const node of calls.slice(allowed)) context.report({ node, message }); + }, + }; + }, +}); diff --git a/package.json b/package.json index 80370d5cc8bc1..91d08f10fd5e2 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "mobile:install": "node scripts/mobile-native-client.ts ensure", - "mobile:e2e": "node scripts/mobile-e2e.ts", "test": "vp run -r test", "test:cached": "node scripts/test-cached.ts", "test:perf:v2-wire": "vp test run apps/server/src/orchestration-v2/WireProjection.test.ts apps/server/src/orchestration-v2/threadHistoryPaging.test.ts apps/server/src/orchestration-v2/ThreadStream.test.ts apps/server/src/orchestration-v2/ThreadTransportPerformance.test.ts apps/server/src/orchestration-v2/ShellStream.test.ts apps/server/src/orchestration-v2/CommandPolicy.test.ts apps/server/src/orchestration-v2/runtimeLayer.test.ts apps/server/src/ws.test.ts packages/contracts/src/orchestrationV2.test.ts packages/client-runtime/src/operations/commands.test.ts packages/client-runtime/src/operations/commands.performance.test.ts packages/client-runtime/src/state/boundedThreadSnapshotHttp.test.ts packages/client-runtime/src/state/threadHistoryController.test.ts packages/client-runtime/src/state/threadHistoryMerge.test.ts packages/client-runtime/src/state/threads-sync.test.ts packages/client-runtime/src/state/shellReducer.test.ts", diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index ba30a863e9397..ebd05d0b2d301 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -170,6 +170,11 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}), ...(options.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), }; + // Config state merges the snapshots it fetches back in, so no subscriber needs them broadcast. + const onDemandSnapshots = (input: ServerConfigSubscriptionInput) => ({ + ...input, + workspaceSnapshotsOnDemand: true, + }); const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { const via = connection.target._tag === "RelayConnectionTarget" ? "relay" : "direct"; @@ -248,7 +253,7 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( detail: `${connection.label} config subscription ended.`, }); const serverConfigSource = protocolClient[WS_METHODS.subscribeServerConfig]( - serverConfigInput, + onDemandSnapshots(serverConfigInput), ).pipe( Stream.runForEach((event) => Effect.gen(function* () { @@ -387,7 +392,7 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( Effect.as( Equal.equals(input, serverConfigInput) ? serverConfigEvents - : protocolClient[WS_METHODS.subscribeServerConfig](input), + : protocolClient[WS_METHODS.subscribeServerConfig](onDemandSnapshots(input)), ), ), ); diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 09b28c539d33f..9ee4e55697faa 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -1,5 +1,6 @@ import { EnvironmentId, + ProviderInstanceId, ServerObservability, EnvironmentThemeId, UsageLimitSourceId, @@ -39,6 +40,8 @@ import { makeEnvironmentServerWelcomeState, makeEnvironmentServerConfigState, isLegacyUpdateHandoffLoss, + recordFetchedWorkspaceSnapshot, + withFetchedWorkspaceSnapshots, matchesServerUpdateReadyEvent, matchesServerUpdateResumeEvent, nudgeReconnectDuringUpdateRestart, @@ -937,6 +940,87 @@ describe("server state projection", () => { ).toBe(live); }); + it("merges fetched workspace snapshots only from servers that omit them", () => { + const claude = ProviderInstanceId.make("claudeAgent"); + const codex = ProviderInstanceId.make("codex"); + const snapshot = (cwd: string, command: string) => ({ + cwd, + checkedAt: "2026-10-25T00:00:00.000Z", + slashCommands: [{ name: command }], + skills: [], + }); + const provider = ( + instanceId: ProviderInstanceId, + workspaceSnapshots?: ReadonlyArray>, + slashCommands: ReadonlyArray<{ readonly name: string }> = [], + ) => + ({ + instanceId, + enabled: true, + slashCommands, + skills: [], + ...(workspaceSnapshots ? { workspaceSnapshots } : {}), + }) as unknown as ServerConfig["providers"][number]; + const configWith = (onDemand: boolean, providers: ServerConfig["providers"]) => + ({ + ...CONFIG, + environment: { capabilities: { providerWorkspaceSnapshotsOnDemand: onDemand } }, + providers, + }) as unknown as ServerConfig; + const claudeA = snapshot("/a", "claude"); + const record = ( + current: Parameters[0], + instanceId: ProviderInstanceId, + fresh: boolean, + reply: ServerConfig["providers"], + ) => + recordFetchedWorkspaceSnapshot( + current, + { instanceId, cwd: "/a", fresh }, + reply, + provider(instanceId), + ); + + let fetched = record([], claude, false, [provider(claude, [claudeA])]); + fetched = record(fetched, codex, false, [provider(codex, [snapshot("/a", "codex")])]); + const merged = withFetchedWorkspaceSnapshots( + configWith(true, [provider(claude), provider(codex)]), + fetched, + ); + expect(merged.providers[0]?.workspaceSnapshots).toEqual([claudeA]); + // An unchanged answer keeps the cache, so config readers do not re-render. + expect( + record(fetched, claude, false, [ + provider(claude, [JSON.parse(JSON.stringify(claudeA)) as typeof claudeA]), + ]), + ).toBe(fetched); + + // Health checks and usage updates leave the snapshot in place. + expect( + withFetchedWorkspaceSnapshots( + configWith(true, [ + { ...provider(claude), checkedAt: "2026-10-25T00:09:00.000Z", usageLimits: {} }, + ] as unknown as ServerConfig["providers"]), + fetched, + ).providers[0]?.workspaceSnapshots, + ).toEqual([claudeA]); + + // New provider-wide commands, such as a live session's, retire the old snapshot. + expect( + withFetchedWorkspaceSnapshots( + configWith(true, [provider(claude, undefined, [{ name: "live" }])]), + fetched, + ).providers[0]?.workspaceSnapshots, + ).toBeUndefined(); + + // Older servers send every snapshot and every drop; their config stands. + const legacy = configWith(false, [provider(claude)]); + expect(withFetchedWorkspaceSnapshots(legacy, fetched)).toBe(legacy); + + // A fresh scan drops the cwd's other provider snapshots, as the server does. + expect(record(fetched, claude, true, [])).toEqual([]); + }); + it.effect("starts from cached configuration and persists the live projection", () => Effect.gen(function* () { const events = yield* Queue.unbounded(); diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 7913f8fe84e22..0f917e81f1b90 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -1,9 +1,11 @@ import { type EnvironmentId, + type ProviderInstanceId, type ServerConfig, type ServerConfigStreamEvent, type ServerLifecycleWelcomePayload, type ServerLifecycleStreamReadyEvent, + type ServerProviderWorkspaceSnapshot, type ServerSelfUpdateProgressEvent, type ServerSelfUpdateResult, shouldRecommendDatabaseCompaction, @@ -11,6 +13,7 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; +import * as Equal from "effect/Equal"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -42,6 +45,7 @@ import { runCachePersistence } from "./cachePersistence.ts"; import { isRpcClientError, request, + requestGuarded, runStream, subscribe, subscribeDynamicWithSession, @@ -51,6 +55,7 @@ import type { RpcSession } from "../rpc/session.ts"; import { followStreamInEnvironment } from "./runtime.ts"; import { applyServerConfigProjection, + reuseUnchanged, type ServerConfigProjection, withoutEnvironmentThemes, } from "./serverConfigProjection.ts"; @@ -648,6 +653,92 @@ export function resolveServerConfigValue( return initialConfig ?? projection?.config ?? null; } +type ServerProviderEntry = ServerConfig["providers"][number]; + +/** + * The provider-wide fields a workspace snapshot builds on. A live session, a sign-out, or a + * /usage-limits change shows up here, so a snapshot fetched before it no longer applies. + */ +const snapshotBasis = (provider: ServerProviderEntry | undefined) => + provider && { + enabled: provider.enabled, + slashCommands: provider.slashCommands, + skills: provider.skills, + }; + +interface FetchedWorkspaceSnapshot { + readonly instanceId: ProviderInstanceId; + readonly snapshot: ServerProviderWorkspaceSnapshot; + readonly basis: ReturnType; +} + +const MAX_FETCHED_WORKSPACE_SNAPSHOTS = 32; + +/** + * Servers with `providerWorkspaceSnapshotsOnDemand` leave per-cwd snapshots out of the config, + * so the ones this client fetched are merged back in. Older servers send every snapshot and + * their drops, so their config is used as is. + */ +export function withFetchedWorkspaceSnapshots( + config: ServerConfig, + fetched: ReadonlyArray, +): ServerConfig { + if ( + fetched.length === 0 || + config.environment.capabilities.providerWorkspaceSnapshotsOnDemand !== true + ) { + return config; + } + return { + ...config, + providers: config.providers.map((provider) => { + // A dropped snapshot reads as lost, and the composer fetches it again. + const own = fetched.filter( + (entry) => + entry.instanceId === provider.instanceId && + Equal.equals(entry.basis, snapshotBasis(provider)), + ); + return own.length === 0 + ? provider + : { ...provider, workspaceSnapshots: own.map((entry) => entry.snapshot) }; + }), + }; +} + +export function recordFetchedWorkspaceSnapshot( + current: ReadonlyArray, + input: { readonly instanceId: ProviderInstanceId; readonly cwd: string; readonly fresh: boolean }, + providers: ServerConfig["providers"], + configProvider: ServerProviderEntry | undefined, +): ReadonlyArray { + const basis = snapshotBasis(configProvider); + const snapshot = providers + .find((provider) => provider.instanceId === input.instanceId) + ?.workspaceSnapshots?.find((candidate) => candidate.cwd === input.cwd); + const stored = current.find( + (entry) => entry.instanceId === input.instanceId && entry.snapshot.cwd === input.cwd, + ); + // A cached answer from the server would otherwise re-render every config reader. + if ( + !input.fresh && + snapshot && + stored && + Equal.equals(stored.snapshot, snapshot) && + Equal.equals(stored.basis, basis) + ) { + return current; + } + // A fresh scan drops the cwd's snapshots for every provider, as the server does. + const kept = current.filter( + (entry) => + entry.snapshot.cwd !== input.cwd || (!input.fresh && entry.instanceId !== input.instanceId), + ); + if (!snapshot) return kept.length === current.length ? current : kept; + return [...kept, { instanceId: input.instanceId, snapshot, basis }].slice( + -MAX_FETCHED_WORKSPACE_SNAPSHOTS, + ); +} + export function createServerEnvironmentAtoms( runtime: Atom.AtomRuntime< EnvironmentRegistry.EnvironmentRegistry | Persistence.EnvironmentCacheStore | R, @@ -686,6 +777,12 @@ export function createServerEnvironmentAtoms( readonly environmentId: EnvironmentId; readonly input: EnvironmentRpcInput; }) => configProjectionFamily(target.environmentId); + const fetchedWorkspaceSnapshotsAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make>([]).pipe( + Atom.keepAlive, + Atom.withLabel(`environment-data:server:fetched-workspace-snapshots:${environmentId}`), + ), + ); const emptyConfigAtom = Atom.make(null).pipe( Atom.withLabel("environment-data:server:config:empty"), ); @@ -693,14 +790,24 @@ export function createServerEnvironmentAtoms( if (environmentId === null) { return emptyConfigAtom; } + let previousProviders: ServerConfig["providers"] | undefined; return Atom.make((get): ServerConfig | null => { const projection = Option.getOrNull( AsyncResult.value(get(configProjection({ environmentId, input: {} }))), ); - return resolveServerConfigValue( + const config = resolveServerConfigValue( projection, get(options.initialConfigValueAtom(environmentId)), ); + if (config === null) return null; + const merged = withFetchedWorkspaceSnapshots( + config, + get(fetchedWorkspaceSnapshotsAtom(environmentId)), + ); + if (merged === config) return config; + // Keep provider identities stable across config events; the projection already reuses the rest. + previousProviders = reuseUnchanged(previousProviders, merged.providers); + return { ...merged, providers: previousProviders }; }).pipe(Atom.withLabel(`environment-data:server:config:${environmentId}`)); }); const updateStateValueAtom = Atom.family((environmentId: EnvironmentId) => @@ -1227,6 +1334,27 @@ export function createServerEnvironmentAtoms( refreshProviders: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:refresh-providers", tag: WS_METHODS.serverRefreshProviders, + execute: (input) => + requestGuarded(WS_METHODS.serverRefreshProviders, { + ...input, + workspaceSnapshotsOnDemand: true, + ...(options.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), + }), + onSuccess: ({ environmentId, input }, registry, result) => + Effect.sync(() => { + const { instanceId, cwd } = input; + if (instanceId === undefined || cwd === undefined) return; + registry.update(fetchedWorkspaceSnapshotsAtom(environmentId), (current) => + recordFetchedWorkspaceSnapshot( + current, + { instanceId, cwd, fresh: input.fresh === true }, + result.providers, + registry + .get(configValueAtom(environmentId)) + ?.providers.find((provider) => provider.instanceId === instanceId), + ), + ); + }), concurrency: { mode: "singleFlight", key: ({ environmentId, input }) => diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 771ade1902f25..7b47d88202c7d 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -102,6 +102,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Clear published themes when absent; older servers never send updates. */ environmentThemes: Schema.optionalKey(Schema.Boolean), usageLimitSources: Schema.optionalKey(Schema.Boolean), + /** Honors `workspaceSnapshotsOnDemand`; older servers send every snapshot and drop them in the stream. */ + providerWorkspaceSnapshotsOnDemand: Schema.optionalKey(Schema.Boolean), usagePriceOverrides: Schema.optionalKey(Schema.Boolean), usageModelAliases: Schema.optionalKey(Schema.Boolean), threadPinning: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 088935c52917b..4920a748d5b97 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -612,13 +612,21 @@ const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), }); -const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { +export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { payload: Schema.Struct({ instanceId: Schema.optional(ProviderInstanceId), cwd: Schema.optional(TrimmedNonEmptyString), fresh: Schema.optional(Schema.Boolean), /** Bypass model caches; explicit rediscovery may open sessions, background refreshes must not. */ refreshModels: Schema.optional(Schema.Boolean), + /** + * Reply without snapshots, or with only the requested instance's cwd snapshot. Snapshots then + * reach the client only through these replies, so another client's scan shows on its next + * refresh, at most one TTL later. + */ + workspaceSnapshotsOnDemand: Schema.optional(Schema.Boolean), + /** As on `subscribeServerConfig`: add /usage-limits to the replied snapshot. */ + usageLimitsCommand: Schema.optional(Schema.Boolean), }), success: ServerProviderUpdatedPayload, error: Schema.Union([EnvironmentAuthorizationError, ProviderSetupError]), @@ -1791,6 +1799,8 @@ export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerCon usageLimitSources: Schema.optional(Schema.Boolean), /** Advertise client handling before the server offers /usage-limits, or older clients send it as a prompt. */ usageLimitsCommand: Schema.optional(Schema.Boolean), + /** Omit per-cwd provider snapshots; the client fetches its cwd through `server.refreshProviders`. */ + workspaceSnapshotsOnDemand: Schema.optional(Schema.Boolean), }), success: ServerConfigStreamEvent, error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), diff --git a/packages/provider-grok/src/server/status.ts b/packages/provider-grok/src/server/status.ts index 1c341d0cf2cdc..c634c5b80c74e 100644 --- a/packages/provider-grok/src/server/status.ts +++ b/packages/provider-grok/src/server/status.ts @@ -527,6 +527,12 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func discoveredModels.length > 0 ? grokModelsFromSettings(grokSettings.customModels, discoveredModels) : fallbackModels; + // The registry replaces cached models on a ready probe, so a fallback-only list must not be ready. + const incompleteMessage = acpFailed + ? "Grok CLI is installed but ACP initialize failed. Model options may be incomplete." + : discoveredModels.length === 0 + ? "Grok CLI did not list any models. Model options may be incomplete." + : undefined; if (auth.status === "unauthenticated") { return buildServerProvider({ @@ -555,14 +561,9 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func probe: { installed: true, version, - status: acpFailed ? "warning" : "ready", + status: incompleteMessage ? "warning" : "ready", auth, - ...(acpFailed - ? { - message: - "Grok CLI is installed but ACP initialize failed. Model options may be incomplete.", - } - : {}), + ...(incompleteMessage ? { message: incompleteMessage } : {}), }, }); }); diff --git a/packages/shared/package.json b/packages/shared/package.json index 5d0c5b76d3525..baa12fd43c5a3 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -59,6 +59,10 @@ "types": "./src/logging.ts", "import": "./src/logging.ts" }, + "./releaseResource": { + "types": "./src/releaseResource.ts", + "import": "./src/releaseResource.ts" + }, "./observability": { "types": "./src/observability.ts", "import": "./src/observability.ts" diff --git a/packages/shared/src/observability.test.ts b/packages/shared/src/observability.test.ts index cab62b1bc2a35..f858f5e3a4588 100644 --- a/packages/shared/src/observability.test.ts +++ b/packages/shared/src/observability.test.ts @@ -24,8 +24,16 @@ import { type TraceRecord, OtlpHeadersFromString, truncateTraceAttributes, + processServiceInstanceId, } from "./observability.ts"; +describe("processServiceInstanceId", () => { + it("stays the same for the process unless the operator set one", () => { + expect(processServiceInstanceId()).toBe(processServiceInstanceId({})); + expect(processServiceInstanceId({ "service.instance.id": "pod-7" })).toBe("pod-7"); + }); +}); + describe("errorTag", () => { it("reports structural tags without retaining arbitrary values", () => { assert.equal(errorTag({ _tag: "AcpRequestError" }), "AcpRequestError"); diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index 241822a302acd..db1f50734bea3 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -26,6 +26,21 @@ export interface SignalExport { readonly exportIntervalMs: number; } +let generatedServiceInstanceId: string | undefined; + +/** + * `service.instance.id` shared by every resource this process exports. Effect lets code + * attributes beat `OTEL_RESOURCE_ATTRIBUTES`, so callers pass the operator's in to keep it winning. + */ +export const processServiceInstanceId = ( + operatorAttributes: Readonly> = {}, +): string => { + if (operatorAttributes["service.instance.id"]) return operatorAttributes["service.instance.id"]; + // @effect-diagnostics-next-line cryptoRandomUUID:off -- Resources are built synchronously, outside any Effect. + generatedServiceInstanceId ??= crypto.randomUUID(); + return generatedServiceInstanceId; +}; + export const DEFAULT_SIGNAL_EXPORT: SignalExport = { protocol: "http/json", headers: undefined, diff --git a/packages/shared/src/relayTracing.ts b/packages/shared/src/relayTracing.ts index 3988e6a71abde..bd00f0ad04a84 100644 --- a/packages/shared/src/relayTracing.ts +++ b/packages/shared/src/relayTracing.ts @@ -18,8 +18,10 @@ export interface RelayClientTracingResource { readonly serviceName: string; readonly serviceVersion?: string; readonly runtime: string; + readonly serviceInstanceId: string; readonly client: string; readonly component?: string; + readonly attributes?: Readonly> | undefined; } export class RelayClientTracer extends Context.Reference( @@ -165,9 +167,11 @@ export function layer( serviceVersion: resource.serviceVersion, attributes: { "service.namespace": "t3code", + "service.instance.id": resource.serviceInstanceId, "service.runtime": resource.runtime, "t3code.component": resource.component ?? "relay-client", "t3code.client.surface": resource.client, + ...resource.attributes, }, }, }).pipe(Layer.provide(OtlpSerialization.layerJson)); diff --git a/packages/shared/src/releaseResource.test.ts b/packages/shared/src/releaseResource.test.ts new file mode 100644 index 0000000000000..c5243a47daa26 --- /dev/null +++ b/packages/shared/src/releaseResource.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { releaseChannelOf, releaseResourceAttributes } from "./releaseResource.ts"; + +describe("releaseResourceAttributes", () => { + it.each([ + ["dev", {}, "dev", "development"], + ["stable", {}, "stable", "production"], + ["nightly", {}, "nightly", "production"], + ["preview", {}, "preview", "staging"], + [ + "stable", + { "deployment.environment.name": "staging", "t3code.release.channel": "" }, + "stable", + "staging", + ], + ] as const)( + "%s (operator: %o) reports %s in %s", + (channel, operatorAttributes, expectedChannel, environment) => { + expect(releaseResourceAttributes(channel, operatorAttributes)).toEqual({ + "t3code.release.channel": expectedChannel, + "deployment.environment.name": environment, + }); + }, + ); + + it.each([ + ["0.1.2", "stable"], + ["0.1.2-nightly.20261025.1", "nightly"], + ["0.1.2-preview.20261025.1", "preview"], + ["0.1.2-pr.24340.123", "preview"], + ] as const)("classifies %s as %s", (version, channel) => { + expect(releaseChannelOf(version)).toBe(channel); + }); +}); diff --git a/packages/shared/src/releaseResource.ts b/packages/shared/src/releaseResource.ts new file mode 100644 index 0000000000000..492a6e05536aa --- /dev/null +++ b/packages/shared/src/releaseResource.ts @@ -0,0 +1,24 @@ +import { type CliReleaseChannel, cliReleaseChannelOf } from "./cliRelease.ts"; + +export type ReleaseChannel = "dev" | CliReleaseChannel; + +// Desktop PR preview builds are stamped `X.Y.Z-pr..`; the CLI updater must not see them as preview. +export const releaseChannelOf = (version: string): ReleaseChannel => + /-pr\.\d+\.\d+$/.test(version) ? "preview" : cliReleaseChannelOf(version); + +// Effect lets code attributes beat OTEL_RESOURCE_ATTRIBUTES, so the operator value is applied here. +export const releaseResourceAttributes = ( + channel: ReleaseChannel, + operatorAttributes: Readonly> = {}, +) => { + const operatorValue = (key: string) => { + const value = operatorAttributes[key]; + return typeof value === "string" && value !== "" ? value : undefined; + }; + return { + "t3code.release.channel": operatorValue("t3code.release.channel") ?? channel, + "deployment.environment.name": + operatorValue("deployment.environment.name") ?? + (channel === "dev" ? "development" : channel === "preview" ? "staging" : "production"), + }; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5965e7456bd6e..26ee3fe1f1958 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2157,9 +2157,6 @@ importers: '@t3tools/tailscale': specifier: workspace:* version: link:../packages/tailscale - agent-device: - specifier: 0.21.25 - version: 0.21.25 effect: specifier: 4.0.5 version: 4.0.5 @@ -7153,16 +7150,6 @@ packages: engines: {node: '>=18.18'} hasBin: true - agent-device@0.21.25: - resolution: {integrity: sha512-JFmAD5AqM03dVU6BVQW1DQ2Kz+MThmbCYBbDxpGw2lka91LcoXHdrb4IjF9NNg4DEQUu41Pg8WnB/7crM3ig2w==} - engines: {node: '>=22.12'} - hasBin: true - peerDependencies: - ai: ^6.0.0 || ^7.0.0 - peerDependenciesMeta: - ai: - optional: true - agent-install@0.0.5: resolution: {integrity: sha512-nHlms9BkP8ZiY79HrwCGiA2DcNaXrAaJrCM/BEqQ7MEsSKyCk+2A76xPGylIfASZSZE0SaU3T0bNSg4rBPIJAQ==} hasBin: true @@ -17001,8 +16988,6 @@ snapshots: agent-cli-detector@0.1.7: {} - agent-device@0.21.25: {} - agent-install@0.0.5: dependencies: '@iarna/toml': 2.2.5 diff --git a/scripts/effect-budget.json b/scripts/effect-budget.json new file mode 100644 index 0000000000000..44c55fb275201 --- /dev/null +++ b/scripts/effect-budget.json @@ -0,0 +1,433 @@ +{ + "apps/mobile/src/App.tsx": 2, + "apps/mobile/src/Stack.tsx": 1, + "apps/mobile/src/components/ComposerAttachmentStrip.tsx": 2, + "apps/mobile/src/components/ComposerContextAttachment.tsx": 1, + "apps/mobile/src/components/ComposerEditor.tsx": 2, + "apps/mobile/src/components/FilePreview.ios.tsx": 1, + "apps/mobile/src/components/FilePreview.tsx": 1, + "apps/mobile/src/components/FilePreviewModal.tsx": 5, + "apps/mobile/src/components/MediaVideoPlayer.tsx": 3, + "apps/mobile/src/components/PdfPreview.android.tsx": 1, + "apps/mobile/src/components/ProjectFavicon.tsx": 1, + "apps/mobile/src/components/VideoPreviewModal.ios.tsx": 6, + "apps/mobile/src/components/VideoPreviewModal.tsx": 3, + "apps/mobile/src/components/VideoThumbnailImage.tsx": 1, + "apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx": 1, + "apps/mobile/src/components/ui/AppBar.android.tsx": 1, + "apps/mobile/src/components/ui/Dialog.android.tsx": 1, + "apps/mobile/src/components/ui/Notice.tsx": 1, + "apps/mobile/src/components/ui/OverlayPortal.tsx": 3, + "apps/mobile/src/components/ui/SliderRow.android.tsx": 1, + "apps/mobile/src/components/ui/SliderRow.tsx": 1, + "apps/mobile/src/components/ui/Toast.tsx": 2, + "apps/mobile/src/features/agent-awareness/notificationNavigation.ts": 3, + "apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx": 2, + "apps/mobile/src/features/browser/PreviewStreamWebView.tsx": 5, + "apps/mobile/src/features/browser/ThreadBrowserFloat.tsx": 2, + "apps/mobile/src/features/cloud/CloudAuthProvider.tsx": 3, + "apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx": 1, + "apps/mobile/src/features/cloud/connectOnboardingNavigation.ts": 1, + "apps/mobile/src/features/cloud/managedRelayState.ts": 1, + "apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx": 5, + "apps/mobile/src/features/connection/PermissionUpdateNotice.tsx": 2, + "apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx": 6, + "apps/mobile/src/features/devices/DeviceStreamWebView.tsx": 2, + "apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx": 1, + "apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts": 3, + "apps/mobile/src/features/files/AttachmentFileScreen.tsx": 1, + "apps/mobile/src/features/files/AudioFilePreview.tsx": 2, + "apps/mobile/src/features/files/FileTreeBrowser.tsx": 3, + "apps/mobile/src/features/files/FilesHeader.tsx": 1, + "apps/mobile/src/features/files/SourceFileSurface.tsx": 1, + "apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx": 1, + "apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx": 1, + "apps/mobile/src/features/files/useFileTreeEntries.ts": 1, + "apps/mobile/src/features/home/HomeRouteScreen.tsx": 2, + "apps/mobile/src/features/home/HomeScreen.tsx": 4, + "apps/mobile/src/features/home/ThreadListToolbar.tsx": 1, + "apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx": 2, + "apps/mobile/src/features/home/home-list-options.ts": 1, + "apps/mobile/src/features/home/home-route-visibility.ts": 1, + "apps/mobile/src/features/home/thread-swipe-actions.tsx": 5, + "apps/mobile/src/features/keyboard/CommandPalette.tsx": 2, + "apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx": 1, + "apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts": 1, + "apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx": 3, + "apps/mobile/src/features/layout/createWorkspaceStackNavigator.ios.tsx": 1, + "apps/mobile/src/features/layout/workspace-inspector-pane.tsx": 2, + "apps/mobile/src/features/projects/AddProjectScreen.tsx": 2, + "apps/mobile/src/features/prompt-links/usePromptLinkResolver.ts": 1, + "apps/mobile/src/features/prompt-links/useThreadPromptLink.ts": 1, + "apps/mobile/src/features/pull-requests/PullRequestFilterMenuContext.tsx": 1, + "apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx": 1, + "apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx": 4, + "apps/mobile/src/features/pull-requests/PullRequestToolbar.android.tsx": 1, + "apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx": 1, + "apps/mobile/src/features/pull-requests/guide/PullRequestGuideScreen.tsx": 1, + "apps/mobile/src/features/pull-requests/usePullRequestDiff.ts": 1, + "apps/mobile/src/features/pull-requests/usePullRequestEnvironmentFallback.ts": 1, + "apps/mobile/src/features/pull-requests/usePullRequestFilesViewed.ts": 4, + "apps/mobile/src/features/pull-requests/usePullRequestList.ts": 3, + "apps/mobile/src/features/pull-requests/usePullRequestReviewDraft.ts": 1, + "apps/mobile/src/features/review/ReviewSheet.tsx": 1, + "apps/mobile/src/features/review/useNativeReviewDiffHighlighting.ts": 2, + "apps/mobile/src/features/review/useReviewCommentSelectionController.ts": 2, + "apps/mobile/src/features/review/useReviewDiffData.ts": 2, + "apps/mobile/src/features/review/useReviewDiffPrewarming.ts": 1, + "apps/mobile/src/features/review/useReviewSections.ts": 5, + "apps/mobile/src/features/settings/OutdatedServerUpdateSection.tsx": 1, + "apps/mobile/src/features/settings/SettingsAboutRouteScreen.tsx": 1, + "apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx": 2, + "apps/mobile/src/features/settings/SettingsEnvironmentDetailRouteScreen.tsx": 1, + "apps/mobile/src/features/settings/SettingsMicrophoneRouteScreen.tsx": 1, + "apps/mobile/src/features/settings/SettingsNotificationsRouteScreen.tsx": 2, + "apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx": 1, + "apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx": 2, + "apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx": 2, + "apps/mobile/src/features/settings/components/BranchNamingSettings.tsx": 1, + "apps/mobile/src/features/settings/components/SettingsLegalDocumentRouteScreen.tsx": 1, + "apps/mobile/src/features/sharing/IncomingShareProvider.tsx": 4, + "apps/mobile/src/features/shortcuts/useAppShortcuts.ts": 4, + "apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx": 11, + "apps/mobile/src/features/terminal/NativeTerminalSurface.tsx": 4, + "apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx": 14, + "apps/mobile/src/features/terminal/terminalLaunchContext.ts": 1, + "apps/mobile/src/features/terminal/useTerminalGridSync.ts": 1, + "apps/mobile/src/features/terminal/useTerminalLifecycle.ts": 1, + "apps/mobile/src/features/threads/ComposerPopoverAnchor.android.tsx": 2, + "apps/mobile/src/features/threads/ComposerPopoverAnchor.tsx": 1, + "apps/mobile/src/features/threads/CopyTextButton.tsx": 1, + "apps/mobile/src/features/threads/CustomSnoozeSheet.android.tsx": 1, + "apps/mobile/src/features/threads/GitActionProgressOverlay.tsx": 1, + "apps/mobile/src/features/threads/HtmlRenderWebView.tsx": 1, + "apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx": 1, + "apps/mobile/src/features/threads/McpAppWebView.tsx": 7, + "apps/mobile/src/features/threads/NewTaskContextPickerScreens.tsx": 2, + "apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx": 5, + "apps/mobile/src/features/threads/NewTaskDraftScreen.tsx": 10, + "apps/mobile/src/features/threads/NewTaskRouteScreen.tsx": 1, + "apps/mobile/src/features/threads/QuestionAttachments.tsx": 2, + "apps/mobile/src/features/threads/ThreadAgentsSheet.tsx": 1, + "apps/mobile/src/features/threads/ThreadArrangementSheet.tsx": 3, + "apps/mobile/src/features/threads/ThreadComposer.tsx": 6, + "apps/mobile/src/features/threads/ThreadDetailScreen.tsx": 20, + "apps/mobile/src/features/threads/ThreadFeed.tsx": 14, + "apps/mobile/src/features/threads/ThreadGuestShareBar.tsx": 1, + "apps/mobile/src/features/threads/ThreadInlineFilePreview.tsx": 2, + "apps/mobile/src/features/threads/ThreadMarkdownImage.tsx": 3, + "apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx": 3, + "apps/mobile/src/features/threads/ThreadQueueControl.tsx": 2, + "apps/mobile/src/features/threads/ThreadRouteScreen.tsx": 2, + "apps/mobile/src/features/threads/ThreadScreenHost.tsx": 2, + "apps/mobile/src/features/threads/ThreadSettingsSheet.tsx": 2, + "apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx": 1, + "apps/mobile/src/features/threads/floating-working-control.tsx": 2, + "apps/mobile/src/features/threads/git/GitOverviewSheet.tsx": 1, + "apps/mobile/src/features/threads/new-task-flow-provider.tsx": 6, + "apps/mobile/src/features/threads/thread-inspector-content-stack.tsx": 1, + "apps/mobile/src/features/threads/thread-list-v2-items.tsx": 1, + "apps/mobile/src/features/threads/thread-subagent-group.tsx": 2, + "apps/mobile/src/features/threads/thread-work-log.tsx": 5, + "apps/mobile/src/features/threads/use-composer-command-menu.ts": 5, + "apps/mobile/src/features/threads/use-thread-settings-sheet-presentation.ts": 2, + "apps/mobile/src/features/threads/use-visible-second-clock.ts": 2, + "apps/mobile/src/features/threads/use-worktree-setup.ts": 1, + "apps/mobile/src/features/threads/useFileChipShare.ts": 2, + "apps/mobile/src/features/usage/UsageLimitsSection.tsx": 1, + "apps/mobile/src/features/usage/UsageRouteScreen.tsx": 1, + "apps/mobile/src/features/voice-input/ComposerDictationControl.tsx": 3, + "apps/mobile/src/features/voice-input/GlobalVoiceInputControl.tsx": 2, + "apps/mobile/src/features/voice-input/VoiceInputProvider.tsx": 4, + "apps/mobile/src/features/voice-input/useVoiceInputController.ts": 1, + "apps/mobile/src/features/workspace/AndroidWorkspaceTabsProvider.tsx": 1, + "apps/mobile/src/features/workspace/WorkspaceNativeTabs.ios.tsx": 1, + "apps/mobile/src/features/workspace/WorkspaceTabsContext.tsx": 1, + "apps/mobile/src/features/workspace/WorkspaceToolbar.ios.tsx": 1, + "apps/mobile/src/features/workspace/WorkspaceToolbar.tsx": 2, + "apps/mobile/src/lib/attachmentDocument.ts": 4, + "apps/mobile/src/lib/useDelayedStatus.ts": 2, + "apps/mobile/src/lib/useUiRuntimeMemoryWarningGc.ts": 1, + "apps/mobile/src/lib/videoAudioSession.ts": 1, + "apps/mobile/src/native/NativeHeaderToolbar.tsx": 2, + "apps/mobile/src/native/NativeWorkspaceColumns.ios.tsx": 1, + "apps/mobile/src/native/StackHeader.tsx": 2, + "apps/mobile/src/native/T3ComposerEditor.ios.tsx": 2, + "apps/mobile/src/native/T3ComposerEditor.native.tsx": 2, + "apps/mobile/src/native/V5StackHeader.ios.tsx": 1, + "apps/mobile/src/native/useRetryingImageSource.ts": 1, + "apps/mobile/src/state/composer-attachment-uploads.ts": 2, + "apps/mobile/src/state/mediaActions.ts": 1, + "apps/mobile/src/state/olderSettledShelf.ts": 2, + "apps/mobile/src/state/queries.ts": 2, + "apps/mobile/src/state/thread-order.ts": 1, + "apps/mobile/src/state/threads.ts": 1, + "apps/mobile/src/state/use-composer-attachment-actions.ts": 1, + "apps/mobile/src/state/use-composer-drafts.ts": 3, + "apps/mobile/src/state/use-pull-to-refresh.ts": 1, + "apps/mobile/src/state/use-selected-thread-git-actions.ts": 1, + "apps/mobile/src/state/use-selected-thread-requests.ts": 2, + "apps/mobile/src/state/use-thread-composer-state.ts": 3, + "apps/mobile/src/state/use-thread-outbox-drain.ts": 4, + "apps/mobile/src/state/use-thread-pr.ts": 1, + "apps/mobile/src/state/use-vcs-action-state.ts": 1, + "apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx": 1, + "apps/mobile/src/widgets/useSubscriptionUsage.ts": 1, + "apps/web/src/AppRoot.tsx": 1, + "apps/web/src/browser/BrowserProfileReporter.tsx": 1, + "apps/web/src/browser/BrowserSurfaceSlot.tsx": 2, + "apps/web/src/browser/ElectronBrowserHost.tsx": 3, + "apps/web/src/browser/HostedBrowserWebview.tsx": 6, + "apps/web/src/browser/ServerBrowserSurface.tsx": 5, + "apps/web/src/browser/useBrowserViewportResize.ts": 2, + "apps/web/src/cloud/managedAuth.tsx": 2, + "apps/web/src/cloud/managedRelayState.ts": 2, + "apps/web/src/components/AddProjectToMachineDialog.tsx": 1, + "apps/web/src/components/AnimatedHeight.tsx": 2, + "apps/web/src/components/AnimatedWidth.tsx": 1, + "apps/web/src/components/AppSidebarLayout.tsx": 4, + "apps/web/src/components/BackgroundThreadAnnouncer.tsx": 1, + "apps/web/src/components/BranchPicker.tsx": 2, + "apps/web/src/components/BranchToolbar.tsx": 4, + "apps/web/src/components/BranchToolbarBranchSelector.tsx": 1, + "apps/web/src/components/ChatMarkdown.tsx": 1, + "apps/web/src/components/ChatView.tsx": 23, + "apps/web/src/components/CommandPalette.tsx": 5, + "apps/web/src/components/CommandPaletteContent.tsx": 1, + "apps/web/src/components/ComposerPromptEditorTiptap.tsx": 13, + "apps/web/src/components/ConfirmDialogHost.tsx": 1, + "apps/web/src/components/CustomSnoozeDialog.tsx": 1, + "apps/web/src/components/DiffPanel.tsx": 4, + "apps/web/src/components/DiffWorkerPoolProvider.tsx": 2, + "apps/web/src/components/GitActionsControl.tsx": 4, + "apps/web/src/components/HostFreezeToastCoordinator.tsx": 1, + "apps/web/src/components/LegacySidebar.tsx": 5, + "apps/web/src/components/LegacyThreadMigrationToast.tsx": 2, + "apps/web/src/components/PermissionUpdateNotice.tsx": 1, + "apps/web/src/components/PersistentThreadTerminals.tsx": 1, + "apps/web/src/components/ProjectCloneToastCoordinator.tsx": 2, + "apps/web/src/components/PullRequestThreadDialog.tsx": 1, + "apps/web/src/components/QuitHoldOverlay.tsx": 1, + "apps/web/src/components/ReopenClosedViewShortcut.tsx": 2, + "apps/web/src/components/RightPanelTabs.tsx": 5, + "apps/web/src/components/Sidebar.logic.ts": 2, + "apps/web/src/components/Sidebar.pointer.ts": 1, + "apps/web/src/components/Sidebar.tsx": 10, + "apps/web/src/components/SlowRpcRequestToastCoordinator.tsx": 2, + "apps/web/src/components/ThreadNotificationCoordinator.tsx": 5, + "apps/web/src/components/ThreadRouteView.tsx": 5, + "apps/web/src/components/ThreadTerminalDrawer.tsx": 13, + "apps/web/src/components/auth/ConnectAgentSurface.tsx": 1, + "apps/web/src/components/auth/PairingRouteSurface.tsx": 2, + "apps/web/src/components/chat/AgentElapsed.tsx": 1, + "apps/web/src/components/chat/AssistantCitationChip.tsx": 2, + "apps/web/src/components/chat/AssistantCitationSource.tsx": 1, + "apps/web/src/components/chat/AssistantSelectionToolbar.tsx": 2, + "apps/web/src/components/chat/ChangedFilesTree.tsx": 1, + "apps/web/src/components/chat/ChatCanvas.tsx": 1, + "apps/web/src/components/chat/ChatComposer.tsx": 26, + "apps/web/src/components/chat/ChatHeader.tsx": 2, + "apps/web/src/components/chat/ChatMarkdownMedia.tsx": 1, + "apps/web/src/components/chat/ChatOverlayHosts.tsx": 1, + "apps/web/src/components/chat/CommandOutputPanel.tsx": 1, + "apps/web/src/components/chat/ComposerAttachmentStrip.tsx": 1, + "apps/web/src/components/chat/ComposerBannerStack.tsx": 3, + "apps/web/src/components/chat/ComposerCommandMenu.tsx": 1, + "apps/web/src/components/chat/ComposerCommandMenuLayer.tsx": 1, + "apps/web/src/components/chat/ComposerImageThumbnail.tsx": 1, + "apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx": 2, + "apps/web/src/components/chat/ComposerStashMenu.tsx": 2, + "apps/web/src/components/chat/DraftHeroHeadline.tsx": 1, + "apps/web/src/components/chat/DraftHeroProjectHeading.tsx": 1, + "apps/web/src/components/chat/ExpandedImageDialog.tsx": 4, + "apps/web/src/components/chat/HtmlRenderFrame.tsx": 3, + "apps/web/src/components/chat/MarkdownCodeBlock.tsx": 3, + "apps/web/src/components/chat/MarkdownTable.tsx": 3, + "apps/web/src/components/chat/McpAppFrame.tsx": 10, + "apps/web/src/components/chat/MermaidDiagram.tsx": 1, + "apps/web/src/components/chat/MessagesTimeline.tsx": 10, + "apps/web/src/components/chat/ModelPickerContent.tsx": 4, + "apps/web/src/components/chat/ModelPickerSidebar.tsx": 1, + "apps/web/src/components/chat/OpenInPicker.tsx": 1, + "apps/web/src/components/chat/OpenInPickerShortcut.ts": 1, + "apps/web/src/components/chat/PierreEntryIcon.tsx": 1, + "apps/web/src/components/chat/ProviderModelPicker.tsx": 1, + "apps/web/src/components/chat/ProviderSubagentBar.tsx": 1, + "apps/web/src/components/chat/ThreadActivityAnnouncer.tsx": 1, + "apps/web/src/components/chat/ThreadDetailsCard.tsx": 4, + "apps/web/src/components/chat/ThreadFindBar.tsx": 1, + "apps/web/src/components/chat/ThreadFindProvider.tsx": 1, + "apps/web/src/components/chat/ThreadTimeline.tsx": 1, + "apps/web/src/components/chat/ThreadVisitWatermark.tsx": 2, + "apps/web/src/components/chat/TimelineMinimap.tsx": 1, + "apps/web/src/components/chat/TimelineWorkGroupRows.tsx": 3, + "apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx": 1, + "apps/web/src/components/chat/WorkLog.tsx": 1, + "apps/web/src/components/chat/WorktreeSetupCard.tsx": 1, + "apps/web/src/components/chat/ZoomableImage.tsx": 3, + "apps/web/src/components/chat/composerContextUndo.ts": 1, + "apps/web/src/components/chat/threadContextDrag.ts": 1, + "apps/web/src/components/chat/threadFindHighlights.ts": 3, + "apps/web/src/components/chat/useAssistantCitationTarget.ts": 2, + "apps/web/src/components/chat/useAttachmentPreviewHandoff.ts": 4, + "apps/web/src/components/chat/useChatShortcuts.ts": 1, + "apps/web/src/components/chat/useComposerCommandKey.ts": 1, + "apps/web/src/components/chat/useComposerMultilinePrompt.ts": 1, + "apps/web/src/components/chat/useComposerRestingTransition.ts": 5, + "apps/web/src/components/chat/useComposerSend.ts": 1, + "apps/web/src/components/chat/useComposerStash.ts": 2, + "apps/web/src/components/chat/useComposerTypingGuard.ts": 1, + "apps/web/src/components/chat/useDraftEnvironmentRouting.ts": 3, + "apps/web/src/components/chat/useDraftHeroLayoutTransition.ts": 1, + "apps/web/src/components/chat/usePasteToFocusComposer.ts": 1, + "apps/web/src/components/chat/usePendingThreadRequests.ts": 2, + "apps/web/src/components/chat/useProviderWorkspaceSnapshotRefresh.ts": 2, + "apps/web/src/components/chat/useQueuedRunEdit.ts": 1, + "apps/web/src/components/chat/useRestingComposerControlsLayout.ts": 1, + "apps/web/src/components/chat/useRightPanelActions.ts": 1, + "apps/web/src/components/chat/useSystemComposerBanners.tsx": 1, + "apps/web/src/components/chat/useThreadFind.ts": 6, + "apps/web/src/components/chat/useThreadFindNavigation.ts": 5, + "apps/web/src/components/chat/useThreadFindPositionReader.ts": 1, + "apps/web/src/components/chat/useThreadLifecycle.tsx": 1, + "apps/web/src/components/chat/useTimelineFollow.ts": 6, + "apps/web/src/components/chat/useTimelinePositionRestore.ts": 2, + "apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx": 3, + "apps/web/src/components/cloud/ConnectCliAuthSurface.tsx": 1, + "apps/web/src/components/contextChipParts.tsx": 1, + "apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx": 1, + "apps/web/src/components/desktop/DesktopWebLinkCoordinator.tsx": 1, + "apps/web/src/components/desktop/SnapShotCoordinator.tsx": 2, + "apps/web/src/components/desktop/SshPasswordPromptDialog.tsx": 3, + "apps/web/src/components/device/DeviceAndroidFoldControls.tsx": 2, + "apps/web/src/components/device/DeviceDuoViewport.tsx": 4, + "apps/web/src/components/device/DevicePanel.tsx": 1, + "apps/web/src/components/device/DevicePhoneViewport.tsx": 3, + "apps/web/src/components/device/DeviceSetup.tsx": 1, + "apps/web/src/components/device/DeviceStreamView.tsx": 4, + "apps/web/src/components/device/DeviceToolsPanel.tsx": 1, + "apps/web/src/components/device/DeviceWorkspace.tsx": 1, + "apps/web/src/components/device/useDeviceControls.ts": 2, + "apps/web/src/components/diffs/DiffCommentAnnotation.tsx": 1, + "apps/web/src/components/diffs/DiffFileLoadingBoundary.tsx": 1, + "apps/web/src/components/diffs/DiffFileTree.tsx": 3, + "apps/web/src/components/diffs/useCodeViewFileReveal.ts": 1, + "apps/web/src/components/diffs/useReviewFilePatches.ts": 1, + "apps/web/src/components/files/AttachmentFilePreview.tsx": 3, + "apps/web/src/components/files/BrowserDocumentFrame.tsx": 7, + "apps/web/src/components/files/FileBrowserPanel.tsx": 9, + "apps/web/src/components/files/FilePreviewPanel.tsx": 3, + "apps/web/src/components/files/useDirectoryEntries.ts": 1, + "apps/web/src/components/files/useFileSaveCoordinator.ts": 2, + "apps/web/src/components/guest/GuestHtmlRender.tsx": 3, + "apps/web/src/components/guest/GuestPage.tsx": 7, + "apps/web/src/components/media/AttachmentImage.tsx": 1, + "apps/web/src/components/media/MediaVideoPlayer.tsx": 2, + "apps/web/src/components/onboarding/FirstRunGate.tsx": 3, + "apps/web/src/components/onboarding/WelcomeWizard.tsx": 7, + "apps/web/src/components/permissions/usePermissionStatus.ts": 1, + "apps/web/src/components/preview/AgentBrowserCursor.tsx": 1, + "apps/web/src/components/preview/PreviewChromeRow.tsx": 1, + "apps/web/src/components/preview/PreviewPanelShell.tsx": 3, + "apps/web/src/components/preview/PreviewView.tsx": 7, + "apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx": 3, + "apps/web/src/components/preview/ZoomIndicator.tsx": 1, + "apps/web/src/components/preview/useAgentOpenedPreviews.ts": 3, + "apps/web/src/components/preview/usePreviewBridge.ts": 2, + "apps/web/src/components/projectScriptEditor.tsx": 1, + "apps/web/src/components/promptLinks/HostedPromptLinkLanding.tsx": 1, + "apps/web/src/components/promptLinks/NewThreadLinkView.tsx": 2, + "apps/web/src/components/promptLinks/ThreadLinkPrompt.tsx": 1, + "apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx": 1, + "apps/web/src/components/pullRequest/PullRequestCodeTab.tsx": 5, + "apps/web/src/components/pullRequest/PullRequestCommentBody.tsx": 1, + "apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx": 5, + "apps/web/src/components/pullRequest/PullRequestGuideTab.tsx": 2, + "apps/web/src/components/pullRequest/PullRequestsColumn.tsx": 5, + "apps/web/src/components/pullRequest/PullRequestsPage.tsx": 9, + "apps/web/src/components/pullRequest/guide/GuideTour.tsx": 1, + "apps/web/src/components/pullRequest/guide/PullRequestGuideRunCoordinator.tsx": 2, + "apps/web/src/components/pullRequest/guide/useReviewGuideWriter.ts": 1, + "apps/web/src/components/pullRequest/useInlineActionConfirm.ts": 1, + "apps/web/src/components/pullRequest/usePullRequestDetail.ts": 1, + "apps/web/src/components/pullRequest/usePullRequestFilesViewed.ts": 2, + "apps/web/src/components/pullRequest/useThreadLinkedPullRequest.ts": 1, + "apps/web/src/components/search/ProjectContentSearchDialog.tsx": 1, + "apps/web/src/components/settings/AcpRegistryIcon.tsx": 1, + "apps/web/src/components/settings/AcpRegistrySearchStep.tsx": 2, + "apps/web/src/components/settings/AddCodexAccountDialog.tsx": 1, + "apps/web/src/components/settings/AddEnvironmentDialog.tsx": 1, + "apps/web/src/components/settings/CliCommandSettingsRow.tsx": 1, + "apps/web/src/components/settings/CodexSetupSection.tsx": 6, + "apps/web/src/components/settings/ConnectionDiagnosisDetails.tsx": 1, + "apps/web/src/components/settings/DiagnosticsSettings.tsx": 2, + "apps/web/src/components/settings/EasDeviceHostsSettings.tsx": 1, + "apps/web/src/components/settings/FontFamilyPicker.tsx": 2, + "apps/web/src/components/settings/OpenSourceLicenses.tsx": 1, + "apps/web/src/components/settings/ProjectFaviconThumbnail.tsx": 1, + "apps/web/src/components/settings/ProjectIconPickerDialog.tsx": 1, + "apps/web/src/components/settings/ProjectSettingsPanel.tsx": 2, + "apps/web/src/components/settings/ProviderAccentColorPicker.tsx": 3, + "apps/web/src/components/settings/ProviderAuthCallbackCoordinator.tsx": 1, + "apps/web/src/components/settings/ProviderAuthTerminal.tsx": 2, + "apps/web/src/components/settings/ProviderInstanceCard.tsx": 1, + "apps/web/src/components/settings/ProviderModelsSection.tsx": 1, + "apps/web/src/components/settings/ProviderSettingsForm.tsx": 1, + "apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx": 2, + "apps/web/src/components/settings/SavedEnvironmentRows.tsx": 1, + "apps/web/src/components/settings/ScheduledTasksSettings.tsx": 2, + "apps/web/src/components/settings/SettingsFontPreviews.tsx": 5, + "apps/web/src/components/settings/SettingsPanels.tsx": 1, + "apps/web/src/components/settings/SettingsSidebarNav.tsx": 3, + "apps/web/src/components/settings/SnapShotSettings.tsx": 4, + "apps/web/src/components/settings/TelemetryExportSettings.tsx": 1, + "apps/web/src/components/settings/ThemeColorPicker.tsx": 1, + "apps/web/src/components/settings/ThemeEditorPanel.tsx": 5, + "apps/web/src/components/settings/ThemeImportDialog.tsx": 1, + "apps/web/src/components/settings/ThemeSearchSection.tsx": 3, + "apps/web/src/components/settings/TypographySettings.tsx": 2, + "apps/web/src/components/settings/settingsLayout.tsx": 1, + "apps/web/src/components/settings/useSnapShotShortcutRecorder.tsx": 1, + "apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx": 2, + "apps/web/src/components/sidebar/SidebarThreadRow.tsx": 5, + "apps/web/src/components/sidebar/SidebarThreadUndoNotice.tsx": 1, + "apps/web/src/components/sidebar/SidebarUpdatePill.tsx": 1, + "apps/web/src/components/sidebar/mainAppLocation.ts": 1, + "apps/web/src/components/sidebar/updateEverythingRun.tsx": 2, + "apps/web/src/components/ui/sidebar.tsx": 3, + "apps/web/src/components/ui/toast.tsx": 2, + "apps/web/src/components/ui/tooltip.tsx": 1, + "apps/web/src/components/usage/UsageLimits.tsx": 1, + "apps/web/src/components/usage/UsageLimitsPooled.tsx": 1, + "apps/web/src/components/usage/UsagePage.tsx": 3, + "apps/web/src/components/usage/UsageProviderChart.tsx": 1, + "apps/web/src/connection/useDesktopLocalBootstraps.ts": 1, + "apps/web/src/hooks/FileMetadataThreadProvider.tsx": 1, + "apps/web/src/hooks/useCopyToClipboard.ts": 1, + "apps/web/src/hooks/useDefaultTheme.ts": 1, + "apps/web/src/hooks/useDelayedStatus.ts": 2, + "apps/web/src/hooks/useEnvironmentDisconnectDelay.ts": 1, + "apps/web/src/hooks/useEnvironmentTheme.ts": 1, + "apps/web/src/hooks/useLiveRefresh.ts": 1, + "apps/web/src/hooks/useLiveRegion.ts": 1, + "apps/web/src/hooks/useNavigateBack.ts": 1, + "apps/web/src/hooks/usePreviewPanelInlineSize.ts": 1, + "apps/web/src/hooks/useProactivePanels.ts": 1, + "apps/web/src/hooks/useResizableWidth.ts": 2, + "apps/web/src/hooks/useResizeDrag.ts": 2, + "apps/web/src/hooks/useSettings.ts": 1, + "apps/web/src/hooks/useStableCallback.ts": 1, + "apps/web/src/hooks/useTheme.ts": 1, + "apps/web/src/hooks/useThreadVisitedMigration.ts": 1, + "apps/web/src/hooks/useWorkspaceMutationRefresh.ts": 1, + "apps/web/src/panelAnimations.ts": 2, + "apps/web/src/remoteOpen.ts": 1, + "apps/web/src/routes/__root.tsx": 13, + "apps/web/src/routes/_chat.index.tsx": 1, + "apps/web/src/routes/_chat.tsx": 1, + "apps/web/src/shortcutModifierState.ts": 1, + "apps/web/src/state/olderSettledShelf.ts": 2, + "apps/web/src/state/pullRequests.ts": 2, + "apps/web/src/state/queries.ts": 2, + "apps/web/src/state/threads.ts": 1 +} diff --git a/scripts/install.ps1 b/scripts/install.ps1 index f440ebaa11902..60a70fefc7211 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -133,8 +133,19 @@ if (-not $version) { "preview" { '^v\d+\.\d+\.\d+-preview\.\d+\.\d+$' } default { Fail "T3CODE_CHANNEL must be stable, nightly, or preview" } } - $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=100" -Headers @{ "User-Agent" = "t3-install" } - $tag = ($releases | Where-Object { -not $_.draft -and $_.tag_name -match $tagPattern } | Select-Object -First 1).tag_name + if ($channel -eq "stable") { + # release.yml marks only plain stable releases latest. This is a github.com + # page, so it is not cut off by a page of newer nightlies or the API quota. + $release = Invoke-RestMethod -Uri "https://github.com/$repo/releases/latest" -Headers @{ "User-Agent" = "t3-install"; "Accept" = "application/json" } + $tag = ($release | Where-Object { $_.tag_name -match $tagPattern }).tag_name + } else { + $tag = $null + for ($page = 1; -not $tag -and $page -le 10; $page++) { + $releases = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases?per_page=100&page=$page" -Headers @{ "User-Agent" = "t3-install" } + $tag = ($releases | Where-Object { -not $_.draft -and $_.tag_name -match $tagPattern } | Select-Object -First 1).tag_name + if (-not $releases) { break } + } + } if (-not $tag) { Fail "could not find a $channel release; set T3CODE_VERSION" } $version = $tag.Substring(1) } diff --git a/scripts/install.sh b/scripts/install.sh index 20a0c0251531d..4c164c45040f8 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -53,14 +53,14 @@ step "Finding your release..." # Exit 44 on a 404 so callers can tell "no such asset" from a network failure. fetch() { if command -v curl >/dev/null 2>&1; then - status="$(curl -sSL -w '%{http_code}' "$1" -o "$2")" || return 1 + status="$(curl -sSL -H "Accept: ${3:-*/*}" -w '%{http_code}' "$1" -o "$2")" || return 1 case "$status" in 2??) return 0 ;; 404) return 44 ;; *) printf 'GET %s returned HTTP %s\n' "$1" "$status" >&2; return 1 ;; esac elif command -v wget >/dev/null 2>&1; then - wget -q --server-response "$1" -O "$2" 2>"$2.headers" && rm -f "$2.headers" && return 0 + wget -q --server-response --header="Accept: ${3:-*/*}" "$1" -O "$2" 2>"$2.headers" && rm -f "$2.headers" && return 0 if grep -q ' 404 ' "$2.headers" 2>/dev/null; then rm -f "$2.headers"; return 44; fi cat "$2.headers" >&2; rm -f "$2.headers"; return 1 else @@ -151,8 +151,20 @@ if [ -z "$version" ]; then *) fail "T3CODE_CHANNEL must be stable, nightly, or preview" ;; esac tmp_index="$(mktemp)" - fetch "https://api.github.com/repos/${repo}/releases?per_page=100" "$tmp_index" - version="$(sed -n "s/.*\"tag_name\": *\"${tag_pattern}\".*/\1/p" "$tmp_index" | head -n 1)" + if [ "$channel" = stable ]; then + # release.yml marks only plain stable releases latest. This is a github.com + # page, so it is not cut off by a page of newer nightlies or the API quota. + fetch "https://github.com/${repo}/releases/latest" "$tmp_index" application/json + version="$(sed -n "s/.*\"tag_name\": *\"${tag_pattern}\".*/\1/p" "$tmp_index" | head -n 1)" + else + page=1 + while [ -z "$version" ] && [ "$page" -le 10 ]; do + fetch "https://api.github.com/repos/${repo}/releases?per_page=100&page=${page}" "$tmp_index" + version="$(sed -n "s/.*\"tag_name\": *\"${tag_pattern}\".*/\1/p" "$tmp_index" | head -n 1)" + grep -q '"tag_name"' "$tmp_index" || break + page=$((page + 1)) + done + fi rm -f "$tmp_index" [ -n "$version" ] || fail "could not find a ${channel} release; set T3CODE_VERSION" fi diff --git a/scripts/install.test.ts b/scripts/install.test.ts new file mode 100644 index 0000000000000..5be7acc8e004d --- /dev/null +++ b/scripts/install.test.ts @@ -0,0 +1,80 @@ +// @effect-diagnostics nodeBuiltinImport:off - Drives the real shell installer against a local GitHub fixture. +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeHttp from "node:http"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +const nightlies = Array.from({ length: 100 }, (_, index) => ({ + tag_name: `v1.2.4-nightly.20261020.${200 - index}`, +})); + +// regression: #22490 +describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("installer channel lookup", () => { + it.each([ + ["stable", "1.2.3"], + ["preview", "1.2.4-preview.20261019.50"], + ])("finds the newest %s release behind a full page of nightlies", async (channel, version) => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-install-index-")); + const target = NodePath.join(root, "home/runtime/versions", version); + const server = NodeHttp.createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://fixture"); + const body = + url.pathname === "/pingdotgg/t3code/releases/latest" && + request.headers.accept === "application/json" + ? { tag_name: "v1.2.3" } + : url.pathname === "/repos/pingdotgg/t3code/releases" + ? { "1": nightlies, "2": [{ tag_name: `v${version}` }, { tag_name: "v1.2.3" }] }[ + url.searchParams.get("page") ?? "" + ] + : undefined; + if (body === undefined) response.statusCode = 404; + // The API pretty-prints and the installer reads it line by line. + response.end(JSON.stringify(body ?? [], null, 2)); + }); + try { + // An existing completed install isolates release resolution from archive downloads. + await NodeFSP.mkdir(target, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(target, ".install-complete"), `${version}\n`); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected a TCP listener"); + const origin = `http://127.0.0.1:${address.port}`; + const source = await NodeFSP.readFile( + NodePath.resolve(import.meta.dirname, "install.sh"), + "utf8", + ); + const installer = NodePath.join(root, "install.sh"); + await NodeFSP.writeFile(installer, source.replace(/https:\/\/(api\.)?github\.com/g, origin)); + const { code, output } = await new Promise<{ code: number | null; output: string }>( + (resolve, reject) => { + const child = NodeChildProcess.spawn("sh", [installer], { + env: { + ...process.env, + T3CODE_VERSION: "", + T3CODE_CHANNEL: channel, + T3CODE_HOME: NodePath.join(root, "home"), + T3CODE_INSTALL_BIN_DIR: NodePath.join(root, "bin"), + // A wrong version must fail here instead of downloading a real release. + T3CODE_RELEASE_BASE_URL: origin, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (chunk: Buffer) => (output += chunk.toString())); + child.stderr.on("data", (chunk: Buffer) => (output += chunk.toString())); + child.on("error", reject); + child.on("close", (code) => resolve({ code, output })); + }, + ); + expect(code, output).toBe(0); + expect(output).toContain(`Installed T3 Code ${version}`); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/mobile-e2e.ts b/scripts/mobile-e2e.ts deleted file mode 100644 index 9050c28089cf9..0000000000000 --- a/scripts/mobile-e2e.ts +++ /dev/null @@ -1,785 +0,0 @@ -#!/usr/bin/env node -// @effect-diagnostics nodeBuiltinImport:off globalTimers:off globalDate:off - Host-side simulator automation uses Node subprocess and timing APIs directly. - -import * as NodeChildProcess from "node:child_process"; -import * as NodeFSP from "node:fs/promises"; -import * as NodeOS from "node:os"; -import * as NodePath from "node:path"; -import * as NodeProcess from "node:process"; -import * as NodeURL from "node:url"; - -import { startShowcaseEnvironments, stopProcess, waitForPort } from "./mobile-showcase.ts"; -import { SHOWCASE_ENVIRONMENTS } from "./mobile-showcase-environment.ts"; - -const REPO_ROOT = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); -const AGENT_DEVICE = NodePath.join( - REPO_ROOT, - "scripts/node_modules/agent-device/bin/agent-device.mjs", -); -const BUNDLE_ID = "com.t3tools.t3code.dev"; -const METRO_PORT = 8081; -// Persistent so the XCTest runner agent-device builds per simulator survives between runs. -const AGENT_DEVICE_STATE = NodePath.join(NodeOS.homedir(), ".cache/t3code/mobile-e2e/agent-device"); - -export interface E2eDevice { - readonly id: string; - readonly platform: "ios"; - readonly deviceType: string; - readonly runtime: string; -} - -const ios = (id: string, deviceType: string): E2eDevice => ({ - id, - platform: "ios", - deviceType: `com.apple.CoreSimulator.SimDeviceType.${deviceType}`, - runtime: "com.apple.CoreSimulator.SimRuntime.iOS-27-0", -}); - -export const DEVICES: ReadonlyArray = [ - ios("iphone-se", "iPhone-SE-3rd-generation"), - ios("iphone-13-mini", "iPhone-13-mini"), - ios("iphone-17", "iPhone-17"), - ios("iphone-17-pro", "iPhone-17-Pro"), - ios("iphone-17-pro-max", "iPhone-17-Pro-Max"), - ios("iphone-air", "iPhone-Air"), -]; - -interface Rect { - readonly x: number; - readonly y: number; - readonly width: number; - readonly height: number; -} - -export interface SnapshotNode { - readonly type?: string; - readonly role?: string; - readonly identifier?: string; - readonly label?: string; - readonly rect?: Rect; -} - -export interface Snapshot { - readonly nodes: ReadonlyArray; - readonly viewport: { readonly width: number; readonly height: number }; -} - -export interface Check { - readonly name: string; - readonly pass: boolean; - readonly detail: string; -} - -interface StepResult { - readonly name: string; - readonly checks: ReadonlyArray; - readonly screenshot?: string | undefined; - readonly video?: string | undefined; - readonly contactSheet?: string | undefined; - readonly lagFrame?: string | undefined; - readonly samples: number; -} - -interface DeviceResult { - readonly device: E2eDevice; - readonly label: string; - readonly steps: ReadonlyArray; - readonly error?: string; -} - -const onScreen = (node: SnapshotNode, snapshot: Snapshot) => - node.rect !== undefined && - node.rect.width > 0 && - node.rect.height > 0 && - node.rect.x < snapshot.viewport.width && - node.rect.y < snapshot.viewport.height && - node.rect.x + node.rect.width > 0 && - node.rect.y + node.rect.height > 0; - -const find = (snapshot: Snapshot, match: (node: SnapshotNode) => boolean) => - snapshot.nodes.filter((node) => match(node) && onScreen(node, snapshot)); - -export const chrome = (snapshot: Snapshot) => ({ - tabBar: find(snapshot, (node) => node.type === "TabBar")[0], - navigationBar: find(snapshot, (node) => node.type === "NavigationBar")[0], - backButton: find(snapshot, (node) => node.identifier === "BackButton")[0], - send: find(snapshot, (node) => node.type === "Button" && node.label === "Send")[0], - newChat: find(snapshot, (node) => node.type === "Button" && node.label === "New chat")[0], - // Thread rows are full-width context-menu wrappers; the filter pills are narrow ones. - threadRows: find( - snapshot, - (node) => - node.role === "MenuView" && - Boolean(node.label) && - (node.rect?.width ?? 0) >= snapshot.viewport.width * 0.9, - ), -}); - -const check = (name: string, pass: boolean, detail: string): Check => ({ name, pass, detail }); -const describeRect = (rect: Rect | undefined) => - rect - ? `${Math.round(rect.x)},${Math.round(rect.y)} ${Math.round(rect.width)}×${Math.round(rect.height)}` - : "absent"; -const inside = (rect: Rect, snapshot: Snapshot) => - rect.x >= 0 && - rect.y >= 0 && - rect.x + rect.width <= snapshot.viewport.width + 0.5 && - rect.y + rect.height <= snapshot.viewport.height + 0.5; - -export function homeChecks(snapshot: Snapshot): Check[] { - const { tabBar, newChat } = chrome(snapshot); - return [ - check("tab bar visible", tabBar !== undefined, describeRect(tabBar?.rect)), - check( - "tab bar inside the screen", - tabBar?.rect !== undefined && inside(tabBar.rect, snapshot), - describeRect(tabBar?.rect), - ), - check("new chat button visible", newChat !== undefined, describeRect(newChat?.rect)), - ]; -} - -export function threadChecks(snapshot: Snapshot, samples: ReadonlyArray): Check[] { - const { tabBar, backButton, send } = chrome(snapshot); - const overlapping = samples.filter((sample) => { - const state = chrome(sample); - return state.backButton !== undefined && state.tabBar !== undefined; - }).length; - return [ - check("tab bar hidden", tabBar === undefined, describeRect(tabBar?.rect)), - check("back button visible", backButton !== undefined, describeRect(backButton?.rect)), - check( - "composer inside the screen", - send?.rect !== undefined && inside(send.rect, snapshot), - describeRect(send?.rect), - ), - check( - "no tabs over the entering thread", - overlapping === 0, - `${overlapping} of ${samples.length} transition samples showed tabs with a Back button`, - ), - ]; -} - -export function backChecks(snapshot: Snapshot, samples: ReadonlyArray): Check[] { - const listWithoutTabs = samples.filter((sample) => { - const state = chrome(sample); - return ( - state.backButton === undefined && state.newChat !== undefined && state.tabBar === undefined - ); - }).length; - return [ - ...homeChecks(snapshot), - check( - "tabs return with the list", - listWithoutTabs === 0, - `${listWithoutTabs} of ${samples.length} transition samples showed the list without tabs`, - ), - ]; -} - -/** Downsampled grayscale crops of one region, one per video frame (or one for a PNG). */ -async function grayFrames(input: string, region: Rect, scale: number): Promise { - const even = (value: number) => Math.max(2, Math.floor(value / 2) * 2); - const [x, y, width, height] = [ - Math.round(region.x * scale), - Math.round(region.y * scale), - even(region.width * scale), - even(region.height * scale), - ]; - const [outWidth, outHeight] = [even(width / 6), even(height / 6)]; - const bytes = await new Promise((resolve, reject) => { - const child = NodeChildProcess.spawn( - "ffmpeg", - [ - "-v", - "error", - "-i", - input, - // One output frame per recorded frame, so indices match select=eq(n,…) for evidence. - "-fps_mode", - "passthrough", - "-vf", - `crop=${width}:${height}:${x}:${y},scale=${outWidth}:${outHeight},format=gray`, - "-f", - "rawvideo", - "-", - ], - { stdio: ["ignore", "pipe", "pipe"] }, - ); - const chunks: Buffer[] = []; - let stderr = ""; - child.stdout.on("data", (chunk: Buffer) => chunks.push(chunk)); - child.stderr.on("data", (chunk) => (stderr += chunk)); - child.on("error", reject); - child.on("close", (code) => - code === 0 ? resolve(Buffer.concat(chunks)) : reject(new Error(`ffmpeg failed: ${stderr}`)), - ); - }); - const size = outWidth * outHeight; - return Array.from({ length: Math.floor(bytes.length / size) }, (_, index) => - bytes.subarray(index * size, (index + 1) * size), - ); -} - -const distance = (a: Uint8Array, b: Uint8Array) => { - let sum = 0; - for (let index = 0; index < a.length; index++) sum += Math.abs(a[index]! - b[index]!); - return sum / a.length; -}; - -/** How far a region has moved from its origin toward its destination look, 0 to 1. */ -export interface FrameProgress { - readonly nav: number; - readonly chrome: number; -} - -/** - * A frame lags when the navigation bar is well into the transition while the tab bar region - * still looks like the origin: tabs lingering over an entering thread, or a returned list drawn - * before its tabs. - */ -export const chromeLags = (frame: FrameProgress) => frame.nav > 0.3 && frame.chrome < 0.4; -// One lagging frame is capture jitter; a regression lasts several. -const LAG_FRAME_LIMIT = 2; - -const progress = (origin: number, destination: number) => - origin + destination === 0 ? 0 : origin / (origin + destination); - -async function chromeLagFrames(input: { - readonly video: string; - readonly origin: string; - readonly destination: string; - readonly nav: Rect; - readonly chrome: Rect; - readonly scale: number; -}) { - const [ - navFrames, - chromeFrames, - [navOrigin], - [navDestination], - [chromeOrigin], - [chromeDestination], - ] = await Promise.all([ - grayFrames(input.video, input.nav, input.scale), - grayFrames(input.video, input.chrome, input.scale), - grayFrames(input.origin, input.nav, input.scale), - grayFrames(input.destination, input.nav, input.scale), - grayFrames(input.origin, input.chrome, input.scale), - grayFrames(input.destination, input.chrome, input.scale), - ]); - if (!navOrigin || !navDestination || !chromeOrigin || !chromeDestination) - throw new Error("Could not read the reference screenshots."); - const verdicts = navFrames.map((nav, index) => { - const chrome = chromeFrames[index]!; - return { - nav: progress(distance(nav, navOrigin), distance(nav, navDestination)), - chrome: progress(distance(chrome, chromeOrigin), distance(chrome, chromeDestination)), - }; - }); - return verdicts.flatMap((verdict, index) => (chromeLags(verdict) ? [index] : [])); -} - -async function saveFrame(video: string, index: number, path: string) { - await run("ffmpeg", [ - "-v", - "error", - "-y", - "-i", - video, - "-vf", - `select=eq(n\\,${index})`, - "-frames:v", - "1", - path, - ]); -} - -async function lagCheck( - name: string, - input: Parameters[0], - evidence: string, -): Promise<{ check: Check; frame?: string }> { - const lagging = await chromeLagFrames(input); - if (lagging.length < LAG_FRAME_LIMIT) - return { check: check(name, true, `${lagging.length} lagging video frames`) }; - await saveFrame(input.video, lagging[0]!, evidence); - return { - check: check(name, false, `${lagging.length} video frames (first #${lagging[0]})`), - frame: evidence, - }; -} - -function run( - command: string, - args: ReadonlyArray, - options: { readonly env?: NodeJS.ProcessEnv; readonly allowFailure?: boolean } = {}, -): Promise<{ readonly code: number; readonly stdout: string; readonly stderr: string }> { - return new Promise((resolve, reject) => { - const child = NodeChildProcess.spawn(command, args, { - cwd: REPO_ROOT, - env: options.env ?? NodeProcess.env, - stdio: ["ignore", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (chunk) => (stdout += chunk)); - child.stderr.on("data", (chunk) => (stderr += chunk)); - child.on("error", reject); - child.on("close", (code) => { - if (code !== 0 && !options.allowFailure) - reject(new Error(`${command} ${args.join(" ")} failed (${code}): ${stderr || stdout}`)); - else resolve({ code: code ?? 1, stdout, stderr }); - }); - }); -} - -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -class Device { - readonly spec: E2eDevice; - readonly udid: string; - - constructor(spec: E2eDevice, udid: string) { - this.spec = spec; - this.udid = udid; - } - - async agent(args: ReadonlyArray): Promise> { - const { stdout } = await run( - NodeProcess.execPath, - [ - AGENT_DEVICE, - ...args, - "--platform", - "ios", - "--udid", - this.udid, - "--session", - `t3-e2e-${this.spec.id}`, - "--json", - ], - { - env: { ...NodeProcess.env, AGENT_DEVICE_STATE_DIR: AGENT_DEVICE_STATE }, - allowFailure: true, - }, - ); - const parsed = JSON.parse(stdout) as { - success: boolean; - data?: Record; - error?: { message: string }; - }; - if (!parsed.success) throw new Error(`agent-device ${args[0]}: ${parsed.error?.message}`); - return parsed.data ?? {}; - } - - async snapshot(): Promise { - return (await this.agent(["snapshot"])) as unknown as Snapshot; - } - - async waitFor(predicate: (snapshot: Snapshot) => boolean, label: string, timeoutMs = 60_000) { - const deadline = Date.now() + timeoutMs; - let last: Snapshot | null = null; - while (Date.now() < deadline) { - last = await this.snapshot().catch(() => null); - if (last && predicate(last)) return last; - await delay(500); - } - throw new Error(`${this.spec.id}: timed out waiting for ${label}`); - } - - /** Open a URL the way a user would; iOS asks before opening a custom scheme from another app. */ - async openUrl(url: string) { - await run("xcrun", ["simctl", "openurl", this.udid, url]); - await delay(1_500); - await this.agent(["alert", "accept"]).catch(() => undefined); - } - - async screenshot(path: string) { - await run("xcrun", ["simctl", "io", this.udid, "screenshot", path]); - } - - /** Run an action while recording and sampling the accessibility tree until the screen settles. */ - async transition(directory: string, name: string, action: () => Promise) { - const video = NodePath.join(directory, `${name}.mp4`); - await this.agent(["record", "start", video, "--hide-touches"]); - const samples: Snapshot[] = []; - const sampling = { active: true }; - const sampler = (async () => { - while (sampling.active) { - const sample = await this.snapshot().catch(() => null); - if (sample) samples.push(sample); - } - })(); - try { - await action(); - await delay(2_000); - } finally { - sampling.active = false; - await sampler; - await this.agent(["record", "stop"]).catch(() => undefined); - } - const contactSheet = NodePath.join(directory, `${name}-frames.png`); - const sheet = await run( - NodeProcess.execPath, - [AGENT_DEVICE, "record", "contact-sheet", video, "--out", contactSheet], - { - env: { ...NodeProcess.env, AGENT_DEVICE_STATE_DIR: AGENT_DEVICE_STATE }, - allowFailure: true, - }, - ); - return { samples, video, contactSheet: sheet.code === 0 ? contactSheet : undefined }; - } -} - -/** Screenshot pixels per snapshot point (3 on most iPhones). */ -async function screenshotScale(path: string, snapshot: Snapshot): Promise { - const { stdout } = await run("ffprobe", [ - "-v", - "error", - "-select_streams", - "v:0", - "-show_entries", - "stream=width", - "-of", - "csv=p=0", - path, - ]); - return Number(stdout.trim()) / snapshot.viewport.width; -} - -async function ensureSimulator(spec: E2eDevice): Promise { - const name = `T3 E2E ${spec.id}`; - const listing = JSON.parse( - (await run("xcrun", ["simctl", "list", "devices", "available", "-j"])).stdout, - ) as { devices: Record> }; - const existing = (listing.devices[spec.runtime] ?? []).find((device) => device.name === name); - const udid = - existing?.udid ?? - (await run("xcrun", ["simctl", "create", name, spec.deviceType, spec.runtime])).stdout.trim(); - if (existing?.state !== "Booted") - await run("xcrun", ["simctl", "boot", udid], { allowFailure: true }); - await run("xcrun", ["simctl", "bootstatus", udid, "-b"]); - await run("xcrun", ["simctl", "ui", udid, "appearance", "light"]); - await run("xcrun", [ - "simctl", - "status_bar", - udid, - "override", - "--time", - "9:41", - "--batteryState", - "charged", - "--batteryLevel", - "100", - ]); - return udid; -} - -function lanAddress(): string { - for (const addresses of Object.values(NodeOS.networkInterfaces())) { - for (const address of addresses ?? []) { - if (address.family === "IPv4" && !address.internal && !address.address.startsWith("169.254.")) - return address.address; - } - } - throw new Error("No LAN IPv4 address for the simulator to reach Metro."); -} - -async function runDevice( - spec: E2eDevice, - outputDirectory: string, - environment: { readonly baseDir: string; readonly port: number }, - metroHost: string, -): Promise { - const steps: StepResult[] = []; - const directory = NodePath.join(outputDirectory, spec.id); - await NodeFSP.mkdir(directory, { recursive: true }); - try { - const udid = await ensureSimulator(spec); - const device = new Device(spec, udid); - // A fresh install drops connections paired by earlier runs; the cached client reinstalls in seconds. - await run("xcrun", ["simctl", "uninstall", udid, BUNDLE_ID], { allowFailure: true }); - log(spec.id, "installing the dev client"); - await run(NodeProcess.execPath, ["scripts/mobile-native-client.ts", "ensure", "ios", udid], { - env: { ...NodeProcess.env, T3_NATIVE_CLIENT_CACHE: NodeProcess.env.T3_NATIVE_CLIENT_CACHE }, - }); - const metroUrl = encodeURIComponent(`http://${metroHost}:${METRO_PORT}`); - await device.openUrl( - `t3code-dev://expo-development-client/?url=${metroUrl}&disableOnboarding=1&disableFab=1&disableAutoLaunch=1`, - ); - // Snapshots read through an agent-device app session; open attaches one to the running app. - await device.agent(["open", BUNDLE_ID]); - // The app ignores the pairing link until its JS has loaded and shown the workspace. - await device.waitFor( - (snapshot) => chrome(snapshot).newChat !== undefined, - "the app to load", - 120_000, - ); - const paired = (snapshot: Snapshot) => - chrome(snapshot).tabBar !== undefined && chrome(snapshot).threadRows.length > 0; - let home: Snapshot | null = null; - for (let attempt = 1; attempt <= 2 && !home; attempt++) { - log(spec.id, attempt === 1 ? "pairing" : "pairing again"); - const pairing = new URLSearchParams({ - pairingUrl: await issuePairUrl(environment), - autoConnect: "1", - }); - await device.openUrl(`t3code-dev://connections/new?${pairing}`); - home = await device.waitFor(paired, "the paired thread list", 45_000).catch(() => null); - } - if (!home) throw new Error("The app did not show the fixture threads after pairing."); - await delay(1_000); - const homeShot = NodePath.join(directory, "home.png"); - await device.screenshot(homeShot); - steps.push({ name: "home", checks: homeChecks(home), screenshot: homeShot, samples: 0 }); - - log(spec.id, "opening a thread"); - const row = chrome(home).threadRows[0]; - if (!row?.label) throw new Error("No thread row to open."); - const opened = await device.transition(directory, "open-thread", () => - device.agent(["press", `label=${JSON.stringify(row.label)}`]), - ); - const thread = await device.waitFor( - (snapshot) => chrome(snapshot).backButton !== undefined, - "the thread screen", - ); - const threadShot = NodePath.join(directory, "thread.png"); - await device.screenshot(threadShot); - const { tabBar, navigationBar } = chrome(home); - const scale = await screenshotScale(homeShot, home); - const regions = - tabBar?.rect && navigationBar?.rect ? { nav: navigationBar.rect, chrome: tabBar.rect } : null; - const openLag = regions - ? await lagCheck( - "no tabs over the entering thread (video)", - { video: opened.video, origin: homeShot, destination: threadShot, scale, ...regions }, - NodePath.join(directory, "open-thread-lag.png"), - ) - : null; - steps.push({ - name: "open thread", - checks: [...threadChecks(thread, opened.samples), ...(openLag ? [openLag.check] : [])], - lagFrame: openLag?.frame, - screenshot: threadShot, - video: opened.video, - contactSheet: opened.contactSheet, - samples: opened.samples.length, - }); - - log(spec.id, "going back"); - const back = await device.transition(directory, "back", () => - device.agent(["press", "id=BackButton"]), - ); - const list = await device.waitFor( - (snapshot) => chrome(snapshot).backButton === undefined, - "the thread list", - ); - const backShot = NodePath.join(directory, "back.png"); - await device.screenshot(backShot); - const backLag = regions - ? await lagCheck( - "tabs return with the list (video)", - { video: back.video, origin: threadShot, destination: backShot, scale, ...regions }, - NodePath.join(directory, "back-lag.png"), - ) - : null; - steps.push({ - name: "back", - checks: [...backChecks(list, back.samples), ...(backLag ? [backLag.check] : [])], - lagFrame: backLag?.frame, - screenshot: backShot, - video: back.video, - contactSheet: back.contactSheet, - samples: back.samples.length, - }); - return { device: spec, label: spec.id, steps }; - } catch (error) { - return { - device: spec, - label: spec.id, - steps, - error: error instanceof Error ? error.message : String(error), - }; - } -} - -async function issuePairUrl(environment: { readonly baseDir: string; readonly port: number }) { - const { stdout } = await run(NodeProcess.execPath, [ - "apps/server/src/bin.ts", - "auth", - "pairing", - "create", - "--base-dir", - environment.baseDir, - "--base-url", - `http://127.0.0.1:${environment.port}`, - "--ttl", - "15m", - "--json", - ]); - const { pairUrl } = JSON.parse( - stdout.slice(stdout.indexOf("{"), stdout.lastIndexOf("}") + 1), - ) as { - pairUrl?: string; - }; - if (!pairUrl) throw new Error("The pairing command returned no pairUrl."); - return pairUrl; -} - -const log = (scope: string, message: string) => NodeProcess.stderr.write(`[${scope}] ${message}\n`); - -const escapeHtml = (value: string) => - value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); - -function renderReport(results: ReadonlyArray, outputDirectory: string): string { - const relative = (path: string | undefined) => - path ? escapeHtml(NodePath.relative(outputDirectory, path)) : ""; - const rows = results - .map((result) => { - const steps = result.steps - .map((step) => { - const checks = step.checks - .map( - (item) => - `
  • ${item.pass ? "✓" : "✗"} ${escapeHtml(item.name)} ${escapeHtml(item.detail)}
  • `, - ) - .join(""); - const media = [ - step.screenshot - ? `` - : "", - step.lagFrame - ? `` - : "", - step.contactSheet - ? `transition frames` - : "", - step.video ? `video` : "", - ].join(" "); - return `

    ${escapeHtml(step.name)}

      ${checks}
    ${media}`; - }) - .join(""); - const error = result.error ? `${escapeHtml(result.error)}` : ""; - return `${escapeHtml(result.label)}${steps}${error}`; - }) - .join(""); - const failures = results.filter( - (result) => result.error || result.steps.some((step) => step.checks.some((item) => !item.pass)), - ).length; - return `Mobile E2E - -

    Mobile E2E · ${failures === 0 ? "all devices passed" : `${failures} device(s) failed`}

    -${rows}
    `; -} - -async function main() { - const args = NodeProcess.argv.slice(2); - if (args.includes("--list")) { - for (const device of DEVICES) NodeProcess.stdout.write(`${device.id}\n`); - return; - } - const selected = args.flatMap((arg, index) => (args[index - 1] === "--device" ? [arg] : [])); - const devices = selected.length - ? DEVICES.filter((device) => selected.includes(device.id)) - : DEVICES; - const unknown = selected.filter((id) => !DEVICES.some((device) => device.id === id)); - if (unknown.length) throw new Error(`Unknown device ${unknown.join(", ")}. Run with --list.`); - const concurrencyArg = args[args.indexOf("--concurrency") + 1]; - const concurrency = args.includes("--concurrency") ? Number(concurrencyArg) : 3; - - const stamp = new Date().toISOString().replaceAll(":", "-"); - const outputDirectory = NodePath.join(REPO_ROOT, "artifacts/mobile-e2e", stamp); - await NodeFSP.mkdir(outputDirectory, { recursive: true }); - const rootDir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-mobile-e2e-")); - const servers: NodeChildProcess.ChildProcess[] = []; - let metro: NodeChildProcess.ChildProcess | null = null; - try { - log("e2e", "starting the fixture server"); - const [environment] = await startShowcaseEnvironments( - rootDir, - (server) => servers.push(server), - SHOWCASE_ENVIRONMENTS.slice(0, 1), - ); - if (!environment) throw new Error("The fixture server did not start."); - const metroHost = lanAddress(); - metro = NodeChildProcess.spawn( - "vp", - [ - "exec", - "expo", - "start", - "--dev-client", - "--scheme", - "t3code-dev", - "--lan", - "--port", - String(METRO_PORT), - ], - { - cwd: NodePath.join(REPO_ROOT, "apps/mobile"), - env: { ...NodeProcess.env, APP_VARIANT: "development", CI: "1" }, - stdio: ["ignore", "ignore", "inherit"], - // Its own group, so stopping it also stops the expo child that owns the port. - detached: true, - }, - ); - await waitForPort(METRO_PORT, "Metro", 120_000); - await run("curl", [ - "-sf", - "-o", - "/dev/null", - `http://127.0.0.1:${METRO_PORT}/apps/mobile/index.bundle?platform=ios&dev=true&minify=false`, - ]); - - // The first install may build; later ones reuse its artifact. - const results: DeviceResult[] = []; - const [first, ...rest] = devices; - if (first) results.push(await runDevice(first, outputDirectory, environment, metroHost)); - for (let index = 0; index < rest.length; index += concurrency) { - results.push( - ...(await Promise.all( - rest - .slice(index, index + concurrency) - .map((device) => runDevice(device, outputDirectory, environment, metroHost)), - )), - ); - } - const report = NodePath.join(outputDirectory, "index.html"); - await NodeFSP.writeFile(report, renderReport(results, outputDirectory)); - await NodeFSP.writeFile( - NodePath.join(outputDirectory, "report.json"), - JSON.stringify(results, null, 2), - ); - const failed = results.filter( - (result) => - result.error || result.steps.some((step) => step.checks.some((item) => !item.pass)), - ); - for (const result of results) { - const failures = result.steps.flatMap((step) => - step.checks - .filter((item) => !item.pass) - .map((item) => `${step.name}: ${item.name} (${item.detail})`), - ); - log(result.label, result.error ?? (failures.length ? failures.join("; ") : "passed")); - } - NodeProcess.stdout.write( - `${JSON.stringify({ report, devices: results.length, failed: failed.length })}\n`, - ); - process.exitCode = failed.length ? 1 : 0; - } finally { - if (metro?.pid) { - try { - process.kill(-metro.pid, "SIGTERM"); - } catch {} - await stopProcess(metro); - } - await Promise.all(servers.map((server) => stopProcess(server))); - await NodeFSP.rm(rootDir, { recursive: true, force: true }); - } -} - -if (import.meta.main) { - await main(); -} diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index db90eec52c32a..9087ebc6ffce0 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -497,7 +497,7 @@ function delay(milliseconds: number): Promise { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } -export async function stopProcess(child: NodeChildProcess.ChildProcess): Promise { +async function stopProcess(child: NodeChildProcess.ChildProcess): Promise { if (child.exitCode !== null || child.signalCode !== null) return; const exited = new Promise((resolve) => { @@ -511,11 +511,7 @@ export async function stopProcess(child: NodeChildProcess.ChildProcess): Promise await Promise.race([exited, delay(1_000)]); } -export async function waitForPort( - port: number, - label = "Process", - timeoutMs = 60_000, -): Promise { +async function waitForPort(port: number, label = "Process", timeoutMs = 60_000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const open = await new Promise((resolve) => { @@ -639,63 +635,6 @@ function startShowcaseServer( ); } -export interface ShowcaseEnvironment { - readonly baseDir: string; - readonly environmentId: string; - readonly label: string; - readonly port: number; -} - -/** Boot one seeded fixture server per showcase environment; `track` receives every spawned server. */ -export async function startShowcaseEnvironments( - rootDir: string, - track: (server: NodeChildProcess.ChildProcess) => void, - environments: ReadonlyArray<(typeof SHOWCASE_ENVIRONMENTS)[number]> = SHOWCASE_ENVIRONMENTS, -): Promise { - const started: ShowcaseEnvironment[] = []; - for (const environment of environments) { - const projectId = environment.projectIds[0]; - const project = SHOWCASE_PROJECTS.find((candidate) => candidate.id === projectId); - if (!project) throw new Error(`Showcase environment '${environment.id}' has no project.`); - - const baseDir = NodePath.join(rootDir, "environments", environment.id); - const workspaceRoot = NodePath.join(baseDir, "workspace", project.directory); - const port = await reserveAvailablePort(); - await NodeFSP.mkdir(workspaceRoot, { recursive: true }); - const shellPath = await createShowcaseShell(baseDir); - const labelProbeDirectory = await createShowcaseLabelProbe(baseDir, environment.label); - const server = startShowcaseServer( - baseDir, - workspaceRoot, - port, - shellPath, - labelProbeDirectory, - ); - track(server); - await waitForPort(port, `${environment.label} server`); - await seedShowcaseEnvironment({ baseDir, projectIds: environment.projectIds }); - // Legacy fixture threads are imported into V2 during server startup. - await stopProcess(server); - const seededServer = startShowcaseServer( - baseDir, - workspaceRoot, - port, - shellPath, - labelProbeDirectory, - ); - track(seededServer); - await waitForPort(port, `${environment.label} seeded server`); - // The server begins listening before the ServerEnvironment layer - // persists the environment id, so poll rather than read once. - const environmentId = await waitForFileContent( - NodePath.join(baseDir, "userdata", "environment-id"), - `${environment.label} environment id`, - ); - started.push({ baseDir, environmentId, label: environment.label, port }); - } - return started; -} - export function parsePairingCredentialOutput(output: string): string { const jsonStart = output.indexOf("{"); const jsonEnd = output.lastIndexOf("}"); @@ -711,7 +650,7 @@ export function parsePairingCredentialOutput(output: string): string { return parsed.credential; } -export async function issuePairingCredential(baseDir: string): Promise { +async function issuePairingCredential(baseDir: string): Promise { const output = await commandOutput( "node", ["apps/server/src/bin.ts", "auth", "pairing", "create", "--base-dir", baseDir, "--json"], @@ -720,7 +659,7 @@ export async function issuePairingCredential(baseDir: string): Promise { return parsePairingCredentialOutput(output); } -export function buildShowcasePairingUrl(host: string, port: number, credential: string): string { +function buildShowcasePairingUrl(host: string, port: number, credential: string): string { const url = new URL(`http://${host}:${port}/`); url.hash = new URLSearchParams([["token", credential]]).toString(); return url.toString(); @@ -1554,17 +1493,57 @@ async function main(): Promise { NodePath.join(NodeOS.tmpdir(), "t3-mobile-showcase-"), ); const showcaseServers: NodeChildProcess.ChildProcess[] = []; - const showcaseEnvironments: ShowcaseEnvironment[] = []; + const showcaseEnvironments: Array<{ + readonly baseDir: string; + readonly environmentId: string; + readonly label: string; + readonly port: number; + }> = []; let metro: NodeChildProcess.ChildProcess | null = null; const iosCleanups: IosCaptureCleanup[] = []; const androidCleanups: AndroidCaptureCleanup[] = []; try { - showcaseEnvironments.push( - ...(await startShowcaseEnvironments(showcaseRootDir, (server) => - showcaseServers.push(server), - )), - ); + for (const environment of SHOWCASE_ENVIRONMENTS) { + const projectId = environment.projectIds[0]; + const project = SHOWCASE_PROJECTS.find((candidate) => candidate.id === projectId); + if (!project) throw new Error(`Showcase environment '${environment.id}' has no project.`); + + const baseDir = NodePath.join(showcaseRootDir, "environments", environment.id); + const workspaceRoot = NodePath.join(baseDir, "workspace", project.directory); + const port = await reserveAvailablePort(); + await NodeFSP.mkdir(workspaceRoot, { recursive: true }); + const shellPath = await createShowcaseShell(baseDir); + const labelProbeDirectory = await createShowcaseLabelProbe(baseDir, environment.label); + const server = startShowcaseServer( + baseDir, + workspaceRoot, + port, + shellPath, + labelProbeDirectory, + ); + showcaseServers.push(server); + await waitForPort(port, `${environment.label} server`); + await seedShowcaseEnvironment({ baseDir, projectIds: environment.projectIds }); + // Legacy fixture threads are imported into V2 during server startup. + await stopProcess(server); + const seededServer = startShowcaseServer( + baseDir, + workspaceRoot, + port, + shellPath, + labelProbeDirectory, + ); + showcaseServers.push(seededServer); + await waitForPort(port, `${environment.label} seeded server`); + // The server begins listening before the ServerEnvironment layer + // persists the environment id, so poll rather than read once. + const environmentId = await waitForFileContent( + NodePath.join(baseDir, "userdata", "environment-id"), + `${environment.label} environment id`, + ); + showcaseEnvironments.push({ baseDir, environmentId, label: environment.label, port }); + } if (!options.skipMetro) { metro = startMetro(showcaseConfig); diff --git a/scripts/package.json b/scripts/package.json index 5d1ab111b584b..a4aa8c1496267 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -12,7 +12,6 @@ "@electron/osx-sign": "2.7.0", "@t3tools/shared": "workspace:*", "@t3tools/tailscale": "workspace:*", - "agent-device": "0.21.25", "effect": "catalog:", "plist": "catalog:", "pngjs": "7.0.0", diff --git a/vite.config.ts b/vite.config.ts index 875cb2cbe3940..d17d7ad57f1e1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -585,6 +585,10 @@ export default defineConfig({ files: ["apps/mobile/src/**/*.{ts,tsx}"], rules: { "t3code/no-manual-memo": ["error", { allowMemoHooks: true }] }, }, + { + files: ["apps/web/src/**", "apps/mobile/src/**", "packages/*/src/**"], + rules: { "t3code/react-effect-budget": "error" }, + }, { // React Compiler caches use* functions only if they call a hook. files: ["apps/web/src/**/*.{ts,tsx}", "apps/mobile/src/**/*.{ts,tsx}"],