Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
aa6b6c1
feat(lastcode): add userland checkpoint builder
lastobelus Aug 14, 2026
d18a579
fix(lastcode): stabilize local checkpoint builds
lastobelus Aug 14, 2026
206dc06
fix(web): recover desktop auth after keychain prompts
lastobelus Aug 14, 2026
ca3af15
feat(lastcode): stabilize local nightly lifecycle
lastobelus Aug 14, 2026
9749b22
docs(lastcode): pass script flags explicitly
lastobelus Aug 14, 2026
4be9ac2
fix(web): keep desktop auth recovery active for an hour
lastobelus Aug 15, 2026
7aa8b04
fix(lastcode): keep local builds ad-hoc signed
lastobelus Aug 15, 2026
0b59855
fix(lastcode): clarify checkpoint recovery status
lastobelus Aug 15, 2026
964fb23
fix(lastcode): continue recorded checkpoint resolutions
lastobelus Aug 15, 2026
3f68f96
fix(lastcode): sanitize checkpoint smoke environment
lastobelus Aug 15, 2026
4019d6d
feat(lastcode): install retained nightly builds
lastobelus Aug 15, 2026
d6469a4
fix(lastcode): complete checkpoint recovery guidance
lastobelus Aug 15, 2026
22686fa
fix(lastcode): serialize local nightly builds
lastobelus Aug 15, 2026
8adc3c4
fix(web): limit delayed credential retries
lastobelus Aug 15, 2026
6cfe4df
fix(lastcode): bound recovery retries
lastobelus Aug 16, 2026
9414db3
fix(lastcode): allow concurrent branch config updates
lastobelus Aug 16, 2026
acf4622
fix(lastcode): serialize local update operations
lastobelus Aug 16, 2026
a039db4
fix(lastcode): serialize local CI test tasks
lastobelus Aug 16, 2026
8b6b5da
fix(lastcode): preserve local artifact safety
lastobelus Aug 16, 2026
097133d
fix(lastcode): serialize local CI cases
lastobelus Aug 16, 2026
627a59f
fix(lastcode): make local tools reversible
lastobelus Aug 16, 2026
80ca6dc
fix(lastcode): verify local command ownership
lastobelus Aug 16, 2026
2ae7438
fix(lastcode): preflight command installs
lastobelus Aug 16, 2026
1a16bc1
fix(lastcode): record checkpoint failure phases
lastobelus Aug 16, 2026
011c47c
test(lastcode): gate macOS file locks
lastobelus Aug 16, 2026
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
85 changes: 85 additions & 0 deletions apps/web/src/authBootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
EnvironmentAuthInvalidError,
EnvironmentInternalError,
type AuthBrowserSessionResult,
type AuthCreatePairingCredentialInput,
type AuthSessionState,
Expand Down Expand Up @@ -269,6 +270,90 @@ describe("resolveInitialServerAuthGateState", () => {
expect(attempts).toBe(4);
});

it("keeps retrying desktop session bootstrap across delayed credential prompts", async () => {
vi.useFakeTimers();
installDesktopBootstrap();
let attempts = 0;
const request = HttpClientRequest.get("http://localhost/api/auth/session");
const response = HttpClientResponse.fromWeb(
request,
new Response("Internal Server Error", { status: 500 }),
);
const runner: PrimaryHttpEffectRunner = async <A>() => {
attempts += 1;
if (attempts < 3) {
await new Promise((resolve) => setTimeout(resolve, 20_000));
throw new HttpClientError.HttpClientError({
reason: new HttpClientError.StatusCodeError({ request, response }),
});
}
return unauthenticatedSession(DESKTOP_AUTH) as A;
};
__setPrimaryHttpRunnerForTests(runner);

const { fetchSessionState } = await import("./environments/primary");

const sessionPromise = fetchSessionState();
await vi.advanceTimersByTimeAsync(41_000);

await expect(sessionPromise).resolves.toEqual(unauthenticatedSession(DESKTOP_AUTH));
expect(attempts).toBe(3);
});

it("surfaces genuine desktop internal errors without an hour-long retry", async () => {
vi.useFakeTimers();
installDesktopBootstrap();
let attempts = 0;
const runner: PrimaryHttpEffectRunner = async () => {
attempts += 1;
await new Promise((resolve) => setTimeout(resolve, 20_000));
throw new EnvironmentInternalError({
code: "internal_error",
reason: "internal_error",
traceId: "trace-persistent-internal-error",
});
};
__setPrimaryHttpRunnerForTests(runner);

const { fetchSessionState, PrimaryEnvironmentRequestError } =
await import("./environments/primary");

const sessionPromise = fetchSessionState();
const rejection = expect(sessionPromise).rejects.toBeInstanceOf(PrimaryEnvironmentRequestError);
await vi.advanceTimersByTimeAsync(20_000);

await rejection;
expect(attempts).toBe(1);
});

