| 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,12 +20,15 @@ import { |
| 20 | clearAgentAwarenessRegistrationRecord, | 20 | clearAgentAwarenessRegistrationRecord, |
| 21 | loadAgentAwarenessRegistrationRecord, | 21 | loadAgentAwarenessRegistrationRecord, |
| 22 | loadOrCreateAgentAwarenessDeviceId, | 22 | loadOrCreateAgentAwarenessDeviceId, |
| 23 | loadPreferences, | ||
| 23 | saveAgentAwarenessRegistrationRecord, | 24 | saveAgentAwarenessRegistrationRecord, |
| 24 | } from "../../persistence/imperative"; | 25 | } from "../../persistence/imperative"; |
| 26 | import type { Preferences } from "../../persistence/mobile-preferences"; | ||
| 25 | import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; | 27 | import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; |
| 26 | import { | 28 | import { |
| 27 | AgentAwarenessOperationError, | 29 | AgentAwarenessOperationError, |
| 28 | __resetAgentAwarenessRemoteRegistrationForTest, | 30 | __resetAgentAwarenessRemoteRegistrationForTest, |
| 31 | armAgentAwarenessLiveActivityForLocalWork, | ||
| 29 | getAgentAwarenessRegistrationStatus, | 32 | getAgentAwarenessRegistrationStatus, |
| 30 | mergeAgentAwarenessRegistrationPreferences, | 33 | mergeAgentAwarenessRegistrationPreferences, |
| 31 | refreshActiveLiveActivityRemoteRegistration, | 34 | refreshActiveLiveActivityRemoteRegistration, |
| ... | @@ -43,6 +46,13 @@ import * as Notifications from "expo-notifications"; | ... | @@ -43,6 +46,13 @@ import * as Notifications from "expo-notifications"; |
| 43 | const secureStore = vi.hoisted(() => new Map<string, string>()); | 46 | const secureStore = vi.hoisted(() => new Map<string, string>()); |
| 44 | const widgetMocks = vi.hoisted(() => ({ | 47 | const widgetMocks = vi.hoisted(() => ({ |
| 45 | getInstances: vi.fn(() => []), | 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 | const backgroundRuntime = vi.hoisted(() => ({ | 57 | const backgroundRuntime = vi.hoisted(() => ({ |
| 48 | pending: [] as Array<{ | 58 | pending: [] as Array<{ |
| ... | @@ -77,9 +87,22 @@ vi.mock("expo-widgets", () => ({ | ... | @@ -77,9 +87,22 @@ vi.mock("expo-widgets", () => ({ |
| 77 | vi.mock("../../widgets/AgentActivity", () => ({ | 87 | vi.mock("../../widgets/AgentActivity", () => ({ |
| 78 | default: { | 88 | default: { |
| 79 | getInstances: widgetMocks.getInstances, | 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 | vi.mock("expo-notifications", () => ({ | 106 | vi.mock("expo-notifications", () => ({ |
| 84 | addPushTokenListener: vi.fn(() => ({ remove: vi.fn() })), | 107 | addPushTokenListener: vi.fn(() => ({ remove: vi.fn() })), |
| 85 | getDevicePushTokenAsync: vi.fn(() => Promise.resolve({ type: "ios", data: "apns-token" })), | 108 | getDevicePushTokenAsync: vi.fn(() => Promise.resolve({ type: "ios", data: "apns-token" })), |
| ... | @@ -227,6 +250,8 @@ describe("makeRelayDeviceRegistrationRequest", () => { | ... | @@ -227,6 +250,8 @@ describe("makeRelayDeviceRegistrationRequest", () => { |
| 227 | vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockResolvedValue("device-1"); | 250 | vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockResolvedValue("device-1"); |
| 228 | widgetMocks.getInstances.mockReset(); | 251 | widgetMocks.getInstances.mockReset(); |
| 229 | widgetMocks.getInstances.mockReturnValue([]); | 252 | widgetMocks.getInstances.mockReturnValue([]); |
| 253 | widgetMocks.start.mockClear(); | ||
| 254 | environmentConfigsMock.configs.clear(); | ||
| 230 | }); | 255 | }); |
| 231 | 256 | ||
| 232 | it("preserves disabled Live Activity preferences in relay registrations", () => { | 257 | it("preserves disabled Live Activity preferences in relay registrations", () => { |
| ... | @@ -856,4 +881,55 @@ describe("makeRelayDeviceRegistrationRequest", () => { | ... | @@ -856,4 +881,55 @@ describe("makeRelayDeviceRegistrationRequest", () => { |
| 856 | }).pipe(Effect.provide(relayTestLayer)); | 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,6 +20,8 @@ import { |
| 20 | 20 | ||
| 21 | import type { SavedRemoteConnection } from "../../lib/connection"; | 21 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 22 | import { runtime } from "../../lib/runtime"; | 22 | import { runtime } from "../../lib/runtime"; |
| 23 | import { appAtomRegistry } from "../../state/atom-registry"; | ||
| 24 | import { environmentServerConfigsAtom } from "../../state/server"; | ||
| 23 | import type { Preferences } from "../../persistence/mobile-preferences"; | 25 | import type { Preferences } from "../../persistence/mobile-preferences"; |
| 24 | import { | 26 | import { |
| 25 | clearAgentAwarenessRegistrationRecord, | 27 | clearAgentAwarenessRegistrationRecord, |
| ... | @@ -448,18 +450,38 @@ function unregisterDeviceWithRelay(input: { | ... | @@ -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 | // Arms the lock-screen card the moment the user starts agent work from this | 464 | // Arms the lock-screen card the moment the user starts agent work from this |
| 452 | // phone, while the app is still foregrounded and the fresh activity's token | 465 | // phone, while the app is still foregrounded and the fresh activity's token |
| 453 | // can be registered immediately. The seeded row is a best-effort placeholder; | 466 | // can be registered immediately. The seeded row is a best-effort placeholder; |
| 454 | // the relay's registration replay repaints it with the authoritative | 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 | export function armAgentAwarenessLiveActivityForLocalWork(input: { | 471 | export function armAgentAwarenessLiveActivityForLocalWork(input: { |
| 472 | readonly environmentId: EnvironmentId; | ||
| 457 | readonly threadTitle: string; | 473 | readonly threadTitle: string; |
| 458 | readonly projectTitle: string; | 474 | readonly projectTitle: string; |
| 459 | }): void { | 475 | }): void { |
| 460 | if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) { | 476 | if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) { |
| 461 | return; | 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 | void loadPreferences() | 485 | void loadPreferences() |
| 464 | .catch(() => null) | 486 | .catch(() => null) |
| 465 | .then((preferences) => { | 487 | .then((preferences) => { |
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+1| ... | @@ -709,6 +709,7 @@ export function NewTaskDraftScreen(props: { | ... | @@ -709,6 +709,7 @@ export function NewTaskDraftScreen(props: { |
| 709 | // -only Activity start. If creation fails, the token registration's replay | 709 | // -only Activity start. If creation fails, the token registration's replay |
| 710 | // finds no work and ends the card within seconds. | 710 | // finds no work and ends the card within seconds. |
| 711 | armAgentAwarenessLiveActivityForLocalWork({ | 711 | armAgentAwarenessLiveActivityForLocalWork({ |
| 712 | environmentId: selectedProject.environmentId, | ||
| 712 | threadTitle: deriveThreadTitleFromPrompt(initialMessageText), | 713 | threadTitle: deriveThreadTitleFromPrompt(initialMessageText), |
| 713 | projectTitle: selectedProject.title, | 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,6 +541,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer |
| 541 | // after the send so its preference read and native Activity start don't | 541 | // after the send so its preference read and native Activity start don't |
| 542 | // contend with the queued-message feedback on the tap frame. | 542 | // contend with the queued-message feedback on the tap frame. |
| 543 | armAgentAwarenessLiveActivityForLocalWork({ | 543 | armAgentAwarenessLiveActivityForLocalWork({ |
| 544 | environmentId: props.environmentId, | ||
| 544 | threadTitle: props.selectedThread.title, | 545 | threadTitle: props.selectedThread.title, |
| 545 | projectTitle: props.environmentLabel ?? "T3 Code", | 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,6 +36,7 @@ import * as CliState from "../cloud/CliState.ts"; |
| 36 | import * as CliTokenManager from "../cloud/CliTokenManager.ts"; | 36 | import * as CliTokenManager from "../cloud/CliTokenManager.ts"; |
| 37 | import { | 37 | import { |
| 38 | CLOUD_LINKED_USER_ID, | 38 | CLOUD_LINKED_USER_ID, |
| 39 | isAgentActivityPublishingEnabledValue, | ||
| 39 | PUBLISH_AGENT_ACTIVITY_SECRET, | 40 | PUBLISH_AGENT_ACTIVITY_SECRET, |
| 40 | RELAY_URL_SECRET, | 41 | RELAY_URL_SECRET, |
| 41 | } from "../cloud/config.ts"; | 42 | } from "../cloud/config.ts"; |
| ... | @@ -142,7 +143,7 @@ function stringToBytes(value: string): Uint8Array { | ... | @@ -142,7 +143,7 @@ function stringToBytes(value: string): Uint8Array { |
| 142 | } | 143 | } |
| 143 | 144 | ||
| 144 | export function isPublishAgentActivityEnabledValue(value: string | null): boolean { | 145 | export function isPublishAgentActivityEnabledValue(value: string | null): boolean { |
| 145 | return value === "true"; | 146 | return isAgentActivityPublishingEnabledValue(value); |
| 146 | } | 147 | } |
| 147 | 148 | ||
| 148 | interface CloudCliStatus { | 149 | interface CloudCliStatus { |
| ... | @@ -447,7 +448,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E | ... | @@ -447,7 +448,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E |
| 447 | ), | 448 | ), |
| 448 | RelayClient.layerCloudflared({ baseDir: config.baseDir }), | 449 | RelayClient.layerCloudflared({ baseDir: config.baseDir }), |
| 449 | EnvironmentAuth.runtimeLayer, | 450 | EnvironmentAuth.runtimeLayer, |
| 450 | ServerEnvironment.layer, | 451 | ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer)), |
| 451 | bootServiceLayer(config), | 452 | bootServiceLayer(config), |
| 452 | headlessRelayClientTracingLayer, | 453 | headlessRelayClientTracingLayer, |
| 453 | ).pipe( | 454 | ).pipe( |
apps/server/src/cloud/config.ts+40| ... | @@ -1,6 +1,10 @@ | ... | @@ -1,6 +1,10 @@ |
| 1 | import { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; | 1 | import { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; |
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 2 | import * as Schema from "effect/Schema"; | 4 | import * as Schema from "effect/Schema"; |
| 3 | 5 | ||
| 6 | import type * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | ||
| 7 | |||
| 4 | export const CLOUD_MINT_PUBLIC_KEY = "cloud-mint-ed25519-public-key"; | 8 | export const CLOUD_MINT_PUBLIC_KEY = "cloud-mint-ed25519-public-key"; |
| 5 | export const CLOUD_ENDPOINT_RUNTIME_CONFIG = "cloud-endpoint-runtime-config"; | 9 | export const CLOUD_ENDPOINT_RUNTIME_CONFIG = "cloud-endpoint-runtime-config"; |
| 6 | export const CLOUD_LINKED_USER_ID = "cloud-linked-user-id"; | 10 | export const CLOUD_LINKED_USER_ID = "cloud-linked-user-id"; |
| ... | @@ -16,3 +20,39 @@ export const encodeEndpointRuntimeConfigJson = Schema.encodeEffect( | ... | @@ -16,3 +20,39 @@ export const encodeEndpointRuntimeConfigJson = Schema.encodeEffect( |
| 16 | export const decodeRuntimeConfig = Schema.decodeUnknownOption( | 20 | export const decodeRuntimeConfig = Schema.decodeUnknownOption( |
| 17 | Schema.fromJsonString(RelayManagedEndpointRuntimeConfig), | 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,9 +3,16 @@ import { expect, it } from "@effect/vitest"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; | 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; | ||
| 6 | import * as PlatformError from "effect/PlatformError"; | 7 | import * as PlatformError from "effect/PlatformError"; |
| 7 | import * as Schema from "effect/Schema"; | 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 | import * as ServerConfig from "../config.ts"; | 16 | import * as ServerConfig from "../config.ts"; |
| 10 | import * as ServerEnvironment from "./ServerEnvironment.ts"; | 17 | import * as ServerEnvironment from "./ServerEnvironment.ts"; |
| 11 | 18 | ||
| ... | @@ -14,7 +21,21 @@ const isServerEnvironmentIdPersistenceError = Schema.is( | ... | @@ -14,7 +21,21 @@ const isServerEnvironmentIdPersistenceError = Schema.is( |
| 14 | ); | 21 | ); |
| 15 | 22 | ||
| 16 | const makeServerEnvironmentLayer = (baseDir: string) => | 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 | const makeServerConfig = Effect.fn(function* (baseDir: string) { | 40 | const makeServerConfig = Effect.fn(function* (baseDir: string) { |
| 20 | const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); | 41 | const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); |
| ... | @@ -71,6 +92,53 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { | ... | @@ -71,6 +92,53 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { |
| 71 | expect(second.capabilities.connectionProbe).toBe(true); | 92 | expect(second.capabilities.connectionProbe).toBe(true); |
| 72 | expect(second.capabilities.pullRequests).toBe(true); | 93 | expect(second.capabilities.pullRequests).toBe(true); |
| 73 | expect(second.capabilities.threadTitleRegeneration).toBe(true); | 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,6 +181,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { |
| 113 | }).pipe( | 181 | }).pipe( |
| 114 | Effect.provide( | 182 | Effect.provide( |
| 115 | ServerEnvironment.layer.pipe( | 183 | ServerEnvironment.layer.pipe( |
| 184 | Layer.provide(emptySecretStoreLayer), | ||
| 116 | Layer.provide(Layer.merge(ServerConfig.layer(serverConfig), failingFileSystemLayer)), | 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,6 +9,8 @@ import * as Path from "effect/Path"; |
| 9 | import * as Schema from "effect/Schema"; | 9 | import * as Schema from "effect/Schema"; |
| 10 | 10 | ||
| 11 | import packageJson from "../../package.json" with { type: "json" }; | 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 | import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; | 14 | import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; |
| 13 | import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts"; | 15 | import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts"; |
| 14 | import * as ServerConfig from "../config.ts"; | 16 | import * as ServerConfig from "../config.ts"; |
| ... | @@ -66,6 +68,7 @@ export const make = Effect.gen(function* () { | ... | @@ -66,6 +68,7 @@ export const make = Effect.gen(function* () { |
| 66 | const fileSystem = yield* FileSystem.FileSystem; | 68 | const fileSystem = yield* FileSystem.FileSystem; |
| 67 | const path = yield* Path.Path; | 69 | const path = yield* Path.Path; |
| 68 | const serverConfig = yield* ServerConfig.ServerConfig; | 70 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 71 | const secrets = yield* ServerSecretStore.ServerSecretStore; | ||
| 69 | const crypto = yield* Crypto.Crypto; | 72 | const crypto = yield* Crypto.Crypto; |
| 70 | const hostPlatform = yield* HostProcessPlatform; | 73 | const hostPlatform = yield* HostProcessPlatform; |
| 71 | const hostArchitecture = yield* HostProcessArchitecture; | 74 | const hostArchitecture = yield* HostProcessArchitecture; |
| ... | @@ -156,13 +159,22 @@ export const make = Effect.gen(function* () { | ... | @@ -156,13 +159,22 @@ export const make = Effect.gen(function* () { |
| 156 | 159 | ||
| 157 | return ServerEnvironment.of({ | 160 | return ServerEnvironment.of({ |
| 158 | getEnvironmentId: Effect.succeed(environmentId), | 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 | * ServerEnvironment is acquired from persisted filesystem and host-process | 175 | * ServerEnvironment is acquired from persisted filesystem and host-process |
| 165 | * state. It intentionally has no fallback Layer.succeed value: callers must | 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 | export const layer = Layer.effect(ServerEnvironment, make).pipe(Layer.provide(ProcessRunner.layer)); | 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,6 +35,7 @@ import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; |
| 35 | 35 | ||
| 36 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 36 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 37 | import { | 37 | import { |
| 38 | isAgentActivityPublishingEnabledValue, | ||
| 38 | PUBLISH_AGENT_ACTIVITY_SECRET, | 39 | PUBLISH_AGENT_ACTIVITY_SECRET, |
| 39 | RELAY_ENVIRONMENT_CREDENTIAL_SECRET, | 40 | RELAY_ENVIRONMENT_CREDENTIAL_SECRET, |
| 40 | RELAY_ISSUER_SECRET, | 41 | RELAY_ISSUER_SECRET, |
| ... | @@ -102,7 +103,7 @@ export function agentAwarenessPublishIdentity(state: RelayAgentActivityState | n | ... | @@ -102,7 +103,7 @@ export function agentAwarenessPublishIdentity(state: RelayAgentActivityState | n |
| 102 | } | 103 | } |
| 103 | 104 | ||
| 104 | export function isAgentActivityPublishingEnabled(value: string | null): boolean { | 105 | export function isAgentActivityPublishingEnabled(value: string | null): boolean { |
| 105 | return value === "true"; | 106 | return isAgentActivityPublishingEnabledValue(value); |
| 106 | } | 107 | } |
| 107 | 108 | ||
| 108 | export function resolveAgentActivityPublishingStartupState(input: { | 109 | export function resolveAgentActivityPublishingStartupState(input: { |
packages/contracts/src/environment.ts+6| ... | @@ -74,6 +74,12 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ | ... | @@ -74,6 +74,12 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 74 | /** Server can stream self-update progress before acknowledging the | 74 | /** Server can stream self-update progress before acknowledging the |
| 75 | restart. Clients fall back to server.updateServer when absent. */ | 75 | restart. Clients fall back to server.updateServer when absent. */ |
| 76 | serverSelfUpdateProgress: Schema.optionalKey(Schema.Boolean), | 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 | export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; | 84 | export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; |
| 79 | 85 |