Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/mobile/src/connection/environment-cache-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,10 +498,11 @@ describe("mobile SQLite environment cache store", () => {
Effect.provideService(MobileDatabase.MobileDatabase, memory.database),
);

yield* store.saveShell(ENVIRONMENT_ID, SHELL_SNAPSHOT);
yield* store.saveShell(ENVIRONMENT_ID, SHELL_SNAPSHOT, "1.0.0");
yield* store.saveThread(ENVIRONMENT_ID, THREAD_SNAPSHOT);

const shell = Option.getOrThrow(yield* store.loadShell(ENVIRONMENT_ID));
expect(shell.verifiedServerVersion).toBe("1.0.0");
const thread = Option.getOrThrow(yield* store.loadThread(ENVIRONMENT_ID, THREAD_ID));

expect(DateTime.formatIso(shell.threads[0]!.updatedAt)).toBe("2026-07-29T12:00:00.000Z");
Expand Down
37 changes: 25 additions & 12 deletions apps/mobile/src/connection/environment-cache-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
encodeStoredThreadSnapshot,
Persistence,
makeStoredShellSnapshotEncoder,
withVerifiedServerVersion,
} from "@t3tools/client-runtime/platform";
import { type EnvironmentId, ServerConfig, VcsListRefsResult } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -96,7 +97,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
const database = yield* MobileDatabase.MobileDatabase;
attachProjectFaviconDatabase(database);
const encodeStoredShellSnapshot = makeStoredShellSnapshotEncoder();
return Persistence.EnvironmentCacheStore.of({
const store = Persistence.EnvironmentCacheStore.of({
loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) =>
loadDecodedCache({
database,
Expand All @@ -106,7 +107,9 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
operation: "load-shell",
decode: decodeStoredShellSnapshot,
select: (stored) =>
stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(),
stored.environmentId === environmentId
? Option.some(withVerifiedServerVersion(stored))
: Option.none(),
}).pipe(
Effect.zipWith(
Effect.promise(() => projectFaviconDatabaseCache.hydrate()),
Expand All @@ -115,16 +118,25 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
),
),
),
saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) {
const payload = yield* encodeStoredShellSnapshot({
schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
environmentId,
snapshot,
}).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause)));
yield* database
.saveCache(environmentId, "shell", "snapshot", ORCHESTRATION_CACHE_SCHEMA_VERSION, payload)
.pipe(Effect.mapError(mapDatabaseError("save-shell")));
}),
saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(
function* (environmentId, snapshot, verifiedServerVersion) {
const payload = yield* encodeStoredShellSnapshot({
schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
environmentId,
snapshot,
...(verifiedServerVersion === undefined ? {} : { verifiedServerVersion }),
}).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause)));
yield* database
.saveCache(
environmentId,
"shell",
"snapshot",
ORCHESTRATION_CACHE_SCHEMA_VERSION,
payload,
)
.pipe(Effect.mapError(mapDatabaseError("save-shell")));
},
),
loadThread: Effect.fn("MobileEnvironmentCache.loadThread")((environmentId, threadId) =>
loadDecodedCache({
database,
Expand Down Expand Up @@ -229,6 +241,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
),
),
});
return Persistence.shareServerConfigReads(store);
});