it("keeps ordinary desktop gateway retries on the short bootstrap deadline", async () => {
vi.useFakeTimers();
installDesktopBootstrap();
let attempts = 0;
const request = HttpClientRequest.get("http://localhost/api/auth/session");
const response = HttpClientResponse.fromWeb(
request,
new Response("Bad Gateway", { status: 502 }),
);
const runner: PrimaryHttpEffectRunner = async () => {
attempts += 1;
throw new HttpClientError.HttpClientError({
reason: new HttpClientError.StatusCodeError({ request, response }),
});
};
__setPrimaryHttpRunnerForTests(runner);

const { fetchSessionState, PrimaryEnvironmentRequestError } =
await import("./environments/primary");

const sessionPromise = fetchSessionState();
const rejection = expect(sessionPromise).rejects.toBeInstanceOf(PrimaryEnvironmentRequestError);
await vi.advanceTimersByTimeAsync(15_000);

await rejection;
expect(attempts).toBe(31);
});

it("takes a pairing token from the location hash and strips it immediately", async () => {
const testWindow = installTestBrowser("http://localhost/#token=pairing-token");
const { takePairingTokenFromUrl } = await import("./environments/primary");
Expand Down
52 changes: 45 additions & 7 deletions apps/web/src/environments/primary/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,14 @@ function getDesktopBootstrapCredential(): string | null {
}

export async function fetchSessionState(): Promise<AuthSessionState> {
const isDesktop = window.desktopBridge !== undefined;
const retryOptions = isDesktop
? {
retryError: (error: unknown, attemptElapsedMs: number) =>
isDelayedDesktopCredentialProtocolError(error, attemptElapsedMs),
retryErrorTimeoutMs: DESKTOP_BOOTSTRAP_RETRY_TIMEOUT_MS,
}
: {};
return retryTransientBootstrap(async () => {
try {
return await runPrimaryHttp(
Expand All @@ -200,7 +208,7 @@ export async function fetchSessionState(): Promise<AuthSessionState> {
cause: error,
});
}
});
}, retryOptions);
}

