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
3 changes: 2 additions & 1 deletion src/Capacitor.App/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1237,7 +1237,8 @@ async Task<bool> RunWizardModeAsync(
HeldDaemonNames: HeldDaemonNamesAsync,
ProfileOverride: _serverEnv.Profile,
UsageNotice: _usageNotice,
ReportCliSetup: ReportWizardCliSetupAsync);
ReportCliSetup: ReportWizardCliSetupAsync,
ResumeWebSignUp: WizardComposition.ResumesWebSignUp(await _appState.LoadAsync(), _time.GetUtcNow()));
_localInstall ??= WizardComposition.NewInstallController(options);
var graph = WizardComposition.BuildGraph(options with { Install = _localInstall });

Expand Down
4 changes: 3 additions & 1 deletion src/Capacitor.App/Services/AppStateStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ public sealed record AppState(
int? WindowX = null,
int? WindowY = null,
bool WindowMaximized = false,
double? UiScale = null);
double? UiScale = null,
// When setup last sent the user to kcap.ai's sign-up; cleared once a sign-in commits.
DateTimeOffset? WebSignUpOpenedAt = null);

public interface IAppStateStore {
Task<AppState> LoadAsync();
Expand Down
14 changes: 14 additions & 0 deletions src/Capacitor.App/Services/Onboarding/WizardAuthBridges.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ public sealed class UiAuthProgress(Action<Action> post) : IAuthProgress {
public event Action<string, string, bool>? DeviceCodeReceived;
public event Action? PollTicked;

/// A discovered workspace is still being created; sign-in waits for it rather than leaving it out.
public event Action<string>? WorkspaceProvisioning;

public void Notice(string message) => post(() => NoticeReceived?.Invoke(message));

public void Error(string message) => post(() => ErrorReceived?.Invoke(message));
Expand All @@ -28,6 +31,17 @@ public void DeviceCode(string code, string verificationUri, string? provider, bo
post(() => DeviceCodeReceived?.Invoke(code, verificationUri, prefilled));

public void PollTick() => post(() => PollTicked?.Invoke());

public void ProvisioningWait(string workspace) => post(() => WorkspaceProvisioning?.Invoke(workspace));

string? _failedWorkspace;

/// kcap.ai stopped creating a discovered workspace, or it is no longer this account's. Held rather than
/// posted, so the attempt's own result can never be handled before it.
public void ProvisioningFailed(string workspace) => Volatile.Write(ref _failedWorkspace, workspace);

/// The workspace the last attempt saw fail to provision, or null; reading clears it.
public string? TakeProvisioningFailure() => Interlocked.Exchange(ref _failedWorkspace, null);
}

/// <summary>
Expand Down
105 changes: 94 additions & 11 deletions src/Capacitor.App/Services/Onboarding/WizardComposition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,21 @@ internal sealed record WizardGraphOptions(
// Null, or already acknowledged, leaves the Privacy page out of the run.
UsageDataNotice? UsageNotice = null,
// Tells the configured server this machine has kcap installed; true once it accepted.
Func<CancellationToken, Task<bool>>? ReportCliSetup = null);
Func<CancellationToken, Task<bool>>? ReportCliSetup = null,
// This app sent the user to kcap.ai's sign-up and no sign-in has committed since.
bool ResumeWebSignUp = false);

/// The wizard half of the composition root, split out of App so it can be driven
/// with fakes: nothing here touches a daemon, a socket or the network until a step is used.
internal static class WizardComposition {
/// How long after setup opened kcap.ai's sign-up a relaunch still opens on sign-in.
internal static readonly TimeSpan WebSignUpResumeWindow = TimeSpan.FromDays(1);

/// A sign-up this app opened in the browser, recent enough that a relaunch is the user coming back
/// with a workspace to sign in to.
internal static bool ResumesWebSignUp(AppState state, DateTimeOffset now) =>
state.WebSignUpOpenedAt is { } opened && now >= opened && now - opened <= WebSignUpResumeWindow;

/// Production bridges: one marshalling boundary, Avalonia's dispatcher in the app.
internal static WizardBridges BuildBridges(Action<Action> post, CliTelemetry telemetry, AuthEndpoints endpoints) =>
new(post, telemetry, endpoints);
Expand All @@ -126,17 +136,78 @@ internal static Func<ConnectIntent, CancellationToken, Task<AuthResult>> BuildOp
operation(new WizardFacadeSpec(
root, tokenStore, httpFactory, proxy, github, workos, profile, bridges.Progress, bridges.Picker,
bridges.Telemetry, bridges.Endpoints, time,
WizardAuthService.ArmingHook(claims), ProbeWorkspace(httpFactory, time), precondition));
WizardAuthService.ArmingHook(claims),
ProbeWorkspace(httpFactory, bridges.Endpoints, bridges.Progress.ProvisioningWait, bridges.Progress.ProvisioningFailed, time),
precondition));

