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
5 changes: 4 additions & 1 deletion apps/mobile/src/connection/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ type ConnectionLayerSource =
| typeof mobileBackgroundActivityObserverLayer
| typeof mobileBackgroundActivityReporterLayer;

const providedClientConnectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe(
const providedClientConnectionLayer = Layer.merge(
Connection.layerWithOptions({ usageLimitSources: true }),
snapshotLoaderLayer,
).pipe(
Layer.provideMerge(
Layer.mergeAll(
runtimeContextLayer,
Expand Down
42 changes: 42 additions & 0 deletions apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
// @effect-diagnostics nodeBuiltinImport:off - cleanup uses Node's retrying rm, which the FileSystem service does not expose.
import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk";
import { vi } from "vite-plus/test";
import * as Deferred from "effect/Deferred";
import * as Fiber from "effect/Fiber";
import * as TestClock from "effect/testing/TestClock";
import { ClaudeSettings } from "@t3tools/contracts";
import * as NodeFSP from "node:fs/promises";
import * as NodeServices from "@effect/platform-node/NodeServices";
Expand All @@ -14,6 +19,8 @@ import {
probeClaudeCapabilities,
} from "./ClaudeProvider.ts";

vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true });

const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);

it("isolates Claude capability probes without dropping workspace setting sources", () => {
Expand Down Expand Up @@ -181,3 +188,38 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
}).pipe(Effect.scoped),
);
});

it.effect("preserves initialized capabilities when optional usage times out", () =>
Effect.gen(function* () {
const usageStarted = yield* Deferred.make<void>();
let abortSignal: AbortSignal | undefined;
const query = vi.spyOn(ClaudeSdk, "query").mockImplementation(({ options }) => {
abortSignal = options?.abortController?.signal;
return {
initializationResult: async () => ({
account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },
commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],
}),
usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET: () => {
Deferred.doneUnsafe(usageStarted, Effect.void);
return new Promise(() => {});
},
} as ReturnType<typeof ClaudeSdk.query>;
});
yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore()));
const probe = yield* probeClaudeCapabilities(
decodeClaudeSettings({ binaryPath: "claude" }),
).pipe(Effect.forkChild);
yield* Deferred.await(usageStarted);
yield* TestClock.adjust("4 seconds");
const capabilities = yield* Fiber.join(probe);
assert.equal(capabilities?.email, "dev@example.com");
assert.equal(capabilities?.subscriptionType, "pro");
assert.equal(capabilities?.tokenSource, "oauth");
assert.deepEqual(capabilities?.slashCommands, [
{ name: "review", description: "Review changes", input: { hint: "[path]" } },
]);
assert.equal(capabilities?.usage, undefined);
assert.equal(abortSignal?.aborted, true);
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
);
63 changes: 33 additions & 30 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,44 +355,47 @@ const probeClaudeCapabilities = (
}),
});
const init = await q.initializationResult();
// Usage is a second control round trip on the same process; a failure
// there must not cost the slash commands and account we already have.
const usage = await q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET().then(
(response) => ({
rate_limits_available: response.rate_limits_available,
rate_limits: response.rate_limits,
}),
() => undefined,
);
const account = init.account as
| {
readonly email?: string;
readonly subscriptionType?: string;
readonly tokenSource?: string;
readonly apiProvider?: string;
}
| undefined;
return {
email: account?.email,
subscriptionType: account?.subscriptionType,
tokenSource: account?.tokenSource,
apiProvider: account?.apiProvider,
slashCommands: parseClaudeInitializationCommands(init.commands),
...(usage ? { usage } : {}),
} satisfies ClaudeCapabilitiesProbe;
return { q, init };
});
}).pipe(
Effect.timeout(CAPABILITIES_PROBE_TIMEOUT_MS),
Effect.flatMap(({ q, init }) =>
Effect.gen(function* () {
// Usage has its own deadline so a slow optional request cannot discard initialization.
const usageResult = yield* Effect.tryPromise(() =>
q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(),
).pipe(Effect.timeout(DEFAULT_TIMEOUT_MS), Effect.result);
const usage = Result.isSuccess(usageResult)
? {
rate_limits_available: usageResult.success.rate_limits_available,
rate_limits: usageResult.success.rate_limits,
}
: undefined;
const account = init.account as
| {
readonly email?: string;
readonly subscriptionType?: string;
readonly tokenSource?: string;
readonly apiProvider?: string;
}
| undefined;
return {
email: account?.email,
subscriptionType: account?.subscriptionType,
tokenSource: account?.tokenSource,
apiProvider: account?.apiProvider,
slashCommands: parseClaudeInitializationCommands(init.commands),
...(usage ? { usage } : {}),
} satisfies ClaudeCapabilitiesProbe;
}),
),
Effect.ensuring(
Effect.sync(() => {
if (!abort.signal.aborted) abort.abort();
}),
),
Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS),
Effect.result,
Effect.map((result) => {
if (Result.isFailure(result)) return undefined;
return Option.isSome(result.success) ? result.success.value : undefined;
}),
Effect.map((result) => (Result.isSuccess(result) ? result.success : undefined)),
);
};

Expand Down
24 changes: 22 additions & 2 deletions apps/server/src/pullRequest/PullRequestService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1005,18 +1005,30 @@ it.effect("refuses an action the host never claimed it could run", () =>
}),
);

