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
8 changes: 5 additions & 3 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, Notification, powerMonitor, protocol, safeStorage, shell } from "electron";

Check warning on line 1 in apps/desktop/src/main/main.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

'shell' is defined but never used. Allowed unused vars must match /^_/u

if (app.isPackaged && process.env.ADE_RUNTIME_PACKAGED === undefined) {
process.env.ADE_RUNTIME_PACKAGED = "1";
Expand Down Expand Up @@ -122,6 +122,7 @@
import { createAgentChatService, writeSessionLinearIssueContextFile } from "./services/chat/agentChatService";
import { createGithubService } from "./services/github/githubService";
import { createProjectScaffoldService } from "./services/projects/projectScaffoldService";
import { consumeFirstOpenStabilityMarker } from "./services/projects/projectLocalDatabase";
import { createFeedbackReporterService } from "./services/feedback/feedbackReporterService";
import { createPrService } from "./services/prs/prService";
import { createPrPollingService } from "./services/prs/prPollingService";
Expand Down Expand Up @@ -213,7 +214,7 @@
import { localIpcListenOptions } from "../../../ade-cli/src/services/runtime/localIpcListenOptions";
import { normalizeProjectRootPath } from "../../../ade-cli/src/services/projects/projectRoots";
import {
ACCOUNT_SESSION_CREDENTIAL_KEY,

Check warning on line 217 in apps/desktop/src/main/main.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

'ACCOUNT_SESSION_CREDENTIAL_KEY' is defined but never used. Allowed unused vars must match /^_/u
getSignedInAccountAccessToken,
} from "../../../ade-cli/src/services/account/accountAuthService";
import { createPushRelayClient } from "../../../ade-cli/src/services/push/pushRelayClient";
Expand Down Expand Up @@ -2570,9 +2571,10 @@
userSelectedProject?: boolean;
}): Promise<AppContext> => {
// The .ade directory may exist from git (shared scaffold files like ade.yaml),
// but the db is gitignored and machine-local. A missing db means this machine
// has never completed setup, so onboarding should run.
// but the db is gitignored and machine-local. A missing db, or a scaffold
// first-open marker, means this machine has not finished a real bind yet.
const hadAdeDir = fs.existsSync(path.join(projectRoot, ".ade", "ade.db"));
const scaffoldedFirstOpen = consumeFirstOpenStabilityMarker(projectRoot);
const adePaths = ensureAdeDirs(projectRoot);
const { initApiKeyStore } = await import("./services/ai/apiKeyStore");
initApiKeyStore(projectRoot, {
Expand All @@ -2591,7 +2593,7 @@
});
const packagedFirstOpenStabilityMode =
app.isPackaged
&& !hadAdeDir
&& (!hadAdeDir || scaffoldedFirstOpen)
&& process.env.ADE_DISABLE_FIRST_OPEN_STABILITY !== "1";
const projectStabilityMode = devStabilityMode || packagedFirstOpenStabilityMode;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
consumeFirstOpenStabilityMarker,
markFirstOpenStability,
} from "./projectLocalDatabase";

const tempDirs: string[] = [];

afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) fs.rmSync(dir, { recursive: true, force: true });
}
});

describe("first-open stability marker", () => {
it("is consumed once after scaffold marks it", () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-first-open-"));
tempDirs.push(projectRoot);

expect(consumeFirstOpenStabilityMarker(projectRoot)).toBe(false);
markFirstOpenStability(projectRoot);
expect(
fs.existsSync(path.join(projectRoot, ".ade", "cache", "first-open-stability")),
).toBe(true);
expect(consumeFirstOpenStabilityMarker(projectRoot)).toBe(true);
expect(consumeFirstOpenStabilityMarker(projectRoot)).toBe(false);
});
});
48 changes: 48 additions & 0 deletions apps/desktop/src/main/services/projects/projectLocalDatabase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import fs from "node:fs";
import path from "node:path";
import { openKvDb } from "../state/kvDb";
import type { Logger } from "../logging/logger";
import { resolveAdeLayout } from "../../../shared/adeLayout";

const FIRST_OPEN_STABILITY_MARKER = "first-open-stability";

function firstOpenStabilityMarkerPath(projectRoot: string): string {
return path.join(resolveAdeLayout(projectRoot).cacheDir, FIRST_OPEN_STABILITY_MARKER);
}

/**
* Remember that this machine has never completed a real project bind, even
* after scaffold warms `ade.db`. Packaged first-open still throttles
* background tasks on the following bind.
*/
export function markFirstOpenStability(projectRoot: string): void {
const { cacheDir } = resolveAdeLayout(projectRoot);
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(path.join(cacheDir, FIRST_OPEN_STABILITY_MARKER), "");
}

