Skip to content
Merged
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
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
14 changes: 7 additions & 7 deletions apps/web/src/components/usage/UsagePage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useAtomValue } from "@effect/atom-react";
import type { UsageProviderKind } from "@t3tools/contracts";
import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react";
import { useMemo, useState } from "react";
Expand All @@ -6,7 +7,7 @@ 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 { serverEnvironment } from "../../state/server";
import { useUsage, type EnvironmentUsageStatus } from "../../state/usage";
import { useAtomCommand } from "../../state/use-atom-command";
Expand Down Expand Up @@ -68,7 +69,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 presentations = useAtomValue(environmentPresentations.presentationsAtom);
const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
reportFailure: false,
});
Expand Down Expand Up @@ -114,12 +115,11 @@ 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, presentation] of presentations) {
if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) {
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