Skip to content

Commit 23879e4

Browse files
Merge pull request #187 from corbitsdev/cl-6457-async-provisioning
CL-6457: Provider connect must not deploy anything
2 parents 5250b25 + 0ac4744 commit 23879e4

18 files changed

Lines changed: 1913 additions & 531 deletions

‎apps/hub/src/bench-session.ts‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// The one thing a background loop cannot inherit: a session. The bench
2+
// provisioner (`@workbench/onboarding`'s `createBenchProvisioner`) runs
3+
// with no request to borrow cookies from, yet everything it drives —
4+
// `seedTenant`'s asset creates, deployments, grants, and the git push
5+
// underneath them — speaks the hub's own HTTP API as the bench's owner.
6+
// This mints that session in-process.
7+
//
8+
// It has to be the user's own session, not an administrator's: the hub
9+
// resolves a tenant by looking up a principal for (tenantId, user), and
10+
// an account with no principal in that tenant is refused outright. A
11+
// parent-org admin does not inherit rights over a child bench — RBAC
12+
// resolves grants within a single tenant — so "just use the operator
13+
// account" would 403 on every call. The owner's own session is the only
14+
// identity that can provision their bench, which is also the honest
15+
// one: the work is theirs, done on their behalf, and it shows up in the
16+
// session table as such (tagged by user agent, so these are greppable
17+
// and never mistaken for a human sign-in).
18+
//
19+
// Sessions are cached per user and re-minted well before expiry, so a
20+
// drain tick every few seconds does not write a session row every few
21+
// seconds.
22+
23+
import { makeSignature } from "better-auth/crypto";
24+
import type { SessionForUser } from "@workbench/onboarding";
25+
26+
/** Re-mint this far ahead of a cached session's own expiry, so a long
27+
* provisioning pass can never have its session expire mid-flight. */
28+
const REMINT_LEAD_MS = 60 * 60 * 1000;
29+
30+
const PROVISIONER_USER_AGENT = "workbench-bench-provisioner";
31+
32+
type MintedSession = {
33+
readonly cookies: string[];
34+
readonly expiresAtMs: number;
35+
};
36+
37+
/**
38+
* The better-auth surface this needs, named structurally so the wiring
39+
* is testable without standing up a whole auth instance.
40+
*/
41+
export type BenchSessionAuth = {
42+
$context: Promise<{
43+
secret: string;
44+
authCookies: { sessionToken: { name: string } };
45+
internalAdapter: {
46+
createSession(
47+
userId: string,
48+
dontRememberMe?: boolean,
49+
override?: Record<string, unknown>,
50+
): Promise<{ token: string; expiresAt: Date } | null>;
51+
};
52+
}>;
53+
};
54+
55+
/**
56+
* Builds the `sessionFor` seam the bench provisioner takes. Returns the
57+
* bare `name=value` cookie pairs `ApiCall` sends, signed exactly the way
58+
* better-auth's own cookie writer signs them — the same HMAC helper the
59+
* library uses, rather than a hand-rolled copy that could drift from the
60+
* verifier.
61+
*
62+
* `undefined` means "no session could be minted right now" (an account
63+
* since deleted, an auth backend briefly unavailable). The provisioner
64+
* treats that as a reason to hold the bench for a later pass, never as a
65+
* reason to discard its pending work.
66+
*/
67+
export function createBenchSessionMinter(args: {
68+
auth: BenchSessionAuth;
69+
log: (line: string) => void;
70+
now?: () => number;
71+
}): SessionForUser {
72+
const now = args.now ?? Date.now;
73+
const cache = new Map<string, MintedSession>();
74+
75+
return async ({ userId }) => {
76+
const cached = cache.get(userId);
77+
if (cached !== undefined && cached.expiresAtMs - REMINT_LEAD_MS > now()) {
78+
return cached.cookies;
79+
}
80+
81+
try {
82+
const context = await args.auth.$context;
83+
const session = await context.internalAdapter.createSession(
84+
userId,
85+
true,
86+
{ userAgent: PROVISIONER_USER_AGENT },
87+
);
88+
if (session === null) {
89+
cache.delete(userId);
90+
return undefined;
91+
}
92+
93+
const signature = await makeSignature(session.token, context.secret);
94+
const value = encodeURIComponent(`${session.token}.${signature}`);
95+
const cookies = [`${context.authCookies.sessionToken.name}=${value}`];
96+
cache.set(userId, {
97+
cookies,
98+
expiresAtMs: session.expiresAt.getTime(),
99+
});
100+
return cookies;
101+
} catch (cause) {
102+
const message = cause instanceof Error ? cause.message : String(cause);
103+
args.log(
104+
`could not mint a provisioning session for user ${userId}: ${message}`,
105+
);
106+
cache.delete(userId);
107+
return undefined;
108+
}
109+
};
110+
}

