Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 6 additions & 1 deletion apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
} from "@react-navigation/native";
import { pipe } from "effect/Function";
import { DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth";
import * as Effect from "effect/Effect";
import { mobileInteraction } from "./connection/background-activity-scopes";

import { RegistryContext } from "@effect/atom-react";
import { ProjectChipIconRasterizer } from "./native/ProjectChipIconImages";
Expand Down Expand Up @@ -243,7 +245,10 @@ function AppContent() {
<>
<SplashScreenCoordinator />
<SubscriptionUsageCoordinator />
<GestureHandlerRootView className="flex-1">
<GestureHandlerRootView
className="flex-1"
onTouchStart={() => Effect.runSync(mobileInteraction.record)}
>
<KeyboardProvider statusBarTranslucent>
<SafeAreaProvider>
<StatusBar barStyle={themeAppearance === "dark" ? "light-content" : "dark-content"} />
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/src/components/ComposerEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/reactivity";
import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import { useEffect, useState } from "react";
import * as Effect from "effect/Effect";
import { mobileInteraction } from "../connection/background-activity-scopes";
import type { EnvironmentId } from "@t3tools/contracts";
import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard";
import { collectComposerContextReferences } from "@t3tools/shared/composerContextReferences";
Expand Down Expand Up @@ -69,6 +71,7 @@ export function ComposerEditor({
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- releases the attachment file leases held for native text undo when the history is replaced or unmounts
useEffect(() => () => contextHistory.dispose(), [contextHistory]);
const changeText = (text: string) => {
Effect.runSync(mobileInteraction.record);
const restored = contextHistory.restore(
text,
draftKey ? getComposerDraftSnapshot(draftKey) : draft,
Expand Down
17 changes: 17 additions & 0 deletions apps/mobile/src/connection/background-activity-scopes.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
import type { EnvironmentRpcSubscriptionObservation } from "@t3tools/client-runtime/rpc";
import { type BackgroundScope, type EnvironmentId, WS_METHODS } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Clock from "effect/Clock";

interface RetainedScope {
readonly environmentId: EnvironmentId;
readonly scope: BackgroundScope;
refCount: number;
}

let lastInteractionNanos: bigint | undefined;
export const mobileInteraction = {
record: Clock.monotonicTimeNanos.pipe(
Effect.tap((now) =>
Effect.sync(() => {
lastInteractionNanos = now;
}),
),
),
isRecent: Clock.monotonicTimeNanos.pipe(
Effect.map(
(now) => lastInteractionNanos !== undefined && now - lastInteractionNanos < 60_000_000_000n,
),
),
};

const retainedScopes = new Map<string, RetainedScope>();
const listeners = new Set<() => void>();

Expand Down
12 changes: 12 additions & 0 deletions apps/mobile/src/connection/background-activity.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
import { EnvironmentId, WS_METHODS } from "@t3tools/contracts";
import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as TestClock from "effect/testing/TestClock";

import {
onRetainedMobileBackgroundScopesChange,
mobileInteraction,
observeMobileBackgroundActivitySubscription,
retainedMobileBackgroundScopes,
} from "./background-activity-scopes";

describe("mobile background activity", () => {
it.effect("expires interaction recency while the app remains foregrounded", () =>
Effect.gen(function* () {
yield* mobileInteraction.record;
expect(yield* mobileInteraction.isRecent).toBe(true);
yield* TestClock.adjust("2 minutes");
expect(yield* mobileInteraction.isRecent).toBe(false);
yield* mobileInteraction.record;
expect(yield* mobileInteraction.isRecent).toBe(true);
}),
);
it.effect("retains VCS demand only while the mobile subscription is active", () =>
Effect.gen(function* () {
const environmentId = EnvironmentId.make("mobile-environment");
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/connection/background-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { AppState } from "react-native";

import * as MobileStorage from "../persistence/mobile-storage";
import {
mobileInteraction,
observeMobileBackgroundActivitySubscription,
onRetainedMobileBackgroundScopesChange,
retainedMobileBackgroundScopes,
Expand Down Expand Up @@ -54,10 +55,13 @@ export const layerReporter = Layer.effectDiscard(
const reportRequests = yield* Queue.sliding<void>(1);
const requestReport = () => Queue.offerUnsafe(reportRequests, undefined);
let appState = AppState.currentState;
yield* mobileInteraction.record;
const runSync = Effect.runSyncWith(yield* Effect.context<never>());

const report = Effect.gen(function* () {
const observedAtMs = yield* Clock.currentTimeMillis;
const active = appState === "active";
const recentlyInteracted = active && (yield* mobileInteraction.isRecent);
const entries = yield* SubscriptionRef.get(registry.entries);
yield* Effect.forEach(
entries.keys(),
Expand All @@ -71,7 +75,7 @@ export const layerReporter = Layer.effectDiscard(
clientKind: "mobile",
visible: active,
focused: active,
recentlyInteracted: active,
recentlyInteracted,
appState: normalizeAppState(appState),
scopes: [
...BASELINE_SCOPES,
Expand All @@ -93,6 +97,7 @@ export const layerReporter = Layer.effectDiscard(
Effect.sync(() => {
const removeScopeListener = onRetainedMobileBackgroundScopesChange(requestReport);
const subscription = AppState.addEventListener("change", (nextState) => {
if (nextState === "active" && appState !== "active") runSync(mobileInteraction.record);
appState = nextState;
requestReport();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,9 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
RUNTIME_MODE_CHOICES,
targets.map((target) => target.environment.serverConfig.managedPolicy),
);
const supportsAutomaticUpdates = targets.every(
(target) => target.environment.serverConfig.environment.capabilities.automaticUpdates === true,
);
const disabledFor = (key: string) =>
disabled ||
managed(key) !== undefined ||
Expand Down Expand Up @@ -542,6 +545,28 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
disabled={disabledFor("enableProviderUpdateChecks")}
onValueChange={(value) => write({ enableProviderUpdateChecks: value })}
/>
<View className="border-t border-border-subtle">
<SettingsSwitchRow
icon="arrow.down.circle"
label="Update automatically"
subtitle={
projectSelected
? "Environment-wide setting. Select All projects to change it."
: !supportsAutomaticUpdates
? "Update older servers to control automatic updates."
: uniform("enableProviderUpdateChecks") === false
? "Turn on Provider update checks to update automatically."
: "Install updates for provider CLIs that support one-click updates on the selected environments while nothing is running."
}
value={uniform("automaticUpdates")}
disabled={
disabledFor("automaticUpdates") ||
!supportsAutomaticUpdates ||
uniform("enableProviderUpdateChecks") === false
}
onValueChange={(value) => write({ automaticUpdates: value })}
/>
</View>
<View className="border-t border-border-subtle">
<SettingsSwitchRow
icon="arrow.uturn.forward"
Expand Down
34 changes: 34 additions & 0 deletions apps/server/src/background/BackgroundPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ import {
type ClientActivityReportInput,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as PubSub from "effect/PubSub";
import * as Stream from "effect/Stream";
import * as TestClock from "effect/testing/TestClock";

import * as ServerSettings from "../serverSettings.ts";
import * as BackgroundPolicy from "./BackgroundPolicy.ts";
Expand Down Expand Up @@ -248,4 +250,36 @@ describe("BackgroundPolicy", () => {
),
),
);

it.effect("an interaction resets the quiet clock and a heartbeat does not", () =>
Effect.gen(function* () {
const policy = yield* BackgroundPolicy.BackgroundPolicy;
const sessionId = AuthSessionId.make("session-1");
yield* TestClock.adjust("20 minutes");
assert.isTrue(
Duration.isGreaterThanOrEqualTo(
yield* policy.sinceLastClientInteraction,
Duration.minutes(20),
),
);

yield* policy.reportClientActivity(
sessionId,
RpcClientId.make(1),
makeReport({ recentlyInteracted: false }),
);
assert.isTrue(
Duration.isGreaterThanOrEqualTo(
yield* policy.sinceLastClientInteraction,
Duration.minutes(20),
),
);

yield* policy.reportClientActivity(sessionId, RpcClientId.make(1), makeReport());
yield* TestClock.adjust("1 minute");
assert.isTrue(
Duration.isLessThan(yield* policy.sinceLastClientInteraction, Duration.minutes(2)),
);
}).pipe(Effect.provide(layerFor(nominalHostPower))),
);
});
13 changes: 13 additions & 0 deletions apps/server/src/background/BackgroundPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import {
resolveServerBackgroundActivitySettings,
type ResolvedBackgroundActivitySettings,
} from "@t3tools/shared/backgroundActivitySettings";
import * as Clock from "effect/Clock";
import * as DateTime from "effect/DateTime";
import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Ref from "effect/Ref";
Expand All @@ -34,6 +36,8 @@ export class BackgroundPolicy extends Context.Service<
rpcClientId: RpcClientId,
) => Effect.Effect<void>;
readonly snapshot: Effect.Effect<BackgroundPolicySnapshot>;
/** Time since recent client interaction, measured on the monotonic clock. */
readonly sinceLastClientInteraction: Effect.Effect<Duration.Duration>;
readonly shouldRunScopeWork: (scope: BackgroundScope) => Effect.Effect<boolean>;
readonly shouldRunOpportunisticWork: Effect.Effect<boolean>;
}
Expand Down Expand Up @@ -196,6 +200,7 @@ export const make = Effect.fn("background.policy.make")(function* () {
const hostPowerMonitor = yield* HostPowerMonitor.HostPowerMonitor;
const serverSettings = yield* ServerSettings.ServerSettingsService;
const leasesRef = yield* Ref.make(new Map<string, ClientActivityLease>());
const lastClientInteractionNanosRef = yield* Ref.make(yield* Clock.monotonicTimeNanos);

const backgroundActivitySettings = serverSettings.getSettings.pipe(
Effect.map(resolveServerBackgroundActivitySettings),
Expand Down Expand Up @@ -233,6 +238,9 @@ export const make = Effect.fn("background.policy.make")(function* () {
expiresAt,
};
yield* Ref.update(leasesRef, (leases) => upsertClientActivityLease(leases, lease, now));
if (input.recentlyInteracted) {
yield* Ref.set(lastClientInteractionNanosRef, yield* Clock.monotonicTimeNanos);
}
});

const removeRpcClient: BackgroundPolicy["Service"]["removeRpcClient"] = (
Expand Down Expand Up @@ -289,6 +297,11 @@ export const make = Effect.fn("background.policy.make")(function* () {
reportClientActivity,
removeRpcClient,
snapshot,
sinceLastClientInteraction: Effect.zipWith(
Clock.monotonicTimeNanos,
Ref.get(lastClientInteractionNanosRef),
(now, last) => Duration.nanos(now - last),
),
shouldRunScopeWork,
shouldRunOpportunisticWork,
});
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ export const make = Effect.gen(function* () {
environmentIcon: true,
projectCloneTracking: true,
projectOrganization: true,
automaticUpdates: true,
ignoredFileListing: true,
triage: true,
oneTimeSchedules: true,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/mcp/toolkits/environment/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export function preferences(settings: ServerSettings) {
defaultThreadEnvMode,
newWorktreesStartFromOrigin,
enableProviderUpdateChecks,
automaticUpdates,
backgroundActivity,
sourceControlWritingStyle,
} = settings;
Expand All @@ -21,6 +22,7 @@ export function preferences(settings: ServerSettings) {
defaultThreadEnvMode,
newWorktreesStartFromOrigin,
enableProviderUpdateChecks,
automaticUpdates,
backgroundActivity: { profile: backgroundActivity.profile },
sourceControlWritingStyle: {
...sourceControlWritingStyle,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/mcp/toolkits/environment/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const PreferenceFields = {
defaultThreadEnvMode: ServerSettings.fields.defaultThreadEnvMode,
newWorktreesStartFromOrigin: ServerSettings.fields.newWorktreesStartFromOrigin,
enableProviderUpdateChecks: ServerSettings.fields.enableProviderUpdateChecks,
automaticUpdates: ServerSettings.fields.automaticUpdates,
backgroundActivity: Schema.Struct({ profile: BackgroundActivityProfileSelection }),
sourceControlWritingStyle: Schema.Struct({
mode: Schema.String,
Expand Down Expand Up @@ -62,6 +63,7 @@ const EnvironmentPreferencesTool = Tool.make("t3_environment_preferences_update"
defaultThreadEnvMode: ServerSettingsPatch.fields.defaultThreadEnvMode,
newWorktreesStartFromOrigin: ServerSettingsPatch.fields.newWorktreesStartFromOrigin,
enableProviderUpdateChecks: ServerSettingsPatch.fields.enableProviderUpdateChecks,
automaticUpdates: ServerSettingsPatch.fields.automaticUpdates,
backgroundActivity: Schema.optionalKey(Schema.Struct({ profile: BackgroundActivityProfile })),
sourceControlWritingStyle: ServerSettingsPatch.fields.sourceControlWritingStyle,
}),
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/orchestration-v2/ProjectionRecovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,8 @@ it.effect("selects unfinished recovery work without reading settled thread histo
JSON.stringify(plan),
);
}
// Only runs count: queued runs and idle background items do not block an update.
assert.deepEqual(yield* projections.getRecoveryThreadIds("active-work"), [blocked]);
const recoveryState = yield* projections.getRuntimeRecoveryProjection(queued);
assert.deepEqual(
recoveryState.runs.map((run) => run.id),
Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/orchestration-v2/ProjectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,9 @@ export type ProjectionRecoveryKind =
| "queued-runs"
| "runtime"
| "restart-continuations"
// Runs a restart or CLI replacement would cut off: preparing, starting,
// running or waiting (a pending approval or question).
| "active-work"
| "subagent-results"
| "delegated-completions";

Expand Down Expand Up @@ -4217,6 +4220,11 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
THEN json_extract(payload_json, '$.restartContinuationOfRunId') IS NOT NULL
ELSE 0 END
`;
case "active-work":
return sql`
SELECT thread_id FROM orchestration_v2_projection_runs
WHERE status IN ('preparing', 'starting', 'running', 'waiting')
`;
case "runtime":
return sql`
WITH pending_provider_threads AS MATERIALIZED (
Expand Down
Loading
Loading