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
110 changes: 110 additions & 0 deletions apps/hub/src/bench-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// The one thing a background loop cannot inherit: a session. The bench
// provisioner (`@workbench/onboarding`'s `createBenchProvisioner`) runs
// with no request to borrow cookies from, yet everything it drives —
// `seedTenant`'s asset creates, deployments, grants, and the git push
// underneath them — speaks the hub's own HTTP API as the bench's owner.
// This mints that session in-process.
//
// It has to be the user's own session, not an administrator's: the hub
// resolves a tenant by looking up a principal for (tenantId, user), and
// an account with no principal in that tenant is refused outright. A
// parent-org admin does not inherit rights over a child bench — RBAC
// resolves grants within a single tenant — so "just use the operator
// account" would 403 on every call. The owner's own session is the only
// identity that can provision their bench, which is also the honest
// one: the work is theirs, done on their behalf, and it shows up in the
// session table as such (tagged by user agent, so these are greppable
// and never mistaken for a human sign-in).
//
// Sessions are cached per user and re-minted well before expiry, so a
// drain tick every few seconds does not write a session row every few
// seconds.

import { makeSignature } from "better-auth/crypto";
import type { SessionForUser } from "@workbench/onboarding";

/** Re-mint this far ahead of a cached session's own expiry, so a long
* provisioning pass can never have its session expire mid-flight. */
const REMINT_LEAD_MS = 60 * 60 * 1000;

const PROVISIONER_USER_AGENT = "workbench-bench-provisioner";

type MintedSession = {
readonly cookies: string[];
readonly expiresAtMs: number;
};

/**
* The better-auth surface this needs, named structurally so the wiring
* is testable without standing up a whole auth instance.
*/
export type BenchSessionAuth = {
$context: Promise<{
secret: string;
authCookies: { sessionToken: { name: string } };
internalAdapter: {
createSession(
userId: string,
dontRememberMe?: boolean,
override?: Record<string, unknown>,
): Promise<{ token: string; expiresAt: Date } | null>;
};
}>;
};