‎apps/hub/src/index.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ import {
275275
import { createGitWorkflowPusher, createHubAPI } from "@workbench/hub-client";
276276
import {
277277
createDrizzlePendingSeedStore,
278+
createBenchProvisioner,
278279
createOnboardingRoutes,
279280
} from "@workbench/onboarding";
280281
import {
@@ -319,6 +320,7 @@ import {
319320
} from "./credential-expiry-sweep";
320321

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

@@ -3239,13 +3241,34 @@ export async function createHub(config: HubConfig) {
32393241
// session it serves belongs to no tenant yet. The route is
32403242
// `@workbench/onboarding`'s; what it decides is documented in that
32413243
// package's provision.ts.
3244+
// Connecting a provider deploys nothing (CL-6457): the onboarding
3245+
// routes persist the credential and hand the workflow deploys to this
3246+
// drain, which converges every bench with a pending row — including
3247+
// one a previous process died halfway through, since the row itself is
3248+
// the durable work item.
3249+
const pendingSeedStore = createDrizzlePendingSeedStore(db, credentialCipher);
3250+
const benchProvisioner = createBenchProvisioner({
3251+
api: selfApi,
3252+
hubUrl: config.baseUrl,
3253+
store: pendingSeedStore,
3254+
pushWorkflow: createGitWorkflowPusher(),
3255+
sessionFor: createBenchSessionMinter({
3256+
auth,
3257+
log: (line) => log.warn`${line}`,
3258+
}),
3259+
log: (line) => log.info`${line}`,
3260+
logError: (line) => log.error`${line}`,
3261+
});
3262+
benchProvisioner.start();
3263+
32423264
const onboardingDeps: Parameters<typeof createOnboardingRoutes>[0] = {
32433265
hubUrl: config.baseUrl,
32443266
pushWorkflow: createGitWorkflowPusher(),
32453267
log: (line) => log.info`${line}`,
32463268
logError: (line) => log.error`${line}`,
32473269
credentialCipher,
3248-
pendingSeedStore: createDrizzlePendingSeedStore(db, credentialCipher),
3270+
pendingSeedStore,
3271+
benchProvisioner,
32493272
accessPolicy: {
32503273
store: accessPolicyStore,
32513274
envSignupMode: config.signupMode,
@@ -3479,6 +3502,7 @@ export async function createHub(config: HubConfig) {
34793502
stuckLegSweep.stop();
34803503
routineScheduler.stop();
34813504
credentialExpirySweep.stop();
3505+
benchProvisioner.stop();
34823506
await insightsUsage.close();
34833507
await insightsLatency.close();
34843508
await preferences.close();

‎apps/web/src/onboarding.ts‎

Lines changed: 73 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -385,24 +385,33 @@ export const CREDENTIAL_PROVIDERS: readonly CredentialProviderCard[] = [
385385
...SECONDARY_CREDENTIAL_PROVIDERS,
386386
];
387387

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

395401
export type CredentialOutcome =
396402
| {
397-
readonly kind: "seeded";
403+
readonly kind: "connected";
398404
/** Absent only for a response older than this field existing --
399405
* every current `/complete` response carries it. The wizard itself
400406
* no longer branches on it (CL-6104 dropped the optional "Connect
401407
* your tools" phase this once fed) — it stays parsed because the
402408
* server response carries it regardless. */
403409
readonly tenantId?: string;
404410
readonly tenantSlug: string;
405-
readonly workflows: string[];
411+
/** Whether this account's agents still have deploying left to do.
412+
* The wizard does not wait on it — it decides whether the next
413+
* screen shows the warm "getting your agents ready" state. */
414+
readonly agentsPending: boolean;
406415
}
407416
| {
408417
readonly kind: "rejected";
@@ -484,41 +493,44 @@ export async function submitCredential(
484493
? { kind: "error", message }
485494
: { kind: "error", message, refId };
486495
}
487-
const parsed = CredentialSeeded(body);
496+
const parsed = CredentialConnected(body);
488497
if (parsed instanceof type.errors) {
489498
return { kind: "error", message: FALLBACK_ERROR_MESSAGE };
490499
}
500+
const agentsPending = parsed.pending.length > 0;
491501
return parsed.tenantId === undefined
492502
? {
493-
kind: "seeded",
503+
kind: "connected",
494504
tenantSlug: parsed.tenantSlug,
495-
workflows: parsed.workflows,
505+
agentsPending,
496506
}
497507
: {
498-
kind: "seeded",
508+
kind: "connected",
499509
tenantId: parsed.tenantId,
500510
tenantSlug: parsed.tenantSlug,
501-
workflows: parsed.workflows,
511+
agentsPending,
502512
};
503513
} catch {
504514
return { kind: "error", message: FALLBACK_ERROR_MESSAGE };
505515
}
506516
}
507517

508518
const CompleteSetupResult = type({
509-
kind: "'seeded' | 'unseeded'",
519+
kind: "'ready' | 'provisioning' | 'unseeded'",
510520
"tenantId?": "string",
511521
"tenantSlug?": "string",
512-
"workflows?": "string[]",
522+
"deployed?": "string[]",
523+
"pending?": "string[]",
513524
});
514525

515526
export type CompleteSetupOutcome =
516527
| {
517-
readonly kind: "seeded";
528+
readonly kind: "connected";
518529
/** See `CredentialOutcome`'s own note on this field. */
519530
readonly tenantId?: string;
520531
readonly tenantSlug: string;
521-
readonly workflows: string[];
532+
/** See `CredentialOutcome.agentsPending`. */
533+
readonly agentsPending: boolean;
522534
}
523535
| { readonly kind: "unseeded" }
524536
| {
@@ -554,22 +566,64 @@ export async function completeSetup(): Promise<CompleteSetupOutcome> {
554566
return { kind: "error", message: FALLBACK_ERROR_MESSAGE };
555567
}
556568
if (parsed.kind === "unseeded") return { kind: "unseeded" };
557-
if (parsed.tenantSlug === undefined || parsed.workflows === undefined) {
569+
if (parsed.tenantSlug === undefined || parsed.pending === undefined) {
558570
return { kind: "error", message: FALLBACK_ERROR_MESSAGE };
559571
}
572+
const agentsPending = parsed.pending.length > 0;
560573
return parsed.tenantId === undefined
561574
? {
562-
kind: "seeded",
575+
kind: "connected",
563576
tenantSlug: parsed.tenantSlug,
564-
workflows: parsed.workflows,
577+
agentsPending,
565578
}
566579
: {
567-
kind: "seeded",
580+
kind: "connected",
568581
tenantId: parsed.tenantId,
569582
tenantSlug: parsed.tenantSlug,
570-
workflows: parsed.workflows,
583+
agentsPending,
571584
};
572585
} catch {
573586
return { kind: "error", message: FALLBACK_ERROR_MESSAGE };
574587
}
575588
}
589+
590+
const ProvisioningStatus = type({
591+
kind: "'ready' | 'provisioning'",
592+
deployed: "string[]",
593+
pending: "string[]",
594+
});
595+
596+
export type ProvisioningProgress = {
597+
readonly ready: boolean;
598+
/** How many of this bench's agents are live, and how many there are in
599+
* total — the numbers a waiting surface shows so the wait reads as
600+
* progress rather than a frozen label. */
601+
readonly live: number;
602+
readonly total: number;
603+
};
604+
605+
/**
606+
* Where this account's agents stand right now. Cheap and read-only, so a
607+
* surface that has to wait may poll it on a short interval. An
608+
* unreachable or unparseable answer reports "not ready yet" rather than
609+
* throwing: a hiccup in a progress check must never turn into an error
610+
* screen over work that is, in fact, still progressing fine.
611+
*/
612+
export async function fetchProvisioningProgress(): Promise<ProvisioningProgress> {
613+
try {
614+
const response = await fetch("/api/onboarding/provisioning-status");
615+
const body: unknown = await response.json().catch(() => null);
616+
if (!response.ok) return { ready: false, live: 0, total: 0 };
617+
const parsed = ProvisioningStatus(body);
618+
if (parsed instanceof type.errors) {
619+
return { ready: false, live: 0, total: 0 };
620+
}
621+
return {
622+
ready: parsed.kind === "ready",
623+
live: parsed.deployed.length,
624+
total: parsed.deployed.length + parsed.pending.length,
625+
};
626+
} catch {
627+
return { ready: false, live: 0, total: 0 };
628+
}
629+
}

0 commit comments

Comments
 (0)