Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,15 @@ import {
clearAgentAwarenessRegistrationRecord,
loadAgentAwarenessRegistrationRecord,
loadOrCreateAgentAwarenessDeviceId,
loadPreferences,
saveAgentAwarenessRegistrationRecord,
} from "../../persistence/imperative";
import type { Preferences } from "../../persistence/mobile-preferences";
import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";
import {
AgentAwarenessOperationError,
__resetAgentAwarenessRemoteRegistrationForTest,
armAgentAwarenessLiveActivityForLocalWork,
getAgentAwarenessRegistrationStatus,
mergeAgentAwarenessRegistrationPreferences,
refreshActiveLiveActivityRemoteRegistration,
Expand All @@ -43,6 +46,13 @@ import * as Notifications from "expo-notifications";
const secureStore = vi.hoisted(() => new Map<string, string>());
const widgetMocks = vi.hoisted(() => ({
getInstances: vi.fn(() => []),
start: vi.fn(() => ({})),
}));
const environmentConfigsMock = vi.hoisted(() => ({
configs: new Map<
string,
{ environment: { capabilities: { agentActivityPublishing?: boolean } } }
>(),
}));
const backgroundRuntime = vi.hoisted(() => ({
pending: [] as Array<{
Expand Down Expand Up @@ -77,9 +87,22 @@ vi.mock("expo-widgets", () => ({
vi.mock("../../widgets/AgentActivity", () => ({
default: {
getInstances: widgetMocks.getInstances,
start: widgetMocks.start,
},
}));

// The state modules pull the whole connection stack (and native expo modules)
// into the import graph; the arming gate only needs the configs map.
vi.mock("../../state/atom-registry", () => ({
appAtomRegistry: {
get: () => environmentConfigsMock.configs,
},
}));

vi.mock("../../state/server", () => ({
environmentServerConfigsAtom: Symbol("environmentServerConfigsAtom"),
}));

vi.mock("expo-notifications", () => ({
addPushTokenListener: vi.fn(() => ({ remove: vi.fn() })),
getDevicePushTokenAsync: vi.fn(() => Promise.resolve({ type: "ios", data: "apns-token" })),
Expand Down Expand Up @@ -227,6 +250,8 @@ describe("makeRelayDeviceRegistrationRequest", () => {
vi.mocked(loadOrCreateAgentAwarenessDeviceId).mockResolvedValue("device-1");
widgetMocks.getInstances.mockReset();
widgetMocks.getInstances.mockReturnValue([]);
widgetMocks.start.mockClear();
environmentConfigsMock.configs.clear();
});

it("preserves disabled Live Activity preferences in relay registrations", () => {
Expand Down Expand Up @@ -856,4 +881,55 @@ describe("makeRelayDeviceRegistrationRequest", () => {
}).pipe(Effect.provide(relayTestLayer));
},
);

it("skips the Live Activity seed when the environment reports publishing disabled", async () => {
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
vi.mocked(loadPreferences).mockResolvedValueOnce({
liveActivitiesEnabled: true,
} as Preferences);
environmentConfigsMock.configs.set("env-1", {
environment: { capabilities: { agentActivityPublishing: false } },
});

armAgentAwarenessLiveActivityForLocalWork({
environmentId: "env-1" as EnvironmentId,
threadTitle: "Fix the flaky test",
projectTitle: "t3code",
});
await new Promise((resolve) => setTimeout(resolve, 0));

expect(widgetMocks.start).not.toHaveBeenCalled();
});

it("seeds the Live Activity for publishing and pre-capability environments", async () => {
setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a"));
environmentConfigsMock.configs.set("env-publishing", {
environment: { capabilities: { agentActivityPublishing: true } },
});

vi.mocked(loadPreferences).mockResolvedValueOnce({
liveActivitiesEnabled: true,
} as Preferences);
armAgentAwarenessLiveActivityForLocalWork({
environmentId: "env-publishing" as EnvironmentId,
threadTitle: "Fix the flaky test",
projectTitle: "t3code",
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(widgetMocks.start).toHaveBeenCalledTimes(1);

// An environment without the capability may run an older server that
// still publishes; only an explicit false skips the seed.
widgetMocks.start.mockClear();
vi.mocked(loadPreferences).mockResolvedValueOnce({
liveActivitiesEnabled: true,
} as Preferences);
armAgentAwarenessLiveActivityForLocalWork({
environmentId: "env-pre-capability" as EnvironmentId,
threadTitle: "Fix the flaky test",
projectTitle: "t3code",
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(widgetMocks.start).toHaveBeenCalledTimes(1);
});
});
24 changes: 23 additions & 1 deletion apps/mobile/src/features/agent-awareness/remoteRegistration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {

import type { SavedRemoteConnection } from "../../lib/connection";
import { runtime } from "../../lib/runtime";
import { appAtomRegistry } from "../../state/atom-registry";
import { environmentServerConfigsAtom } from "../../state/server";
import type { Preferences } from "../../persistence/mobile-preferences";
import {
clearAgentAwarenessRegistrationRecord,
Expand Down Expand Up @@ -448,18 +450,38 @@ function unregisterDeviceWithRelay(input: {
});
}

// The environment descriptor advertises whether agent-activity publishes
// currently leave that server (`capabilities.agentActivityPublishing`). Only
// an explicit false skips the seed card: older servers omit the capability
// but may still publish.
function environmentPublishesAgentActivity(environmentId: EnvironmentId): boolean {
return (
appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities
.agentActivityPublishing !== false
);
}

// Arms the lock-screen card the moment the user starts agent work from this
// phone, while the app is still foregrounded and the fresh activity's token
// can be registered immediately. The seeded row is a best-effort placeholder;
// the relay's registration replay repaints it with the authoritative
// aggregate within seconds. No-ops when a card is already armed.
// aggregate within seconds. No-ops when a card is already armed, and skips
// environments that report publishing disabled — the seed would sit on
// "Connecting" forever with no update ever arriving to repaint or end it.
export function armAgentAwarenessLiveActivityForLocalWork(input: {
readonly environmentId: EnvironmentId;
readonly threadTitle: string;
readonly projectTitle: string;
}): void {
if (!canRegisterRemoteLiveActivities() || !relayTokenProvider) {
return;
}
if (!environmentPublishesAgentActivity(input.environmentId)) {
logRegistrationDebug("live activity arming skipped; environment does not publish", {
environmentId: input.environmentId,
});
return;
}
void loadPreferences()
.catch(() => null)
.then((preferences) => {
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,7 @@ export function NewTaskDraftScreen(props: {
// -only Activity start. If creation fails, the token registration's replay
// finds no work and ends the card within seconds.
armAgentAwarenessLiveActivityForLocalWork({
environmentId: selectedProject.environmentId,
threadTitle: deriveThreadTitleFromPrompt(initialMessageText),
projectTitle: selectedProject.title,
});
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
// after the send so its preference read and native Activity start don't
// contend with the queued-message feedback on the tap frame.
armAgentAwarenessLiveActivityForLocalWork({
environmentId: props.environmentId,
threadTitle: props.selectedThread.title,
projectTitle: props.environmentLabel ?? "T3 Code",
});
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/cli/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import * as CliState from "../cloud/CliState.ts";
import * as CliTokenManager from "../cloud/CliTokenManager.ts";
import {
CLOUD_LINKED_USER_ID,
isAgentActivityPublishingEnabledValue,
PUBLISH_AGENT_ACTIVITY_SECRET,
RELAY_URL_SECRET,
} from "../cloud/config.ts";
Expand Down Expand Up @@ -142,7 +143,7 @@ function stringToBytes(value: string): Uint8Array {
}

export function isPublishAgentActivityEnabledValue(value: string | null): boolean {
return value === "true";
return isAgentActivityPublishingEnabledValue(value);
}

interface CloudCliStatus {
Expand Down
36 changes: 36 additions & 0 deletions apps/server/src/cloud/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";

import type * as ServerSecretStore from "../auth/ServerSecretStore.ts";

export const CLOUD_MINT_PUBLIC_KEY = "cloud-mint-ed25519-public-key";
export const CLOUD_ENDPOINT_RUNTIME_CONFIG = "cloud-endpoint-runtime-config";
export const CLOUD_LINKED_USER_ID = "cloud-linked-user-id";
Expand All @@ -16,3 +20,35 @@ export const encodeEndpointRuntimeConfigJson = Schema.encodeEffect(
export const decodeRuntimeConfig = Schema.decodeUnknownOption(
Schema.fromJsonString(RelayManagedEndpointRuntimeConfig),
);

export function isAgentActivityPublishingEnabledValue(value: string | null): boolean {
return value === "true";
}

/** Whether agent-activity publishes currently leave this environment: the
publish opt-in secret is enabled and the relay link credentials exist.
Mirrors the per-publish gate in AgentAwarenessRelay, so the descriptor
capability never advertises publishing that the publisher would skip. */
export const readAgentActivityPublishingActive = (
secrets: ServerSecretStore.ServerSecretStore["Service"],
): Effect.Effect<boolean> =>
Effect.gen(function* () {
const readSecretString = (name: string) =>
secrets
.get(name)
.pipe(
Effect.map((bytes) =>
Option.isSome(bytes) ? new TextDecoder().decode(bytes.value) : null,
),
);
const [enabled, url, environmentCredential] = yield* Effect.all([
readSecretString(PUBLISH_AGENT_ACTIVITY_SECRET),
readSecretString(RELAY_URL_SECRET),
readSecretString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET),
]);
return (
isAgentActivityPublishingEnabledValue(enabled) &&
url !== null &&
environmentCredential !== null
);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}).pipe(Effect.orElseSucceed(() => false));
51 changes: 51 additions & 0 deletions apps/server/src/environment/ServerEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import * as Layer from "effect/Layer";
import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";

import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import {
PUBLISH_AGENT_ACTIVITY_SECRET,
RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
RELAY_URL_SECRET,
} from "../cloud/config.ts";
import * as ServerConfig from "../config.ts";
import * as ServerEnvironment from "./ServerEnvironment.ts";

Expand Down Expand Up @@ -70,6 +76,46 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
expect(second.capabilities.repositoryIdentity).toBe(true);
expect(second.capabilities.connectionProbe).toBe(true);
expect(second.capabilities.threadTitleRegeneration).toBe(true);
expect(second.capabilities.agentActivityPublishing).toBe(false);
}),
);

it.effect("reports agent activity publishing from the current secret state", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-server-environment-publish-test-",
});
const testLayer = Layer.mergeAll(
makeServerEnvironmentLayer(baseDir),
ServerSecretStore.layer.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))),
);

yield* Effect.gen(function* () {
const secrets = yield* ServerSecretStore.ServerSecretStore;
const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
const encode = (value: string) => new TextEncoder().encode(value);

const unlinked = yield* serverEnvironment.getDescriptor;
expect(unlinked.capabilities.agentActivityPublishing).toBe(false);

// The opt-in alone is not enough: without relay link credentials no
// publish would leave this environment.
yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, encode("true"));
const withoutLink = yield* serverEnvironment.getDescriptor;
expect(withoutLink.capabilities.agentActivityPublishing).toBe(false);

yield* secrets.set(RELAY_URL_SECRET, encode("https://relay.example"));
yield* secrets.set(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, encode("credential"));
const linked = yield* serverEnvironment.getDescriptor;
expect(linked.capabilities.agentActivityPublishing).toBe(true);

// The toggle changes at runtime, so the same service instance must
// reflect a flip without a restart.
yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, encode("false"));
const disabled = yield* serverEnvironment.getDescriptor;
expect(disabled.capabilities.agentActivityPublishing).toBe(false);
}).pipe(Effect.provide(testLayer));
}),
);

Expand Down Expand Up @@ -104,6 +150,11 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
writeAttempts.push(path);
return Effect.fail(cause);
},
// The secret store the environment layer builds prepares its
// directory on construction; only the environment-id operations
// above are meant to fail here.
makeDirectory: () => Effect.void,
chmod: () => Effect.void,
});

const error = yield* Effect.gen(function* () {
Expand Down
18 changes: 16 additions & 2 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import * as Path from "effect/Path";
import * as Schema from "effect/Schema";

import packageJson from "../../package.json" with { type: "json" };
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import { readAgentActivityPublishingActive } from "../cloud/config.ts";
import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts";
import * as ServerConfig from "../config.ts";
import * as ProcessRunner from "../processRunner.ts";
Expand Down Expand Up @@ -65,6 +67,7 @@ export const make = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const serverConfig = yield* ServerConfig.ServerConfig;
const secrets = yield* ServerSecretStore.ServerSecretStore;
const crypto = yield* Crypto.Crypto;
const hostPlatform = yield* HostProcessPlatform;
const hostArchitecture = yield* HostProcessArchitecture;
Expand Down Expand Up @@ -152,7 +155,15 @@ export const make = Effect.gen(function* () {

return ServerEnvironment.of({
getEnvironmentId: Effect.succeed(environmentId),
getDescriptor: Effect.succeed(descriptor),
// The publish opt-in and relay link change at runtime (`t3 connect
// publish`, the client settings toggle), so the capability is read per
// descriptor request rather than baked in at startup.
getDescriptor: readAgentActivityPublishingActive(secrets).pipe(
Effect.map((agentActivityPublishing) => ({
...descriptor,
capabilities: { ...descriptor.capabilities, agentActivityPublishing },
})),
),
});
});

Expand All @@ -161,4 +172,7 @@ export const make = Effect.gen(function* () {
* state. It intentionally has no fallback Layer.succeed value: callers must
* provide the external platform services and a ServerConfig.
*/
export const layer = Layer.effect(ServerEnvironment, make).pipe(Layer.provide(ProcessRunner.layer));
export const layer = Layer.effect(ServerEnvironment, make).pipe(
Layer.provide(ProcessRunner.layer),
Layer.provide(ServerSecretStore.layer),
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
);
3 changes: 2 additions & 1 deletion apps/server/src/relay/AgentAwarenessRelay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";

import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import {
isAgentActivityPublishingEnabledValue,
PUBLISH_AGENT_ACTIVITY_SECRET,
RELAY_ENVIRONMENT_CREDENTIAL_SECRET,
RELAY_ISSUER_SECRET,
Expand Down Expand Up @@ -101,7 +102,7 @@ export function agentAwarenessPublishIdentity(state: RelayAgentActivityState | n
}

export function isAgentActivityPublishingEnabled(value: string | null): boolean {
return value === "true";
return isAgentActivityPublishingEnabledValue(value);
}

export function resolveAgentActivityPublishingStartupState(input: {
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
/** Server can stream self-update progress before acknowledging the
restart. Clients fall back to server.updateServer when absent. */
serverSelfUpdateProgress: Schema.optionalKey(Schema.Boolean),
/** Agent-activity publishes (push notifications and Live Activities)
currently leave this environment: the publish opt-in is enabled and the
relay link credentials exist. Clients skip seeding a Live Activity when
this is false — no update would ever repaint it. Absent on older
servers, which may still publish, so only an explicit false skips. */
agentActivityPublishing: Schema.optionalKey(Schema.Boolean),
});
export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type;

Expand Down
Loading