it.effect("publishes a successful merge for immediate settlement", () =>
it.effect("publishes a merge for immediate settlement only after host confirmation", () =>
Effect.scoped(
Effect.gen(function* () {
const mergedAt = "2026-09-03T02:00:00.000Z";
let state: "open" | "merged" = "open";
let confirmationFails = false;
const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 };
const service = yield* makeService({
projects: [
project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }),
],
providers: [
fakeProvider("github", {
runAction: () => TestClock.setTime(Date.parse(mergedAt)),
getChangeRequestSummary: () =>
confirmationFails
? Effect.fail(
new PullRequestProviderError({
provider: "github",
operation: "getChangeRequestSummary",
reason: "failed",
detail: "HTTP 504",
}),
)
: Effect.succeed({ ...changeRequest(1, mergedAt), state }),
}),
],
});
Expand All @@ -1025,6 +1037,13 @@ it.effect("publishes a successful merge for immediate settlement", () =>
Effect.forkChild({ startImmediately: true }),
);

// Queueing succeeds while the host still reports an open PR.
yield* service.runAction({ ...reference, action: "merge" });
confirmationFails = true;
yield* service.runAction({ ...reference, action: "merge" });
confirmationFails = false;
state = "merged";
yield* TestClock.setTime(Date.parse(mergedAt));
yield* service.runAction({
...reference,
repository: " ACME/WEB ",
Expand Down Expand Up @@ -1965,6 +1984,7 @@ it.effect("refuses a merge strategy the host does not offer", () =>
review: FULL_REVIEW,
reviewers: FULL_REVIEWERS,
},
getChangeRequestSummary: () => Effect.succeed(changeRequest(1, "2026-07-02T00:00:00Z")),
runAction: (input) => {
ranWith = input.mergeMethod ?? "merge";
return Effect.void;
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/pullRequest/PullRequestService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2432,6 +2432,15 @@ export const make = Effect.gen(function* () {
bumpRefEpoch({ ...input, repository });
listingsEpoch = ++epochCounter;
if (input.action === "merge") {
// A successful merge action can merely enqueue the PR or enable auto-merge.
const confirmed = yield* summaryUncached({ ...input, repository }).pipe(
Effect.catch((error) =>
Effect.logWarning("failed to confirm pull request merge", { error }).pipe(
Effect.as(null),
),
),
);
if (confirmed?.state !== "merged") return;
yield* PubSub.publish(mergedPullRequests, {
projectId: input.projectId,
repository,
Expand Down
39 changes: 32 additions & 7 deletions apps/web/src/components/usage/UsagePage.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { useAtomValue } from "@effect/atom-react";
import type { UsageProviderKind } from "@t3tools/contracts";
import * as Option from "effect/Option";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react";
import { useMemo, useState } from "react";

import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge";

import { isElectron } from "../../env";
import { cn } from "../../lib/utils";
import { usePrimaryEnvironmentId } from "../../state/environments";
import { environmentPresentations } from "../../state/presentation";
import { environmentSession } from "../../state/session";
import { serverEnvironment } from "../../state/server";
import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
import { useAtomCommand } from "../../state/use-atom-command";
Expand Down Expand Up @@ -57,6 +61,30 @@ const WINDOW_OPTIONS = [
{ days: 90, label: "90 days" },
] as const;

const limitsRefreshEnvironmentsAtom = Atom.family((enabled: boolean) =>
Atom.make((get) =>
!enabled
? []
: [...get(environmentPresentations.presentationsAtom)]
.filter(([environmentId, presentation]) => {
if (
presentation.connection.phase !== "connected" ||
presentation.serverConfig === null
) {
return false;
}
const session = Option.getOrNull(
AsyncResult.value(get(environmentSession.sessionStateAtom(environmentId))),
);
return (
session?.authenticated === true &&
(session.scopes === undefined || session.scopes.includes("orchestration:operate"))
);
})
.map(([environmentId]) => environmentId),
),
);
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

export function UsagePage() {
const [windowSelection, setWindowSelection] = useState(() => ({
days: 30,
Expand All @@ -68,7 +96,7 @@ export function UsagePage() {
const { days: windowDays, window } = windowSelection;
const isPast24Hours = windowDays === 1;
const { merged, environments, isPending, isPartial, refresh } = useUsage(window);
const primaryEnvironmentId = usePrimaryEnvironmentId();
const limitsRefreshEnvironments = useAtomValue(limitsRefreshEnvironmentsAtom(showingLimits));
const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
reportFailure: false,
});
Expand Down Expand Up @@ -114,12 +142,9 @@ export function UsagePage() {
});
};
const refreshWindow = () => {
// On Limits the button re-probes every provider (and usage-limit source)
// on the primary environment; the live snapshots then flow in over the
// config stream, so nothing else needs to move.
if (showingLimits) {
if (primaryEnvironmentId) {
void refreshProviders({ environmentId: primaryEnvironmentId, input: {} });
for (const environmentId of limitsRefreshEnvironments) {
void refreshProviders({ environmentId, input: {} });
}
return;
}
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/connection/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ type ConnectionLayerSource =
| typeof backgroundActivityReporterLayer;

const providedClientConnectionLayer = Layer.merge(
Connection.layerWithOptions({ environmentThemes: true }),
Connection.layerWithOptions({ environmentThemes: true, usageLimitSources: true }),
snapshotLoaderLayer,
).pipe(
Layer.provideMerge(
Expand Down
Loading
Loading