function readHttpApiStatus(error: unknown): number | null {
Expand Down Expand Up @@ -278,19 +286,36 @@ async function waitForAuthenticatedSessionAfterBootstrap(): Promise<AuthSessionS

const TRANSIENT_BOOTSTRAP_STATUS_CODES = new Set([502, 503, 504]);
const BOOTSTRAP_RETRY_TIMEOUT_MS = 15_000;
const DESKTOP_BOOTSTRAP_RETRY_TIMEOUT_MS = 60 * 60 * 1_000;
const DESKTOP_CREDENTIAL_DELAY_THRESHOLD_MS = 10_000;
const BOOTSTRAP_RETRY_STEP_MS = 500;

export async function retryTransientBootstrap<T>(operation: () => Promise<T>): Promise<T> {
const startedAt = Date.now();
export async function retryTransientBootstrap<T>(
operation: () => Promise<T>,
options: {
readonly retryError?: (error: unknown, attemptElapsedMs: number) => boolean;
readonly retryErrorTimeoutMs?: number;
readonly timeoutMs?: number;
} = {},
): Promise<T> {
let retryStartedAt: number | null = null;
while (true) {
const attemptStartedAt = Date.now();
try {
return await operation();
} catch (error) {
if (!isTransientBootstrapError(error)) {
const matchesAdditionalRetry =
options.retryError?.(error, Date.now() - attemptStartedAt) ?? false;
if (!isTransientBootstrapError(error, matchesAdditionalRetry)) {
throw error;
}

if (Date.now() - startedAt >= BOOTSTRAP_RETRY_TIMEOUT_MS) {
const now = Date.now();
retryStartedAt ??= now;
const timeoutMs = matchesAdditionalRetry
? (options.retryErrorTimeoutMs ?? options.timeoutMs ?? BOOTSTRAP_RETRY_TIMEOUT_MS)
: (options.timeoutMs ?? BOOTSTRAP_RETRY_TIMEOUT_MS);
if (now - retryStartedAt >= timeoutMs) {
throw error;
}

Expand All @@ -305,9 +330,22 @@ function waitForBootstrapRetry(delayMs: number): Promise<void> {
});
}

function isTransientBootstrapError(error: unknown): boolean {
function isDelayedDesktopCredentialProtocolError(
error: unknown,
attemptElapsedMs: number,
): boolean {
return (
attemptElapsedMs >= DESKTOP_CREDENTIAL_DELAY_THRESHOLD_MS &&
isPrimaryEnvironmentRequestError(error) &&
error.status === 500 &&
HttpClientError.isHttpClientError(error.cause) &&
error.cause.response?.status === 500
);
}

function isTransientBootstrapError(error: unknown, retryError: boolean): boolean {
if (isPrimaryEnvironmentRequestError(error)) {
return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status);
return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status) || retryError;
}

if (error instanceof TypeError) {
Expand Down
11 changes: 6 additions & 5 deletions docs/lastcode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,22 +28,23 @@ directories.

```bash
# Inspect what the checkpoint job would do.
pnpm lastcode:checkpoint --dry-run
pnpm run lastcode:checkpoint -- --dry-run

# Checkpoint every missing nightly and push immutable tags.
pnpm lastcode:checkpoint --push-tags --promote-if-no-open-prs
pnpm run lastcode:checkpoint -- --push-tags --promote-if-no-open-prs

# Enable the same operation at login and hourly.
pnpm lastcode:checkpoint:service install

# Install and inspect the checkpoint dashboard (eight rows by default).
pnpm lastcode:checkpoints --install
pnpm run lastcode:checkpoints -- --install
lastcode-checkpoints
lastcode-checkpoints -n 20
lastcode-checkpoints --verbose

# Validate and build one explicit checkpoint.
pnpm lastcode:ci --checkpoint lastcode/checkpoint/<upstream-nightly-tag>
pnpm lastcode:build:mac:arm64 --checkpoint lastcode/checkpoint/<upstream-nightly-tag>
pnpm run lastcode:ci -- --checkpoint lastcode/checkpoint/<upstream-nightly-tag>
pnpm run lastcode:build:mac:arm64 -- --checkpoint lastcode/checkpoint/<upstream-nightly-tag>
```

None of the checkpoint commands builds an application. An opted-in packaged
Expand Down
40 changes: 39 additions & 1 deletion docs/lastcode/local-nightly-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,38 @@ Before enabling it, install the checkpoint service and dashboard:

```bash
pnpm lastcode:checkpoint:service install
pnpm lastcode:checkpoints --install
pnpm run lastcode:checkpoints -- --install
pnpm run lastcode:build -- --install
pnpm run lastcode:install -- --install
```

The two optional userland commands can be removed later with
`lastcode-build --uninstall` and `lastcode-install --uninstall`. Their shared
checkpoint configuration and build artifacts are preserved.

The dashboard installer records the dedicated automation worktree in
`~/.lastcode/dashboard.json`. The desktop updater uses that worktree only to
read checkpoint tags and launch the versioned helper; it never checks out or
cleans a human development worktree.

The optional `lastcode-build [CHECKPOINT]` command exposes the same builder for
manual bootstrap builds. It defaults to the newest checkpoint; a final nightly
number such as `1090` selects the unique checkpoint ending in `.1090`.
Manual and in-app builds share one cross-process lock. If either path is already
building, the other exits with the owning process and start time instead of
mutating the shared build worktree. A lock left by a terminated process is
reclaimed automatically on the next attempt.

The companion `lastcode-install` command uses `fzf` to choose a retained DMG,
with the most recently built image selected by default. It stages and validates
the replacement before quitting LastCode, then replaces
`/Applications/LastCode.app` and relaunches it. Passing a DMG path skips the
picker. An install-wide lock prevents overlapping commands from racing the app
replacement. This manual bootstrap path does not require the currently installed
app to have local nightly updates enabled.
Quarantined `.incomplete-*` artifact directories are never offered in the
picker.

## User flow

1. The desktop checks the local repository at startup, every four minutes, and
Expand All @@ -35,6 +59,8 @@ cleans a human development worktree.
3. The first click creates or reuses
`~/.lastcode/local-updates/build-worktree`, installs its pinned dependencies,
runs full checkpoint CI, and builds the checkpoint's DMG plus updater ZIP.
One build at a time owns this worktree, from checkout through final artifact
validation.
The build generates updater metadata for `lastobelus/lastCode` by default;
a fork can set `LASTCODE_GITHUB_REPOSITORY=owner/repo` in its configured
environment to select its own metadata source.
Expand All @@ -52,6 +78,15 @@ The DMG is retained as the inspectable, manually installable artifact. The
paired ZIP and `nightly-mac.yml` are generated by the same ad-hoc-signed build
and are used only to reuse Electron's existing install machinery.

Electron's macOS credential storage can synchronously block its main process
while the Keychain prompt is open. If a long-running request returns Electron's
generic protocol-level HTTP 500 after that delay, the desktop client keeps
retrying for one hour. Structured server HTTP 500 responses still surface
immediately, and ordinary 502/503/504 gateway failures retain the normal
15-second retry deadline. You can leave the build or install unattended, return
to handle a prompt, and continue without rebuilding, reinstalling, or relaunching
LastCode.

## Failure handling and logs

A failed check or build leaves the current app installed and changes the
Expand All @@ -72,6 +107,9 @@ also requires the checksum file and the manifest's annotated
interrupted finalization is retried instead of treated as complete.
Interrupting or quitting during a build terminates the helper's entire process
group so CI and packaging cannot continue orphaned against that worktree.
The next build also reclaims an ownerless or partial lock left behind during the
lock's initialization, after a short grace period that avoids stealing it from a
still-starting process.

Turning the setting off hides the local updater and stops future checks. It
does not delete build artifacts, CI stamps, Git tags, or worktrees.
Expand Down
Loading