| author | |
| committer | |
| log | baaeda305c60933e5e573b30807ecfc3a0871a80 |
| tree | b38b09f039d9e1a53cd7631f255abeabf2390794 |
| parent | 7e01d33f0eeb9435299791392d546756cc09c5d3 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>10 files changed, 236 insertions(+), 7 deletions(-)
apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts+76| ... | ... | @@ -20,12 +20,15 @@ import { |
| 20 | 20 | clearAgentAwarenessRegistrationRecord, |
| 21 | 21 | loadAgentAwarenessRegistrationRecord, |
| 22 | 22 | loadOrCreateAgentAwarenessDeviceId, |
| 23 | loadPreferences, | |
| 23 | 24 | saveAgentAwarenessRegistrationRecord, |
| 24 | 25 | } from "../../persistence/imperative"; |
| 26 | import type { Preferences } from "../../persistence/mobile-preferences"; | |
| 25 | 27 | import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; |
| 26 | 28 | import { |
| 27 | 29 | AgentAwarenessOperationError, |
| 28 | 30 | __resetAgentAwarenessRemoteRegistrationForTest, |
| 31 | armAgentAwarenessLiveActivityForLocalWork, | |
| 29 | 32 | getAgentAwarenessRegistrationStatus, |
| 30 | 33 | mergeAgentAwarenessRegistrationPreferences, |
| 31 | 34 | refreshActiveLiveActivityRemoteRegistration, |
| ... | ... | @@ -43,6 +46,13 @@ import * as Notifications from "expo-notifications"; |
| 43 | 46 | const secureStore = vi.hoisted(() => new Map<string, string>()); |
| 44 | 47 | const widgetMocks = vi.hoisted(() => ({ |
| 45 | 48 | getInstances: vi.fn(() => []), |
| 49 | start: vi.fn(() => ({})), | |
| 50 | })); | |
| 51 | const environmentConfigsMock = vi.hoisted(() => ({ | |
| 52 | configs: new Map< | |
| 53 | string, | |
| 54 | { environment: { capabilities: { agentActivityPublishing?: boolean } } } | |
| 55 | >(), | |
| 46 | 56 | })); |
| 47 | 57 | const backgroundRuntime = vi.hoisted(() => ({ |
| 48 | 58 | pending: [] as Array<{ |
| ... | ... | @@ -77,9 +87,22 @@ vi.mock("expo-widgets", () => ({ |
| 77 | 87 | vi.mock("../../widgets/AgentActivity", () => ({ |
| 78 | 88 | default: { |
| 79 | 89 | getInstances: widgetMocks.getInstances, |
| 90 | start: widgetMocks.start, | |
| 80 | 91 | }, |
| 81 | 92 | })); |
| 82 | 93 | |
| 94 | // The state modules pull the whole connection stack (and native expo modules) | |
| 95 | // into the import graph; the arming gate only needs the configs map. | |
| 96 | vi.mock("../../state/atom-registry", () => ({ | |
| 97 | appAtomRegistry: { | |
| 98 | get: () => environmentConfigsMock.configs, | |
| 99 | }, | |
| 100 | })); | |
| 101 | ||
| 102 | vi.mock("../../state/server", () => ({ | |
| 103 | environmentServerConfigsAtom: Symbol("environmentServerConfigsAtom"), | |
| 104 | })); | |
| 105 | ||
| 83 | 106 | vi.mock("expo-notifications", () => ({ |
| 84 | 107 | addPushTokenListener: vi.fn(() => ({ remove: vi.fn() })), |
| 85 | 108 | getDevicePushTokenAsync: vi.fn(() => Promise.resolve({ type: "ios", data: "apns-token" })), |
| ... | ... | @@ -227,6 +250,8 @@ describe("makeRelayDeviceRegistrationRequest", () => { |
| 227 | 250 | vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockResolvedValue("device-1"); |
| 228 | 251 | widgetMocks.getInstances.mockReset(); |
| 229 | 252 | widgetMocks.getInstances.mockReturnValue([]); |
| 253 | widgetMocks.start.mockClear(); | |
| 254 | environmentConfigsMock.configs.clear(); | |
| 230 | 255 | }); |
| 231 | 256 | |
| 232 | 257 | it("preserves disabled Live Activity preferences in relay registrations", () => { |
| ... | ... | @@ -856,4 +881,55 @@ describe("makeRelayDeviceRegistrationRequest", () => { |
| 856 | 881 | }).pipe(Effect.provide(relayTestLayer)); |
| 857 | 882 | }, |
| 858 | 883 | ); |
| 884 | ||
| 885 | it("skips the Live Activity seed when the environment reports publishing disabled", async () => { | |
| 886 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); | |
| 887 | vi.mocked(loadPreferences).mockResolvedValueOnce({ | |
| 888 | liveActivitiesEnabled: true, | |
| 889 | } as Preferences); | |
| 890 | environmentConfigsMock.configs.set("env-1", { | |
| 891 | environment: { capabilities: { agentActivityPublishing: false } }, | |
| 892 | }); | |
| 893 | ||
| 894 | armAgentAwarenessLiveActivityForLocalWork({ | |
| 895 | environmentId: "env-1" as EnvironmentId, | |
| 896 | threadTitle: "Fix the flaky test", | |
| 897 | projectTitle: "t3code", | |
| 898 | }); | |
| 899 | await new Promise((resolve) => setTimeout(resolve, 0)); | |
| 900 | ||
| 901 | expect(widgetMocks.start).not.toHaveBeenCalled(); | |
| 902 | }); | |
| 903 | ||
| 904 | it("seeds the Live Activity for publishing and pre-capability environments", async () => { | |
| 905 | setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); | |
| 906 | environmentConfigsMock.configs.set("env-publishing", { | |
| 907 | environment: { capabilities: { agentActivityPublishing: true } }, | |
| 908 | }); | |
| 909 | ||
| 910 | vi.mocked(loadPreferences).mockResolvedValueOnce({ | |
| 911 | liveActivitiesEnabled: true, | |
| 912 | } as Preferences); | |
| 913 | armAgentAwarenessLiveActivityForLocalWork({ | |
| 914 | environmentId: "env-publishing" as EnvironmentId, | |
| 915 | threadTitle: "Fix the flaky test", | |
| 916 | projectTitle: "t3code", | |
| 917 | }); | |
| 918 | await new Promise((resolve) => setTimeout(resolve, 0)); | |
| 919 | expect(widgetMocks.start).toHaveBeenCalledTimes(1); | |
| 920 | ||
| 921 | // An environment without the capability may run an older server that | |
| 922 | // still publishes; only an explicit false skips the seed. | |
| 923 | widgetMocks.start.mockClear(); | |
| 924 | vi.mocked(loadPreferences).mockResolvedValueOnce({ | |
| 925 | liveActivitiesEnabled: true, | |
| 926 | } as Preferences); | |
| 927 | armAgentAwarenessLiveActivityForLocalWork({ | |
| 928 | environmentId: "env-pre-capability" as EnvironmentId, | |
| 929 | threadTitle: "Fix the flaky test", | |
| 930 | projectTitle: "t3code", | |
| 931 | }); | |
| 932 | await new Promise((resolve) => setTimeout(resolve, 0)); | |
| 933 | expect(widgetMocks.start).toHaveBeenCalledTimes(1); | |
| 934 | }); | |
| 859 | 935 | }); |
apps/mobile/src/features/agent-awareness/remoteRegistration.ts+23-1| ... | ... | @@ -20,6 +20,8 @@ import { |
| 20 | 20 | |
| 21 | 21 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 22 | 22 | import { runtime } from "../../lib/runtime"; |
| 23 | import { appAtomRegistry } from "../../state/atom-registry"; | |
| 24 | import { environmentServerConfigsAtom } from "../../state/server"; | |
| 23 | 25 | import type { Preferences } from "../../persistence/mobile-preferences"; |
| 24 | 26 | import { |
| 25 | 27 | clearAgentAwarenessRegistrationRecord, |
| ... | ... | @@ -448,18 +450,38 @@ function unregisterDeviceWithRelay(input: { |
| 448 | 450 | }); |
| 449 | 451 | } |
| 450 | 452 | |
| 453 | // The environment descriptor advertises whether agent-activity publishes | |
| 454 | // currently leave that server (`capabilities.agentActivityPublishing`). Only | |
| 455 | // an explicit false skips the seed card: older servers omit the capability | |
| 456 | // but may still publish. | |
| 457 | function environmentPublishesAgentActivity(environmentId: EnvironmentId): boolean { | |
| 458 | return ( | |
| 459 | appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | |
| 460 | .agentActivityPublishing !== false | |
| 461 | ); | |
| 462 | } | |
| 463 | ||
| 451 | 464 | // Arms the lock-screen card the moment the user starts agent work from this |
| 452 | 465 | // phone, while the app is still foregrounded and the fresh activity's token |
| 453 | 466 | // can be registered immediately. The seeded row is a best-effort placeholder; |
| 454 | 467 | // the relay's registration replay repaints it with the authoritative |
| 455 | // aggregate within seconds. No-ops when a card is already armed. | |
| 468 | // aggregate within seconds. No-ops when a card is already armed, and skips | |
| 469 | // environments that report publishing disabled — the seed would sit on | |
| 470 | // "Connecting" forever with no update ever arriving to repaint or end it. | |
| 456 | 471 | export function armAgentAwarenessLiveActivityForLocalWork(input: { |
| 472 | readonly environmentId: EnvironmentId; | |
| 457 | 473 | readonly threadTitle: string; |
| 458 | 474 | readonly projectTitle: string; |
| 459 | 475 | }): void { |
| 460 | 476 | if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) { |
| 461 | 477 | return; |
| 462 | 478 | } |
| 479 | if (!environmentPublishesAgentActivity(input.environmentId)) { | |
| 480 | logRegistrationDebug("live activity arming skipped; environment does not publish", { | |
| 481 | environmentId: input.environmentId, | |
| 482 | }); | |
| 483 | return; | |
| 484 | } | |
| 463 | 485 | void loadPreferences() |
| 464 | 486 | .catch(() => null) |
| 465 | 487 | .then((preferences) => { |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+1| ... | ... | @@ -709,6 +709,7 @@ export function NewTaskDraftScreen(props: { |
| 709 | 709 | // -only Activity start. If creation fails, the token registration's replay |
| 710 | 710 | // finds no work and ends the card within seconds. |
| 711 | 711 | armAgentAwarenessLiveActivityForLocalWork({ |
| 712 | environmentId: selectedProject.environmentId, | |
| 712 | 713 | threadTitle: deriveThreadTitleFromPrompt(initialMessageText), |
| 713 | 714 | projectTitle: selectedProject.title, |
| 714 | 715 | }); |
apps/mobile/src/features/threads/ThreadComposer.tsx+1| ... | ... | @@ -541,6 +541,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 541 | 541 | // after the send so its preference read and native Activity start don't |
| 542 | 542 | // contend with the queued-message feedback on the tap frame. |
| 543 | 543 | armAgentAwarenessLiveActivityForLocalWork({ |
| 544 | environmentId: props.environmentId, | |
| 544 | 545 | threadTitle: props.selectedThread.title, |
| 545 | 546 | projectTitle: props.environmentLabel ?? "T3 Code", |
| 546 | 547 | }); |
apps/server/src/cli/connect.ts+3-2| ... | ... | @@ -36,6 +36,7 @@ import * as CliState from "../cloud/CliState.ts"; |
| 36 | 36 | import * as CliTokenManager from "../cloud/CliTokenManager.ts"; |
| 37 | 37 | import { |
| 38 | 38 | CLOUD_LINKED_USER_ID, |
| 39 | isAgentActivityPublishingEnabledValue, | |
| 39 | 40 | PUBLISH_AGENT_ACTIVITY_SECRET, |
| 40 | 41 | RELAY_URL_SECRET, |
| 41 | 42 | } from "../cloud/config.ts"; |
| ... | ... | @@ -142,7 +143,7 @@ function stringToBytes(value: string): Uint8Array { |
| 142 | 143 | } |
| 143 | 144 | |
| 144 | 145 | export function isPublishAgentActivityEnabledValue(value: string | null): boolean { |
| 145 | return value === "true"; | |
| 146 | return isAgentActivityPublishingEnabledValue(value); | |
| 146 | 147 | } |
| 147 | 148 | |
| 148 | 149 | interface CloudCliStatus { |
| ... | ... | @@ -447,7 +448,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E |
| 447 | 448 | ), |
| 448 | 449 | RelayClient.layerCloudflared({ baseDir: config.baseDir }), |
| 449 | 450 | EnvironmentAuth.runtimeLayer, |
| 450 | ServerEnvironment.layer, | |
| 451 | ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer)), | |
| 451 | 452 | bootServiceLayer(config), |
| 452 | 453 | headlessRelayClientTracingLayer, |
| 453 | 454 | ).pipe( |
apps/server/src/cloud/config.ts+40| ... | ... | @@ -1,6 +1,10 @@ |
| 1 | 1 | import { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Option from "effect/Option"; | |
| 2 | 4 | import * as Schema from "effect/Schema"; |
| 3 | 5 | |
| 6 | import type * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | |
| 7 | ||
| 4 | 8 | export const CLOUD_MINT_PUBLIC_KEY = "cloud-mint-ed25519-public-key"; |
| 5 | 9 | export const CLOUD_ENDPOINT_RUNTIME_CONFIG = "cloud-endpoint-runtime-config"; |
| 6 | 10 | export const CLOUD_LINKED_USER_ID = "cloud-linked-user-id"; |
| ... | ... | @@ -16,3 +20,39 @@ export const encodeEndpointRuntimeConfigJson = Schema.encodeEffect( |
| 16 | 20 | export const decodeRuntimeConfig = Schema.decodeUnknownOption( |
| 17 | 21 | Schema.fromJsonString(RelayManagedEndpointRuntimeConfig), |
| 18 | 22 | ); |
| 23 | ||
| 24 | export function isAgentActivityPublishingEnabledValue(value: string | null): boolean { | |
| 25 | return value === "true"; | |
| 26 | } | |
| 27 | ||
| 28 | /** Whether agent-activity publishes currently leave this environment: the | |
| 29 | publish opt-in secret is enabled and the relay link credentials exist. | |
| 30 | Mirrors the per-publish gate in AgentAwarenessRelay, so the descriptor | |
| 31 | capability never advertises publishing that the publisher would skip. */ | |
| 32 | export const readAgentActivityPublishingActive = ( | |
| 33 | secrets: ServerSecretStore.ServerSecretStore["Service"], | |
| 34 | ): Effect.Effect<boolean> => | |
| 35 | Effect.gen(function* () { | |
| 36 | const readSecretString = (name: string) => | |
| 37 | secrets | |
| 38 | .get(name) | |
| 39 | .pipe( | |
| 40 | Effect.map((bytes) => | |
| 41 | Option.isSome(bytes) ? new TextDecoder().decode(bytes.value) : null, | |
| 42 | ), | |
| 43 | ); | |
| 44 | const [enabled, url, environmentCredential] = yield* Effect.all([ | |
| 45 | readSecretString(PUBLISH_AGENT_ACTIVITY_SECRET), | |
| 46 | readSecretString(RELAY_URL_SECRET), | |
| 47 | readSecretString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), | |
| 48 | ]); | |
| 49 | // Empty strings are as unconfigured as missing files: the publisher's | |
| 50 | // truthiness gate skips them, so the capability must too. | |
| 51 | return ( | |
| 52 | isAgentActivityPublishingEnabledValue(enabled) && | |
| 53 | url !== null && | |
| 54 | url !== "" && | |
| 55 | environmentCredential !== null && | |
| 56 | environmentCredential !== "" | |
| 57 | ); | |
| 58 | }).pipe(Effect.orElseSucceed(() => false)); |
apps/server/src/environment/ServerEnvironment.test.ts+70-1| ... | ... | @@ -3,9 +3,16 @@ import { expect, it } from "@effect/vitest"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; | |
| 6 | 7 | import * as PlatformError from "effect/PlatformError"; |
| 7 | 8 | import * as Schema from "effect/Schema"; |
| 8 | 9 | |
| 10 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | |
| 11 | import { | |
| 12 | PUBLISH_AGENT_ACTIVITY_SECRET, | |
| 13 | RELAY_ENVIRONMENT_CREDENTIAL_SECRET, | |
| 14 | RELAY_URL_SECRET, | |
| 15 | } from "../cloud/config.ts"; | |
| 9 | 16 | import * as ServerConfig from "../config.ts"; |
| 10 | 17 | import * as ServerEnvironment from "./ServerEnvironment.ts"; |
| 11 | 18 | |
| ... | ... | @@ -14,7 +21,21 @@ const isServerEnvironmentIdPersistenceError = Schema.is( |
| 14 | 21 | ); |
| 15 | 22 | |
| 16 | 23 | const makeServerEnvironmentLayer = (baseDir: string) => |
| 17 | ServerEnvironment.layer.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); | |
| 24 | ServerEnvironment.layer.pipe( | |
| 25 | Layer.provide(ServerSecretStore.layer), | |
| 26 | Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)), | |
| 27 | ); | |
| 28 | ||
| 29 | const emptySecretStoreLayer = Layer.succeed( | |
| 30 | ServerSecretStore.ServerSecretStore, | |
| 31 | ServerSecretStore.ServerSecretStore.of({ | |
| 32 | get: () => Effect.succeed(Option.none()), | |
| 33 | set: () => Effect.void, | |
| 34 | create: () => Effect.void, | |
| 35 | getOrCreateRandom: () => Effect.succeed(new Uint8Array()), | |
| 36 | remove: () => Effect.void, | |
| 37 | }), | |
| 38 | ); | |
| 18 | 39 | |
| 19 | 40 | const makeServerConfig = Effect.fn(function* (baseDir: string) { |
| 20 | 41 | const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); |
| ... | ... | @@ -71,6 +92,53 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { |
| 71 | 92 | expect(second.capabilities.connectionProbe).toBe(true); |
| 72 | 93 | expect(second.capabilities.pullRequests).toBe(true); |
| 73 | 94 | expect(second.capabilities.threadTitleRegeneration).toBe(true); |
| 95 | expect(second.capabilities.agentActivityPublishing).toBe(false); | |
| 96 | }), | |
| 97 | ); | |
| 98 | ||
| 99 | it.effect("reports agent activity publishing from the current secret state", () => | |
| 100 | Effect.gen(function* () { | |
| 101 | const fileSystem = yield* FileSystem.FileSystem; | |
| 102 | const baseDir = yield* fileSystem.makeTempDirectoryScoped({ | |
| 103 | prefix: "t3-server-environment-publish-test-", | |
| 104 | }); | |
| 105 | const testLayer = Layer.mergeAll( | |
| 106 | ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer)), | |
| 107 | ServerSecretStore.layer, | |
| 108 | ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); | |
| 109 | ||
| 110 | yield* Effect.gen(function* () { | |
| 111 | const secrets = yield* ServerSecretStore.ServerSecretStore; | |
| 112 | const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; | |
| 113 | const encode = (value: string) => new TextEncoder().encode(value); | |
| 114 | ||
| 115 | const unlinked = yield* serverEnvironment.getDescriptor; | |
| 116 | expect(unlinked.capabilities.agentActivityPublishing).toBe(false); | |
| 117 | ||
| 118 | // The opt-in alone is not enough: without relay link credentials no | |
| 119 | // publish would leave this environment. | |
| 120 | yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, encode("true")); | |
| 121 | const withoutLink = yield* serverEnvironment.getDescriptor; | |
| 122 | expect(withoutLink.capabilities.agentActivityPublishing).toBe(false); | |
| 123 | ||
| 124 | // Empty credentials are as unconfigured as missing ones: the | |
| 125 | // publisher's truthiness gate skips them, so the capability must not | |
| 126 | // advertise publishing. | |
| 127 | yield* secrets.set(RELAY_URL_SECRET, encode("")); | |
| 128 | yield* secrets.set(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, encode("credential")); | |
| 129 | const emptyUrl = yield* serverEnvironment.getDescriptor; | |
| 130 | expect(emptyUrl.capabilities.agentActivityPublishing).toBe(false); | |
| 131 | ||
| 132 | yield* secrets.set(RELAY_URL_SECRET, encode("https://relay.example")); | |
| 133 | const linked = yield* serverEnvironment.getDescriptor; | |
| 134 | expect(linked.capabilities.agentActivityPublishing).toBe(true); | |
| 135 | ||
| 136 | // The toggle changes at runtime, so the same service instance must | |
| 137 | // reflect a flip without a restart. | |
| 138 | yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, encode("false")); | |
| 139 | const disabled = yield* serverEnvironment.getDescriptor; | |
| 140 | expect(disabled.capabilities.agentActivityPublishing).toBe(false); | |
| 141 | }).pipe(Effect.provide(testLayer)); | |
| 74 | 142 | }), |
| 75 | 143 | ); |
| 76 | 144 | |
| ... | ... | @@ -113,6 +181,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { |
| 113 | 181 | }).pipe( |
| 114 | 182 | Effect.provide( |
| 115 | 183 | ServerEnvironment.layer.pipe( |
| 184 | Layer.provide(emptySecretStoreLayer), | |
| 116 | 185 | Layer.provide(Layer.merge(ServerConfig.layer(serverConfig), failingFileSystemLayer)), |
| 117 | 186 | ), |
| 118 | 187 | ), |
apps/server/src/environment/ServerEnvironment.ts+14-2| ... | ... | @@ -9,6 +9,8 @@ import * as Path from "effect/Path"; |
| 9 | 9 | import * as Schema from "effect/Schema"; |
| 10 | 10 | |
| 11 | 11 | import packageJson from "../../package.json" with { type: "json" }; |
| 12 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | |
| 13 | import { readAgentActivityPublishingActive } from "../cloud/config.ts"; | |
| 12 | 14 | import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; |
| 13 | 15 | import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts"; |
| 14 | 16 | import * as ServerConfig from "../config.ts"; |
| ... | ... | @@ -66,6 +68,7 @@ export const make = Effect.gen(function* () { |
| 66 | 68 | const fileSystem = yield* FileSystem.FileSystem; |
| 67 | 69 | const path = yield* Path.Path; |
| 68 | 70 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 71 | const secrets = yield* ServerSecretStore.ServerSecretStore; | |
| 69 | 72 | const crypto = yield* Crypto.Crypto; |
| 70 | 73 | const hostPlatform = yield* HostProcessPlatform; |
| 71 | 74 | const hostArchitecture = yield* HostProcessArchitecture; |
| ... | ... | @@ -156,13 +159,22 @@ export const make = Effect.gen(function* () { |
| 156 | 159 | |
| 157 | 160 | return ServerEnvironment.of({ |
| 158 | 161 | getEnvironmentId: Effect.succeed(environmentId), |
| 159 | getDescriptor: Effect.succeed(descriptor), | |
| 162 | // The publish opt-in and relay link change at runtime (`t3 connect | |
| 163 | // publish`, the client settings toggle), so the capability is read per | |
| 164 | // descriptor request rather than baked in at startup. | |
| 165 | getDescriptor: readAgentActivityPublishingActive(secrets).pipe( | |
| 166 | Effect.map((agentActivityPublishing) => ({ | |
| 167 | ...descriptor, | |
| 168 | capabilities: { ...descriptor.capabilities, agentActivityPublishing }, | |
| 169 | })), | |
| 170 | ), | |
| 160 | 171 | }); |
| 161 | 172 | }); |
| 162 | 173 | |
| 163 | 174 | /** |
| 164 | 175 | * ServerEnvironment is acquired from persisted filesystem and host-process |
| 165 | 176 | * state. It intentionally has no fallback Layer.succeed value: callers must |
| 166 | * provide the external platform services and a ServerConfig. | |
| 177 | * provide the external platform services, a ServerConfig, and the | |
| 178 | * ServerSecretStore backing the descriptor's publishing capability. | |
| 167 | 179 | */ |
| 168 | 180 | export const layer = Layer.effect(ServerEnvironment, make).pipe(Layer.provide(ProcessRunner.layer)); |
apps/server/src/relay/AgentAwarenessRelay.ts+2-1| ... | ... | @@ -35,6 +35,7 @@ import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; |
| 35 | 35 | |
| 36 | 36 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 37 | 37 | import { |
| 38 | isAgentActivityPublishingEnabledValue, | |
| 38 | 39 | PUBLISH_AGENT_ACTIVITY_SECRET, |
| 39 | 40 | RELAY_ENVIRONMENT_CREDENTIAL_SECRET, |
| 40 | 41 | RELAY_ISSUER_SECRET, |
| ... | ... | @@ -102,7 +103,7 @@ export function agentAwarenessPublishIdentity(state: RelayAgentActivityState | n |
| 102 | 103 | } |
| 103 | 104 | |
| 104 | 105 | export function isAgentActivityPublishingEnabled(value: string | null): boolean { |
| 105 | return value === "true"; | |
| 106 | return isAgentActivityPublishingEnabledValue(value); | |
| 106 | 107 | } |
| 107 | 108 | |
| 108 | 109 | export function resolveAgentActivityPublishingStartupState(input: { |
packages/contracts/src/environment.ts+6| ... | ... | @@ -74,6 +74,12 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 74 | 74 | /** Server can stream self-update progress before acknowledging the |
| 75 | 75 | restart. Clients fall back to server.updateServer when absent. */ |
| 76 | 76 | serverSelfUpdateProgress: Schema.optionalKey(Schema.Boolean), |
| 77 | /** Agent-activity publishes (push notifications and Live Activities) | |
| 78 | currently leave this environment: the publish opt-in is enabled and the | |
| 79 | relay link credentials exist. Clients skip seeding a Live Activity when | |
| 80 | this is false — no update would ever repaint it. Absent on older | |
| 81 | servers, which may still publish, so only an explicit false skips. */ | |
| 82 | agentActivityPublishing: Schema.optionalKey(Schema.Boolean), | |
| 77 | 83 | }); |
| 78 | 84 | export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; |
| 79 | 85 |