internal static readonly TimeSpan ProvisioningPollInterval = TimeSpan.FromSeconds(5);

/// How long sign-in waits on a workspace kcap.ai is still creating, the same as the browser sign-up's own wait.
internal static readonly TimeSpan ProvisioningWaitLimit = SignInStepViewModel.SignUpWait;

/// <summary>
/// kcap-web adds the owner to a workspace's organization before the workspace is live, so discovery
/// can list one still provisioning. Its host 404s until then, and discovery leaves it out.
static Func<DiscoveredTenant, string, CancellationToken, Task<WorkspaceAnswer>> ProbeWorkspace(
IHttpClientFactory httpFactory, TimeProvider time) =>
async (tenant, _, ct) => {
/// can list one still provisioning, whose host 404s until then. kcap-web's own status for it tells
/// the two apart: a workspace still being created is waited for, so sign-in finishes on its own once
/// it is live. One kcap-web gave up on is gone; one still not live when the wait ends is pending.
/// </summary>
internal static Func<DiscoveredTenant, string, CancellationToken, Task<WorkspaceAnswer>> ProbeWorkspace(
IHttpClientFactory httpFactory, AuthEndpoints endpoints, Action<string> waiting, Action<string> failed,
TimeProvider time) {
// The bearers of discoveries that already found a live workspace. Discovery waits on every probe,
// so a wait on one still being created would otherwise hold back the live ones.
var liveFound = new System.Collections.Concurrent.ConcurrentDictionary<string, byte>(StringComparer.Ordinal);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return async (tenant, bearer, ct) => {
var answer = await AskAsync(tenant, ct);
if (answer == WorkspaceAnswer.Live) liveFound.TryAdd(bearer, 0);
if (answer != WorkspaceAnswer.Gone || tenant.Slug is not { Length: > 0 } slug) return answer;

var provisioning = new TenantProvisioningClient(
httpFactory.CreateClient(nameof(TenantProvisioningClient)), ClientAgent.Desktop);
// The status client reads a cancelled request as a transport failure.
var status = await provisioning.GetStatusAsync(endpoints.SignupUrl, bearer, slug, ct);
ct.ThrowIfCancellationRequested();
if (ProvisioningPoll.Classify(status.StatusCode, status.Body?.State, status.Body?.WorkosOrgId)
is PollVerdict.Failed or PollVerdict.ActiveNoOrg) {
failed(tenant.Label);
return answer;
}
if (!ProvisioningPoll.IsPending(status.StatusCode, status.Body?.State)) return answer;
Comment thread
realtonyyoung marked this conversation as resolved.
Comment thread
realtonyyoung marked this conversation as resolved.

waiting(tenant.Label);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
var deadline = time.GetUtcNow() + ProvisioningWaitLimit;
while (time.GetUtcNow() < deadline) {
if (liveFound.ContainsKey(bearer)) return WorkspaceAnswer.Pending;
await Task.Delay(ProvisioningPollInterval, time, ct);
if (liveFound.ContainsKey(bearer)) return WorkspaceAnswer.Pending;

answer = await AskAsync(tenant, ct);
if (answer == WorkspaceAnswer.Live) {
liveFound.TryAdd(bearer, 0);
return answer;
}

// An expired bearer can no longer see a failure, so the wait ends there and sign-in offers another try.
status = await provisioning.GetStatusAsync(endpoints.SignupUrl, bearer, slug, ct);
ct.ThrowIfCancellationRequested();
if (status.StatusCode == 401) return WorkspaceAnswer.Pending;
Comment thread
realtonyyoung marked this conversation as resolved.
if (ProvisioningPoll.Classify(status.StatusCode, status.Body?.State, status.Body?.WorkosOrgId)
is PollVerdict.Failed or PollVerdict.NotFound or PollVerdict.Forbidden or PollVerdict.ActiveNoOrg) {
failed(tenant.Label);
return WorkspaceAnswer.Gone;
Comment thread
realtonyyoung marked this conversation as resolved.
}
}

return WorkspaceAnswer.Pending;
};

async Task<WorkspaceAnswer> AskAsync(DiscoveredTenant tenant, CancellationToken ct) {
using var client = httpFactory.CreateClient(CapacitorClients.Anonymous);

return await WorkspaceProbe.AskAsync(client, tenant.Origin, time, ct);
};
}
}