export const layer = Layer.effect(Persistence.EnvironmentCacheStore, make());
48 changes: 48 additions & 0 deletions apps/mobile/src/features/observability/devSpanLog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { expect, vi } from "vite-plus/test";
import { it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Tracer from "effect/Tracer";
import { RelayClientTracer, withRelayClientTracing } from "@t3tools/shared/relayTracing";

it.effect("logs relay spans while preserving their exporter and attributes", () =>
Effect.gen(function* () {
vi.stubGlobal("__DEV__", true);
vi.stubEnv("EXPO_PUBLIC_T3CODE_LOG_SPANS", "1");
using log = vi.spyOn(console, "log").mockImplementation(() => {});
const exported: Array<string> = [];
const tracer = Tracer.make({
span: (options) => {
const span = new Tracer.NativeSpan(options);
const end = span.end.bind(span);
span.end = (time, exit) => {
end(time, exit);
exported.push(span.name);
};
return span;
},
});
try {
const DevSpanLog = yield* Effect.promise(() => import("./devSpanLog"));
yield* Effect.gen(function* () {
const span = yield* Effect.makeSpan("relay.test", { attributes: { route: "relay" } });
span.end(span.status.startTime + 2_000_000n, Exit.succeed(undefined));
}).pipe(
withRelayClientTracing,
Effect.provide(
DevSpanLog.layer.pipe(
Layer.provideMerge(Layer.succeed(RelayClientTracer, Option.some(tracer))),
),
),
);
expect(exported).toEqual(["relay.test"]);
expect(log).toHaveBeenCalledWith(expect.stringContaining('"name":"relay.test"'));
expect(log).toHaveBeenCalledWith(expect.stringContaining('"attributes":{"route":"relay"}'));
} finally {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
}
}),
);
48 changes: 48 additions & 0 deletions apps/mobile/src/features/observability/devSpanLog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { RelayClientTracer } from "@t3tools/shared/relayTracing";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Tracer from "effect/Tracer";

// Simulators share the host clock, so these lines line up with the server's trace file.
const MIN_LOGGED_SPAN_NANOS = 1_000_000n;

function loggedTracer(delegate: Tracer.Tracer): Tracer.Tracer {
return Tracer.make({
...delegate,
span(options) {
const span = delegate.span(options);
const end = span.end.bind(span);
span.end = (endTime, exit) => {
end(endTime, exit);
const startTime = span.status.startTime;
if (endTime - startTime < MIN_LOGGED_SPAN_NANOS) return;
console.log(
`[t3-span] ${JSON.stringify({
name: span.name,
startMs: Number(startTime / 1_000_000n),
endMs: Number(endTime / 1_000_000n),
ok: exit._tag === "Success",
attributes: Object.fromEntries(span.attributes),
})}`,
);
};
return span;
},
});
}

/** Dev builds started with `EXPO_PUBLIC_T3CODE_LOG_SPANS=1` print every span of 1 ms or more. */
export const layer =
__DEV__ && process.env.EXPO_PUBLIC_T3CODE_LOG_SPANS === "1"
? Layer.effectContext(
Effect.gen(function* () {
const local = yield* Tracer.Tracer;
const relay = yield* RelayClientTracer;
return Context.make(Tracer.Tracer, loggedTracer(local)).pipe(
Context.add(RelayClientTracer, Option.map(relay, loggedTracer)),
);
}),
)
: Layer.empty;
5 changes: 4 additions & 1 deletion apps/mobile/src/lib/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { layerRemoteHttpClient } from "@t3tools/client-runtime/rpc";
import * as Dpop from "../features/cloud/dpop";
import * as ManagedRelayLayer from "../features/cloud/managedRelayLayer";
import { resolveCloudPublicConfig } from "../features/cloud/publicConfig";
import * as DevSpanLog from "../features/observability/devSpanLog";
import * as Tracing from "../features/observability/tracing";
import * as Persistence from "../persistence/layer";
import { disposeOnFoundationReplace, type FoundationHotModule } from "./foundation-fast-refresh";
Expand All @@ -33,9 +34,11 @@ const layerRuntime = Layer.merge(
ManagedRelayLayer.layer(configuredRelayUrl()),
Socket.layerWebSocketConstructorGlobal,
).pipe(
Layer.provideMerge(
DevSpanLog.layer.pipe(Layer.provideMerge(Tracing.layer.pipe(Layer.provide(layerHttpClient)))),
),
Layer.provideMerge(Dpop.layer),
Layer.provideMerge(layerHttpClient),
Layer.provideMerge(Tracing.layer.pipe(Layer.provide(layerHttpClient))),
Layer.provideMerge(Persistence.layer),
// These layers read the keychain and the database while they build, and a
// failed build is kept for the life of the process.
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/persistence/mobile-secure-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const make = MobileSecureStorage.of({
Effect.tryPromise({
try: () => SecureStore.getItemAsync(key),
catch: (cause) => new MobileSecureStorageError({ operation: "read", key, cause }),
}),
}).pipe(Effect.tap(() => Effect.annotateCurrentSpan("secure_storage.key", key))),
),
setItem: Effect.fn("MobileSecureStorage.setItem")((key, value) =>
Effect.tryPromise({
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/persistence/mobile-storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ describe("mobile device identity", () => {
const storage = yield* makeStorage;

expect(yield* storage.loadOrCreateAgentAwarenessDeviceId).toBe("existing-device");
// Every connect asks again; only the first may pay for a keychain read.
expect(yield* storage.loadOrCreateAgentAwarenessDeviceId).toBe("existing-device");
expect(yield* storage.loadAgentAwarenessDeviceId).toBe("existing-device");
expect(getItemAsync).toHaveBeenCalledTimes(1);
expect(uuidv4).not.toHaveBeenCalled();
expect(setItemAsync).not.toHaveBeenCalled();
}),
Expand Down
18 changes: 13 additions & 5 deletions apps/mobile/src/persistence/mobile-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,20 +125,28 @@ export const make = Effect.fn("MobileStorage.make")(function* () {
yield* secureStorage.setItem(key, encoded);
});

// The id never changes once written, and every connect reads it; a keychain read can take
// hundreds of milliseconds while the app starts.
let knownDeviceId: string | null = null;
const loadOrCreateAgentAwarenessDeviceId = Effect.gen(function* () {
if (knownDeviceId !== null) return knownDeviceId;
const existing = yield* secureStorage.getItem(AGENT_AWARENESS_DEVICE_ID_KEY);
if (existing?.trim()) return existing;
if (existing?.trim()) return (knownDeviceId = existing);
const deviceId = yield* Effect.tryPromise({
try: () => import("../lib/uuid").then(({ uuidv4 }) => uuidv4()),
catch: (cause) => new MobileDeviceIdGenerationError({ cause }),
});
yield* secureStorage.setItem(AGENT_AWARENESS_DEVICE_ID_KEY, deviceId);
return deviceId;
return (knownDeviceId = deviceId);
}).pipe(deviceIdLock.withPermits(1));

const loadAgentAwarenessDeviceId = secureStorage
.getItem(AGENT_AWARENESS_DEVICE_ID_KEY)
.pipe(Effect.map((existing) => (existing?.trim() ? existing : null)));
const loadAgentAwarenessDeviceId = Effect.suspend(() =>
knownDeviceId !== null
? Effect.succeed(knownDeviceId)
: secureStorage
.getItem(AGENT_AWARENESS_DEVICE_ID_KEY)
.pipe(Effect.map((existing) => (existing?.trim() ? (knownDeviceId = existing) : null))),
);

const loadAgentAwarenessRegistrationRecord = readJson<AgentAwarenessRegistrationRecord>(
AGENT_AWARENESS_REGISTRATION_KEY,
Expand Down
50 changes: 29 additions & 21 deletions apps/server/src/environment/DirectEndpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@ import {
isWildcardHost,
VIRTUAL_INTERFACE,
} from "../startupAccess.ts";
import { makeBackgroundRefreshed } from "./backgroundRefresh.ts";

type NetworkInterfacesMap = ReturnType<typeof NodeOS.networkInterfaces>;

// Stay below the config discovery timeout so slow Tailscale drops only the tailnet name.
const TAILSCALE_ENDPOINT_TIMEOUT = Duration.seconds(3);
const SERVED_URL_MAX_AGE = Duration.seconds(30);

export class DirectEndpoints extends Context.Service<
DirectEndpoints,
Expand Down Expand Up @@ -119,6 +121,30 @@ export const make = Effect.gen(function* () {
: Effect.succeed(false);
yield* Effect.forkDetach(onCloudVm);

// Serve setup can fail without stopping startup; advertise only after it answers as this server.
const servedUrl = yield* makeBackgroundRefreshed(
readTailscaleStatus.pipe(
Effect.map((status) => status.magicDnsName),
Effect.flatMap((magicDnsName) => {
if (magicDnsName === null) return Effect.succeed(null);
const httpBaseUrl = buildTailscaleHttpsBaseUrl({
magicDnsName,
servePort: config.tailscaleServePort,
});
return probeTailscaleHttpsEndpoint({ baseUrl: httpBaseUrl }).pipe(
Effect.map((reachable) => (reachable ? httpBaseUrl : null)),
);
}),
Effect.timeoutOption(TAILSCALE_ENDPOINT_TIMEOUT),
Effect.map(Option.flatMap(Option.fromNullishOr)),
Effect.orElseSucceed(() => Option.none<string>()),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.provideService(HttpClient.HttpClient, httpClient),
),
SERVED_URL_MAX_AGE,
{ warm: config.tailscaleServeEnabled },
);

const resolve = Effect.gen(function* () {
const endpoints = [
...resolveBoundEndpoints({
Expand All @@ -128,28 +154,10 @@ export const make = Effect.gen(function* () {
onCloudVm: yield* onCloudVm,
}),
];
// Serve setup can fail without stopping startup; advertise only after it answers as this server.
if (config.tailscaleServeEnabled) {
const servedUrl = yield* readTailscaleStatus.pipe(
Effect.map((status) => status.magicDnsName),
Effect.flatMap((magicDnsName) => {
if (magicDnsName === null) return Effect.succeed(null);
const httpBaseUrl = buildTailscaleHttpsBaseUrl({
magicDnsName,
servePort: config.tailscaleServePort,
});
return probeTailscaleHttpsEndpoint({ baseUrl: httpBaseUrl }).pipe(
Effect.map((reachable) => (reachable ? httpBaseUrl : null)),
);
}),
Effect.timeoutOption(TAILSCALE_ENDPOINT_TIMEOUT),
Effect.map(Option.flatMap(Option.fromNullishOr)),
Effect.orElseSucceed(() => Option.none<string>()),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.provideService(HttpClient.HttpClient, httpClient),
);
if (Option.isSome(servedUrl)) {
endpoints.push({ kind: "tailnet", httpBaseUrl: servedUrl.value });
const served = yield* servedUrl;
if (Option.isSome(served)) {
endpoints.push({ kind: "tailnet", httpBaseUrl: served.value });
}
}
return endpoints;
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/environment/RemoteOpenTargets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,16 @@ const resolveTargets = (input: {
readonly hostname: string;
readonly username?: string | null;
}) => {
// The service warms its cache while the layer builds, so host facts must reach construction.
const resolve = Effect.flatMap(RemoteOpenTargets.RemoteOpenTargets, (service) =>
service.resolveTargets(),
).pipe(
Effect.provideService(HostProcess.Hostname, input.hostname),
Effect.provide(
RemoteOpenTargets.layer.pipe(
Layer.provide(Layer.mergeAll(layerNet(input.sshd), layerSpawner(input.tailscale))),
),
),
Effect.provideService(HostProcess.Hostname, input.hostname),
);

return input.username === undefined
Expand Down
9 changes: 8 additions & 1 deletion apps/server/src/environment/RemoteOpenTargets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ import * as HostProcess from "@t3tools/shared/HostProcess";
import * as NetService from "@t3tools/shared/Net";
import { readTailscaleSshEnabled, readTailscaleStatus } from "@t3tools/tailscale";
import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";

import { makeBackgroundRefreshed } from "./backgroundRefresh.ts";

const SSH_PORT = 22;
const TARGETS_MAX_AGE = Duration.seconds(30);

export class RemoteOpenTargets extends Context.Service<
RemoteOpenTargets,
Expand Down Expand Up @@ -77,10 +81,13 @@ export const make = Effect.gen(function* () {

return targets;
});
const cachedTargets = yield* makeBackgroundRefreshed(resolveTargets, TARGETS_MAX_AGE, {
warm: true,
});

return RemoteOpenTargets.of({
resolveTargets: Effect.fn("RemoteOpenTargets.resolveTargets")(function () {
return resolveTargets;
return cachedTargets;
}),
});
});
Expand Down
Loading
Loading