diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 1e116a91fea73dffbbf8e0f9ce63cb24818f572e..5e3b4c6e4afe32903a74b077c17a0ebb0f90e27f 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -420,6 +420,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, + requestCatchUp: () => Effect.void, start: () => Effect.void, }), ), diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 64a512a33abaec933fd48177c7af2dd3249e38b8..1d6b78e56cefc44037ad1564be85efa4df66c253 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -33,6 +33,7 @@ import { type ServiceUpdateRecord, } from "./serviceProtocol.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts"; import { CLOUD_CLI_DESIRED_LINK_SECRET } from "./CliState.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; import { @@ -81,6 +82,12 @@ const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => }); const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation"); +// Linking wakes the awareness relay; these tests do not run it. +const idleAwarenessRelay = AgentAwarenessRelay.AgentAwarenessRelay.of({ + publishThread: () => Effect.void, + requestCatchUp: () => Effect.void, + start: () => Effect.void, +}); const decodeManagedTunnelRecoveryRegistration = Schema.decodeUnknownEffect( Schema.fromJsonString(RelayManagedEndpointRecoveryRegistrationRequest), ); @@ -262,6 +269,7 @@ describe("reconcileDesiredCloudLink", () => { HttpClient.HttpClient, HttpClient.make(() => unusedSecretStoreOperation()), ), + Effect.provideService(AgentAwarenessRelay.AgentAwarenessRelay, idleAwarenessRelay), Effect.provide(NodeServices.layer), ), ); @@ -375,6 +383,7 @@ describe("releaseManagedTunnelOnShutdown", () => { (effect: Effect.Effect) => effect.pipe( Effect.provideService(ServerSecretStore.ServerSecretStore, harness.store), + Effect.provideService(AgentAwarenessRelay.AgentAwarenessRelay, idleAwarenessRelay), Effect.provideService( ServerEnvironment.ServerEnvironment, ServerEnvironment.ServerEnvironment.of({ diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 8e2d0e3614582e77da9d74e6e957aef05941afcb..a7e3de8c72831b2288f5a31cec5394bfd0fc38ea 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -69,6 +69,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { requireEnvironmentScope } from "../auth/http.ts"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import * as AgentAwarenessRelay from "../relay/AgentAwarenessRelay.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import { SERVICE_STATE_FILE, @@ -411,6 +412,7 @@ interface CloudHttpDependencies { readonly environmentAuth: EnvironmentAuth.EnvironmentAuth["Service"]; readonly cliTokenManager: CliTokenManager.CloudCliTokenManager["Service"]; readonly httpClient: HttpClient.HttpClient; + readonly awarenessRelay: AgentAwarenessRelay.AgentAwarenessRelay["Service"]; } const cloudHttpDependencies = Effect.gen(function* () { @@ -421,6 +423,7 @@ const cloudHttpDependencies = Effect.gen(function* () { environmentAuth: yield* EnvironmentAuth.EnvironmentAuth, cliTokenManager: yield* CliTokenManager.CloudCliTokenManager, httpClient: yield* HttpClient.HttpClient, + awarenessRelay: yield* AgentAwarenessRelay.AgentAwarenessRelay, } satisfies CloudHttpDependencies; }); @@ -668,6 +671,7 @@ const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(fu CLOUD_MINT_PUBLIC_KEY, stringToBytes(payload.cloudMintPublicKey), ); + yield* dependencies.awarenessRelay.requestCatchUp(); if (payload.endpointRuntime) { const endpointRuntimeJson = yield* encodeEndpointRuntimeConfigJson(payload.endpointRuntime); yield* dependencies.secrets.set( @@ -1349,6 +1353,7 @@ const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")( PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes(String(payload.publishAgentActivity)), ); + yield* dependencies.awarenessRelay.requestCatchUp(); return yield* readCloudLinkState(dependencies); }, Effect.catchIf( diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index d25c442ac8b23bac7c37a2f838693f7e6b0803b2..207ad9a20d3478dd6ae17746330fe62d7c552844 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -108,6 +108,7 @@ describe("OrchestrationReactor", () => { Layer.provideMerge( Layer.succeed(AgentAwarenessRelay.AgentAwarenessRelay, { publishThread: () => Effect.void, + requestCatchUp: () => Effect.void, start: () => { started.push("agent-awareness-relay"); return Effect.void; diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts index 80588f58858fcdf54e7d09cbe1f36b8dd226b9ff..1ec7adcd623ba8999120bce640aa213df73497b8 100644 --- a/apps/server/src/persistence/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -102,11 +102,14 @@ export class ProviderSessionRuntimeRepository extends Context.Service< >; /** - * List all provider runtime rows. + * List provider runtime rows. * - * Returned in ascending last-seen order. + * Returned in ascending last-seen order. `excludeStopped` filters stopped + * rows in SQL. Long-lived installs keep thousands for their resume cursors. */ - readonly list: () => Effect.Effect< + readonly list: (options?: { + readonly excludeStopped?: boolean; + }) => Effect.Effect< ReadonlyArray, ProviderSessionRuntimeRepositoryError >; @@ -336,9 +339,9 @@ export const make = Effect.gen(function* () { }); const listRuntimeRows = SqlSchema.findAll({ - Request: Schema.Void, + Request: Schema.Struct({ excludeStopped: Schema.Boolean }), Result: ProviderSessionRuntimeRawDbRowSchema, - execute: () => + execute: ({ excludeStopped }) => sql` SELECT thread_id AS "threadId", @@ -351,6 +354,7 @@ export const make = Effect.gen(function* () { resume_cursor_json AS "resumeCursor", runtime_payload_json AS "runtimePayload" FROM provider_session_runtime + ${excludeStopped ? sql`WHERE status != 'stopped'` : sql``} ORDER BY last_seen_at ASC, thread_id ASC `, }); @@ -414,8 +418,8 @@ export const make = Effect.gen(function* () { ), ); - const list: ProviderSessionRuntimeRepository["Service"]["list"] = () => - listRuntimeRows(undefined).pipe( + const list: ProviderSessionRuntimeRepository["Service"]["list"] = (options) => + listRuntimeRows({ excludeStopped: options?.excludeStopped === true }).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProviderSessionRuntimeRepository.list:query", diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 8b41bd3e518c0a9f3f5546ca24e3b32c38ca8d6b..45615fe7b440a04b6c8bc49c6df267fc36410a07 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -372,6 +372,41 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }), ); + it.effect("lists only bindings that are not stopped when asked", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const statuses = ["running", "starting", "error", "stopped"] as const; + const threadIds = new Set(); + + for (const status of statuses) { + const threadId = ThreadId.make(`thread-exclude-stopped-${status}`); + threadIds.add(threadId); + yield* runtimeRepository.upsert({ + threadId, + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + adapterKey: "codex", + runtimeMode: "full-access", + status, + lastSeenAt: "2026-04-14T12:00:00.000Z", + resumeCursor: null, + runtimePayload: null, + }); + } + + const liveStatuses = (yield* directory.listBindings({ excludeStopped: true })) + .filter((binding) => threadIds.has(binding.threadId)) + .map((binding) => binding.status); + const allStatuses = (yield* directory.listBindings()) + .filter((binding) => threadIds.has(binding.threadId)) + .map((binding) => binding.status); + + assert.deepEqual(liveStatuses.toSorted(), ["error", "running", "starting"]); + assert.deepEqual(allStatuses.toSorted(), ["error", "running", "starting", "stopped"]); + }), + ); + it.effect( "resets adapterKey to the new provider when provider changes without an explicit adapter key", () => diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 03f1ece0b5e080ccf5b7f49271e8dfef2f42f091..0e9de8fb868f0b3d66baaa8e8eebc768337d2c70 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -182,8 +182,8 @@ const makeProviderSessionDirectory = Effect.gen(function* () { Effect.map((rows) => rows.map((row) => row.threadId)), ); - const listBindings: ProviderSessionDirectoryShape["listBindings"] = () => - repository.list().pipe( + const listBindings: ProviderSessionDirectoryShape["listBindings"] = (options) => + repository.list(options).pipe( Effect.mapError(toPersistenceError("ProviderSessionDirectory.listBindings:list")), Effect.flatMap((rows) => Effect.forEach( diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.ts index 8f04bd1821fd9aca05256c627384300cdd5097b6..bf8199f80eaccde832a9e41b4b38b9abbc70149c 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.ts @@ -35,15 +35,13 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) = const sweepIntervalMs = Math.max(1, options?.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS); const sweep = Effect.gen(function* () { - const bindings = yield* directory.listBindings(); + // Stopped rows stay for their resume cursors and far outnumber live + // ones, so the query skips them. + const bindings = yield* directory.listBindings({ excludeStopped: true }); const now = yield* Clock.currentTimeMillis; let reapedCount = 0; for (const binding of bindings) { - if (binding.status === "stopped") { - continue; - } - const lastSeenMs = Date.parse(binding.lastSeenAt); if (Number.isNaN(lastSeenMs)) { yield* Effect.logWarning("provider.session.reaper.invalid-last-seen", { @@ -122,7 +120,7 @@ const makeProviderSessionReaper = (options?: ProviderSessionReaperLiveOptions) = if (reapedCount > 0) { yield* Effect.logInfo("provider.session.reaper.sweep-complete", { reapedCount, - totalBindings: bindings.length, + liveBindings: bindings.length, }); } }); diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index 9dbafd3e804ee2a89d1f921f298effb278ea307c..1b5c47ec1d621e888903b74f60d8654306b85701 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -70,7 +70,10 @@ export interface ProviderSessionDirectoryShape { ProviderSessionDirectoryPersistenceError >; - readonly listBindings: () => Effect.Effect< + /** `excludeStopped` skips stopped rows in the query, not after decoding. */ + readonly listBindings: (options?: { + readonly excludeStopped?: boolean; + }) => Effect.Effect< ReadonlyArray, ProviderSessionDirectoryPersistenceError >; diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index db4f1f53b7106c4246eb74a1b2d68e1c48ef6314..71ec0719325dcccdee90f9e16168add4759d2275 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -28,6 +28,7 @@ import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as Tracer from "effect/Tracer"; +import * as TestClock from "effect/testing/TestClock"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; @@ -975,3 +976,114 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { ), ); }); + +describe.sequential("startup catch-up", () => { + // An unlinked relay with publishing off. `link` writes the link secrets and + // `enablePublishing` the opt-in. Counts link checks (relay URL reads) and + // catch-up publishes (shell snapshot reads). + function makeUnlinkedRelay() { + const secrets = makeMemorySecretStore(); + const counts = { linkChecks: 0, catchUpPublishes: 0 }; + const countingStore = { + ...secrets.store, + get: (name: string) => + Effect.suspend(() => { + if (name === RELAY_URL_SECRET) counts.linkChecks += 1; + return secrets.store.get(name); + }), + } satisfies ServerSecretStore.ServerSecretStore["Service"]; + + const layer = AgentAwarenessRelay.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ServerSecretStore.ServerSecretStore, countingStore), + Layer.succeed(ServerEnvironment.ServerEnvironment, { + getEnvironmentId: Effect.succeed("env-1" as EnvironmentId), + getDescriptor: Effect.die("unused descriptor"), + }), + Layer.succeed(OrchestrationEngineService, { + streamDomainEvents: Stream.never, + } as unknown as OrchestrationEngineShape), + Layer.succeed(ProjectionSnapshotQuery, { + getShellSnapshot: () => + Effect.sync(() => { + counts.catchUpPublishes += 1; + return { + snapshotSequence: 1, + projects: [], + threads: [], + updatedAt: "2026-05-25T00:00:00.000Z", + } satisfies OrchestrationShellSnapshot; + }), + } as unknown as ProjectionSnapshotQueryShape), + ), + ), + Layer.provideMerge(NodeServices.layer), + ); + const link = Effect.all( + [ + secrets.setString(RELAY_URL_SECRET, "https://relay.example.test"), + secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential"), + ], + { discard: true }, + ); + const enablePublishing = secrets.setString(PUBLISH_AGENT_ACTIVITY_SECRET, "true"); + return { counts, layer, link, enablePublishing }; + } + + it.effect("checks an unlinked environment once a minute and still catches up once linked", () => { + const { counts, layer, link, enablePublishing } = makeUnlinkedRelay(); + return Effect.gen(function* () { + const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; + yield* enablePublishing; + yield* relay.start(); + + // Get past the backoff ramp, then count checks in a steady window. + yield* TestClock.adjust("10 minutes"); + const checksBeforeWindow = counts.linkChecks; + yield* TestClock.adjust("10 minutes"); + expect(counts.linkChecks - checksBeforeWindow).toBe(10); + expect(counts.catchUpPublishes).toBe(0); + + yield* link; + yield* TestClock.adjust("1 minute"); + expect(counts.catchUpPublishes).toBe(1); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + + it.effect("publishes at once when this process links while the check is backed off", () => { + const { counts, layer, link, enablePublishing } = makeUnlinkedRelay(); + return Effect.gen(function* () { + const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; + yield* enablePublishing; + yield* relay.start(); + + // Backed off to 60 s: the next check is still seconds away. + yield* TestClock.adjust("10 minutes"); + yield* link; + yield* TestClock.adjust("1 second"); + expect(counts.catchUpPublishes).toBe(0); + + yield* relay.requestCatchUp(); + yield* TestClock.adjust("1 second"); + expect(counts.catchUpPublishes).toBe(1); + }).pipe(Effect.provide(layer), Effect.scoped); + }); + + it.effect("catches up within 5 s when another process enables publishing on a link", () => { + const { counts, layer, link, enablePublishing } = makeUnlinkedRelay(); + return Effect.gen(function* () { + const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; + yield* link; + yield* relay.start(); + + yield* TestClock.adjust("10 minutes"); + expect(counts.catchUpPublishes).toBe(0); + + // `t3 connect publish` writes the opt-in without waking this process. + yield* enablePublishing; + yield* TestClock.adjust("5 seconds"); + expect(counts.catchUpPublishes).toBe(1); + }).pipe(Effect.provide(layer), Effect.scoped); + }); +}); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 29d2abecebb7787344b1adc79284ec6e22f13c55..c9acf6b510fceab9a00260de26e6adca7e19582a 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -25,6 +25,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -51,6 +52,8 @@ export class AgentAwarenessRelay extends Context.Service< AgentAwarenessRelay, { readonly publishThread: (threadId: ThreadId) => Effect.Effect; + /** Retries a pending catch-up publish now. Call after this process links or enables publishing. */ + readonly requestCatchUp: () => Effect.Effect; readonly start: () => Effect.Effect; } >()("t3/relay/AgentAwarenessRelay") {} @@ -309,6 +312,8 @@ export const make = Effect.gen(function* () { const cloudLinkKeyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); const startedAt = (yield* DateTime.now).epochMilliseconds; const activeSnapshotPublishedRef = yield* Ref.make(false); + // Holds at most one pending wake, so a burst of requests costs one retry. + const catchUpRequests = yield* Queue.dropping(1); const publishedStateByThreadRef = yield* Ref.make(new Map()); const readSecretString = (name: string) => @@ -534,18 +539,29 @@ export const make = Effect.gen(function* () { withRelayClientTracing, ); + // Publishes the active threads once. Returns why it did not, so the retry + // knows whether it is waiting on a link or on the publish setting. const publishActiveThreadsUnsafe = Effect.gen(function* () { + // One secret read settles the common never-linked case; the full link + // config is read only once publishing is on. + const relayUrl = yield* readSecretString(RELAY_URL_SECRET).pipe( + Effect.orElseSucceed(() => null), + ); + if (!relayUrl) { + yield* Effect.logDebug("agent activity snapshot skipped; relay link credentials unavailable"); + return "unlinked" as const; + } const publishAgentActivity = yield* readPublishAgentActivityEnabled.pipe( Effect.orElseSucceed(() => false), ); if (!publishAgentActivity) { yield* Effect.logDebug("agent activity snapshot skipped; publication disabled"); - return false; + return "disabled" as const; } const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); if (!relayConfig) { yield* Effect.logDebug("agent activity snapshot skipped; relay link credentials unavailable"); - return false; + return "unlinked" as const; } const environmentId = yield* serverEnvironment.getEnvironmentId; const snapshot = yield* snapshotQuery.getShellSnapshot(); @@ -557,20 +573,29 @@ export const make = Effect.gen(function* () { }); if (activeThreadIds.length === 0) { yield* Effect.logDebug("agent activity snapshot has no publishable threads"); - return true; + return "published" as const; } yield* Effect.logInfo("publishing active agent activity snapshot", { count: activeThreadIds.length, }); yield* Effect.forEach(activeThreadIds, publishThread, { concurrency: 4, discard: true }); - return true; + return "published" as const; }); + // Publishes the catch-up snapshot of active threads once the environment is + // linked and publishing is enabled. Many environments never link, so while + // unlinked the retry backs off from 5 s to 60 s. Only this process writes + // the link, and it calls `requestCatchUp`, which ends the wait early. A + // linked environment keeps the 5 s retry, because `t3 connect publish` can + // turn publishing on from another process. const publishActiveThreadsOnceWhenConfigured = (logEnabledWhenReady: boolean) => Effect.gen(function* () { + let unlinkedRetryDelayMs = 5_000; while (!(yield* Ref.get(activeSnapshotPublishedRef))) { - const published = yield* publishActiveThreadsUnsafe.pipe(Effect.orElseSucceed(() => false)); - if (published) { + const result = yield* publishActiveThreadsUnsafe.pipe( + Effect.orElseSucceed(() => "failed" as const), + ); + if (result === "published") { yield* Ref.set(activeSnapshotPublishedRef, true); if (logEnabledWhenReady) { const relayConfig = yield* readRelayConfig.pipe(Effect.orElseSucceed(() => null)); @@ -580,7 +605,11 @@ export const make = Effect.gen(function* () { } return; } - yield* Effect.sleep("5 seconds"); + const retryDelayMs = result === "unlinked" ? unlinkedRetryDelayMs : 5_000; + yield* Effect.race(Effect.sleep(retryDelayMs), Queue.take(catchUpRequests)); + if (result === "unlinked") { + unlinkedRetryDelayMs = Math.min(unlinkedRetryDelayMs * 2, 60_000); + } } }); @@ -657,6 +686,7 @@ export const make = Effect.gen(function* () { return AgentAwarenessRelay.of({ publishThread, + requestCatchUp: () => Queue.offer(catchUpRequests, undefined).pipe(Effect.asVoid), start, }); }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 192cdd3191f0efc4118017abdf5338bdecfa3719..43595dbafce181d338593518542de06b74530b34 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -181,6 +181,7 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; +import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as HostResources from "./resourceTelemetry/HostResources.ts"; @@ -562,6 +563,7 @@ const buildAppUnderTest = (options?: { CloudManagedEndpointRuntime.CloudManagedEndpointRuntime["Service"] >; relayClient?: Partial; + agentAwarenessRelay?: Partial; cloudCliTokenManager?: Partial; httpClient?: HttpClient.HttpClient; nativeTelemetryClient?: Partial; @@ -1173,14 +1175,20 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.succeed( - CloudManagedEndpointRuntime.CloudManagedEndpointRuntime, - CloudManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ - applyConfig: () => Effect.succeed({ status: "disabled" }), - recoveryRequests: Stream.empty, - requestRecovery: () => Effect.void, - withLinkStateLock: (effect) => effect, - ...options?.layers?.cloudManagedEndpointRuntime, + Layer.mergeAll( + Layer.succeed( + CloudManagedEndpointRuntime.CloudManagedEndpointRuntime, + CloudManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ + applyConfig: () => Effect.succeed({ status: "disabled" }), + recoveryRequests: Stream.empty, + requestRecovery: () => Effect.void, + withLinkStateLock: (effect) => effect, + ...options?.layers?.cloudManagedEndpointRuntime, + }), + ), + Layer.mock(AgentAwarenessRelay.AgentAwarenessRelay)({ + requestCatchUp: () => Effect.void, + ...options?.layers?.agentAwarenessRelay, }), ), ), @@ -3114,6 +3122,55 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("wakes the agent awareness relay when this server links or changes publishing", () => + Effect.gen(function* () { + let catchUpRequests = 0; + yield* buildAppUnderTest({ + layers: { + agentAwarenessRelay: { + requestCatchUp: () => + Effect.sync(() => { + catchUpRequests += 1; + }), + }, + }, + }); + + const cloudKeyPair = NodeCrypto.generateKeyPairSync("ed25519", { + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + publicKeyEncoding: { format: "pem", type: "spki" }, + }); + const ownerCookie = yield* getAuthenticatedSessionCookieHeader(); + const relayConfigResponse = yield* fetchEffect( + yield* getHttpServerUrl("/api/connect/relay-config"), + { + method: "POST", + headers: { cookie: ownerCookie, "content-type": "application/json" }, + body: jsonRequestBody({ + relayUrl: "https://relay.example.test", + cloudUserId: "user_123", + environmentCredential: "t3env_test_credential", + cloudMintPublicKey: cloudKeyPair.publicKey, + endpointRuntime: null, + }), + }, + ); + assert.equal(relayConfigResponse.status, 200); + assert.equal(catchUpRequests, 1); + + const preferencesResponse = yield* fetchEffect( + yield* getHttpServerUrl("/api/connect/preferences"), + { + method: "POST", + headers: { cookie: ownerCookie, "content-type": "application/json" }, + body: jsonRequestBody({ publishAgentActivity: true }), + }, + ); + assert.equal(preferencesResponse.status, 200); + assert.equal(catchUpRequests, 2); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("rejects relay config with an invalid cloud mint public key", () => Effect.gen(function* () { yield* buildAppUnderTest();