internal static LocalInstallController NewInstallController(WizardGraphOptions options) {
var cli = new LateBoundKcapCli(options.ResolveCli, options.CliPath);
Expand Down Expand Up @@ -248,6 +319,10 @@ void OnInstallSettled(LocalInstallOutcome outcome) {
install.Settled += OnInstallSettled;
if (committedLocalAtStart) _ = daemon.EnsureAsync(options.ShutdownToken);

OnboardingViewModel? shell = null;
// The daemon and its outcome lines change while Done is open; the page re-reads its facts on each.
var serviceChanged = daemon.WhenAnyValue(x => x.Satisfied, x => x.Busy, x => x.Status, x => x.Message, (_, _, _, _) => Unit.Default)
.Merge(options.Surface.WhenAnyValue(x => x.StatusText, x => x.AttentionText, (_, _) => Unit.Default));
// Read on every entry, so a Back-then-forward re-render sees each step's current state.
var done = new DoneStepViewModel(() => {
var recording = harnesses.Rows.Where(r => r is { Record: true, Succeeded: true }).ToList();
Expand All @@ -263,17 +338,25 @@ [.. recording.Select(r => r.Label)],
!local && Connected() ? options.ResolveIdentity()?.Server : null,
daemon.Problem,
local,
local ? LocalEndpoints.Read(root).Active?.ServerUrl : null);
}, options.UrlOpener, PrepareWorkspaceAsync);
local ? LocalEndpoints.Read(root).Active?.ServerUrl : null,
daemon.Busy,
options.Surface.AttentionText ?? options.Surface.StatusText);
}, options.UrlOpener, PrepareWorkspaceAsync, serviceChanged,
// The same page Help and support → Set up kcap leads through to connect an agent.
addAgent: () => shell?.TryGoTo(WizardStepId.Harnesses));
IWizardStep[] steps = [edition, installStep, signIn, harnesses, history, new UsageDataStepViewModel(options.UsageNotice), done];

// Before a server is connected the app has nothing to show, so deferring is offered only for the optional steps after.
var canFinishLater = signIn.WhenAnyValue(x => x.Satisfied).Select(_ => Unit.Default)
.Merge(install.WhenAnyValue(x => x.Outcome).Select(_ => Unit.Default))
.Merge(durable.Select(_ => Unit.Default))
.Select(_ => Connected());
var wizard = new OnboardingViewModel(
steps, options.ShutdownToken, options.Surface, canFinishLater: canFinishLater, restate: durable.Select(_ => Unit.Default));
// Someone coming back from kcap.ai's sign-up already has a workspace; everyone else chooses first.
var startAt = options.ResumeWebSignUp && !signedInAtStart && !OnLocalPath() && !Installed()
? WizardStepId.SignIn
: (WizardStepId?)null;
var wizard = shell = new OnboardingViewModel(
steps, options.ShutdownToken, options.Surface, startAt, canFinishLater, durable.Select(_ => Unit.Default));
Comment thread
realtonyyoung marked this conversation as resolved.
signIn.SignUpExpired += () => {
edition.ShowNotice(SignInStepViewModel.SignUpExpiredNotice);
wizard.TryGoTo(WizardStepId.Edition);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace Capacitor.App.ViewModels.Onboarding;

/// Where the daemon's service stands on the Ready page.
public enum BackgroundServiceState { Installing, Running, NotRunning }
4 changes: 2 additions & 2 deletions src/Capacitor.App/ViewModels/Onboarding/CaptureItem.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
namespace Capacitor.App.ViewModels.Onboarding;

/// One harness on the Done page's capture line: "turned on", or "needs you" while the login shell
/// One agent in the Done page's Connected agents card: connected, or "needs you" while the login shell
/// cannot find kcap.
public sealed record CaptureItem(string Label, bool NeedsYou) {
public string State => NeedsYou ? "needs you" : "turned on";
public string State => NeedsYou ? "Needs you" : "Connected";
}
10 changes: 7 additions & 3 deletions src/Capacitor.App/ViewModels/Onboarding/DoneFacts.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
namespace Capacitor.App.ViewModels.Onboarding;

/// What the earlier pages did, as the Done page reads it on every entry.
/// <param name="Recording">The harnesses whose capture installed.</param>
/// <param name="PathHazard">The login shell still cannot find kcap, so hooks record nothing.</param>
/// <param name="Recording">The agents whose hooks installed.</param>
/// <param name="PathHazard">The login shell still cannot find kcap, so hooks store nothing.</param>
/// <param name="Import">The history import, while it runs and after; null when none started.</param>
/// <param name="WorkspaceUrl">The workspace this machine signed in to, or null.</param>
/// <param name="DaemonProblem">Why the daemon is not running, or null while it is being set up.</param>
/// <param name="Local">The machine runs its own local server, so the page names no terminal command.</param>
/// <param name="DashboardUrl">The local server's web dashboard, or null when unknown.</param>
/// <param name="DaemonBusy">An attempt to start the service is in flight.</param>
/// <param name="ServiceDetail">What the daemon's last outcome reported beyond the problem itself, or null.</param>
public sealed record DoneFacts(
IReadOnlyList<string> Recording,
bool CodexRecording,
Expand All @@ -18,6 +20,8 @@ public sealed record DoneFacts(
string? WorkspaceUrl,
string? DaemonProblem = null,
bool Local = false,
string? DashboardUrl = null) {
string? DashboardUrl = null,
bool DaemonBusy = false,
string? ServiceDetail = null) {
public static readonly DoneFacts Empty = new([], false, false, null, false, "this machine", null);
}
Loading
Loading