/** True when scaffold asked the next bind to stay in first-open stability mode. */
export function consumeFirstOpenStabilityMarker(projectRoot: string): boolean {
const markerPath = firstOpenStabilityMarkerPath(projectRoot);
if (!fs.existsSync(markerPath)) return false;
try {
fs.unlinkSync(markerPath);
} catch {
// Keep treating this bind as first-open even if unlink fails.
}
return true;
}

/**
* Open (creating if needed) and immediately close `ade.db` so first project
* bind does not pay schema setup on the Work paint path.
*/
export async function ensureProjectLocalDatabase(
projectRoot: string,
logger: Logger,
): Promise<void> {
const { dbPath } = resolveAdeLayout(projectRoot);
const db = await openKvDb(dbPath, logger);
db.close();
markFirstOpenStability(projectRoot);
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ describe("createLocalProject", () => {
runGitMock.mockReset();
});

it("creates README + .gitignore and runs init/add/commit on main", async () => {
it("creates README + .gitignore and inits git without an initial commit", async () => {
runGitMock.mockResolvedValue(gitOk());
const parentDir = makeTempDir("ade-scaffold-create-");
const service = createProjectScaffoldService({
Expand All @@ -100,17 +100,19 @@ describe("createLocalProject", () => {

const argsList = runGitMock.mock.calls.map((c) => c[0] as string[]);
expect(argsList[0]).toEqual(["init", "--initial-branch=main"]);
expect(argsList).toContainEqual(["add", "."]);
expect(argsList).toContainEqual(["commit", "-m", "Initial commit"]);
expect(argsList).not.toContainEqual(["add", "."]);
expect(argsList.some((args) => args[0] === "commit")).toBe(false);
expect(fs.existsSync(path.join(result.rootPath, ".ade", "ade.db"))).toBe(true);
expect(
fs.existsSync(path.join(result.rootPath, ".ade", "cache", "first-open-stability")),
).toBe(true);
});

it("falls back to plain init + symbolic-ref when --initial-branch is unsupported", async () => {
runGitMock
.mockResolvedValueOnce(gitFail("error: unknown option `initial-branch'"))
.mockResolvedValueOnce(gitOk()) // git init (plain)
.mockResolvedValueOnce(gitOk()) // symbolic-ref
.mockResolvedValueOnce(gitOk()) // add .
.mockResolvedValueOnce(gitOk()); // commit
.mockResolvedValueOnce(gitOk()); // symbolic-ref

const parentDir = makeTempDir("ade-scaffold-fallback-init-");
const service = createProjectScaffoldService({
Expand All @@ -126,51 +128,18 @@ describe("createLocalProject", () => {
expect(argsList[2]).toEqual(["symbolic-ref", "HEAD", "refs/heads/main"]);
});

it("retries the initial commit with the ADE author when git identity is missing", async () => {
runGitMock
.mockResolvedValueOnce(gitOk()) // init --initial-branch=main
.mockResolvedValueOnce(gitOk()) // add .
.mockResolvedValueOnce(gitFail("Please tell me who you are."))
.mockResolvedValueOnce(gitOk()); // retry commit

const parentDir = makeTempDir("ade-scaffold-author-fallback-");
it("never runs git commit during create", async () => {
runGitMock.mockResolvedValue(gitOk());
const parentDir = makeTempDir("ade-scaffold-no-commit-");
const service = createProjectScaffoldService({
logger: makeLogger(),
githubService: makeGithubServiceStub(),
});

await service.createLocalProject({ name: "no-config-project", parentDir });

const calls = runGitMock.mock.calls;
expect(calls).toHaveLength(4);
expect(calls[2]?.[0]).toEqual(["commit", "-m", "Initial commit"]);
expect(calls[3]?.[0]).toEqual([
"commit",
"-m",
"Initial commit",
"--author=ADE <ade@local>",
]);
const retryEnv = (calls[3]?.[1] as { env?: Record<string, string> }).env ?? {};
expect(retryEnv.GIT_COMMITTER_NAME).toBe("ADE");
expect(retryEnv.GIT_COMMITTER_EMAIL).toBe("ade@local");
});

it("does not throw when the author-fallback commit also fails (best-effort)", async () => {
runGitMock
.mockResolvedValueOnce(gitOk())
.mockResolvedValueOnce(gitOk())
.mockResolvedValueOnce(gitFail("Please tell me who you are."))
.mockResolvedValueOnce(gitFail("still no identity"));

const parentDir = makeTempDir("ade-scaffold-author-retry-fail-");
const service = createProjectScaffoldService({
logger: makeLogger(),
githubService: makeGithubServiceStub(),
});

await expect(
service.createLocalProject({ name: "uncommittable", parentDir }),
).resolves.toEqual({ rootPath: path.join(parentDir, "uncommittable") });
const argsList = runGitMock.mock.calls.map((c) => c[0] as string[]);
expect(argsList.some((args) => args[0] === "commit")).toBe(false);
});

it("rejects names with path separators", async () => {
Expand Down Expand Up @@ -239,11 +208,10 @@ describe("createLocalProject", () => {
).resolves.toEqual({ rootPath: empty });
});

it("rolls back the created directory when a step after mkdir fails", async () => {
// init ok, then `git add .` fails after README/.gitignore are written.
it("rolls back the created directory when git init fails", async () => {
runGitMock
.mockResolvedValueOnce(gitOk())
.mockResolvedValueOnce(gitFail("fatal: not a git repository", 128));
.mockResolvedValueOnce(gitFail("fatal: could not create work tree"))
.mockResolvedValueOnce(gitFail("fatal: could not create work tree"));

const parentDir = makeTempDir("ade-scaffold-rollback-");
const service = createProjectScaffoldService({
Expand All @@ -254,15 +222,14 @@ describe("createLocalProject", () => {
const rootPath = path.join(parentDir, "doomed");
await expect(
service.createLocalProject({ name: "doomed", parentDir }),
).rejects.toThrow();
).rejects.toThrow(/git init failed/i);

// The directory we created must be gone so a retry isn't blocked by target_exists.
expect(fs.existsSync(rootPath)).toBe(false);
});

it("does not roll back a pre-existing empty directory on failure", async () => {
runGitMock
.mockResolvedValueOnce(gitOk())
.mockResolvedValueOnce(gitFail("boom", 128))
.mockResolvedValueOnce(gitFail("boom", 128));

const parentDir = makeTempDir("ade-scaffold-rollback-preexist-");
Expand Down Expand Up @@ -343,6 +310,10 @@ describe("cloneRepository", () => {
"https://github.com/octocat/Hello-World",
path.join(parentDir, "Hello-World"),
]);
expect(fs.existsSync(path.join(result.rootPath, ".ade", "ade.db"))).toBe(true);
expect(
fs.existsSync(path.join(result.rootPath, ".ade", "cache", "first-open-stability")),
).toBe(true);
});

it("uses the explicit name override when provided", async () => {
Expand Down
64 changes: 18 additions & 46 deletions apps/desktop/src/main/services/projects/projectScaffoldService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { runGit } from "../git/git";
import type { Logger } from "../logging/logger";
import type { createGithubService } from "../github/githubService";
import { initializeOrRepairAdeProject } from "./adeProjectService";
import { ensureProjectLocalDatabase } from "./projectLocalDatabase";

type GithubService = ReturnType<typeof createGithubService>;

Expand Down Expand Up @@ -62,15 +63,6 @@ function isDirectoryNonEmpty(dirPath: string): boolean {
}
}

function isGitIdentityError(stderr: string): boolean {
const text = stderr.toLowerCase();
return (
text.includes("please tell me who you are") ||
text.includes("user.email") ||
text.includes("author identity")
);
}

function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex").slice(0, 16);
}
Expand All @@ -84,6 +76,17 @@ export function createProjectScaffoldService({
}) {
let cachedRepos: { tokenHash: string; expiresAt: number; repos: MyGitHubRepoSummary[] } | null = null;

const warmLocalDatabase = async (rootPath: string, eventName: string): Promise<void> => {
try {
await ensureProjectLocalDatabase(rootPath, logger);
} catch (dbErr) {
logger.warn(eventName, {
rootPath,
error: dbErr instanceof Error ? dbErr.message : String(dbErr),
});
}
};

const createLocalProject = async (input: CreateProjectInput): Promise<CreateProjectResult> => {
const name = validateProjectName(input.name);
const parentDir = (input.parentDir ?? "").trim();
Expand All @@ -98,9 +101,8 @@ export function createProjectScaffoldService({
}

// Track whether WE created the dir so failures only roll back our own work.
// Without this, a partial scaffold (init + README written, then `git add`
// fails) leaves a non-empty dir that the `target_exists` guard rejects on
// retry, permanently blocking the user.
// Without this, a partial scaffold (git init or README write) leaves a
// non-empty dir that the `target_exists` guard rejects on retry.
const preexisted = fs.existsSync(rootPath);
fs.mkdirSync(rootPath, { recursive: true });

Expand All @@ -126,40 +128,7 @@ export function createProjectScaffoldService({
fs.writeFileSync(path.join(rootPath, "README.md"), `# ${name}\n`, "utf8");
fs.writeFileSync(path.join(rootPath, ".gitignore"), GITIGNORE_CONTENT, "utf8");
initializeOrRepairAdeProject(rootPath, { logger, mode: "shared" });

const addRes = await runGit(["add", "."], { cwd: rootPath, timeoutMs: 15_000 });
if (addRes.exitCode !== 0) {
throw new Error(`git add failed: ${addRes.stderr.trim() || `exit ${addRes.exitCode}`}`);
}

const commitRes = await runGit(["commit", "-m", "Initial commit"], { cwd: rootPath, timeoutMs: 15_000 });
if (commitRes.exitCode !== 0) {
if (isGitIdentityError(commitRes.stderr)) {
const retry = await runGit(
["commit", "-m", "Initial commit", "--author=ADE <ade@local>"],
{
cwd: rootPath,
timeoutMs: 15_000,
env: {
...process.env,
GIT_COMMITTER_NAME: "ADE",
GIT_COMMITTER_EMAIL: "ade@local",
},
},
);
if (retry.exitCode !== 0) {
logger.warn("project_scaffold.initial_commit_retry_failed", {
rootPath,
stderr: retry.stderr.trim(),
});
}
} else {
logger.warn("project_scaffold.initial_commit_failed", {
rootPath,
stderr: commitRes.stderr.trim(),
});
}
}
await warmLocalDatabase(rootPath, "project_scaffold.local_db_warm_failed");

return { rootPath };
} catch (err) {
Expand Down Expand Up @@ -241,6 +210,9 @@ export function createProjectScaffoldService({
throw new Error(cloneRes.stderr.trim() || `git clone failed (exit ${cloneRes.exitCode})`);
}

initializeOrRepairAdeProject(rootPath, { logger, mode: "shared" });
await warmLocalDatabase(rootPath, "project_scaffold.clone_local_db_warm_failed");

return { rootPath };
} catch (err) {
if (!preexistedRoot) {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/services/state/projectState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ function resolveProjectRoot(): string {
return path.resolve(process.cwd(), "..", "..");
}

// Packaged fallback: keep state somewhere writable until onboarding picks a repo.
// Packaged fallback: keep state somewhere writable until a project is opened.
return path.resolve(app.getPath("userData"), "ade-project");
}

Expand Down
9 changes: 2 additions & 7 deletions apps/desktop/src/renderer/components/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import { ClipboardDeeplinkBanner } from "./ClipboardDeeplinkBanner";
import { CrossRepoPrBanner } from "./CrossRepoPrBanner";
import { ProjectRecoveryScreen } from "./ProjectRecoveryScreen";
import { ProjectWelcomePage } from "../projects/ProjectWelcomePage";
import { ProjectSetupPage } from "../onboarding/ProjectSetupPage";
import { OnboardingBootstrap } from "../onboarding/OnboardingBootstrap";
import { LaunchGate } from "../onboarding/LaunchGate";
import { GlossaryPage } from "../onboarding/GlossaryPage";
Expand Down Expand Up @@ -324,7 +323,6 @@ function serializeProjectRoute(location: ReturnType<typeof useLocation>): string
"/automations",
"/cto",
"/settings",
"/onboarding",
];
if (!allowedRoots.some((root) => pathname === root || pathname.startsWith(`${root}/`))) {
return null;
Expand Down Expand Up @@ -545,7 +543,7 @@ function ProjectRouteContent({ active, route }: { active: boolean; route: string
{active && !isWorkRoute && !isLanesRoute ? (
<Routes location={route}>
<Route path="/" element={<Navigate to="/work" replace />} />
<Route path="/onboarding" element={<PageErrorBoundary><ProjectSetupPage /></PageErrorBoundary>} />
<Route path="/onboarding" element={<Navigate to="/work" replace />} />
<Route path="/glossary" element={<PageErrorBoundary><GlossaryPage /></PageErrorBoundary>} />
<Route path="/files" element={
<PageErrorBoundary>
Expand Down Expand Up @@ -789,10 +787,7 @@ function ProjectTabHost() {
}
previousActiveSurfaceKeyRef.current = activeSurfaceKey;
if (!activeSurfaceKey) return;
const shouldKeepInitialRoute =
currentRoute &&
currentRoute !== "/onboarding";
if (!previousSurfaceKey && shouldKeepInitialRoute) {
if (!previousSurfaceKey && currentRoute) {
writeStoredProjectRoute(activeSurfaceKey, currentRoute);
setRoutesBySurfaceKey((prev) => (prev[activeSurfaceKey] === currentRoute ? prev : { ...prev, [activeSurfaceKey]: currentRoute }));
return;
Expand Down
Loading
Loading