authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-14 10:00:25+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-14 10:00:25+02:00
logbaaeda305c60933e5e573b30807ecfc3a0871a80
treeb38b09f039d9e1a53cd7631f255abeabf2390794
parent7e01d33f0eeb9435299791392d546756cc09c5d3
signature Signed by PGP key B5690EEEBB952194

fix: avoid stale Live Activities when publishing is disabled (#6325)

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";
26import type { Preferences } from "../../persistence/mobile-preferences";
25import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";27import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";
26import {28import {
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";
43const secureStore = vi.hoisted(() => new Map<string, string>());46const secureStore = vi.hoisted(() => new Map<string, string>());
44const widgetMocks = vi.hoisted(() => ({47const widgetMocks = vi.hoisted(() => ({
45 getInstances: vi.fn(() => []),48 getInstances: vi.fn(() => []),
49 start: vi.fn(() => ({})),
50}));
51const environmentConfigsMock = vi.hoisted(() => ({
52 configs: new Map<
53 string,
54 { environment: { capabilities: { agentActivityPublishing?: boolean } } }
55 >(),
46}));56}));
47const backgroundRuntime = vi.hoisted(() => ({57const 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", () => ({
77vi.mock("../../widgets/AgentActivity", () => ({87vi.mock("../../widgets/AgentActivity", () => ({
78 default: {88 default: {
79 getInstances: widgetMocks.getInstances,89 getInstances: widgetMocks.getInstances,
90 start: widgetMocks.start,
80 },91 },
81}));92}));
8293
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.
96vi.mock("../../state/atom-registry", () => ({
97 appAtomRegistry: {
98 get: () => environmentConfigsMock.configs,
99 },
100}));
101
102vi.mock("../../state/server", () => ({
103 environmentServerConfigsAtom: Symbol("environmentServerConfigsAtom"),
104}));
105
83vi.mock("expo-notifications", () => ({106vi.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 });
231256
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 {
2020
21import type { SavedRemoteConnection } from "../../lib/connection";21import type { SavedRemoteConnection } from "../../lib/connection";
22import { runtime } from "../../lib/runtime";22import { runtime } from "../../lib/runtime";
23import { appAtomRegistry } from "../../state/atom-registry";
24import { environmentServerConfigsAtom } from "../../state/server";
23import type { Preferences } from "../../persistence/mobile-preferences";25import type { Preferences } from "../../persistence/mobile-preferences";
24import {26import {
25 clearAgentAwarenessRegistrationRecord,27 clearAgentAwarenessRegistrationRecord,
...@@ -448,18 +450,38 @@ function unregisterDeviceWithRelay(input: {...@@ -448,18 +450,38 @@ function unregisterDeviceWithRelay(input: {
448 });450 });
449}451}
450452
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.
457function 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 this464// 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 token465// 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 authoritative467// 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.
456export function armAgentAwarenessLiveActivityForLocalWork(input: {471export 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 replay709 // -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't541 // 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";
36import * as CliTokenManager from "../cloud/CliTokenManager.ts";36import * as CliTokenManager from "../cloud/CliTokenManager.ts";
37import {37import {
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}
143144
144export function isPublishAgentActivityEnabledValue(value: string | null): boolean {145export function isPublishAgentActivityEnabledValue(value: string | null): boolean {
145 return value === "true";146 return isAgentActivityPublishingEnabledValue(value);
146}147}
147148
148interface CloudCliStatus {149interface 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 @@
1import { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay";1import { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
2import * as Schema from "effect/Schema";4import * as Schema from "effect/Schema";
35
6import type * as ServerSecretStore from "../auth/ServerSecretStore.ts";
7
4export const CLOUD_MINT_PUBLIC_KEY = "cloud-mint-ed25519-public-key";8export const CLOUD_MINT_PUBLIC_KEY = "cloud-mint-ed25519-public-key";
5export const CLOUD_ENDPOINT_RUNTIME_CONFIG = "cloud-endpoint-runtime-config";9export const CLOUD_ENDPOINT_RUNTIME_CONFIG = "cloud-endpoint-runtime-config";
6export const CLOUD_LINKED_USER_ID = "cloud-linked-user-id";10export 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(
16export const decodeRuntimeConfig = Schema.decodeUnknownOption(20export const decodeRuntimeConfig = Schema.decodeUnknownOption(
17 Schema.fromJsonString(RelayManagedEndpointRuntimeConfig),21 Schema.fromJsonString(RelayManagedEndpointRuntimeConfig),
18);22);
23
24export 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. */
32export 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";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
6import * as PlatformError from "effect/PlatformError";7import * as PlatformError from "effect/PlatformError";
7import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
89
10import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
11import {
12 PUBLISH_AGENT_ACTIVITY_SECRET,
13 RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
14 RELAY_URL_SECRET,
15} from "../cloud/config.ts";
9import * as ServerConfig from "../config.ts";16import * as ServerConfig from "../config.ts";
10import * as ServerEnvironment from "./ServerEnvironment.ts";17import * as ServerEnvironment from "./ServerEnvironment.ts";
1118
...@@ -14,7 +21,21 @@ const isServerEnvironmentIdPersistenceError = Schema.is(...@@ -14,7 +21,21 @@ const isServerEnvironmentIdPersistenceError = Schema.is(
14);21);
1522
16const makeServerEnvironmentLayer = (baseDir: string) =>23const 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
29const 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);
1839
19const makeServerConfig = Effect.fn(function* (baseDir: string) {40const 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 );
76144
...@@ -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";
9import * as Schema from "effect/Schema";9import * as Schema from "effect/Schema";
1010
11import packageJson from "../../package.json" with { type: "json" };11import packageJson from "../../package.json" with { type: "json" };
12import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
13import { readAgentActivityPublishingActive } from "../cloud/config.ts";
12import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts";14import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts";
13import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts";15import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts";
14import * as ServerConfig from "../config.ts";16import * 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* () {
156159
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});
162173
163/**174/**
164 * ServerEnvironment is acquired from persisted filesystem and host-process175 * ServerEnvironment is acquired from persisted filesystem and host-process
165 * state. It intentionally has no fallback Layer.succeed value: callers must176 * 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 */
168export const layer = Layer.effect(ServerEnvironment, make).pipe(Layer.provide(ProcessRunner.layer));180export 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";
3535
36import * as ServerSecretStore from "../auth/ServerSecretStore.ts";36import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
37import {37import {
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}
103104
104export function isAgentActivityPublishingEnabled(value: string | null): boolean {105export function isAgentActivityPublishingEnabled(value: string | null): boolean {
105 return value === "true";106 return isAgentActivityPublishingEnabledValue(value);
106}107}
107108
108export function resolveAgentActivityPublishingStartupState(input: {109export 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 the74 /** 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});
78export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type;84export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type;
7985