/**
* Builds the `sessionFor` seam the bench provisioner takes. Returns the
* bare `name=value` cookie pairs `ApiCall` sends, signed exactly the way
* better-auth's own cookie writer signs them — the same HMAC helper the
* library uses, rather than a hand-rolled copy that could drift from the
* verifier.
*
* `undefined` means "no session could be minted right now" (an account
* since deleted, an auth backend briefly unavailable). The provisioner
* treats that as a reason to hold the bench for a later pass, never as a
* reason to discard its pending work.
*/
export function createBenchSessionMinter(args: {
auth: BenchSessionAuth;
log: (line: string) => void;
now?: () => number;
}): SessionForUser {
const now = args.now ?? Date.now;
const cache = new Map<string, MintedSession>();

return async ({ userId }) => {
const cached = cache.get(userId);
if (cached !== undefined && cached.expiresAtMs - REMINT_LEAD_MS > now()) {
return cached.cookies;
}

try {
const context = await args.auth.$context;
const session = await context.internalAdapter.createSession(
userId,
true,
{ userAgent: PROVISIONER_USER_AGENT },
);
if (session === null) {
cache.delete(userId);
return undefined;
}

const signature = await makeSignature(session.token, context.secret);
const value = encodeURIComponent(`${session.token}.${signature}`);
const cookies = [`${context.authCookies.sessionToken.name}=${value}`];
cache.set(userId, {
cookies,
expiresAtMs: session.expiresAt.getTime(),
});
return cookies;
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
args.log(
`could not mint a provisioning session for user ${userId}: ${message}`,
);
cache.delete(userId);
return undefined;
}
};
}
26 changes: 25 additions & 1 deletion apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ import {
import { createGitWorkflowPusher, createHubAPI } from "@workbench/hub-client";
import {
createDrizzlePendingSeedStore,
createBenchProvisioner,
createOnboardingRoutes,
} from "@workbench/onboarding";
import {
Expand Down Expand Up @@ -319,6 +320,7 @@ import {
} from "./credential-expiry-sweep";

import { betterAuth } from "better-auth";
import { createBenchSessionMinter } from "./bench-session";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { type Context, Hono, type Next } from "hono";

Expand Down Expand Up @@ -3239,13 +3241,34 @@ export async function createHub(config: HubConfig) {
// session it serves belongs to no tenant yet. The route is
// `@workbench/onboarding`'s; what it decides is documented in that
// package's provision.ts.
// Connecting a provider deploys nothing (CL-6457): the onboarding
// routes persist the credential and hand the workflow deploys to this
// drain, which converges every bench with a pending row — including
// one a previous process died halfway through, since the row itself is
// the durable work item.
const pendingSeedStore = createDrizzlePendingSeedStore(db, credentialCipher);
const benchProvisioner = createBenchProvisioner({
api: selfApi,
hubUrl: config.baseUrl,
store: pendingSeedStore,
pushWorkflow: createGitWorkflowPusher(),
sessionFor: createBenchSessionMinter({
auth,
log: (line) => log.warn`${line}`,
}),
log: (line) => log.info`${line}`,
logError: (line) => log.error`${line}`,
});
benchProvisioner.start();

const onboardingDeps: Parameters<typeof createOnboardingRoutes>[0] = {
hubUrl: config.baseUrl,
pushWorkflow: createGitWorkflowPusher(),
log: (line) => log.info`${line}`,
logError: (line) => log.error`${line}`,
credentialCipher,
pendingSeedStore: createDrizzlePendingSeedStore(db, credentialCipher),
pendingSeedStore,
benchProvisioner,
accessPolicy: {
store: accessPolicyStore,
envSignupMode: config.signupMode,
Expand Down Expand Up @@ -3479,6 +3502,7 @@ export async function createHub(config: HubConfig) {
stuckLegSweep.stop();
routineScheduler.stop();
credentialExpirySweep.stop();
benchProvisioner.stop();
await insightsUsage.close();
await insightsLatency.close();
await preferences.close();
Expand Down
92 changes: 73 additions & 19 deletions apps/web/src/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,24 +385,33 @@ export const CREDENTIAL_PROVIDERS: readonly CredentialProviderCard[] = [
...SECONDARY_CREDENTIAL_PROVIDERS,
];

const CredentialSeeded = type({
kind: "'seeded'",
/** What every provisioning-aware onboarding route answers with
* (CL-6457). `ready` means the bench's agents are all live;
* `provisioning` means connecting succeeded and the agents are still
* coming online in the background — both are success, and the wizard
* moves the person forward either way. */
const CredentialConnected = type({
kind: "'ready' | 'provisioning'",
"tenantId?": "string",
tenantSlug: "string",
workflows: "string[]",
deployed: "string[]",
pending: "string[]",
});

export type CredentialOutcome =
| {
readonly kind: "seeded";
readonly kind: "connected";
/** Absent only for a response older than this field existing --
* every current `/complete` response carries it. The wizard itself
* no longer branches on it (CL-6104 dropped the optional "Connect
* your tools" phase this once fed) — it stays parsed because the
* server response carries it regardless. */
readonly tenantId?: string;
readonly tenantSlug: string;
readonly workflows: string[];
/** Whether this account's agents still have deploying left to do.
* The wizard does not wait on it — it decides whether the next
* screen shows the warm "getting your agents ready" state. */
readonly agentsPending: boolean;
}
| {
readonly kind: "rejected";
Expand Down Expand Up @@ -484,41 +493,44 @@ export async function submitCredential(
? { kind: "error", message }
: { kind: "error", message, refId };
}
const parsed = CredentialSeeded(body);
const parsed = CredentialConnected(body);
if (parsed instanceof type.errors) {
return { kind: "error", message: FALLBACK_ERROR_MESSAGE };
}
const agentsPending = parsed.pending.length > 0;
return parsed.tenantId === undefined
? {
kind: "seeded",
kind: "connected",
tenantSlug: parsed.tenantSlug,
workflows: parsed.workflows,
agentsPending,
}
: {
kind: "seeded",
kind: "connected",
tenantId: parsed.tenantId,
tenantSlug: parsed.tenantSlug,
workflows: parsed.workflows,
agentsPending,
};
} catch {
return { kind: "error", message: FALLBACK_ERROR_MESSAGE };
}
}

const CompleteSetupResult = type({
kind: "'seeded' | 'unseeded'",
kind: "'ready' | 'provisioning' | 'unseeded'",
"tenantId?": "string",
"tenantSlug?": "string",
"workflows?": "string[]",
"deployed?": "string[]",
"pending?": "string[]",
});

export type CompleteSetupOutcome =
| {
readonly kind: "seeded";
readonly kind: "connected";
/** See `CredentialOutcome`'s own note on this field. */
readonly tenantId?: string;
readonly tenantSlug: string;
readonly workflows: string[];
/** See `CredentialOutcome.agentsPending`. */
readonly agentsPending: boolean;
}
| { readonly kind: "unseeded" }
| {
Expand Down Expand Up @@ -554,22 +566,64 @@ export async function completeSetup(): Promise<CompleteSetupOutcome> {
return { kind: "error", message: FALLBACK_ERROR_MESSAGE };
}
if (parsed.kind === "unseeded") return { kind: "unseeded" };
if (parsed.tenantSlug === undefined || parsed.workflows === undefined) {
if (parsed.tenantSlug === undefined || parsed.pending === undefined) {
return { kind: "error", message: FALLBACK_ERROR_MESSAGE };
}
const agentsPending = parsed.pending.length > 0;
return parsed.tenantId === undefined
? {
kind: "seeded",
kind: "connected",
tenantSlug: parsed.tenantSlug,
workflows: parsed.workflows,
agentsPending,
}
: {
kind: "seeded",
kind: "connected",
tenantId: parsed.tenantId,
tenantSlug: parsed.tenantSlug,
workflows: parsed.workflows,
agentsPending,
};
} catch {
return { kind: "error", message: FALLBACK_ERROR_MESSAGE };
}
}

const ProvisioningStatus = type({
kind: "'ready' | 'provisioning'",
deployed: "string[]",
pending: "string[]",
});

export type ProvisioningProgress = {
readonly ready: boolean;
/** How many of this bench's agents are live, and how many there are in
* total — the numbers a waiting surface shows so the wait reads as
* progress rather than a frozen label. */
readonly live: number;
readonly total: number;
};

/**
* Where this account's agents stand right now. Cheap and read-only, so a
* surface that has to wait may poll it on a short interval. An
* unreachable or unparseable answer reports "not ready yet" rather than
* throwing: a hiccup in a progress check must never turn into an error
* screen over work that is, in fact, still progressing fine.
*/
export async function fetchProvisioningProgress(): Promise<ProvisioningProgress> {
try {
const response = await fetch("/api/onboarding/provisioning-status");
const body: unknown = await response.json().catch(() => null);
if (!response.ok) return { ready: false, live: 0, total: 0 };
const parsed = ProvisioningStatus(body);
if (parsed instanceof type.errors) {
return { ready: false, live: 0, total: 0 };
}
return {
ready: parsed.kind === "ready",
live: parsed.deployed.length,
total: parsed.deployed.length + parsed.pending.length,
};
} catch {
return { ready: false, live: 0, total: 0 };
}
}
Loading
Loading