diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 000000000..47363fed9 --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,14 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "ADE" + +[setup] +script = "" + +[[actions]] +name = "Run" +icon = "run" +command = ''' +cd apps/desktop +npm run dev +''' diff --git a/.gitignore b/.gitignore index 098a108c6..0e5d8117c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ .ade/ .DS_Store +*.pem infra/node_modules/ infra/.sst/ infra/.pulumi/ +/infra/.home diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 9f477f330..bb64ea8e4 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -19,6 +19,10 @@ import { createGitOperationsService } from "./services/git/gitOperationsService" import { createPackService } from "./services/packs/packService"; import { createJobEngine } from "./services/jobs/jobEngine"; import { createHostedAgentService } from "./services/hosted/hostedAgentService"; +import { createByokLlmService } from "./services/byok/byokLlmService"; +import { createGithubService } from "./services/github/githubService"; +import { createPrService } from "./services/prs/prService"; +import { createPrPollingService } from "./services/prs/prPollingService"; import { detectDefaultBaseRef, ensureAdeExcluded, resolveRepoRoot, toProjectInfo, upsertProjectRow } from "./services/projects/projectService"; import { IPC } from "../shared/ipc"; import type { AppContext } from "./services/ipc/registerIpc"; @@ -139,13 +143,17 @@ app.whenReady().then(async () => { onHeadChanged: ({ laneId, reason }) => { jobEngine?.onHeadChanged({ laneId, reason }); } - }); - await laneService.ensurePrimaryLane(); - const sessionService = createSessionService({ db }); - const diffService = createDiffService({ laneService }); - const projectConfigService = createProjectConfigService({ - projectRoot, - adeDir: adePaths.adeDir, + }); + await laneService.ensurePrimaryLane(); + const sessionService = createSessionService({ db }); + const reconciledSessions = sessionService.reconcileStaleRunningSessions({ status: "disposed" }); + if (reconciledSessions > 0) { + logger.warn("sessions.reconciled_stale_running", { count: reconciledSessions }); + } + const diffService = createDiffService({ laneService }); + const projectConfigService = createProjectConfigService({ + projectRoot, + adeDir: adePaths.adeDir, projectId, db, logger @@ -176,14 +184,29 @@ app.whenReady().then(async () => { } }); + const byokLlmService = createByokLlmService({ + logger, + projectConfigService + }); + + const githubService = createGithubService({ + logger, + adeDir: adePaths.adeDir, + projectRoot, + projectConfigService, + hostedAgentService + }); + const conflictService = createConflictService({ db, logger, projectId, projectRoot, laneService, + projectConfigService, operationService, hostedAgentService, + byokLlmService, conflictPacksDir: path.join(adePaths.packsDir, "conflicts"), onEvent: (event) => broadcast(IPC.conflictsEvent, event) }); @@ -195,6 +218,30 @@ app.whenReady().then(async () => { hostedAgentService }); + const prService = createPrService({ + db, + logger, + projectId, + projectRoot, + laneService, + operationService, + githubService, + packService, + hostedAgentService, + byokLlmService, + projectConfigService, + openExternal: async (url) => { + await shell.openExternal(url); + } + }); + + const prPollingService = createPrPollingService({ + logger, + prService, + projectConfigService, + onEvent: (event) => broadcast(IPC.prsEvent, event) + }); + const fileService = createFileService({ laneService, onLaneWorktreeMutation: ({ laneId, reason }) => { @@ -271,6 +318,10 @@ app.whenReady().then(async () => { gitService, conflictService, hostedAgentService, + byokLlmService, + githubService, + prService, + prPollingService, jobEngine, packService, projectConfigService, @@ -280,6 +331,11 @@ app.whenReady().then(async () => { }; const closeContext = () => { + try { + ctxRef.prPollingService.dispose(); + } catch { + // ignore + } try { ctxRef.jobEngine.dispose(); } catch { diff --git a/apps/desktop/src/main/services/byok/byokLlmService.ts b/apps/desktop/src/main/services/byok/byokLlmService.ts new file mode 100644 index 000000000..800cf832d --- /dev/null +++ b/apps/desktop/src/main/services/byok/byokLlmService.ts @@ -0,0 +1,404 @@ +import type { ProviderMode } from "../../../shared/types"; +import type { Logger } from "../logging/logger"; +import type { createProjectConfigService } from "../config/projectConfigService"; + +type ByokProvider = "openai" | "anthropic" | "gemini"; + +type ByokConfig = { + provider: ByokProvider; + model: string; + apiKey: string; +}; + +type PromptTemplate = { + system: string; + user: string; +}; + +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function redactSecrets(text: string): string { + let output = text; + output = output.replace( + /((?:api[_-]?key|token|secret|password)\s*[:=]\s*)(["']?)[^\s"']{6,}\2/gi, + "$1" + ); + output = output.replace( + /-----BEGIN [^-]+ PRIVATE KEY-----[\s\S]*?-----END [^-]+ PRIVATE KEY-----/g, + "" + ); + output = output.replace( + /\b(?:ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,})\b/g, + "" + ); + return output; +} + +function asPrettyJson(value: unknown): string { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function parseConfidence(text: string): number | null { + const match = text.match(/confidence\s*[:=]\s*([0-9]+(?:\.[0-9]+)?)(%?)/i); + if (!match) return null; + const raw = Number(match[1]); + if (!Number.isFinite(raw)) return null; + const isPercent = match[2] === "%"; + const confidence = isPercent ? raw / 100 : raw; + if (confidence < 0 || confidence > 1) return null; + return confidence; +} + +function normalizeGeminiModel(model: string): string { + const normalized = model.trim(); + if (!normalized) { + throw new Error("BYOK Gemini model is missing. Set a valid Gemini model such as gemini-1.5-flash-latest."); + } + + const withoutPrefix = normalized.startsWith("models/") ? normalized.slice("models/".length) : normalized; + if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(withoutPrefix) || !withoutPrefix.startsWith("gemini-")) { + throw new Error("BYOK Gemini model should start with 'gemini-' (for example, gemini-1.5-flash-latest)."); + } + + return withoutPrefix; +} + +function extractDiffPatch(text: string): string { + const fence = text.match(/```diff\s*\n([\s\S]*?)\n```/i); + if (fence?.[1]) return fence[1].trim() + "\n"; + return ""; +} + +function stripDiffFence(text: string): string { + return text.replace(/```diff\s*\n[\s\S]*?\n```/gi, "").trim(); +} + +function buildPromptTemplate(kind: "narrative" | "pr-description" | "conflict", params: unknown): PromptTemplate { + if (kind === "narrative") { + return { + system: + "You are ADE's narrative writer. Produce concise, developer-facing markdown. Avoid marketing language. Never invent file names or commands.", + user: [ + "Generate a lane narrative for this ADE lane context.", + "Focus on what changed, risks, open questions, and recommended next checks.", + "", + "Return markdown with sections:", + "## Summary", + "## Key Changes", + "## Risks", + "## Suggested Next Steps", + "", + "Context JSON:", + asPrettyJson(params) + ].join("\\n") + }; + } + + if (kind === "pr-description") { + return { + system: + "You are ADE's PR drafting assistant. Return clear markdown that can be pasted into GitHub. Keep statements factual and tied to provided data.", + user: [ + "Draft a PR description from this ADE project/lane context.", + "", + "Return markdown with sections:", + "## Summary", + "## What Changed", + "## Validation", + "## Risks", + "", + "Context JSON:", + asPrettyJson(params) + ].join("\\n") + }; + } + + return { + system: + "You are ADE's conflict resolution assistant. Output a concise explanation plus a unified diff patch when possible. If resolution is uncertain, be explicit.", + user: [ + "Generate a conflict resolution proposal.", + "", + "Return markdown with sections:", + "## Resolution Strategy", + "## Confidence", + "## Patch", + "", + "Include a fenced code block with language 'diff' for the patch.", + "", + "Context JSON:", + asPrettyJson(params) + ].join("\\n") + }; +} + +function parseByokConfig(providerMode: ProviderMode, rawProviders: unknown): ByokConfig { + if (providerMode !== "byok") { + throw new Error("BYOK provider mode is not enabled."); + } + + const providers = isRecord(rawProviders) ? rawProviders : {}; + const byok = isRecord(providers.byok) ? providers.byok : {}; + + const providerRaw = asString(byok.provider).toLowerCase(); + const provider: ByokProvider = + providerRaw === "openai" || providerRaw === "anthropic" || providerRaw === "gemini" + ? (providerRaw as ByokProvider) + : ""; + + if (!provider) { + throw new Error("BYOK provider is invalid. Supported providers are: openai, anthropic, gemini."); + } + + const model = asString(byok.model).trim(); + const nextModel = + provider === "gemini" + ? normalizeGeminiModel(model) + : model; + + const apiKey = asString(byok.apiKey).trim(); + + if (!apiKey) throw new Error("BYOK API key is missing. Set it in Settings → Provider Mode (BYOK)."); + if (!nextModel) throw new Error("BYOK model is missing. Set it in Settings → Provider Mode (BYOK)."); + + return { + provider, + model: nextModel, + apiKey + }; +} + +async function callOpenai(args: { + apiKey: string; + model: string; + system: string; + user: string; + maxOutputTokens: number; +}): Promise { + const res = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + "content-type": "application/json", + authorization: `Bearer ${args.apiKey}` + }, + body: JSON.stringify({ + model: args.model, + messages: [ + { role: "system", content: args.system }, + { role: "user", content: args.user } + ], + temperature: 0.2, + max_tokens: args.maxOutputTokens + }) + }); + + const json = (await res.json().catch(() => null)) as any; + if (!res.ok) { + const message = typeof json?.error?.message === "string" ? json.error.message : `OpenAI API error (${res.status})`; + throw new Error(message); + } + + const text = asString(json?.choices?.[0]?.message?.content); + if (!text.trim()) throw new Error("OpenAI returned empty content."); + return text; +} + +async function callAnthropic(args: { + apiKey: string; + model: string; + system: string; + user: string; + maxOutputTokens: number; +}): Promise { + const res = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": args.apiKey, + "anthropic-version": "2023-06-01" + }, + body: JSON.stringify({ + model: args.model, + system: args.system, + max_tokens: args.maxOutputTokens, + messages: [{ role: "user", content: args.user }] + }) + }); + + const json = (await res.json().catch(() => null)) as any; + if (!res.ok) { + const message = asString(json?.error?.message) || `Anthropic API error (${res.status})`; + throw new Error(message); + } + + const parts = Array.isArray(json?.content) ? json.content : []; + const text = parts.map((p: any) => asString(p?.text)).join("").trim(); + if (!text.trim()) throw new Error("Anthropic returned empty content."); + return text; +} + +async function callGemini(args: { + apiKey: string; + model: string; + system: string; + user: string; + maxOutputTokens: number; +}): Promise { + // Gemini API expects model name like "gemini-1.5-pro-latest" in the path. + const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(args.model)}:generateContent?key=${encodeURIComponent(args.apiKey)}`; + const res = await fetch(url, { + method: "POST", + headers: { + "content-type": "application/json" + }, + body: JSON.stringify({ + systemInstruction: { + role: "system", + parts: [{ text: args.system }] + }, + contents: [ + { + role: "user", + parts: [{ text: args.user }] + } + ], + generationConfig: { + temperature: 0.2, + maxOutputTokens: args.maxOutputTokens + } + }) + }); + + const json = (await res.json().catch(() => null)) as any; + if (!res.ok) { + const message = asString(json?.error?.message) || `Gemini API error (${res.status})`; + throw new Error(message); + } + + const candidates = Array.isArray(json?.candidates) ? json.candidates : []; + const parts = Array.isArray(candidates?.[0]?.content?.parts) ? candidates[0].content.parts : []; + const text = parts.map((p: any) => asString(p?.text)).join("").trim(); + if (!text.trim()) throw new Error("Gemini returned empty content."); + return text; +} + +export function createByokLlmService({ + logger, + projectConfigService +}: { + logger: Logger; + projectConfigService: ReturnType; +}) { + const generateText = async ({ + kind, + params, + maxOutputTokens + }: { + kind: "narrative" | "pr-description" | "conflict"; + params: unknown; + maxOutputTokens: number; + }): Promise<{ text: string; provider: ByokProvider; model: string }> => { + const snapshot = projectConfigService.get().effective; + const cfg = parseByokConfig(snapshot.providerMode ?? "guest", snapshot.providers); + + const prompt = buildPromptTemplate(kind, params); + const redactedUser = redactSecrets(prompt.user); + + try { + const text = + cfg.provider === "openai" + ? await callOpenai({ + apiKey: cfg.apiKey, + model: cfg.model, + system: prompt.system, + user: redactedUser, + maxOutputTokens + }) + : cfg.provider === "anthropic" + ? await callAnthropic({ + apiKey: cfg.apiKey, + model: cfg.model, + system: prompt.system, + user: redactedUser, + maxOutputTokens + }) + : await callGemini({ + apiKey: cfg.apiKey, + model: cfg.model, + system: prompt.system, + user: redactedUser, + maxOutputTokens + }); + + return { text, provider: cfg.provider, model: cfg.model }; + } catch (error) { + logger.warn("byok.llm_failed", { + provider: cfg.provider, + model: cfg.model, + kind, + error: error instanceof Error ? error.message : String(error) + }); + throw error; + } + }; + + return { + async generateLaneNarrative(args: { + laneId: string; + packBody: string; + }): Promise<{ narrative: string; rawContent: string; provider: ByokProvider; model: string; confidence: number | null }> { + const params = { + laneId: args.laneId, + packBody: args.packBody + }; + const result = await generateText({ kind: "narrative", params, maxOutputTokens: 900 }); + return { + narrative: result.text.trim() + "\n", + rawContent: result.text, + provider: result.provider, + model: result.model, + confidence: parseConfidence(result.text) + }; + }, + + async draftPrDescription(args: { laneId: string; prContext: unknown }): Promise<{ body: string; rawContent: string; provider: ByokProvider; model: string; confidence: number | null }> { + const result = await generateText({ kind: "pr-description", params: args.prContext, maxOutputTokens: 1200 }); + return { + body: result.text.trim() + "\n", + rawContent: result.text, + provider: result.provider, + model: result.model, + confidence: parseConfidence(result.text) + }; + }, + + async proposeConflictResolution(args: { laneId: string; peerLaneId: string | null; conflictContext: unknown }): Promise<{ diffPatch: string; explanation: string; rawContent: string; provider: ByokProvider; model: string; confidence: number | null }> { + const params = { + laneId: args.laneId, + peerLaneId: args.peerLaneId, + ...((isRecord(args.conflictContext) ? args.conflictContext : {}) as Record) + }; + const result = await generateText({ kind: "conflict", params, maxOutputTokens: 1600 }); + const diffPatch = extractDiffPatch(result.text); + const explanation = stripDiffFence(result.text) || result.text.trim(); + return { + diffPatch, + explanation, + rawContent: result.text, + provider: result.provider, + model: result.model, + confidence: parseConfidence(result.text) + }; + } + }; +} diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index 91776e13e..62e459116 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -220,12 +220,22 @@ function coerceConfigFile(value: unknown): ProjectConfigFile { ? value.laneOverlayPolicies.map(coerceLaneOverlayPolicy).filter((x): x is ConfigLaneOverlayPolicy => x != null) : []; + const github = + isRecord(value.github) && (asNumber(value.github.prPollingIntervalSeconds) != null) + ? { + ...(asNumber(value.github.prPollingIntervalSeconds) != null + ? { prPollingIntervalSeconds: asNumber(value.github.prPollingIntervalSeconds) } + : {}) + } + : undefined; + return { version, processes, stackButtons, testSuites, laneOverlayPolicies, + ...(github ? { github } : {}), ...(isRecord(value.providers) ? { providers: value.providers } : {}) }; } @@ -253,6 +263,7 @@ function toCanonicalYaml(config: ProjectConfigFile): string { stackButtons: config.stackButtons ?? [], testSuites: config.testSuites ?? [], laneOverlayPolicies: config.laneOverlayPolicies ?? [], + ...(config.github ? { github: config.github } : {}), ...(config.providers ? { providers: config.providers } : {}) }; return YAML.stringify(normalized, { indent: 2 }); @@ -390,6 +401,13 @@ function resolveEffectiveConfig(shared: ProjectConfigFile, local: ProjectConfigF } : undefined; + const mergedGithub = shared.github || local.github + ? { + ...(shared.github ?? {}), + ...(local.github ?? {}) + } + : undefined; + const modeRaw = typeof mergedProviders?.mode === "string" ? mergedProviders.mode : undefined; const providerMode: ProviderMode = modeRaw === "hosted" || modeRaw === "byok" || modeRaw === "cli" || modeRaw === "guest" ? modeRaw : "guest"; @@ -401,6 +419,7 @@ function resolveEffectiveConfig(shared: ProjectConfigFile, local: ProjectConfigF testSuites, laneOverlayPolicies, providerMode, + ...(mergedGithub ? { github: mergedGithub } : {}), ...(mergedProviders ? { providers: mergedProviders } : {}) }; } @@ -480,6 +499,15 @@ function validateEffectiveConfig( validateDuplicateIds(shared.laneOverlayPolicies ?? [], "laneOverlayPolicies", issues, "shared"); validateDuplicateIds(local.laneOverlayPolicies ?? [], "laneOverlayPolicies", issues, "local"); + const prPoll = effective.github?.prPollingIntervalSeconds; + if (prPoll != null) { + if (!Number.isFinite(prPoll) || prPoll <= 0) { + issues.push({ path: "effective.github.prPollingIntervalSeconds", message: "prPollingIntervalSeconds must be > 0" }); + } else if (prPoll < 5 || prPoll > 300) { + issues.push({ path: "effective.github.prPollingIntervalSeconds", message: "prPollingIntervalSeconds must be between 5 and 300" }); + } + } + const processIds = new Set(); for (const [idx, proc] of effective.processes.entries()) { const p = `effective.processes[${idx}]`; diff --git a/apps/desktop/src/main/services/conflicts/conflictService.ts b/apps/desktop/src/main/services/conflicts/conflictService.ts index ca8b92a50..d44b03bc0 100644 --- a/apps/desktop/src/main/services/conflicts/conflictService.ts +++ b/apps/desktop/src/main/services/conflicts/conflictService.ts @@ -31,6 +31,8 @@ import type { AdeDb } from "../state/kvDb"; import type { createLaneService } from "../lanes/laneService"; import type { createOperationService } from "../history/operationService"; import type { createHostedAgentService } from "../hosted/hostedAgentService"; +import type { createProjectConfigService } from "../config/projectConfigService"; +import type { createByokLlmService } from "../byok/byokLlmService"; import { runGit, runGitMergeTree, runGitOrThrow } from "../git/git"; type PredictionStatus = "clean" | "conflict" | "unknown"; @@ -67,6 +69,7 @@ type ConflictProposalRow = { job_id: string | null; artifact_id: string | null; applied_operation_id: string | null; + metadata_json: string | null; created_at: string; updated_at: string; }; @@ -296,12 +299,39 @@ function rowToProposal(row: ConflictProposalRow): ConflictProposal { }; } +function safeParseMetadata(raw: string | null | undefined): Record { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + return parsed as Record; + } catch { + return {}; + } +} + function writePatchFile(content: string): string { const filePath = path.join(os.tmpdir(), `ade-proposal-${randomUUID()}.patch`); fs.writeFileSync(filePath, content, "utf8"); return filePath; } +function extractPathsFromUnifiedDiff(diffPatch: string): string[] { + const paths = new Set(); + for (const line of diffPatch.split(/\r?\n/)) { + if (line.startsWith("+++ b/")) { + const p = line.slice("+++ b/".length).trim(); + if (p && p !== "/dev/null") paths.add(p); + } + if (line.startsWith("diff --git ")) { + const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/); + const p = match?.[2]?.trim(); + if (p && p !== "/dev/null") paths.add(p); + } + } + return Array.from(paths).sort((a, b) => a.localeCompare(b)); +} + function deletePatchFile(filePath: string): void { try { fs.unlinkSync(filePath); @@ -316,8 +346,10 @@ export function createConflictService({ projectId, projectRoot, laneService, + projectConfigService, operationService, hostedAgentService, + byokLlmService, conflictPacksDir, onEvent }: { @@ -326,8 +358,10 @@ export function createConflictService({ projectId: string; projectRoot: string; laneService: ReturnType; + projectConfigService: ReturnType; operationService?: ReturnType; hostedAgentService?: ReturnType; + byokLlmService?: ReturnType; conflictPacksDir?: string; onEvent?: (event: ConflictEventPayload) => void; }) { @@ -366,6 +400,22 @@ export function createConflictService({ return lanes.filter((lane) => !lane.archivedAt); }; + const packsRootDir = conflictPacksDir ? path.dirname(conflictPacksDir) : null; + + const readLanePackBody = (laneId: string): string | null => { + if (!packsRootDir) return null; + const filePath = path.join(packsRootDir, "lanes", laneId, "lane_pack.md"); + if (!fs.existsSync(filePath)) return null; + try { + const raw = fs.readFileSync(filePath, "utf8"); + const trimmed = raw.trim(); + if (!trimmed) return null; + return trimmed.length > 12_000 ? `${trimmed.slice(0, 12_000)}\n\n…(truncated)…\n` : trimmed; + } catch { + return null; + } + }; + const getLatestRows = (): Map => { const rows = db.all( ` @@ -1204,20 +1254,95 @@ export function createConflictService({ }; const requestProposal = async (args: RequestConflictProposalArgs): Promise => { - const lane = (await listActiveLanes()).find((entry) => entry.id === args.laneId); + const lanes = await listActiveLanes(); + const lane = lanes.find((entry) => entry.id === args.laneId); if (!lane) { throw new Error(`Lane not found: ${args.laneId}`); } - if (!hostedAgentService || !hostedAgentService.getStatus().enabled) { - throw new Error("Hosted provider is not configured. Set Provider Mode to Hosted and sign in first."); + // CONF-022: stack-aware conflict resolution. If a lane is stacked, resolve parent conflicts first. + if (lane.parentLaneId) { + const parentStatus = await getLaneStatus({ laneId: lane.parentLaneId }).catch(() => null); + if (parentStatus && parentStatus.status !== "merge-ready") { + throw new Error( + `Stack-aware resolution: resolve parent lane conflicts first (parent status: ${parentStatus.status}).` + ); + } } + const peerLaneId = args.peerLaneId ?? null; + + const overlaps = await listOverlaps({ laneId: args.laneId }); + const status = await getLaneStatus({ laneId: args.laneId }); + const overlapEntry = overlaps.find((entry) => entry.peerId === peerLaneId) ?? null; + const overlapPaths = (overlapEntry?.files ?? []).map((file) => file.path).filter(Boolean); + + const lanePackBody = readLanePackBody(args.laneId); + const peerPackBody = peerLaneId ? readLanePackBody(peerLaneId) : null; + + const overlapDiffs = await (async () => { + const MAX_FILES = 6; + const MAX_DIFF_CHARS = 9000; + + const truncate = (text: string): string => { + if (text.length <= MAX_DIFF_CHARS) return text; + return `${text.slice(0, MAX_DIFF_CHARS)}\n...(truncated)...\n`; + }; + + const laneGit = laneService.getLaneBaseAndBranch(args.laneId); + const laneHeadSha = await readHeadSha(laneGit.worktreePath).catch(() => ""); + if (!laneHeadSha) return null; + + if (peerLaneId) { + const peerGit = laneService.getLaneBaseAndBranch(peerLaneId); + const peerHeadSha = await readHeadSha(peerGit.worktreePath).catch(() => ""); + if (!peerHeadSha) return null; + const mergeBaseSha = await readMergeBase(laneGit.worktreePath, laneHeadSha, peerHeadSha).catch(() => ""); + const base = mergeBaseSha.trim(); + if (!base) return null; + + const files: Array<{ path: string; laneDiff: string; peerDiff: string }> = []; + for (const filePath of overlapPaths.slice(0, MAX_FILES)) { + const [laneDiff, peerDiff] = await Promise.all([ + runGit(["diff", "--unified=3", `${base}..${laneHeadSha}`, "--", filePath], { cwd: laneGit.worktreePath, timeoutMs: 25_000 }) + .then((res) => (res.exitCode === 0 ? truncate(res.stdout) : "")), + runGit(["diff", "--unified=3", `${base}..${peerHeadSha}`, "--", filePath], { cwd: laneGit.worktreePath, timeoutMs: 25_000 }) + .then((res) => (res.exitCode === 0 ? truncate(res.stdout) : "")) + ]); + files.push({ path: filePath, laneDiff, peerDiff }); + } + + return { + mergeBaseSha: base, + laneHeadSha, + peerHeadSha, + files + }; + } + + const parentLane = lane.parentLaneId ? lanes.find((entry) => entry.id === lane.parentLaneId) ?? null : null; + const baseRef = parentLane?.branchRef ?? lane.baseRef; + const files: Array<{ path: string; laneDiff: string }> = []; + for (const filePath of overlapPaths.slice(0, MAX_FILES)) { + const diff = await runGit(["diff", "--unified=3", `${baseRef}..${laneHeadSha}`, "--", filePath], { cwd: laneGit.worktreePath, timeoutMs: 25_000 }) + .then((res) => (res.exitCode === 0 ? truncate(res.stdout) : "")); + files.push({ path: filePath, laneDiff: diff }); + } + return { + baseRef, + laneHeadSha, + files + }; + })().catch(() => null); + let conflictContext: Record = { laneId: args.laneId, - peerLaneId: args.peerLaneId ?? null, - overlaps: await listOverlaps({ laneId: args.laneId }), - status: await getLaneStatus({ laneId: args.laneId }) + peerLaneId, + overlaps, + status, + ...(lanePackBody ? { lanePackBody } : {}), + ...(peerPackBody ? { peerPackBody } : {}), + ...(overlapDiffs ? { overlapDiffs } : {}) }; if (conflictPacksDir) { @@ -1227,7 +1352,12 @@ export function createConflictService({ const raw = fs.readFileSync(packPath, "utf8"); const parsed = JSON.parse(raw); if (parsed && typeof parsed === "object") { - conflictContext = parsed as Record; + conflictContext = { + ...conflictContext, + ...(parsed as Record), + laneId: args.laneId, + peerLaneId + }; } } catch { // Ignore malformed conflict pack and fall back to runtime context. @@ -1235,11 +1365,29 @@ export function createConflictService({ } } - const hostedResult = await hostedAgentService.requestConflictProposal({ - laneId: args.laneId, - peerLaneId: args.peerLaneId ?? null, - conflictContext - }); + const providerMode = projectConfigService.get().effective.providerMode ?? "guest"; + const usingHosted = providerMode === "hosted" && hostedAgentService?.getStatus().enabled; + const usingByok = providerMode === "byok"; + + if (!usingHosted && !usingByok) { + throw new Error("AI conflict resolution requires Hosted or BYOK provider mode."); + } + + if (usingByok && !byokLlmService) { + throw new Error("BYOK provider is enabled but BYOK LLM service is unavailable."); + } + + const result = usingHosted + ? await hostedAgentService!.requestConflictProposal({ + laneId: args.laneId, + peerLaneId: args.peerLaneId ?? null, + conflictContext + }) + : await byokLlmService!.proposeConflictResolution({ + laneId: args.laneId, + peerLaneId: args.peerLaneId ?? null, + conflictContext + }); const createdAt = new Date().toISOString(); const proposalId = randomUUID(); @@ -1264,7 +1412,7 @@ export function createConflictService({ metadata_json, created_at, updated_at - ) values (?, ?, ?, ?, ?, 'hosted', ?, ?, ?, 'pending', ?, ?, null, ?, ?, ?) + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, null, ?, ?, ?) `, [ proposalId, @@ -1272,13 +1420,16 @@ export function createConflictService({ args.laneId, args.peerLaneId ?? null, predictionId, - hostedResult.confidence, - hostedResult.explanation, - hostedResult.diffPatch, - hostedResult.jobId, - hostedResult.artifactId, + usingHosted ? "hosted" : "local", + result.confidence, + result.explanation, + result.diffPatch, + usingHosted ? (result as any).jobId : null, + usingHosted ? (result as any).artifactId : null, JSON.stringify({ - rawContent: hostedResult.rawContent + provider: usingHosted ? "hosted" : "byok", + model: usingHosted ? null : (result as any).model, + rawContent: result.rawContent }), createdAt, createdAt @@ -1301,6 +1452,12 @@ export function createConflictService({ throw new Error("Proposal does not include a diff patch"); } + const applyMode = args.applyMode ?? "unstaged"; + const commitMessage = args.commitMessage?.trim() ?? ""; + if (applyMode === "commit" && !commitMessage) { + throw new Error("commitMessage is required when applyMode='commit'"); + } + const lane = laneService.getLaneBaseAndBranch(args.laneId); const preHeadSha = await readHeadSha(lane.worktreePath); const operation = operationService?.start({ @@ -1308,7 +1465,8 @@ export function createConflictService({ kind: "conflict_proposal_apply", preHeadSha, metadata: { - proposalId: args.proposalId + proposalId: args.proposalId, + applyMode } }); @@ -1322,6 +1480,22 @@ export function createConflictService({ throw new Error(applyResult.stderr.trim() || "Failed to apply conflict proposal patch"); } + const touchedFiles = extractPathsFromUnifiedDiff(row.diff_patch); + if (applyMode === "staged" || applyMode === "commit") { + if (touchedFiles.length) { + await runGitOrThrow(["add", "--", ...touchedFiles], { cwd: lane.worktreePath, timeoutMs: 60_000 }); + } else { + // Fall back to staging all changes; diff parsing missed something. + await runGitOrThrow(["add", "-A"], { cwd: lane.worktreePath, timeoutMs: 60_000 }); + } + } + + let appliedCommitSha: string | null = null; + if (applyMode === "commit") { + await runGitOrThrow(["commit", "-m", commitMessage], { cwd: lane.worktreePath, timeoutMs: 60_000 }); + appliedCommitSha = await readHeadSha(lane.worktreePath); + } + const postHeadSha = await readHeadSha(lane.worktreePath); if (operationService && operation) { operationService.finish({ @@ -1329,22 +1503,30 @@ export function createConflictService({ status: "succeeded", postHeadSha, metadataPatch: { - proposalId: args.proposalId + proposalId: args.proposalId, + ...(appliedCommitSha ? { appliedCommitSha } : {}) } }); } const now = new Date().toISOString(); + const nextMetadata = { + ...safeParseMetadata(row.metadata_json), + applyMode, + ...(commitMessage ? { commitMessage } : {}), + ...(appliedCommitSha ? { appliedCommitSha } : {}) + }; db.run( ` update conflict_proposals set status = 'applied', applied_operation_id = ?, + metadata_json = ?, updated_at = ? where id = ? and project_id = ? `, - [operation?.operationId ?? null, now, args.proposalId, projectId] + [operation?.operationId ?? null, JSON.stringify(nextMetadata), now, args.proposalId, projectId] ); } catch (error) { const postHeadSha = await readHeadSha(lane.worktreePath); @@ -1390,14 +1572,26 @@ export function createConflictService({ } }); - const patchFile = writePatchFile(row.diff_patch); try { - const undoResult = await runGit( - ["apply", "-R", "--3way", "--whitespace=nowarn", patchFile], - { cwd: lane.worktreePath, timeoutMs: 60_000 } - ); - if (undoResult.exitCode !== 0) { - throw new Error(undoResult.stderr.trim() || "Failed to undo applied proposal patch"); + const metadata = safeParseMetadata(row.metadata_json); + const applyMode = typeof metadata.applyMode === "string" ? metadata.applyMode : "unstaged"; + const appliedCommitSha = typeof metadata.appliedCommitSha === "string" ? metadata.appliedCommitSha : ""; + + if (applyMode === "commit" && appliedCommitSha.trim()) { + await runGitOrThrow(["revert", "--no-edit", appliedCommitSha.trim()], { cwd: lane.worktreePath, timeoutMs: 90_000 }); + } else { + const patchFile = writePatchFile(row.diff_patch); + try { + const undoResult = await runGit( + ["apply", "-R", "--3way", "--whitespace=nowarn", patchFile], + { cwd: lane.worktreePath, timeoutMs: 60_000 } + ); + if (undoResult.exitCode !== 0) { + throw new Error(undoResult.stderr.trim() || "Failed to undo applied proposal patch"); + } + } finally { + deletePatchFile(patchFile); + } } const postHeadSha = await readHeadSha(lane.worktreePath); @@ -1418,11 +1612,12 @@ export function createConflictService({ update conflict_proposals set status = 'pending', applied_operation_id = null, + metadata_json = ?, updated_at = ? where id = ? and project_id = ? `, - [now, args.proposalId, projectId] + [JSON.stringify({ ...safeParseMetadata(row.metadata_json), applyMode: "unstaged", appliedCommitSha: null }), now, args.proposalId, projectId] ); } catch (error) { const postHeadSha = await readHeadSha(lane.worktreePath); @@ -1438,7 +1633,6 @@ export function createConflictService({ } throw error; } finally { - deletePatchFile(patchFile); } const updated = getProposalRow(args.proposalId); diff --git a/apps/desktop/src/main/services/diffs/diffService.ts b/apps/desktop/src/main/services/diffs/diffService.ts index c5043822b..0cd3446a3 100644 --- a/apps/desktop/src/main/services/diffs/diffService.ts +++ b/apps/desktop/src/main/services/diffs/diffService.ts @@ -98,12 +98,14 @@ export function createDiffService({ laneService }: { laneService: ReturnType { const { worktreePath } = laneService.getLaneBaseAndBranch(laneId); const abs = path.join(worktreePath, filePath); @@ -126,6 +128,25 @@ export function createDiffService({ laneService }: { laneService: ReturnType { const lane = laneService.getLaneBaseAndBranch(args.laneId); - const limit = typeof args.limit === "number" ? Math.max(1, Math.min(100, Math.floor(args.limit))) : 30; + const limit = typeof args.limit === "number" ? Math.max(1, Math.min(200, Math.floor(args.limit))) : 30; const out = await runGitOrThrow( - ["log", `-n${limit}`, "--date=iso-strict", "--pretty=format:%H%x1f%h%x1f%an%x1f%aI%x1f%s"], + ["log", `-n${limit}`, "--date=iso-strict", "--pretty=format:%H%x1f%h%x1f%P%x1f%an%x1f%aI%x1f%s"], { cwd: lane.worktreePath, timeoutMs: 15_000 } ); @@ -285,11 +287,16 @@ export function createGitOperationsService({ .map((line) => line.trim()) .filter(Boolean) .map((line): GitCommitSummary | null => { - const [sha, shortSha, authorName, authoredAt, subject] = parseDelimited(line); + const [sha, shortSha, parentsRaw, authorName, authoredAt, subject] = parseDelimited(line); if (!sha || !shortSha) return null; + const parents = (parentsRaw ?? "") + .split(" ") + .map((entry) => entry.trim()) + .filter(Boolean); return { sha, shortSha, + parents, authorName: authorName ?? "", authoredAt: authoredAt ?? "", subject: subject ?? "" @@ -300,6 +307,36 @@ export function createGitOperationsService({ return rows; }, + async listCommitFiles(args: GitListCommitFilesArgs): Promise { + const lane = laneService.getLaneBaseAndBranch(args.laneId); + const sha = args.commitSha.trim(); + if (!sha.length) throw new Error("commitSha is required"); + const res = await runGitOrThrow(["show", "--pretty=format:", "--name-only", sha], { + cwd: lane.worktreePath, + timeoutMs: 12_000 + }); + return res + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + }, + + async getCommitMessage(args: GitGetCommitMessageArgs): Promise { + const lane = laneService.getLaneBaseAndBranch(args.laneId); + const sha = args.commitSha.trim(); + if (!sha.length) throw new Error("commitSha is required"); + const res = await runGitOrThrow(["show", "-s", "--format=%B", sha], { + cwd: lane.worktreePath, + timeoutMs: 12_000 + }); + const message = res.trimEnd(); + const MAX = 8000; + if (message.length > MAX) { + return `${message.slice(0, MAX)}\n\n...(truncated)...\n`; + } + return message; + }, + async revertCommit(args: GitRevertArgs): Promise { const commitSha = args.commitSha.trim(); if (!commitSha.length) throw new Error("Commit SHA is required"); diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts new file mode 100644 index 000000000..cf1ee89c6 --- /dev/null +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -0,0 +1,322 @@ +import fs from "node:fs"; +import path from "node:path"; +import { safeStorage } from "electron"; +import type { Logger } from "../logging/logger"; +import { runGit } from "../git/git"; +import type { GitHubRepoRef, GitHubStatus, HostedGitHubAppStatus } from "../../../shared/types"; +import type { createHostedAgentService } from "../hosted/hostedAgentService"; +import type { createProjectConfigService } from "../config/projectConfigService"; + +const AUTH_STORE_FILE_NAME = "github-token.v1.bin"; + +function nowIso(): string { + return new Date().toISOString(); +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function parseGitHubRepoFromRemoteUrl(remoteUrlRaw: string): GitHubRepoRef | null { + const remoteUrl = remoteUrlRaw.trim(); + if (!remoteUrl) return null; + + // git@github.com:owner/repo.git + const sshScp = remoteUrl.match(/^git@github\.com:(.+)$/i); + if (sshScp) { + const slug = sshScp[1].replace(/\.git$/i, "").trim(); + const [owner, name] = slug.split("/"); + if (owner && name) return { owner, name }; + return null; + } + + // ssh://git@github.com/owner/repo.git + if (remoteUrl.startsWith("ssh://") || remoteUrl.startsWith("https://") || remoteUrl.startsWith("http://")) { + try { + const url = new URL(remoteUrl); + if (!/github\.com$/i.test(url.hostname)) return null; + const parts = url.pathname.replace(/^\/+/, "").replace(/\.git$/i, "").split("/"); + const owner = parts[0]?.trim() ?? ""; + const name = parts[1]?.trim() ?? ""; + if (owner && name) return { owner, name }; + return null; + } catch { + return null; + } + } + + return null; +} + +export function createGithubService({ + logger, + adeDir, + projectRoot, + projectConfigService, + hostedAgentService +}: { + logger: Logger; + adeDir: string; + projectRoot: string; + projectConfigService: ReturnType; + hostedAgentService?: ReturnType; +}) { + const githubStateDir = path.join(adeDir, "github"); + const tokenPath = path.join(githubStateDir, AUTH_STORE_FILE_NAME); + + const readStoredToken = (): string | null => { + if (!fs.existsSync(tokenPath)) return null; + try { + const bytes = fs.readFileSync(tokenPath); + if (!safeStorage.isEncryptionAvailable()) { + logger.warn("github.token_store_unavailable", { + message: "OS secure storage is unavailable; GitHub token cannot be decrypted." + }); + return null; + } + const decrypted = safeStorage.decryptString(bytes); + const parsed = JSON.parse(decrypted) as { token?: unknown }; + const token = asString(parsed?.token); + return token.trim().length ? token.trim() : null; + } catch (error) { + logger.warn("github.token_store_read_failed", { error: error instanceof Error ? error.message : String(error) }); + return null; + } + }; + + const persistToken = (token: string | null): void => { + const clean = (token ?? "").trim(); + if (!clean) { + try { + if (fs.existsSync(tokenPath)) fs.unlinkSync(tokenPath); + } catch { + // ignore + } + return; + } + + if (!safeStorage.isEncryptionAvailable()) { + throw new Error("OS secure storage is unavailable. Cannot persist GitHub token."); + } + + fs.mkdirSync(githubStateDir, { recursive: true }); + const encrypted = safeStorage.encryptString(JSON.stringify({ token: clean })); + fs.writeFileSync(tokenPath, encrypted); + try { + fs.chmodSync(tokenPath, 0o600); + } catch { + // ignore best-effort chmod + } + }; + + const detectRepo = async (): Promise => { + const res = await runGit(["remote", "get-url", "origin"], { cwd: projectRoot, timeoutMs: 8000 }); + if (res.exitCode !== 0) return null; + return parseGitHubRepoFromRemoteUrl(res.stdout); + }; + + const validateToken = async (token: string): Promise<{ userLogin: string | null; scopes: string[] }> => { + const response = await fetch("https://api.github.com/user", { + method: "GET", + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "user-agent": "ade-desktop" + } + }); + + const scopes = (response.headers.get("x-oauth-scopes") ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + + const payload = (await response.json().catch(() => ({}))) as Record; + if (!response.ok) { + const msg = asString(payload.message) || `GitHub token validation failed (HTTP ${response.status})`; + throw new Error(msg); + } + + return { + userLogin: asString(payload.login) || null, + scopes + }; + }; + + const apiRequest = async (args: { + method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; + path: string; + query?: Record; + body?: unknown; + token?: string; + }): Promise<{ data: T; response: Response | null }> => { + const providerMode = projectConfigService.get().effective.providerMode ?? "guest"; + const shouldUseHostedProxy = providerMode === "hosted" && hostedAgentService?.getStatus().enabled; + if (shouldUseHostedProxy) { + const status = await getHostedGitHubStatus(); + if (!status.configured) { + throw new Error( + "Hosted GitHub App is not configured on the cloud API. Configure ADE_GITHUB_APP_ID/ADE_GITHUB_APP_SLUG/ADE_GITHUB_APP_PRIVATE_KEY_BASE64 and ADE_GITHUB_WEBHOOK_SECRET, then redeploy the API." + ); + } + if (!status.connected) { + throw new Error("GitHub App is not connected for this project. Go to Settings → Hosted GitHub App and click Connect."); + } + + const data = await hostedAgentService!.githubProxyRequest({ + method: args.method, + path: args.path, + query: args.query, + body: args.body + }); + return { data, response: null }; + } + + const token = (args.token ?? readStoredToken() ?? "").trim(); + if (!token) { + throw new Error("GitHub token missing. Set it in Settings."); + } + + const baseUrl = "https://api.github.com"; + const url = new URL(`${baseUrl}${args.path}`); + for (const [key, value] of Object.entries(args.query ?? {})) { + if (value == null) continue; + url.searchParams.set(key, String(value)); + } + + const response = await fetch(url.toString(), { + method: args.method, + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "content-type": args.body != null ? "application/json" : "text/plain", + "user-agent": "ade-desktop" + }, + body: args.body != null ? JSON.stringify(args.body) : undefined + }); + + const text = await response.text(); + let data: unknown = text; + try { + data = text.trim().length ? JSON.parse(text) : {}; + } catch { + // keep text + } + + if (!response.ok) { + const message = + (data && typeof data === "object" && !Array.isArray(data) ? asString((data as any).message) : "") || + `GitHub API request failed (HTTP ${response.status})`; + const rateRemaining = response.headers.get("x-ratelimit-remaining"); + const rateReset = response.headers.get("x-ratelimit-reset"); + if (rateRemaining === "0" && rateReset) { + const resetAtMs = Number(rateReset) * 1000; + const err = new Error( + `${message} (rate limit exceeded; resets at ${new Date(resetAtMs).toLocaleString()})` + ); + (err as any).rateLimitResetAtMs = resetAtMs; + throw err; + } + throw new Error(message); + } + + return { data: data as T, response }; + }; + + let cachedStatus: GitHubStatus | null = null; + let cachedAt = 0; + + let cachedHostedStatus: HostedGitHubAppStatus | null = null; + let cachedHostedAt = 0; + + const getHostedGitHubStatus = async (): Promise => { + if (!hostedAgentService) { + throw new Error("Hosted GitHub App status is unavailable (hosted agent service missing)."); + } + + const now = Date.now(); + if (cachedHostedStatus && now - cachedHostedAt < 5_000) { + return cachedHostedStatus; + } + + const status = await hostedAgentService.githubGetStatus(); + cachedHostedStatus = status; + cachedHostedAt = now; + return status; + }; + + const getStatus = async (): Promise => { + const token = readStoredToken(); + const repo = await detectRepo().catch(() => null); + if (!token) { + cachedStatus = { + tokenStored: false, + repo, + userLogin: null, + scopes: [], + checkedAt: null + }; + cachedAt = Date.now(); + return cachedStatus; + } + + const now = Date.now(); + if (cachedStatus && now - cachedAt < 30_000 && cachedStatus.tokenStored) { + // Still re-detect repo, it is cheap and reflects changed remotes. + return { ...cachedStatus, repo }; + } + + try { + const validated = await validateToken(token); + cachedStatus = { + tokenStored: true, + repo, + userLogin: validated.userLogin, + scopes: validated.scopes, + checkedAt: nowIso() + }; + cachedAt = now; + return cachedStatus; + } catch (error) { + logger.warn("github.token_validation_failed", { error: error instanceof Error ? error.message : String(error) }); + cachedStatus = { + tokenStored: true, + repo, + userLogin: null, + scopes: [], + checkedAt: nowIso() + }; + cachedAt = now; + return cachedStatus; + } + }; + + return { + getStatus, + + setToken(token: string): void { + persistToken(token); + cachedStatus = null; + cachedAt = 0; + }, + + clearToken(): void { + persistToken(null); + cachedStatus = null; + cachedAt = 0; + }, + + async getRepoOrThrow(): Promise { + const repo = await detectRepo(); + if (!repo) throw new Error("Unable to detect GitHub repo from git remote 'origin'."); + return repo; + }, + + getTokenOrThrow(): string { + const token = readStoredToken(); + if (!token) throw new Error("GitHub token missing. Set it in Settings."); + return token; + }, + + apiRequest + }; +} diff --git a/apps/desktop/src/main/services/hosted/hostedAgentService.ts b/apps/desktop/src/main/services/hosted/hostedAgentService.ts index 85d7bbd44..9b9a4ca9c 100644 --- a/apps/desktop/src/main/services/hosted/hostedAgentService.ts +++ b/apps/desktop/src/main/services/hosted/hostedAgentService.ts @@ -7,6 +7,11 @@ import type { HostedArtifactResult, HostedAuthStatus, HostedBootstrapConfig, + HostedGitHubAppStatus, + HostedGitHubConnectStartResult, + HostedGitHubDisconnectResult, + HostedGitHubEventsResult, + HostedGitHubProxyRequestArgs, HostedJobStatusResult, HostedJobSubmissionArgs, HostedJobSubmissionResult, @@ -103,6 +108,12 @@ const DEFAULT_EXCLUDE_PATTERNS = [ ".git/" ]; +const HOSTED_FETCH_TIMEOUT_MS = 20_000; +const POLL_INITIAL_DELAY_MS = 700; +const POLL_MAX_DELAY_MS = 4_000; +const POLL_TIMEOUT_FLOOR_MS = 60_000; +const POLL_STALL_TIMEOUT_MS = 90_000; + const TEXT_EXTENSIONS = new Set([ ".md", ".txt", @@ -205,10 +216,51 @@ function nowIso(): string { return new Date().toISOString(); } +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +const stableStringify = (value: unknown): string => { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(",")}]`; + } + + const record = value as Record; + const keys = Object.keys(record).sort(); + const body = keys + .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) + .join(","); + return `{${body}}`; +}; + +async function fetchWithTimeout(url: string, init: RequestInit = {}, timeoutMs = HOSTED_FETCH_TIMEOUT_MS): Promise { + const controller = new AbortController(); + const timeout = Math.max(5_000, Math.floor(timeoutMs)); + const timer = setTimeout(() => controller.abort(), timeout); + + try { + return await fetch(url, { ...init, signal: controller.signal }); + } catch (error) { + if ((error as DOMException | null)?.name === "AbortError") { + throw new Error(`Request to hosted endpoint timed out after ${timeout}ms`); + } + throw error; + } finally { + clearTimeout(timer); + } +} + function toJsonBody(value: unknown): string { return JSON.stringify(value); } +const makeRequestKey = (label: string, payload: unknown): string => + `${label}:${createHash("sha256").update(stableStringify(payload)).digest("hex")}`; + function normalizeHttpUrl(input: string): string { const trimmed = input.trim(); if (!trimmed) return ""; @@ -657,13 +709,13 @@ export function createHostedAgentService({ return config; }; - const refreshAccessTokenIfNeeded = async (): Promise => { + const refreshAccessTokenIfNeeded = async (force = false): Promise => { const config = ensureHostedConfigured(); const accessToken = config.auth.accessToken; const expiresAt = config.auth.expiresAt ? Date.parse(config.auth.expiresAt) : Number.NaN; const hasValidToken = !!accessToken && Number.isFinite(expiresAt) && Date.now() < expiresAt - 60_000; - if (hasValidToken && accessToken) { + if (!force && hasValidToken && accessToken) { return accessToken; } @@ -676,7 +728,7 @@ export function createHostedAgentService({ throw new Error("Hosted OAuth token endpoint is missing. Apply bootstrap config and try again."); } - const response = await fetch(tokenUrl, { + const response = await fetchWithTimeout(tokenUrl, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" @@ -726,14 +778,17 @@ export function createHostedAgentService({ let config = ensureHostedConfigured(); await refreshAccessTokenIfNeeded(); config = ensureHostedConfigured(); - const token = tokenHasIdentityClaim(config.auth.accessToken) - ? asString(config.auth.accessToken) - : tokenHasIdentityClaim(config.auth.idToken) - ? asString(config.auth.idToken) - : asString(config.auth.accessToken); + // Prefer idToken for API calls — its aud matches the API Gateway's + // configured OAuth client_id audience, while the access_token may have + // a different audience that the JWT authorizer rejects. + const token = tokenHasIdentityClaim(config.auth.idToken) + ? asString(config.auth.idToken) + : tokenHasIdentityClaim(config.auth.accessToken) + ? asString(config.auth.accessToken) + : asString(config.auth.idToken || config.auth.accessToken); const doRequest = async (authToken: string) => { - const response = await fetch(`${config.apiBaseUrl.replace(/\/$/, "")}${args.path}`, { + const response = await fetchWithTimeout(`${config.apiBaseUrl.replace(/\/$/, "")}${args.path}`, { method: args.method, headers: { "content-type": "application/json", @@ -748,13 +803,13 @@ export function createHostedAgentService({ const first = await doRequest(token); if (first.response.status === 401 && args.retryOnUnauthorized !== false) { - await refreshAccessTokenIfNeeded(); + await refreshAccessTokenIfNeeded(true); config = ensureHostedConfigured(); - const nextToken = tokenHasIdentityClaim(config.auth.accessToken) - ? asString(config.auth.accessToken) - : tokenHasIdentityClaim(config.auth.idToken) - ? asString(config.auth.idToken) - : asString(config.auth.accessToken); + const nextToken = tokenHasIdentityClaim(config.auth.idToken) + ? asString(config.auth.idToken) + : tokenHasIdentityClaim(config.auth.accessToken) + ? asString(config.auth.accessToken) + : asString(config.auth.idToken || config.auth.accessToken); const second = await doRequest(nextToken); if (!second.response.ok) { throw new Error(parseErrorMessage(second.payload)); @@ -1061,17 +1116,97 @@ export function createHostedAgentService({ }; }; + const laneNarrativeRequests = new Map< + string, + Promise<{ + jobId: string; + artifactId: string; + narrative: string; + }> + >(); + + const conflictProposalRequests = new Map< + string, + Promise<{ + jobId: string; + artifactId: string; + explanation: string; + diffPatch: string; + confidence: number | null; + rawContent: string; + }> + >(); + + const prDescriptionRequests = new Map< + string, + Promise<{ + jobId: string; + artifactId: string; + title: string; + body: string; + }> + >(); + + const runSingleRequest = async (args: { + key: string; + inFlight: Map>; + run: () => Promise; + }): Promise => { + const existing = args.inFlight.get(args.key); + if (existing) return existing; + const inFlight = args.run().finally(() => { + args.inFlight.delete(args.key); + }); + args.inFlight.set(args.key, inFlight); + return inFlight; + }; + const pollJob = async (jobId: string, timeoutMs = 120_000): Promise => { const started = Date.now(); + const effectiveTimeout = Math.max(POLL_TIMEOUT_FLOOR_MS, timeoutMs); + let delayMs = POLL_INITIAL_DELAY_MS; + let statusStreakStart = started; + let lastStatus: HostedJobStatusResult["status"] | null = null; + let consecutiveFailures = 0; + while (true) { - const status = await getJob(jobId); - if (status.status === "completed" || status.status === "failed") { + let status: HostedJobStatusResult; + try { + status = await getJob(jobId); + consecutiveFailures = 0; + } catch (error) { + consecutiveFailures += 1; + if (consecutiveFailures > 4) { + throw new Error( + `Unable to poll hosted job ${jobId} after ${consecutiveFailures} attempts: ${error instanceof Error ? error.message : String(error)}` + ); + } + await sleep(Math.min(POLL_MAX_DELAY_MS, POLL_INITIAL_DELAY_MS * 2 ** consecutiveFailures)); + continue; + } + + const normalizedStatus = status.status === "in_progress" ? "processing" : status.status; + + if (normalizedStatus !== lastStatus) { + lastStatus = normalizedStatus; + statusStreakStart = Date.now(); + } + + if (!["queued", "processing", "completed", "failed"].includes(normalizedStatus)) { + throw new Error(`Hosted job ${jobId} returned unsupported status: ${status.status}`); + } + + if (normalizedStatus === "completed" || normalizedStatus === "failed") { return status; } - if (Date.now() - started > timeoutMs) { + if (Date.now() - statusStreakStart > POLL_STALL_TIMEOUT_MS) { + throw new Error(`Hosted job ${jobId} is stuck on status '${normalizedStatus}' for too long.`); + } + if (Date.now() - started > effectiveTimeout) { throw new Error(`Timed out waiting for hosted job ${jobId}`); } - await new Promise((resolve) => setTimeout(resolve, 1500)); + await sleep(delayMs); + delayMs = Math.min(POLL_MAX_DELAY_MS, Math.max(POLL_INITIAL_DELAY_MS, Math.floor(delayMs * 1.8))); } }; @@ -1166,7 +1301,7 @@ export function createHostedAgentService({ }); }); - const tokenResponse = await fetch(tokenEndpoint, { + const tokenResponse = await fetchWithTimeout(tokenEndpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" @@ -1194,7 +1329,7 @@ export function createHostedAgentService({ let profile = extractProfileFromClaims(decodeJwtClaims(idToken || accessToken)); if ((!profile.email || !profile.displayName) && config.clerkOauthUserInfoUrl.trim() && accessToken) { - const userInfoResponse = await fetch(config.clerkOauthUserInfoUrl, { + const userInfoResponse = await fetchWithTimeout(config.clerkOauthUserInfoUrl, { headers: { authorization: `Bearer ${accessToken}` } @@ -1317,6 +1452,55 @@ export function createHostedAgentService({ }; }; + const githubGetStatus = async (): Promise => { + const remoteProjectId = await ensureRemoteProject(); + return await apiRequest({ + method: "GET", + path: `/projects/${remoteProjectId}/github/status` + }); + }; + + const githubConnectStart = async (): Promise => { + const remoteProjectId = await ensureRemoteProject(); + const response = await apiRequest({ + method: "POST", + path: `/projects/${remoteProjectId}/github/connect/start` + }); + await openExternal(response.installUrl); + return response; + }; + + const githubDisconnect = async (): Promise => { + const remoteProjectId = await ensureRemoteProject(); + return await apiRequest({ + method: "POST", + path: `/projects/${remoteProjectId}/github/disconnect` + }); + }; + + const githubListEvents = async (): Promise => { + const remoteProjectId = await ensureRemoteProject(); + return await apiRequest({ + method: "GET", + path: `/projects/${remoteProjectId}/github/events` + }); + }; + + const githubProxyRequest = async (args: HostedGitHubProxyRequestArgs): Promise => { + const remoteProjectId = await ensureRemoteProject(); + const response = await apiRequest<{ data: T }>({ + method: "POST", + path: `/projects/${remoteProjectId}/github/api`, + body: { + method: args.method, + path: args.path, + query: args.query ?? {}, + body: args.body + } + }); + return response.data; + }; + const syncMirror = async (args: HostedMirrorSyncArgs = {}): Promise => { const config = ensureHostedConfigured(); const remoteProjectId = await ensureRemoteProject(); @@ -1405,6 +1589,26 @@ export function createHostedAgentService({ return await getArtifact(artifactId); }, + async githubGetStatus(): Promise { + return await githubGetStatus(); + }, + + async githubConnectStart(): Promise { + return await githubConnectStart(); + }, + + async githubDisconnect(): Promise { + return await githubDisconnect(); + }, + + async githubListEvents(): Promise { + return await githubListEvents(); + }, + + async githubProxyRequest(args: HostedGitHubProxyRequestArgs): Promise { + return await githubProxyRequest(args); + }, + async waitForJob(jobId: string, timeoutMs?: number): Promise { return await pollJob(jobId, timeoutMs); }, @@ -1421,40 +1625,50 @@ export function createHostedAgentService({ confidence: number | null; rawContent: string; }> { - await syncMirror({ laneId: args.laneId, includeTranscripts: false }); - - const submission = await submitJob({ - type: "ProposeConflictResolution" as HostedJobType, + const requestKey = makeRequestKey("conflict-proposal", { laneId: args.laneId, - params: { - peerLaneId: args.peerLaneId ?? null, - ...args.conflictContext - } + peerLaneId: args.peerLaneId ?? null, + context: args.conflictContext }); - const status = await pollJob(submission.jobId, 180_000); - if (status.status !== "completed" || !status.artifactId) { - const message = status.error?.message ?? `Proposal job ${status.jobId} did not complete successfully.`; - throw new Error(message); - } + return runSingleRequest({ + key: requestKey, + inFlight: conflictProposalRequests, + run: async () => { + const submission = await submitJob({ + type: "ProposeConflictResolution" as HostedJobType, + laneId: args.laneId, + params: { + peerLaneId: args.peerLaneId ?? null, + ...args.conflictContext + } + }); + + const status = await pollJob(submission.jobId, 180_000); + if (status.status !== "completed" || !status.artifactId) { + const message = status.error?.message ?? `Proposal job ${status.jobId} did not complete successfully.`; + throw new Error(message); + } - const artifact = await getArtifact(status.artifactId); - const contentRaw = isRecord(artifact.content) && typeof artifact.content.content === "string" - ? (artifact.content.content as string) - : typeof artifact.content === "string" - ? artifact.content - : JSON.stringify(artifact.content, null, 2); - - const parsed = parseDiffFromContent(contentRaw); - - return { - jobId: submission.jobId, - artifactId: artifact.artifactId, - explanation: parsed.explanation, - diffPatch: parsed.diffPatch, - confidence: parsed.confidence, - rawContent: contentRaw - }; + const artifact = await getArtifact(status.artifactId); + const contentRaw = isRecord(artifact.content) && typeof artifact.content.content === "string" + ? (artifact.content.content as string) + : typeof artifact.content === "string" + ? artifact.content + : JSON.stringify(artifact.content, null, 2); + + const parsed = parseDiffFromContent(contentRaw); + + return { + jobId: submission.jobId, + artifactId: artifact.artifactId, + explanation: parsed.explanation, + diffPatch: parsed.diffPatch, + confidence: parsed.confidence, + rawContent: contentRaw + }; + } + }); }, async requestLaneNarrative(args: { laneId: string; packBody: string }): Promise<{ @@ -1462,34 +1676,90 @@ export function createHostedAgentService({ artifactId: string; narrative: string; }> { - await syncMirror({ laneId: args.laneId, includeTranscripts: false }); - - const submission = await submitJob({ - type: "NarrativeGeneration" as HostedJobType, + const requestKey = makeRequestKey("lane-narrative", { laneId: args.laneId, - params: { - packBody: args.packBody + packBody: args.packBody + }); + + return runSingleRequest({ + key: requestKey, + inFlight: laneNarrativeRequests, + run: async () => { + const submission = await submitJob({ + type: "NarrativeGeneration" as HostedJobType, + laneId: args.laneId, + params: { + packBody: args.packBody + } + }); + + const status = await pollJob(submission.jobId, 120_000); + if (status.status !== "completed" || !status.artifactId) { + const message = status.error?.message ?? `Narrative job ${status.jobId} did not complete successfully.`; + throw new Error(message); + } + + const artifact = await getArtifact(status.artifactId); + const narrative = isRecord(artifact.content) && typeof artifact.content.content === "string" + ? (artifact.content.content as string) + : typeof artifact.content === "string" + ? artifact.content + : JSON.stringify(artifact.content, null, 2); + + return { + jobId: submission.jobId, + artifactId: artifact.artifactId, + narrative + }; } }); + }, - const status = await pollJob(submission.jobId, 120_000); - if (status.status !== "completed" || !status.artifactId) { - const message = status.error?.message ?? `Narrative job ${status.jobId} did not complete successfully.`; - throw new Error(message); - } + async requestPrDescription(args: { + laneId: string; + prContext: Record; + }): Promise<{ + jobId: string; + artifactId: string; + title: string; + body: string; + }> { + const requestKey = makeRequestKey("pr-description", { + laneId: args.laneId, + prContext: args.prContext + }); - const artifact = await getArtifact(status.artifactId); - const narrative = isRecord(artifact.content) && typeof artifact.content.content === "string" - ? (artifact.content.content as string) - : typeof artifact.content === "string" - ? artifact.content - : JSON.stringify(artifact.content, null, 2); - - return { - jobId: submission.jobId, - artifactId: artifact.artifactId, - narrative - }; + return runSingleRequest({ + key: requestKey, + inFlight: prDescriptionRequests, + run: async () => { + const submission = await submitJob({ + type: "DraftPrDescription" as HostedJobType, + laneId: args.laneId, + params: args.prContext + }); + + const status = await pollJob(submission.jobId, 120_000); + if (status.status !== "completed" || !status.artifactId) { + const message = status.error?.message ?? `PR drafting job ${status.jobId} did not complete successfully.`; + throw new Error(message); + } + + const artifact = await getArtifact(status.artifactId); + const body = isRecord(artifact.content) && typeof artifact.content.content === "string" + ? (artifact.content.content as string) + : typeof artifact.content === "string" + ? artifact.content + : JSON.stringify(artifact.content, null, 2); + + return { + jobId: submission.jobId, + artifactId: artifact.artifactId, + title: "", + body + }; + } + }); } }; } diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 51fc5302e..97ea0debe 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -35,6 +35,8 @@ import type { GitCherryPickArgs, GitCommitArgs, GitCommitSummary, + GitGetCommitMessageArgs, + GitListCommitFilesArgs, GitFileActionArgs, GitPushArgs, GitRevertArgs, @@ -42,6 +44,17 @@ import type { GitStashRefArgs, GitStashSummary, GitSyncArgs, + GitHubStatus, + CreatePrFromLaneArgs, + LinkPrToLaneArgs, + LandResult, + PrCheck, + PrReview, + PrStatus, + PrSummary, + UpdatePrDescriptionArgs, + LandPrArgs, + LandStackArgs, GetLaneConflictStatusArgs, GetDiffChangesArgs, GetFileDiffArgs, @@ -49,6 +62,10 @@ import type { GetTestLogTailArgs, HostedArtifactResult, HostedBootstrapConfig, + HostedGitHubAppStatus, + HostedGitHubConnectStartResult, + HostedGitHubDisconnectResult, + HostedGitHubEventsResult, HostedJobStatusResult, HostedJobSubmissionArgs, HostedJobSubmissionResult, @@ -115,6 +132,10 @@ import type { createOperationService } from "../history/operationService"; import type { createConflictService } from "../conflicts/conflictService"; import type { createJobEngine } from "../jobs/jobEngine"; import type { createHostedAgentService } from "../hosted/hostedAgentService"; +import type { createGithubService } from "../github/githubService"; +import type { createPrService } from "../prs/prService"; +import type { createPrPollingService } from "../prs/prPollingService"; +import type { createByokLlmService } from "../byok/byokLlmService"; export type AppContext = { db: AdeDb; @@ -131,6 +152,10 @@ export type AppContext = { gitService: ReturnType; conflictService: ReturnType; hostedAgentService: ReturnType; + byokLlmService: ReturnType; + githubService: ReturnType; + prService: ReturnType; + prPollingService: ReturnType; jobEngine: ReturnType; packService: ReturnType; projectConfigService: ReturnType; @@ -158,6 +183,21 @@ export function registerIpc({ ipcMain.handle(IPC.appGetProject, async () => getCtx().project); + ipcMain.handle(IPC.appOpenExternal, async (_event, arg: { url: string }): Promise => { + const urlRaw = typeof arg?.url === "string" ? arg.url.trim() : ""; + if (!urlRaw) return; + let parsed: URL; + try { + parsed = new URL(urlRaw); + } catch { + throw new Error("Invalid URL"); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Only http(s) URLs are allowed."); + } + await shell.openExternal(parsed.toString()); + }); + ipcMain.handle(IPC.appGetInfo, async (): Promise => { return { appVersion: app.getVersion(), @@ -328,7 +368,7 @@ export function registerIpc({ ctx.ptyService.resize(arg); }); - ipcMain.handle(IPC.ptyDispose, async (_event, arg: { ptyId: string }): Promise => { + ipcMain.handle(IPC.ptyDispose, async (_event, arg: { ptyId: string; sessionId?: string }): Promise => { const ctx = getCtx(); ctx.ptyService.dispose(arg); }); @@ -344,7 +384,8 @@ export function registerIpc({ laneId: arg.laneId, filePath: arg.path, mode: arg.mode, - compareRef: arg.compareRef + compareRef: arg.compareRef, + compareTo: arg.compareTo }); }); @@ -450,6 +491,16 @@ export function registerIpc({ return ctx.gitService.listRecentCommits(arg); }); + ipcMain.handle(IPC.gitListCommitFiles, async (_event, arg: GitListCommitFilesArgs): Promise => { + const ctx = getCtx(); + return await ctx.gitService.listCommitFiles(arg); + }); + + ipcMain.handle(IPC.gitGetCommitMessage, async (_event, arg: GitGetCommitMessageArgs): Promise => { + const ctx = getCtx(); + return await ctx.gitService.getCommitMessage(arg); + }); + ipcMain.handle(IPC.gitRevertCommit, async (_event, arg: GitRevertArgs): Promise => { const ctx = getCtx(); return ctx.gitService.revertCommit(arg); @@ -542,12 +593,16 @@ export function registerIpc({ ipcMain.handle(IPC.conflictsApplyProposal, async (_event, arg: ApplyConflictProposalArgs): Promise => { const ctx = getCtx(); - return await ctx.conflictService.applyProposal(arg); + const updated = await ctx.conflictService.applyProposal(arg); + ctx.jobEngine.runConflictPredictionNow({ laneId: arg.laneId }); + return updated; }); ipcMain.handle(IPC.conflictsUndoProposal, async (_event, arg: UndoConflictProposalArgs): Promise => { const ctx = getCtx(); - return await ctx.conflictService.undoProposal(arg); + const updated = await ctx.conflictService.undoProposal(arg); + ctx.jobEngine.runConflictPredictionNow({ laneId: arg.laneId }); + return updated; }); ipcMain.handle(IPC.packsGetProjectPack, async (): Promise => { @@ -581,6 +636,48 @@ export function registerIpc({ }); }); + ipcMain.handle(IPC.packsGenerateNarrative, async (_event, arg: { laneId: string }): Promise => { + const ctx = getCtx(); + const lanePack = ctx.packService.getLanePack(arg.laneId); + if (!lanePack.exists || !lanePack.body.trim().length) { + throw new Error("Lane pack is empty. Refresh the deterministic pack first."); + } + + const providerMode = ctx.projectConfigService.get().effective.providerMode ?? "guest"; + if (providerMode === "hosted" && ctx.hostedAgentService.getStatus().enabled) { + const narrative = await ctx.hostedAgentService.requestLaneNarrative({ + laneId: arg.laneId, + packBody: lanePack.body + }); + return ctx.packService.applyHostedNarrative({ + laneId: arg.laneId, + narrative: narrative.narrative, + metadata: { + jobId: narrative.jobId, + artifactId: narrative.artifactId + } + }); + } + + if (providerMode === "byok") { + const narrative = await ctx.byokLlmService.generateLaneNarrative({ + laneId: arg.laneId, + packBody: lanePack.body + }); + return ctx.packService.applyHostedNarrative({ + laneId: arg.laneId, + narrative: narrative.narrative, + metadata: { + source: "byok", + provider: narrative.provider, + model: narrative.model + } + }); + } + + throw new Error("AI narrative generation requires Hosted or BYOK provider mode."); + }); + ipcMain.handle(IPC.hostedGetStatus, async (): Promise => { const ctx = getCtx(); return ctx.hostedAgentService.getStatus(); @@ -626,6 +723,108 @@ export function registerIpc({ return await ctx.hostedAgentService.getArtifact(arg.artifactId); }); + ipcMain.handle(IPC.hostedGithubGetStatus, async (): Promise => { + const ctx = getCtx(); + return await ctx.hostedAgentService.githubGetStatus(); + }); + + ipcMain.handle(IPC.hostedGithubConnectStart, async (): Promise => { + const ctx = getCtx(); + return await ctx.hostedAgentService.githubConnectStart(); + }); + + ipcMain.handle(IPC.hostedGithubDisconnect, async (): Promise => { + const ctx = getCtx(); + return await ctx.hostedAgentService.githubDisconnect(); + }); + + ipcMain.handle(IPC.hostedGithubListEvents, async (): Promise => { + const ctx = getCtx(); + return await ctx.hostedAgentService.githubListEvents(); + }); + + ipcMain.handle(IPC.githubGetStatus, async (): Promise => { + const ctx = getCtx(); + return await ctx.githubService.getStatus(); + }); + + ipcMain.handle(IPC.githubSetToken, async (_event, arg: { token: string }): Promise => { + const ctx = getCtx(); + ctx.githubService.setToken(arg.token); + return await ctx.githubService.getStatus(); + }); + + ipcMain.handle(IPC.githubClearToken, async (): Promise => { + const ctx = getCtx(); + ctx.githubService.clearToken(); + return await ctx.githubService.getStatus(); + }); + + ipcMain.handle(IPC.prsCreateFromLane, async (_event, arg: CreatePrFromLaneArgs): Promise => { + const ctx = getCtx(); + return await ctx.prService.createFromLane(arg); + }); + + ipcMain.handle(IPC.prsLinkToLane, async (_event, arg: LinkPrToLaneArgs): Promise => { + const ctx = getCtx(); + return await ctx.prService.linkToLane(arg); + }); + + ipcMain.handle(IPC.prsGetForLane, async (_event, arg: { laneId: string }): Promise => { + const ctx = getCtx(); + return ctx.prService.getForLane(arg.laneId); + }); + + ipcMain.handle(IPC.prsListAll, async (): Promise => { + const ctx = getCtx(); + return ctx.prService.listAll(); + }); + + ipcMain.handle(IPC.prsRefresh, async (_event, arg: { prId?: string } = {}): Promise => { + const ctx = getCtx(); + return await ctx.prService.refresh(arg); + }); + + ipcMain.handle(IPC.prsGetStatus, async (_event, arg: { prId: string }): Promise => { + const ctx = getCtx(); + return await ctx.prService.getStatus(arg.prId); + }); + + ipcMain.handle(IPC.prsGetChecks, async (_event, arg: { prId: string }): Promise => { + const ctx = getCtx(); + return await ctx.prService.getChecks(arg.prId); + }); + + ipcMain.handle(IPC.prsGetReviews, async (_event, arg: { prId: string }): Promise => { + const ctx = getCtx(); + return await ctx.prService.getReviews(arg.prId); + }); + + ipcMain.handle(IPC.prsUpdateDescription, async (_event, arg: UpdatePrDescriptionArgs): Promise => { + const ctx = getCtx(); + return await ctx.prService.updateDescription(arg); + }); + + ipcMain.handle(IPC.prsDraftDescription, async (_event, arg: { laneId: string }): Promise<{ title: string; body: string }> => { + const ctx = getCtx(); + return await ctx.prService.draftDescription(arg.laneId); + }); + + ipcMain.handle(IPC.prsLand, async (_event, arg: LandPrArgs): Promise => { + const ctx = getCtx(); + return await ctx.prService.land(arg); + }); + + ipcMain.handle(IPC.prsLandStack, async (_event, arg: LandStackArgs): Promise => { + const ctx = getCtx(); + return await ctx.prService.landStack(arg); + }); + + ipcMain.handle(IPC.prsOpenInGitHub, async (_event, arg: { prId: string }): Promise => { + const ctx = getCtx(); + return await ctx.prService.openInGitHub(arg.prId); + }); + ipcMain.handle(IPC.historyListOperations, async (_event, arg: ListOperationsArgs = {}): Promise => { const ctx = getCtx(); return ctx.operationService.list(arg); diff --git a/apps/desktop/src/main/services/jobs/jobEngine.ts b/apps/desktop/src/main/services/jobs/jobEngine.ts index cd4617cd7..bf1bbdf1b 100644 --- a/apps/desktop/src/main/services/jobs/jobEngine.ts +++ b/apps/desktop/src/main/services/jobs/jobEngine.ts @@ -53,11 +53,23 @@ export function createJobEngine({ try { logger.info("jobs.refresh_lane.begin", payload); - await packService.refreshLanePack({ + + const existingLanePack = packService.getLanePack(payload.laneId); + const existingHeadSha = existingLanePack.lastHeadSha ?? null; + + const lanePack = await packService.refreshLanePack({ laneId: payload.laneId, reason: payload.reason, sessionId: payload.sessionId }); + + const refreshedHeadSha = lanePack.lastHeadSha ?? null; + const shouldRefreshNarrative = + !existingLanePack.narrativeUpdatedAt || + !existingHeadSha || + !refreshedHeadSha || + existingHeadSha !== refreshedHeadSha; + await packService.refreshProjectPack({ reason: payload.reason, laneId: payload.laneId @@ -65,8 +77,19 @@ export function createJobEngine({ if (hostedAgentService?.getStatus().enabled) { try { - const lanePack = packService.getLanePack(payload.laneId); - if (lanePack.exists && lanePack.body.trim().length) { + if (!lanePack.exists || !lanePack.body.trim().length) { + logger.warn("jobs.hosted_refresh.empty_pack", { + laneId: payload.laneId, + reason: payload.reason + }); + } else if (!shouldRefreshNarrative) { + logger.info("jobs.hosted_refresh.narrative_up_to_date", { + laneId: payload.laneId, + reason: payload.reason + }); + } else { + await hostedAgentService.syncMirror({ laneId: payload.laneId, includeTranscripts: false }); + const narrative = await hostedAgentService.requestLaneNarrative({ laneId: payload.laneId, packBody: lanePack.body @@ -80,7 +103,6 @@ export function createJobEngine({ } }); } - await hostedAgentService.syncMirror({ laneId: payload.laneId, includeTranscripts: false }); } catch (hostedError) { logger.warn("jobs.hosted_refresh.failed", { laneId: payload.laneId, @@ -88,6 +110,7 @@ export function createJobEngine({ }); } } + logger.info("jobs.refresh_lane.done", payload); } catch (error) { logger.error("jobs.refresh_lane.failed", { diff --git a/apps/desktop/src/main/services/packs/packService.ts b/apps/desktop/src/main/services/packs/packService.ts index 977079328..6ed8a6139 100644 --- a/apps/desktop/src/main/services/packs/packService.ts +++ b/apps/desktop/src/main/services/packs/packService.ts @@ -256,6 +256,82 @@ export function createPackService({ const getLanePackPath = (laneId: string) => path.join(packsDir, "lanes", laneId, "lane_pack.md"); + const PACK_RETENTION_KEEP_DAYS = 14; + const PACK_RETENTION_MAX_ARCHIVED_LANES = 25; + const PACK_RETENTION_CLEANUP_INTERVAL_MS = 60 * 60_000; + let lastCleanupAt = 0; + + const cleanupPacks = async (): Promise => { + const lanes = await laneService.list({ includeArchived: true }); + const laneById = new Map(lanes.map((lane) => [lane.id, lane] as const)); + + const now = Date.now(); + const keepBeforeMs = now - PACK_RETENTION_KEEP_DAYS * 24 * 60 * 60_000; + + const lanesDir = path.join(packsDir, "lanes"); + const conflictsDir = path.join(packsDir, "conflicts"); + + const archivedDirs: Array<{ laneId: string; archivedAtMs: number }> = []; + + if (fs.existsSync(lanesDir)) { + for (const entry of fs.readdirSync(lanesDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const laneId = entry.name; + const lane = laneById.get(laneId); + const absDir = path.join(lanesDir, laneId); + + if (!lane) { + fs.rmSync(absDir, { recursive: true, force: true }); + continue; + } + + if (!lane.archivedAt) continue; + const ts = Date.parse(lane.archivedAt); + const archivedAtMs = Number.isFinite(ts) ? ts : now; + archivedDirs.push({ laneId, archivedAtMs }); + } + } + + archivedDirs.sort((a, b) => b.archivedAtMs - a.archivedAtMs); + const keepByCount = new Set(archivedDirs.slice(0, PACK_RETENTION_MAX_ARCHIVED_LANES).map((entry) => entry.laneId)); + + for (const { laneId, archivedAtMs } of archivedDirs) { + if (keepByCount.has(laneId) && archivedAtMs >= keepBeforeMs) continue; + const absDir = path.join(lanesDir, laneId); + fs.rmSync(absDir, { recursive: true, force: true }); + } + + if (fs.existsSync(conflictsDir)) { + for (const entry of fs.readdirSync(conflictsDir, { withFileTypes: true })) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith(".json")) continue; + const laneId = entry.name.slice(0, -".json".length); + const lane = laneById.get(laneId); + if (!lane) { + fs.rmSync(path.join(conflictsDir, entry.name), { force: true }); + continue; + } + if (!lane.archivedAt) continue; + const ts = Date.parse(lane.archivedAt); + const archivedAtMs = Number.isFinite(ts) ? ts : now; + if (!keepByCount.has(laneId) || archivedAtMs < keepBeforeMs) { + fs.rmSync(path.join(conflictsDir, entry.name), { force: true }); + } + } + } + }; + + const maybeCleanupPacks = () => { + const now = Date.now(); + if (now - lastCleanupAt < PACK_RETENTION_CLEANUP_INTERVAL_MS) return; + lastCleanupAt = now; + void cleanupPacks().catch((error: unknown) => { + logger.warn("packs.cleanup_failed", { + error: error instanceof Error ? error.message : String(error) + }); + }); + }; + const getSessionRow = (sessionId: string): LaneSessionRow | null => db.get( ` @@ -490,7 +566,7 @@ export function createPackService({ if (providerMode === "guest") { lines.push("Template narrative mode active (Guest Mode). Deterministic sections are fully local."); } else { - lines.push("Template narrative mode active (LLM augmentation not yet enabled in this build)."); + lines.push("Narrative sections are generated by the active AI provider (Hosted/BYOK) and merged into this pack."); } lines.push(""); @@ -580,7 +656,7 @@ export function createPackService({ if ((config.providerMode ?? "guest") === "guest") { lines.push("- Guest Mode active: narrative sections use local templates only."); } else { - lines.push("- Narrative fields remain local placeholders until hosted integration is enabled."); + lines.push("- Narrative sections are AI-assisted when Hosted or BYOK is configured and available."); } lines.push(""); @@ -833,6 +909,8 @@ export function createPackService({ } }); + maybeCleanupPacks(); + return { packType: "lane", path: packPath, @@ -900,6 +978,8 @@ export function createPackService({ } }); + maybeCleanupPacks(); + return { packType: "project", path: projectPackPath, @@ -967,6 +1047,8 @@ export function createPackService({ } }); + maybeCleanupPacks(); + return { packType: "lane", path: lanePackPath, diff --git a/apps/desktop/src/main/services/prs/prPollingService.ts b/apps/desktop/src/main/services/prs/prPollingService.ts new file mode 100644 index 000000000..7a14cd693 --- /dev/null +++ b/apps/desktop/src/main/services/prs/prPollingService.ts @@ -0,0 +1,204 @@ +import type { Logger } from "../logging/logger"; +import type { createProjectConfigService } from "../config/projectConfigService"; +import type { createPrService } from "./prService"; +import type { PrEventPayload, PrNotificationKind, PrSummary } from "../../../shared/types"; + +function nowIso(): string { + return new Date().toISOString(); +} + +function clampMs(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) return min; + return Math.max(min, Math.min(max, value)); +} + +function jitterMs(value: number): number { + // +/- 10% jitter to avoid synchronized polling. + const pct = 0.1; + const delta = value * pct; + const rand = (Math.random() * 2 - 1) * delta; + return Math.max(1000, Math.round(value + rand)); +} + +function summarizeNotification(args: { kind: PrNotificationKind; pr: PrSummary }): { title: string; message: string } { + const prLabel = args.pr.githubPrNumber ? `#${args.pr.githubPrNumber}` : "PR"; + if (args.kind === "checks_failing") { + return { title: `Checks failing ${prLabel}`, message: args.pr.title || "A pull request has failing checks." }; + } + if (args.kind === "review_requested") { + return { title: `Review requested ${prLabel}`, message: args.pr.title || "A pull request needs review." }; + } + if (args.kind === "changes_requested") { + return { title: `Changes requested ${prLabel}`, message: args.pr.title || "A pull request has requested changes." }; + } + return { title: `Merge ready ${prLabel}`, message: args.pr.title || "A pull request looks merge-ready." }; +} + +export function createPrPollingService({ + logger, + prService, + projectConfigService, + onEvent +}: { + logger: Logger; + prService: ReturnType; + projectConfigService: ReturnType; + onEvent: (event: PrEventPayload) => void; +}) { + const DEFAULT_INTERVAL_MS = 25_000; + const MIN_INTERVAL_MS = 5_000; + const MAX_INTERVAL_MS = 5 * 60_000; + + const readIntervalMs = (): number => { + const seconds = projectConfigService.get().effective.github?.prPollingIntervalSeconds; + if (typeof seconds === "number" && Number.isFinite(seconds)) { + return clampMs(Math.round(seconds * 1000), MIN_INTERVAL_MS, MAX_INTERVAL_MS); + } + return DEFAULT_INTERVAL_MS; + }; + + let stopped = false; + let timer: NodeJS.Timeout | null = null; + let running = false; + let initialized = false; + let consecutiveFailures = 0; + let nextDelayOverrideMs: number | null = null; + + const lastByPrId = new Map< + string, + { + checksStatus: PrSummary["checksStatus"]; + reviewStatus: PrSummary["reviewStatus"]; + state: PrSummary["state"]; + mergeReady: boolean; + } + >(); + + const schedule = (delayMs: number) => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => void tick(), delayMs); + }; + + const computeBackoffMs = (): number => { + const base = readIntervalMs(); + if (consecutiveFailures <= 0) return base; + const factor = Math.min(6, consecutiveFailures); + return clampMs(base * Math.pow(2, factor), base, MAX_INTERVAL_MS); + }; + + const tick = async () => { + if (stopped) return; + if (running) { + schedule(jitterMs(readIntervalMs())); + return; + } + running = true; + + const polledAt = nowIso(); + try { + const existing = prService.listAll(); + if (existing.length === 0) { + consecutiveFailures = 0; + initialized = true; + onEvent({ type: "prs-updated", polledAt, prs: [] }); + return; + } + + const prs = await prService.refresh(); + onEvent({ type: "prs-updated", polledAt, prs }); + + if (!initialized) { + lastByPrId.clear(); + for (const pr of prs) { + lastByPrId.set(pr.id, { + checksStatus: pr.checksStatus, + reviewStatus: pr.reviewStatus, + state: pr.state, + mergeReady: pr.state === "open" && pr.checksStatus === "passing" && pr.reviewStatus === "approved" + }); + } + initialized = true; + consecutiveFailures = 0; + return; + } + + for (const pr of prs) { + const prev = lastByPrId.get(pr.id) ?? null; + const mergeReady = pr.state === "open" && pr.checksStatus === "passing" && pr.reviewStatus === "approved"; + + const shouldNotify = (kind: PrNotificationKind): boolean => { + if (pr.state !== "open" && pr.state !== "draft") return false; + if (!prev) return false; + if (kind === "checks_failing") return prev.checksStatus !== "failing" && pr.checksStatus === "failing"; + if (kind === "review_requested") return prev.reviewStatus !== "requested" && pr.reviewStatus === "requested"; + if (kind === "changes_requested") return prev.reviewStatus !== "changes_requested" && pr.reviewStatus === "changes_requested"; + if (kind === "merge_ready") return prev.mergeReady !== true && mergeReady === true && pr.state === "open"; + return false; + }; + + const kinds: PrNotificationKind[] = ["checks_failing", "review_requested", "changes_requested", "merge_ready"]; + for (const kind of kinds) { + if (!shouldNotify(kind)) continue; + const summary = summarizeNotification({ kind, pr }); + onEvent({ + type: "pr-notification", + polledAt, + kind, + laneId: pr.laneId, + prId: pr.id, + prNumber: pr.githubPrNumber, + title: summary.title, + githubUrl: pr.githubUrl, + message: summary.message, + state: pr.state, + checksStatus: pr.checksStatus, + reviewStatus: pr.reviewStatus + }); + } + + lastByPrId.set(pr.id, { + checksStatus: pr.checksStatus, + reviewStatus: pr.reviewStatus, + state: pr.state, + mergeReady + }); + } + + // Drop any PRs removed from the DB. + const seen = new Set(prs.map((pr) => pr.id)); + for (const prId of Array.from(lastByPrId.keys())) { + if (!seen.has(prId)) lastByPrId.delete(prId); + } + + consecutiveFailures = 0; + } catch (error) { + consecutiveFailures += 1; + logger.warn("prs.poll_failed", { error: error instanceof Error ? error.message : String(error) }); + + const resetAtMs = (error as any)?.rateLimitResetAtMs; + if (typeof resetAtMs === "number" && Number.isFinite(resetAtMs)) { + // Schedule after reset (+ a small buffer) so we don't keep hammering. + const untilReset = Math.max(10_000, resetAtMs - Date.now() + 5_000); + nextDelayOverrideMs = clampMs(untilReset, 10_000, MAX_INTERVAL_MS); + } + } finally { + running = false; + const base = computeBackoffMs(); + const delay = jitterMs(Math.max(base, nextDelayOverrideMs ?? 0)); + nextDelayOverrideMs = null; + schedule(delay); + } + }; + + // Start soon after app init, but not immediately, so the renderer can attach listeners. + schedule(2_500); + + return { + dispose() { + stopped = true; + if (timer) clearTimeout(timer); + timer = null; + } + }; +} + diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts new file mode 100644 index 000000000..ec3b4c434 --- /dev/null +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -0,0 +1,1017 @@ +import fs from "node:fs"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import type { + CreatePrFromLaneArgs, + GitHubRepoRef, + LandResult, + LandPrArgs, + LandStackArgs, + LinkPrToLaneArgs, + MergeMethod, + PrCheck, + PrChecksStatus, + PrReview, + PrReviewStatus, + PrState, + PrStatus, + PrSummary, + UpdatePrDescriptionArgs +} from "../../../shared/types"; +import type { AdeDb } from "../state/kvDb"; +import type { Logger } from "../logging/logger"; +import type { createLaneService } from "../lanes/laneService"; +import type { createOperationService } from "../history/operationService"; +import type { createGithubService } from "../github/githubService"; +import type { createPackService } from "../packs/packService"; +import type { createHostedAgentService } from "../hosted/hostedAgentService"; +import type { createProjectConfigService } from "../config/projectConfigService"; +import type { createByokLlmService } from "../byok/byokLlmService"; +import { runGit } from "../git/git"; + +type PullRequestRow = { + id: string; + lane_id: string; + project_id: string; + repo_owner: string; + repo_name: string; + github_pr_number: number; + github_url: string; + github_node_id: string | null; + title: string | null; + state: string; + base_branch: string; + head_branch: string; + checks_status: string | null; + review_status: string | null; + additions: number | null; + deletions: number | null; + last_synced_at: string | null; + created_at: string; + updated_at: string; +}; + +function nowIso(): string { + return new Date().toISOString(); +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function asNumber(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : Number(value); +} + +function branchNameFromRef(ref: string): string { + const trimmed = ref.trim(); + if (trimmed.startsWith("refs/heads/")) return trimmed.slice("refs/heads/".length); + return trimmed; +} + +function toPrState(args: { state: string; draft: boolean; mergedAt: string | null }): PrState { + if (args.mergedAt) return "merged"; + const state = args.state.toLowerCase(); + if (state === "open" && args.draft) return "draft"; + if (state === "open") return "open"; + return "closed"; +} + +function toChecksStatus(state: string | null | undefined): PrChecksStatus { + const value = (state ?? "").toLowerCase(); + if (value === "success") return "passing"; + if (value === "failure" || value === "error") return "failing"; + if (value === "pending") return "pending"; + return "none"; +} + +function toChecksStatusFromCheckRuns(checkRuns: any[]): PrChecksStatus | null { + if (!Array.isArray(checkRuns) || checkRuns.length === 0) return null; + + let hasPending = false; + let hasFailure = false; + let hasSuccessLike = false; + for (const run of checkRuns) { + const status = asString(run?.status).toLowerCase(); + const conclusion = asString(run?.conclusion).toLowerCase(); + if (status && status !== "completed") { + hasPending = true; + continue; + } + if (!conclusion) continue; + if (conclusion === "success" || conclusion === "neutral" || conclusion === "skipped") { + hasSuccessLike = true; + continue; + } + if ( + conclusion === "failure" || + conclusion === "cancelled" || + conclusion === "timed_out" || + conclusion === "action_required" || + conclusion === "stale" + ) { + hasFailure = true; + } + } + + if (hasPending) return "pending"; + if (hasFailure) return "failing"; + if (hasSuccessLike) return "passing"; + return "none"; +} + +function computeReviewStatus(args: { requestedReviewers: string[]; reviewStatesByUser: Map }): PrReviewStatus { + for (const state of args.reviewStatesByUser.values()) { + if (state === "CHANGES_REQUESTED") return "changes_requested"; + } + for (const state of args.reviewStatesByUser.values()) { + if (state === "APPROVED") return "approved"; + } + if (args.requestedReviewers.length > 0) return "requested"; + return "none"; +} + +function rowToSummary(row: PullRequestRow): PrSummary { + return { + id: row.id, + laneId: row.lane_id, + projectId: row.project_id, + repoOwner: row.repo_owner, + repoName: row.repo_name, + githubPrNumber: Number(row.github_pr_number), + githubUrl: row.github_url, + githubNodeId: row.github_node_id, + title: row.title ?? "", + state: (row.state as PrState) ?? "open", + baseBranch: row.base_branch, + headBranch: row.head_branch, + checksStatus: (row.checks_status as PrChecksStatus) ?? "none", + reviewStatus: (row.review_status as PrReviewStatus) ?? "none", + additions: Number(row.additions ?? 0), + deletions: Number(row.deletions ?? 0), + lastSyncedAt: row.last_synced_at ?? null, + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} + +function parsePrLocator(raw: string): { owner?: string; repo?: string; number: number } { + const trimmed = raw.trim(); + if (!trimmed) throw new Error("PR URL or number is required"); + if (/^[0-9]+$/.test(trimmed)) { + return { number: Number(trimmed) }; + } + try { + const url = new URL(trimmed); + const match = url.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/([0-9]+)(?:\/|$)/); + if (!match) throw new Error("Invalid PR URL format"); + return { owner: match[1], repo: match[2], number: Number(match[3]) }; + } catch { + throw new Error("Invalid PR URL format"); + } +} + +function readPrTemplate(projectRoot: string): string | null { + const templatePath = path.join(projectRoot, ".github", "PULL_REQUEST_TEMPLATE.md"); + if (!fs.existsSync(templatePath)) return null; + try { + const raw = fs.readFileSync(templatePath, "utf8"); + return raw.trim().length ? raw : null; + } catch { + return null; + } +} + +export function createPrService({ + db, + logger, + projectId, + projectRoot, + laneService, + operationService, + githubService, + packService, + hostedAgentService, + byokLlmService, + projectConfigService, + openExternal +}: { + db: AdeDb; + logger: Logger; + projectId: string; + projectRoot: string; + laneService: ReturnType; + operationService: ReturnType; + githubService: ReturnType; + packService: ReturnType; + hostedAgentService?: ReturnType; + byokLlmService?: ReturnType; + projectConfigService: ReturnType; + openExternal: (url: string) => Promise; +}) { + const getRow = (prId: string): PullRequestRow | null => + db.get( + ` + select + id, + lane_id, + project_id, + repo_owner, + repo_name, + github_pr_number, + github_url, + github_node_id, + title, + state, + base_branch, + head_branch, + checks_status, + review_status, + additions, + deletions, + last_synced_at, + created_at, + updated_at + from pull_requests + where id = ? + and project_id = ? + limit 1 + `, + [prId, projectId] + ); + + const getRowForLane = (laneId: string): PullRequestRow | null => + db.get( + ` + select + id, + lane_id, + project_id, + repo_owner, + repo_name, + github_pr_number, + github_url, + github_node_id, + title, + state, + base_branch, + head_branch, + checks_status, + review_status, + additions, + deletions, + last_synced_at, + created_at, + updated_at + from pull_requests + where lane_id = ? + and project_id = ? + limit 1 + `, + [laneId, projectId] + ); + + const listRows = (): PullRequestRow[] => + db.all( + ` + select + id, + lane_id, + project_id, + repo_owner, + repo_name, + github_pr_number, + github_url, + github_node_id, + title, + state, + base_branch, + head_branch, + checks_status, + review_status, + additions, + deletions, + last_synced_at, + created_at, + updated_at + from pull_requests + where project_id = ? + order by updated_at desc + `, + [projectId] + ); + + const upsertRow = (summary: Omit & { projectId?: string }): void => { + const now = nowIso(); + db.run( + ` + insert into pull_requests( + id, + project_id, + lane_id, + repo_owner, + repo_name, + github_pr_number, + github_url, + github_node_id, + title, + state, + base_branch, + head_branch, + checks_status, + review_status, + additions, + deletions, + last_synced_at, + created_at, + updated_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + on conflict(project_id, lane_id) do update set + repo_owner = excluded.repo_owner, + repo_name = excluded.repo_name, + github_pr_number = excluded.github_pr_number, + github_url = excluded.github_url, + github_node_id = excluded.github_node_id, + title = excluded.title, + state = excluded.state, + base_branch = excluded.base_branch, + head_branch = excluded.head_branch, + checks_status = excluded.checks_status, + review_status = excluded.review_status, + additions = excluded.additions, + deletions = excluded.deletions, + last_synced_at = excluded.last_synced_at, + updated_at = excluded.updated_at + `, + [ + summary.id, + projectId, + summary.laneId, + summary.repoOwner, + summary.repoName, + summary.githubPrNumber, + summary.githubUrl, + summary.githubNodeId, + summary.title, + summary.state, + summary.baseBranch, + summary.headBranch, + summary.checksStatus, + summary.reviewStatus, + summary.additions, + summary.deletions, + summary.lastSyncedAt, + summary.createdAt ?? now, + summary.updatedAt ?? now + ] + ); + }; + + const fetchPr = async (repo: GitHubRepoRef, prNumber: number): Promise => { + const { data } = await githubService.apiRequest({ + method: "GET", + path: `/repos/${repo.owner}/${repo.name}/pulls/${prNumber}` + }); + return data; + }; + + const fetchReviews = async (repo: GitHubRepoRef, prNumber: number): Promise => { + const { data } = await githubService.apiRequest({ + method: "GET", + path: `/repos/${repo.owner}/${repo.name}/pulls/${prNumber}/reviews`, + query: { per_page: 100 } + }); + + return (data ?? []).map((entry: any) => ({ + reviewer: asString(entry?.user?.login) || "unknown", + state: (asString(entry?.state).toLowerCase() === "approved" + ? "approved" + : asString(entry?.state).toLowerCase() === "changes_requested" + ? "changes_requested" + : asString(entry?.state).toLowerCase() === "dismissed" + ? "dismissed" + : "commented") as PrReview["state"], + body: asString(entry?.body) || null, + submittedAt: asString(entry?.submitted_at) || null + })); + }; + + const fetchCombinedStatus = async (repo: GitHubRepoRef, sha: string): Promise<{ + state: string; + statuses: Array<{ context: string; state: string; description: string | null; target_url: string | null; created_at: string | null; updated_at: string | null }>; + }> => { + const { data } = await githubService.apiRequest({ + method: "GET", + path: `/repos/${repo.owner}/${repo.name}/commits/${sha}/status` + }); + return { + state: asString(data?.state), + statuses: Array.isArray(data?.statuses) ? data.statuses : [] + }; + }; + + const fetchCheckRuns = async (repo: GitHubRepoRef, sha: string): Promise => { + const { data } = await githubService.apiRequest({ + method: "GET", + path: `/repos/${repo.owner}/${repo.name}/commits/${sha}/check-runs`, + query: { per_page: 100 } + }); + const runs = Array.isArray(data?.check_runs) ? data.check_runs : []; + return runs; + }; + + const fetchCompare = async (repo: GitHubRepoRef, baseSha: string, headSha: string): Promise<{ behindBy: number }> => { + const { data } = await githubService.apiRequest({ + method: "GET", + path: `/repos/${repo.owner}/${repo.name}/compare/${baseSha}...${headSha}` + }); + return { + behindBy: Number(data?.behind_by ?? 0) + }; + }; + + const refreshOne = async (prId: string): Promise => { + const row = getRow(prId); + if (!row) throw new Error(`PR not found: ${prId}`); + const repo = { owner: row.repo_owner, name: row.repo_name }; + + const pr = await fetchPr(repo, Number(row.github_pr_number)); + const headSha = asString(pr?.head?.sha); + const baseSha = asString(pr?.base?.sha); + const requestedReviewers = Array.isArray(pr?.requested_reviewers) ? pr.requested_reviewers.map((u: any) => asString(u?.login)).filter(Boolean) : []; + + const [combinedStatus, checkRuns, reviews] = await Promise.all([ + headSha ? fetchCombinedStatus(repo, headSha) : Promise.resolve({ state: "", statuses: [] }), + headSha ? fetchCheckRuns(repo, headSha).catch(() => []) : Promise.resolve([]), + fetchReviews(repo, Number(row.github_pr_number)).catch(() => []) + ]); + const reviewStatesByUser = new Map(); + for (const review of reviews) { + // Only treat these as gating states. + if (review.state === "approved") reviewStatesByUser.set(review.reviewer, "APPROVED"); + if (review.state === "changes_requested") reviewStatesByUser.set(review.reviewer, "CHANGES_REQUESTED"); + } + + const state = toPrState({ + state: asString(pr?.state) || "open", + draft: Boolean(pr?.draft), + mergedAt: asString(pr?.merged_at) || null + }); + + const checksStatus = toChecksStatusFromCheckRuns(checkRuns) ?? toChecksStatus(combinedStatus.state); + const reviewStatus = computeReviewStatus({ requestedReviewers, reviewStatesByUser }); + const additions = Number(pr?.additions ?? 0); + const deletions = Number(pr?.deletions ?? 0); + const baseBranch = asString(pr?.base?.ref) || row.base_branch; + const headBranch = asString(pr?.head?.ref) || row.head_branch; + + const updated: PrSummary = { + id: row.id, + laneId: row.lane_id, + projectId, + repoOwner: repo.owner, + repoName: repo.name, + githubPrNumber: Number(row.github_pr_number), + githubUrl: asString(pr?.html_url) || row.github_url, + githubNodeId: asString(pr?.node_id) || row.github_node_id, + title: asString(pr?.title) || row.title || "", + state, + baseBranch, + headBranch, + checksStatus, + reviewStatus, + additions, + deletions, + lastSyncedAt: nowIso(), + createdAt: row.created_at, + updatedAt: nowIso() + }; + + upsertRow(updated); + + return updated; + }; + + const computeStatus = async (summary: PrSummary): Promise => { + const repo: GitHubRepoRef = { owner: summary.repoOwner, name: summary.repoName }; + const pr = await fetchPr(repo, summary.githubPrNumber); + const headSha = asString(pr?.head?.sha); + const baseSha = asString(pr?.base?.sha); + const mergeableState = asString(pr?.mergeable_state); + const mergeConflicts = mergeableState.toLowerCase() === "dirty"; + + const [combinedStatus, checkRuns, reviews, compare] = await Promise.all([ + headSha ? fetchCombinedStatus(repo, headSha) : Promise.resolve({ state: "", statuses: [] }), + headSha ? fetchCheckRuns(repo, headSha).catch(() => []) : Promise.resolve([]), + fetchReviews(repo, summary.githubPrNumber).catch(() => []), + baseSha && headSha ? fetchCompare(repo, baseSha, headSha).catch(() => ({ behindBy: 0 })) : Promise.resolve({ behindBy: 0 }) + ]); + + const requestedReviewers = Array.isArray(pr?.requested_reviewers) ? pr.requested_reviewers.map((u: any) => asString(u?.login)).filter(Boolean) : []; + const reviewStatesByUser = new Map(); + for (const review of reviews) { + if (review.state === "approved") reviewStatesByUser.set(review.reviewer, "APPROVED"); + if (review.state === "changes_requested") reviewStatesByUser.set(review.reviewer, "CHANGES_REQUESTED"); + } + + const nextState = toPrState({ + state: asString(pr?.state) || "open", + draft: Boolean(pr?.draft), + mergedAt: asString(pr?.merged_at) || null + }); + const checksStatus = toChecksStatusFromCheckRuns(checkRuns) ?? toChecksStatus(combinedStatus.state); + const reviewStatus = computeReviewStatus({ requestedReviewers, reviewStatesByUser }); + const isMergeable = Boolean(pr?.mergeable) && checksStatus !== "failing" && reviewStatus !== "changes_requested"; + + const refreshed: PrSummary = { + ...summary, + state: nextState, + checksStatus, + reviewStatus, + additions: Number(pr?.additions ?? summary.additions), + deletions: Number(pr?.deletions ?? summary.deletions), + lastSyncedAt: nowIso(), + updatedAt: nowIso() + }; + upsertRow(refreshed); + + return { + prId: summary.id, + state: nextState, + checksStatus, + reviewStatus, + isMergeable, + mergeConflicts, + behindBaseBy: compare.behindBy + }; + }; + + const getChecks = async (prId: string): Promise => { + const row = getRow(prId); + if (!row) throw new Error(`PR not found: ${prId}`); + const repo: GitHubRepoRef = { owner: row.repo_owner, name: row.repo_name }; + const pr = await fetchPr(repo, Number(row.github_pr_number)); + const headSha = asString(pr?.head?.sha); + if (!headSha) return []; + const [combinedStatus, checkRuns] = await Promise.all([ + fetchCombinedStatus(repo, headSha).catch(() => ({ state: "", statuses: [] })), + fetchCheckRuns(repo, headSha).catch(() => []) + ]); + + const out: PrCheck[] = []; + const seen = new Set(); + + for (const run of checkRuns) { + const name = asString(run?.name) || "check"; + if (seen.has(name)) continue; + seen.add(name); + const statusRaw = asString(run?.status).toLowerCase(); + const status: PrCheck["status"] = + statusRaw === "queued" ? "queued" : statusRaw === "in_progress" ? "in_progress" : "completed"; + const conclusionRaw = asString(run?.conclusion).toLowerCase(); + const conclusion: PrCheck["conclusion"] = + conclusionRaw === "success" + ? "success" + : conclusionRaw === "failure" || conclusionRaw === "timed_out" || conclusionRaw === "action_required" + ? "failure" + : conclusionRaw === "neutral" + ? "neutral" + : conclusionRaw === "skipped" + ? "skipped" + : conclusionRaw === "cancelled" + ? "cancelled" + : null; + out.push({ + name, + status, + conclusion, + detailsUrl: asString(run?.details_url) || asString(run?.html_url) || null, + startedAt: asString(run?.started_at) || null, + completedAt: asString(run?.completed_at) || null + }); + } + + for (const s of combinedStatus.statuses) { + const name = asString(s.context) || "status"; + if (seen.has(name)) continue; + seen.add(name); + out.push({ + name, + status: s.state === "pending" ? "in_progress" : "completed", + conclusion: s.state === "success" ? "success" : s.state === "failure" || s.state === "error" ? "failure" : null, + detailsUrl: s.target_url ?? null, + startedAt: s.created_at ?? null, + completedAt: s.updated_at ?? null + }); + } + + return out; + }; + + const getReviews = async (prId: string): Promise => { + const row = getRow(prId); + if (!row) throw new Error(`PR not found: ${prId}`); + const repo: GitHubRepoRef = { owner: row.repo_owner, name: row.repo_name }; + return await fetchReviews(repo, Number(row.github_pr_number)); + }; + + const updateDescription = async (args: UpdatePrDescriptionArgs): Promise => { + const row = getRow(args.prId); + if (!row) throw new Error(`PR not found: ${args.prId}`); + const repo: GitHubRepoRef = { owner: row.repo_owner, name: row.repo_name }; + await githubService.apiRequest({ + method: "PATCH", + path: `/repos/${repo.owner}/${repo.name}/pulls/${Number(row.github_pr_number)}`, + body: { body: args.body } + }); + await refreshOne(args.prId); + }; + + const draftDescription = async (laneId: string): Promise<{ title: string; body: string }> => { + const lane = (await laneService.list({ includeArchived: true })).find((entry) => entry.id === laneId); + if (!lane) throw new Error(`Lane not found: ${laneId}`); + + const template = readPrTemplate(projectRoot); + const lanePack = packService.getLanePack(laneId); + const packBody = lanePack.body; + + const commits = await runGit( + ["log", "-n20", "--date=iso-strict", "--pretty=format:%h %aI %an %s"], + { cwd: lane.worktreePath, timeoutMs: 15_000 } + ).then((res) => (res.exitCode === 0 ? res.stdout.trim().split("\n").filter(Boolean) : [])); + + const context = { + laneId, + laneName: lane.name, + branchRef: lane.branchRef, + baseRef: lane.baseRef, + parentLaneId: lane.parentLaneId, + commits, + packBody, + prTemplate: template + }; + + const providerMode = projectConfigService.get().effective.providerMode ?? "guest"; + if (providerMode === "hosted" && hostedAgentService?.getStatus().enabled) { + const result = await hostedAgentService.requestPrDescription({ + laneId, + prContext: context + }); + return { + title: result.title || lane.name, + body: result.body + }; + } + + if (providerMode === "byok" && byokLlmService) { + const draft = await byokLlmService.draftPrDescription({ + laneId, + prContext: context + }); + const defaultTitle = lane.name.replace(/[-_/]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()).trim(); + return { + title: defaultTitle || lane.name, + body: draft.body + }; + } + + // Guest/CLI fallback: deterministic content. + const defaultTitle = lane.name.replace(/[-_/]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()).trim(); + const lines: string[] = []; + lines.push("## Summary"); + lines.push(""); + lines.push("_Describe the change._"); + lines.push(""); + lines.push("## What Changed"); + lines.push(""); + lines.push("_Key files and behaviors._"); + lines.push(""); + lines.push("## Validation"); + lines.push(""); + lines.push("_How you tested._"); + lines.push(""); + lines.push("## Risks"); + lines.push(""); + lines.push("_Anything to watch._"); + if (template) { + lines.push(""); + lines.push("---"); + lines.push(""); + lines.push(template); + } + return { + title: defaultTitle || lane.name, + body: `${lines.join("\n")}\n` + }; + }; + + const createFromLane = async (args: CreatePrFromLaneArgs): Promise => { + const lane = (await laneService.list({ includeArchived: true })).find((entry) => entry.id === args.laneId); + if (!lane) throw new Error(`Lane not found: ${args.laneId}`); + + const repo = await githubService.getRepoOrThrow(); + const headBranch = branchNameFromRef(lane.branchRef); + const parentLane = lane.parentLaneId ? (await laneService.list({ includeArchived: true })).find((entry) => entry.id === lane.parentLaneId) ?? null : null; + const baseBranch = (args.baseBranch ?? branchNameFromRef(parentLane?.branchRef ?? lane.baseRef)).trim(); + + const createdAt = nowIso(); + const created = await githubService.apiRequest({ + method: "POST", + path: `/repos/${repo.owner}/${repo.name}/pulls`, + body: { + title: args.title, + head: headBranch, + base: baseBranch, + body: args.body, + draft: Boolean(args.draft) + } + }); + + const pr = created.data; + const prNumber = Number(pr?.number); + if (!Number.isFinite(prNumber) || prNumber <= 0) { + throw new Error("GitHub returned an invalid PR number."); + } + + if (args.labels?.length) { + await githubService.apiRequest({ + method: "POST", + path: `/repos/${repo.owner}/${repo.name}/issues/${prNumber}/labels`, + body: { labels: args.labels } + }).catch((error) => { + logger.warn("prs.labels_failed", { prNumber, error: error instanceof Error ? error.message : String(error) }); + }); + } + + if (args.reviewers?.length) { + await githubService.apiRequest({ + method: "POST", + path: `/repos/${repo.owner}/${repo.name}/pulls/${prNumber}/requested_reviewers`, + body: { reviewers: args.reviewers } + }).catch((error) => { + logger.warn("prs.reviewers_failed", { prNumber, error: error instanceof Error ? error.message : String(error) }); + }); + } + + const summary: PrSummary = { + id: randomUUID(), + laneId: lane.id, + projectId, + repoOwner: repo.owner, + repoName: repo.name, + githubPrNumber: prNumber, + githubUrl: asString(pr?.html_url), + githubNodeId: asString(pr?.node_id) || null, + title: asString(pr?.title), + state: toPrState({ state: asString(pr?.state) || "open", draft: Boolean(pr?.draft), mergedAt: asString(pr?.merged_at) || null }), + baseBranch, + headBranch, + checksStatus: "none", + reviewStatus: "none", + additions: Number(pr?.additions ?? 0), + deletions: Number(pr?.deletions ?? 0), + lastSyncedAt: null, + createdAt, + updatedAt: createdAt + }; + + upsertRow(summary); + + return await refreshOne(summary.id); + }; + + const linkToLane = async (args: LinkPrToLaneArgs): Promise => { + const lane = (await laneService.list({ includeArchived: true })).find((entry) => entry.id === args.laneId); + if (!lane) throw new Error(`Lane not found: ${args.laneId}`); + + const locator = parsePrLocator(args.prUrlOrNumber); + const repo = locator.owner && locator.repo ? { owner: locator.owner, name: locator.repo } : await githubService.getRepoOrThrow(); + if (!locator.number) throw new Error("PR number missing."); + + const pr = await fetchPr(repo, locator.number); + const createdAt = nowIso(); + const headBranch = asString(pr?.head?.ref) || branchNameFromRef(lane.branchRef); + const baseBranch = asString(pr?.base?.ref) || branchNameFromRef(lane.baseRef); + + const summary: PrSummary = { + id: randomUUID(), + laneId: lane.id, + projectId, + repoOwner: repo.owner, + repoName: repo.name, + githubPrNumber: locator.number, + githubUrl: asString(pr?.html_url) || "", + githubNodeId: asString(pr?.node_id) || null, + title: asString(pr?.title) || "", + state: toPrState({ state: asString(pr?.state) || "open", draft: Boolean(pr?.draft), mergedAt: asString(pr?.merged_at) || null }), + baseBranch, + headBranch, + checksStatus: "none", + reviewStatus: "none", + additions: Number(pr?.additions ?? 0), + deletions: Number(pr?.deletions ?? 0), + lastSyncedAt: null, + createdAt, + updatedAt: createdAt + }; + + upsertRow(summary); + return await refreshOne(summary.id); + }; + + const land = async (args: LandPrArgs): Promise => { + const row = getRow(args.prId); + if (!row) throw new Error(`PR not found: ${args.prId}`); + const repo: GitHubRepoRef = { owner: row.repo_owner, name: row.repo_name }; + + const op = operationService.start({ + laneId: row.lane_id, + kind: "pr_land", + metadata: { + prId: row.id, + prNumber: Number(row.github_pr_number), + method: args.method + } + }); + + try { + const merge = await githubService.apiRequest({ + method: "PUT", + path: `/repos/${repo.owner}/${repo.name}/pulls/${Number(row.github_pr_number)}/merge`, + body: { + merge_method: args.method + } + }); + + const mergeCommitSha = asString(merge.data?.sha) || null; + const headBranch = row.head_branch; + let branchDeleted = false; + try { + await githubService.apiRequest({ + method: "DELETE", + path: `/repos/${repo.owner}/${repo.name}/git/refs/heads/${encodeURIComponent(headBranch)}` + }); + branchDeleted = true; + } catch (error) { + logger.warn("prs.delete_branch_failed", { prId: row.id, headBranch, error: error instanceof Error ? error.message : String(error) }); + } + + await laneService.archive({ laneId: row.lane_id }); + + operationService.finish({ + operationId: op.operationId, + status: "succeeded", + metadataPatch: { mergeCommitSha, branchDeleted } + }); + + await refreshOne(row.id).catch(() => {}); + + return { + prId: row.id, + prNumber: Number(row.github_pr_number), + success: true, + mergeCommitSha, + branchDeleted, + laneArchived: true, + error: null + }; + } catch (error) { + operationService.finish({ + operationId: op.operationId, + status: "failed", + metadataPatch: { error: error instanceof Error ? error.message : String(error) } + }); + return { + prId: row.id, + prNumber: Number(row.github_pr_number), + success: false, + mergeCommitSha: null, + branchDeleted: false, + laneArchived: false, + error: error instanceof Error ? error.message : String(error) + }; + } + }; + + const retargetBase = async (prId: string, baseBranch: string): Promise => { + const row = getRow(prId); + if (!row) throw new Error(`PR not found: ${prId}`); + const repo: GitHubRepoRef = { owner: row.repo_owner, name: row.repo_name }; + await githubService.apiRequest({ + method: "PATCH", + path: `/repos/${repo.owner}/${repo.name}/pulls/${Number(row.github_pr_number)}`, + body: { base: baseBranch } + }); + await refreshOne(prId); + }; + + const landStack = async (args: LandStackArgs): Promise => { + const chain = await laneService.getStackChain(args.rootLaneId); + if (!chain.length) return []; + + // Root base branch is derived from the root lane PR. + const rootRow = getRowForLane(chain[0]!.laneId); + if (!rootRow) throw new Error("Root lane has no PR linked."); + const baseTarget = rootRow.base_branch; + + const results: LandResult[] = []; + for (const item of chain) { + const row = getRowForLane(item.laneId); + if (!row) { + results.push({ + prId: "", + prNumber: 0, + success: false, + mergeCommitSha: null, + branchDeleted: false, + laneArchived: false, + error: `Lane '${item.laneName}' has no PR linked.` + }); + break; + } + + if (row.base_branch !== baseTarget) { + await retargetBase(row.id, baseTarget).catch((error) => { + logger.warn("prs.retarget_failed", { prId: row.id, error: error instanceof Error ? error.message : String(error) }); + }); + } + + const landed = await land({ prId: row.id, method: args.method }); + results.push(landed); + if (!landed.success) break; + } + + return results; + }; + + return { + async createFromLane(args: CreatePrFromLaneArgs): Promise { + return await createFromLane(args); + }, + + async linkToLane(args: LinkPrToLaneArgs): Promise { + return await linkToLane(args); + }, + + getForLane(laneId: string): PrSummary | null { + const row = getRowForLane(laneId); + return row ? rowToSummary(row) : null; + }, + + listAll(): PrSummary[] { + return listRows().map(rowToSummary); + }, + + async refresh(args: { prId?: string } = {}): Promise { + if (args.prId) { + return [await refreshOne(args.prId)]; + } + const rows = listRows(); + const out: PrSummary[] = []; + for (const row of rows) { + try { + out.push(await refreshOne(row.id)); + } catch (error) { + logger.warn("prs.refresh_failed", { prId: row.id, error: error instanceof Error ? error.message : String(error) }); + } + } + return out; + }, + + async getStatus(prId: string): Promise { + const row = getRow(prId); + if (!row) throw new Error(`PR not found: ${prId}`); + return await computeStatus(rowToSummary(row)); + }, + + async getChecks(prId: string): Promise { + return await getChecks(prId); + }, + + async getReviews(prId: string): Promise { + return await getReviews(prId); + }, + + async updateDescription(args: UpdatePrDescriptionArgs): Promise { + return await updateDescription(args); + }, + + async draftDescription(laneId: string): Promise<{ title: string; body: string }> { + return await draftDescription(laneId); + }, + + async land(args: LandPrArgs): Promise { + return await land(args); + }, + + async landStack(args: LandStackArgs): Promise { + return await landStack(args); + }, + + async openInGitHub(prId: string): Promise { + const row = getRow(prId); + if (!row) throw new Error(`PR not found: ${prId}`); + await openExternal(row.github_url); + } + }; +} diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index ff5fde298..90c3acc7c 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -258,9 +258,27 @@ export function createPtyService({ } }, - dispose({ ptyId }: { ptyId: string }): void { + dispose({ ptyId, sessionId }: { ptyId: string; sessionId?: string }): void { const entry = ptys.get(ptyId); - if (!entry) return; + if (!entry) { + if (!sessionId) return; + const session = sessionService.get(sessionId); + if (!session) return; + // The renderer can outlive the pty map (for example after app restart). Allow closing by session id + // so stale sessions do not get stuck in a "running" state forever. + const endedAt = new Date().toISOString(); + sessionService.end({ sessionId, endedAt, exitCode: null, status: "disposed" }); + broadcastExit({ ptyId, sessionId, exitCode: null }); + if (session.tracked) { + try { + onSessionEnded?.({ laneId: session.laneId, sessionId, exitCode: null }); + } catch { + // ignore + } + } + logger.warn("pty.dispose_orphaned", { ptyId, sessionId }); + return; + } if (entry.disposed) return; entry.disposed = true; try { diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index 4bdeb80d0..1d0d27915 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -67,6 +67,27 @@ export function createSessionService({ db }: { db: AdeDb }) { return { list, + reconcileStaleRunningSessions({ + endedAt, + status + }: { + endedAt?: string; + status?: TerminalSessionStatus; + } = {}): number { + const row = db.get<{ count: number }>("select count(1) as count from terminal_sessions where status = 'running'"); + const count = Number(row?.count ?? 0); + if (!Number.isFinite(count) || count <= 0) return 0; + + const finalEndedAt = endedAt ?? new Date().toISOString(); + const finalStatus = status ?? "disposed"; + db.run("update terminal_sessions set ended_at = ?, exit_code = ?, status = ?, pty_id = null where status = 'running'", [ + finalEndedAt, + null, + finalStatus + ]); + return count; + }, + get(sessionId: string): TerminalSessionDetail | null { const row = db.get( ` diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index d734207dd..c7ff8e7a3 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -540,6 +540,47 @@ function migrate(db: Database) { "conflict_proposals", ["project_id", "status"] ); + + // Phase 7 GitHub PR tracking (lane -> PR mapping). + db.run(` + create table if not exists pull_requests ( + id text primary key, + project_id text not null, + lane_id text not null, + repo_owner text not null, + repo_name text not null, + github_pr_number integer not null, + github_url text not null, + github_node_id text, + title text, + state text not null, + base_branch text not null, + head_branch text not null, + checks_status text, + review_status text, + additions integer not null default 0, + deletions integer not null default 0, + last_synced_at text, + created_at text not null, + updated_at text not null, + unique(project_id, lane_id), + unique(project_id, repo_owner, repo_name, github_pr_number), + foreign key(project_id) references projects(id), + foreign key(lane_id) references lanes(id) + ) + `); + createIndexIfColumnsExist( + db, + "create index if not exists idx_pull_requests_lane_id on pull_requests(lane_id)", + "pull_requests", + ["lane_id"] + ); + createIndexIfColumnsExist( + db, + "create index if not exists idx_pull_requests_project_id on pull_requests(project_id)", + "pull_requests", + ["project_id"] + ); } export async function openKvDb(dbPath: string, logger: Logger): Promise { diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index e99b4e3b4..7dffa61ec 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -39,6 +39,10 @@ import type { GetTestLogTailArgs, HostedArtifactResult, HostedBootstrapConfig, + HostedGitHubAppStatus, + HostedGitHubConnectStartResult, + HostedGitHubDisconnectResult, + HostedGitHubEventsResult, HostedJobStatusResult, HostedJobSubmissionArgs, HostedJobSubmissionResult, @@ -51,6 +55,8 @@ import type { GitCherryPickArgs, GitCommitArgs, GitCommitSummary, + GitGetCommitMessageArgs, + GitListCommitFilesArgs, GitFileActionArgs, GitPushArgs, GitRevertArgs, @@ -58,6 +64,18 @@ import type { GitStashRefArgs, GitStashSummary, GitSyncArgs, + GitHubStatus, + CreatePrFromLaneArgs, + LinkPrToLaneArgs, + PrEventPayload, + PrCheck, + PrReview, + PrStatus, + PrSummary, + UpdatePrDescriptionArgs, + LandPrArgs, + LandStackArgs, + LandResult, ListOverlapsArgs, LaneSummary, MergeSimulationArgs, @@ -115,6 +133,7 @@ declare global { ping: () => Promise<"pong">; getInfo: () => Promise; getProject: () => Promise; + openExternal: (url: string) => Promise; }; project: { openRepo: () => Promise; @@ -145,7 +164,7 @@ declare global { create: (args: PtyCreateArgs) => Promise; write: (args: { ptyId: string; data: string }) => Promise; resize: (args: { ptyId: string; cols: number; rows: number }) => Promise; - dispose: (args: { ptyId: string }) => Promise; + dispose: (args: { ptyId: string; sessionId?: string }) => Promise; onData: (cb: (ev: PtyDataEvent) => void) => () => void; onExit: (cb: (ev: PtyExitEvent) => void) => () => void; }; @@ -176,6 +195,8 @@ declare global { restoreStagedFile: (args: GitFileActionArgs) => Promise; commit: (args: GitCommitArgs) => Promise; listRecentCommits: (args: { laneId: string; limit?: number }) => Promise; + listCommitFiles: (args: GitListCommitFilesArgs) => Promise; + getCommitMessage: (args: GitGetCommitMessageArgs) => Promise; revertCommit: (args: GitRevertArgs) => Promise; cherryPickCommit: (args: GitCherryPickArgs) => Promise; stashPush: (args: GitStashPushArgs) => Promise; @@ -205,6 +226,28 @@ declare global { getLanePack: (laneId: string) => Promise; refreshLanePack: (laneId: string) => Promise; applyHostedNarrative: (args: { laneId: string; narrative: string }) => Promise; + generateNarrative: (laneId: string) => Promise; + }; + github: { + getStatus: () => Promise; + setToken: (token: string) => Promise; + clearToken: () => Promise; + }; + prs: { + createFromLane: (args: CreatePrFromLaneArgs) => Promise; + linkToLane: (args: LinkPrToLaneArgs) => Promise; + getForLane: (laneId: string) => Promise; + listAll: () => Promise; + refresh: (args?: { prId?: string }) => Promise; + getStatus: (prId: string) => Promise; + getChecks: (prId: string) => Promise; + getReviews: (prId: string) => Promise; + updateDescription: (args: UpdatePrDescriptionArgs) => Promise; + draftDescription: (laneId: string) => Promise<{ title: string; body: string }>; + land: (args: LandPrArgs) => Promise; + landStack: (args: LandStackArgs) => Promise; + openInGitHub: (prId: string) => Promise; + onEvent: (cb: (ev: PrEventPayload) => void) => () => void; }; hosted: { getStatus: () => Promise; @@ -216,6 +259,12 @@ declare global { submitJob: (args: HostedJobSubmissionArgs) => Promise; getJob: (jobId: string) => Promise; getArtifact: (artifactId: string) => Promise; + github: { + getStatus: () => Promise; + connectStart: () => Promise; + disconnect: () => Promise; + listEvents: () => Promise; + }; }; history: { listOperations: (args?: ListOperationsArgs) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index cb7dc6137..7bbba0b6c 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -38,6 +38,8 @@ import type { GitCherryPickArgs, GitCommitArgs, GitCommitSummary, + GitGetCommitMessageArgs, + GitListCommitFilesArgs, GitFileActionArgs, GitPushArgs, GitRevertArgs, @@ -45,6 +47,18 @@ import type { GitStashRefArgs, GitStashSummary, GitSyncArgs, + GitHubStatus, + CreatePrFromLaneArgs, + LinkPrToLaneArgs, + PrEventPayload, + PrCheck, + PrReview, + PrStatus, + PrSummary, + UpdatePrDescriptionArgs, + LandPrArgs, + LandStackArgs, + LandResult, GetDiffChangesArgs, GetLaneConflictStatusArgs, GetFileDiffArgs, @@ -52,6 +66,10 @@ import type { GetTestLogTailArgs, HostedArtifactResult, HostedBootstrapConfig, + HostedGitHubAppStatus, + HostedGitHubConnectStartResult, + HostedGitHubDisconnectResult, + HostedGitHubEventsResult, HostedJobStatusResult, HostedJobSubmissionArgs, HostedJobSubmissionResult, @@ -112,7 +130,8 @@ contextBridge.exposeInMainWorld("ade", { app: { ping: async (): Promise<"pong"> => ipcRenderer.invoke(IPC.appPing), getInfo: async (): Promise => ipcRenderer.invoke(IPC.appGetInfo), - getProject: async (): Promise => ipcRenderer.invoke(IPC.appGetProject) + getProject: async (): Promise => ipcRenderer.invoke(IPC.appGetProject), + openExternal: async (url: string): Promise => ipcRenderer.invoke(IPC.appOpenExternal, { url }) }, project: { openRepo: async (): Promise => ipcRenderer.invoke(IPC.projectOpenRepo), @@ -150,7 +169,7 @@ contextBridge.exposeInMainWorld("ade", { write: async (arg: { ptyId: string; data: string }): Promise => ipcRenderer.invoke(IPC.ptyWrite, arg), resize: async (arg: { ptyId: string; cols: number; rows: number }): Promise => ipcRenderer.invoke(IPC.ptyResize, arg), - dispose: async (arg: { ptyId: string }): Promise => ipcRenderer.invoke(IPC.ptyDispose, arg), + dispose: async (arg: { ptyId: string; sessionId?: string }): Promise => ipcRenderer.invoke(IPC.ptyDispose, arg), onData: (cb: (ev: PtyDataEvent) => void) => { const listener = (_event: Electron.IpcRendererEvent, payload: PtyDataEvent) => cb(payload); ipcRenderer.on(IPC.ptyData, listener); @@ -197,6 +216,10 @@ contextBridge.exposeInMainWorld("ade", { commit: async (args: GitCommitArgs): Promise => ipcRenderer.invoke(IPC.gitCommit, args), listRecentCommits: async (args: { laneId: string; limit?: number }): Promise => ipcRenderer.invoke(IPC.gitListRecentCommits, args), + listCommitFiles: async (args: GitListCommitFilesArgs): Promise => + ipcRenderer.invoke(IPC.gitListCommitFiles, args), + getCommitMessage: async (args: GitGetCommitMessageArgs): Promise => + ipcRenderer.invoke(IPC.gitGetCommitMessage, args), revertCommit: async (args: GitRevertArgs): Promise => ipcRenderer.invoke(IPC.gitRevertCommit, args), cherryPickCommit: async (args: GitCherryPickArgs): Promise => ipcRenderer.invoke(IPC.gitCherryPickCommit, args), @@ -239,7 +262,35 @@ contextBridge.exposeInMainWorld("ade", { getLanePack: async (laneId: string): Promise => ipcRenderer.invoke(IPC.packsGetLanePack, { laneId }), refreshLanePack: async (laneId: string): Promise => ipcRenderer.invoke(IPC.packsRefreshLanePack, { laneId }), applyHostedNarrative: async (args: { laneId: string; narrative: string }): Promise => - ipcRenderer.invoke(IPC.packsApplyHostedNarrative, args) + ipcRenderer.invoke(IPC.packsApplyHostedNarrative, args), + generateNarrative: async (laneId: string): Promise => + ipcRenderer.invoke(IPC.packsGenerateNarrative, { laneId }) + }, + github: { + getStatus: async (): Promise => ipcRenderer.invoke(IPC.githubGetStatus), + setToken: async (token: string): Promise => ipcRenderer.invoke(IPC.githubSetToken, { token }), + clearToken: async (): Promise => ipcRenderer.invoke(IPC.githubClearToken) + }, + prs: { + createFromLane: async (args: CreatePrFromLaneArgs): Promise => ipcRenderer.invoke(IPC.prsCreateFromLane, args), + linkToLane: async (args: LinkPrToLaneArgs): Promise => ipcRenderer.invoke(IPC.prsLinkToLane, args), + getForLane: async (laneId: string): Promise => ipcRenderer.invoke(IPC.prsGetForLane, { laneId }), + listAll: async (): Promise => ipcRenderer.invoke(IPC.prsListAll), + refresh: async (args: { prId?: string } = {}): Promise => ipcRenderer.invoke(IPC.prsRefresh, args), + getStatus: async (prId: string): Promise => ipcRenderer.invoke(IPC.prsGetStatus, { prId }), + getChecks: async (prId: string): Promise => ipcRenderer.invoke(IPC.prsGetChecks, { prId }), + getReviews: async (prId: string): Promise => ipcRenderer.invoke(IPC.prsGetReviews, { prId }), + updateDescription: async (args: UpdatePrDescriptionArgs): Promise => ipcRenderer.invoke(IPC.prsUpdateDescription, args), + draftDescription: async (laneId: string): Promise<{ title: string; body: string }> => + ipcRenderer.invoke(IPC.prsDraftDescription, { laneId }), + land: async (args: LandPrArgs): Promise => ipcRenderer.invoke(IPC.prsLand, args), + landStack: async (args: LandStackArgs): Promise => ipcRenderer.invoke(IPC.prsLandStack, args), + openInGitHub: async (prId: string): Promise => ipcRenderer.invoke(IPC.prsOpenInGitHub, { prId }), + onEvent: (cb: (ev: PrEventPayload) => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: PrEventPayload) => cb(payload); + ipcRenderer.on(IPC.prsEvent, listener); + return () => ipcRenderer.removeListener(IPC.prsEvent, listener); + } }, hosted: { getStatus: async (): Promise => ipcRenderer.invoke(IPC.hostedGetStatus), @@ -254,7 +305,13 @@ contextBridge.exposeInMainWorld("ade", { getJob: async (jobId: string): Promise => ipcRenderer.invoke(IPC.hostedGetJob, { jobId }), getArtifact: async (artifactId: string): Promise => - ipcRenderer.invoke(IPC.hostedGetArtifact, { artifactId }) + ipcRenderer.invoke(IPC.hostedGetArtifact, { artifactId }), + github: { + getStatus: async (): Promise => ipcRenderer.invoke(IPC.hostedGithubGetStatus), + connectStart: async (): Promise => ipcRenderer.invoke(IPC.hostedGithubConnectStart), + disconnect: async (): Promise => ipcRenderer.invoke(IPC.hostedGithubDisconnect), + listEvents: async (): Promise => ipcRenderer.invoke(IPC.hostedGithubListEvents) + } }, history: { listOperations: async (args: ListOperationsArgs = {}): Promise => diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 907b15373..777657e17 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -1,16 +1,28 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router-dom"; import { CommandPalette } from "./CommandPalette"; import { TabNav } from "./TabNav"; import { TopBar } from "./TopBar"; import { useAppStore } from "../../state/appStore"; +import { Button } from "../ui/Button"; +import type { PrEventPayload } from "../../../shared/types"; + +type PrToast = { + id: string; + event: Extract; +}; export function AppShell({ children }: { children: React.ReactNode }) { const setProject = useAppStore((s) => s.setProject); const refreshLanes = useAppStore((s) => s.refreshLanes); const refreshProviderMode = useAppStore((s) => s.refreshProviderMode); const providerMode = useAppStore((s) => s.providerMode); + const lanes = useAppStore((s) => s.lanes); + const selectLane = useAppStore((s) => s.selectLane); + const setLaneInspectorTab = useAppStore((s) => s.setLaneInspectorTab); const [commandOpen, setCommandOpen] = useState(false); + const [prToasts, setPrToasts] = useState([]); + const toastTimersRef = useRef>(new Map()); useEffect(() => { window.ade.app @@ -35,13 +47,38 @@ export function AppShell({ children }: { children: React.ReactNode }) { return () => window.removeEventListener("keydown", onKeyDown); }, []); + useEffect(() => { + const dismiss = (id: string) => { + setPrToasts((prev) => prev.filter((toast) => toast.id !== id)); + const timer = toastTimersRef.current.get(id); + if (timer != null) window.clearTimeout(timer); + toastTimersRef.current.delete(id); + }; + + const unsub = window.ade.prs.onEvent((event) => { + if (event.type !== "pr-notification") return; + const id = globalThis.crypto?.randomUUID ? globalThis.crypto.randomUUID() : `${Date.now()}-${Math.random()}`; + setPrToasts((prev) => [{ id, event }, ...prev].slice(0, 4)); + const timer = window.setTimeout(() => dismiss(id), 18_000); + toastTimersRef.current.set(id, timer); + }); + + return () => { + unsub(); + for (const timer of toastTimersRef.current.values()) { + window.clearTimeout(timer); + } + toastTimersRef.current.clear(); + }; + }, []); + const cmdK = useMemo(() => (navigator.platform.toLowerCase().includes("mac") ? "Cmd" : "Ctrl"), []); return (
{/* TopBar is now part of the 'paper' flow - less like a floating header */ /* CONSOLE LAYOUT: Integrated Header */} -
+
setCommandOpen(true)} commandHint={ @@ -69,6 +106,64 @@ export function AppShell({ children }: { children: React.ReactNode }) {
{children}
+ + {prToasts.length > 0 ? ( +
+ {prToasts.map((toast) => { + const laneName = lanes.find((lane) => lane.id === toast.event.laneId)?.name ?? toast.event.laneId; + return ( +
+
+
+
{toast.event.title}
+
{laneName}
+
+ +
+
{toast.event.message}
+
+ + +
+
+ ); + })} +
+ ) : null}
diff --git a/apps/desktop/src/renderer/components/app/SettingsPage.tsx b/apps/desktop/src/renderer/components/app/SettingsPage.tsx index ab8906a9f..3928f21fb 100644 --- a/apps/desktop/src/renderer/components/app/SettingsPage.tsx +++ b/apps/desktop/src/renderer/components/app/SettingsPage.tsx @@ -2,13 +2,17 @@ import React, { useEffect, useState } from "react"; import { EmptyState } from "../ui/EmptyState"; import type { AppInfo, + GitHubStatus, + HostedGitHubAppStatus, + HostedGitHubEvent, HostedBootstrapConfig, HostedStatus, ProviderMode, ProjectConfigSnapshot } from "../../../shared/types"; -import { useAppStore } from "../../state/appStore"; +import { useAppStore, ThemeId, THEME_IDS } from "../../state/appStore"; import { Button } from "../ui/Button"; +import { cn } from "../ui/cn"; type ProviderDraft = { mode: ProviderMode; @@ -31,7 +35,7 @@ type ProviderDraft = { mirrorExcludePatternsText: string; }; byok: { - provider: "openai" | "anthropic"; + provider: "openai" | "anthropic" | "gemini"; model: string; apiKey: string; }; @@ -100,7 +104,13 @@ function readProviderDraft(snapshot: ProjectConfigSnapshot): ProviderDraft { mirrorExcludePatternsText: asStringArray(hosted.mirrorExcludePatterns).join("\n") }, byok: { - provider: asString(byok.provider) === "openai" ? "openai" : "anthropic", + provider: (() => { + const value = asString(byok.provider).trim().toLowerCase(); + if (value === "openai" || value === "anthropic" || value === "gemini") { + return value; + } + return "openai"; + })(), model: asString(byok.model), apiKey: asString(byok.apiKey) }, @@ -135,9 +145,137 @@ function validateProviderDraft(draft: ProviderDraft, hasBootstrapConfig: boolean return "BYOK mode requires a model name."; } + if ( + draft.mode === "byok" && + draft.byok.provider === "gemini" && + !/^gemini-[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(draft.byok.model.trim()) + ) { + return "Gemini model should start with 'gemini-' (for example, gemini-1.5-flash-latest)."; + } + return null; } +const THEME_META: Record< + ThemeId, + { label: string; colors: { bg: string; fg: string; card: string; muted: string; border: string; accent: string; accentSecondary: string } } +> = { + "e-paper": { + label: "E-Paper", + colors: { + bg: "#fdfbf7", + fg: "#201a14", + card: "#fdfbf7", + muted: "#efe8dd", + border: "#d3cfc6", + accent: "#c22323", + accentSecondary: "#ddd1be" + } + }, + bloomberg: { + label: "Bloomberg", + colors: { + bg: "#0a0a0a", + fg: "#ffc87a", + card: "#16110a", + muted: "#1f180f", + border: "#403121", + accent: "#ff7a00", + accentSecondary: "#4f3c1f" + } + }, + github: { + label: "GitHub", + colors: { + bg: "#0d1117", + fg: "#c9d1d9", + card: "#111b2c", + muted: "#1d2a3a", + border: "#2f3b49", + accent: "#58a6ff", + accentSecondary: "#1f6feb" + } + }, + rainbow: { + label: "Rainbow", + colors: { + bg: "#1b1f23", + fg: "#e6edf3", + card: "#222737", + muted: "#2a3342", + border: "#525e72", + accent: "#fb7185", + accentSecondary: "#c084fc" + } + }, + sky: { + label: "Sky", + colors: { + bg: "#f0f6ff", + fg: "#1e3a8a", + card: "#f7faff", + muted: "#dbeafe", + border: "#b7d5ff", + accent: "#2563eb", + accentSecondary: "#14b8a6" + } + }, + pats: { + label: "Pats", + colors: { + bg: "#001a36", + fg: "#edf4ff", + card: "#001a34", + muted: "#163f66", + border: "#c60c30", + accent: "#c60c30", + accentSecondary: "#0d426b" + } + } +}; + +function ThemeSwatch({ themeId, selected, onClick }: { themeId: ThemeId; selected: boolean; onClick: () => void }) { + const { label, colors } = THEME_META[themeId]; + return ( + + ); +} + export function SettingsPage() { const [info, setInfo] = useState(null); const [loadError, setLoadError] = useState(null); @@ -148,7 +286,18 @@ export function SettingsPage() { const [hostedBootstrapConfig, setHostedBootstrapConfig] = useState(null); const [hostedBusy, setHostedBusy] = useState(false); const [showAdvancedHostedFields, setShowAdvancedHostedFields] = useState(false); + const [githubStatus, setGithubStatus] = useState(null); + const [githubTokenDraft, setGithubTokenDraft] = useState(""); + const [githubBusy, setGithubBusy] = useState(false); + const [prPollingIntervalDraft, setPrPollingIntervalDraft] = useState("25"); + const [prPollingBusy, setPrPollingBusy] = useState(false); + const [hostedGithubStatus, setHostedGithubStatus] = useState(null); + const [hostedGithubEvents, setHostedGithubEvents] = useState([]); + const [hostedGithubBusy, setHostedGithubBusy] = useState(false); + const [hostedGithubPollingUntil, setHostedGithubPollingUntil] = useState(null); const providerMode = useAppStore((s) => s.providerMode); + const theme = useAppStore((s) => s.theme); + const setTheme = useAppStore((s) => s.setTheme); const refreshProviderMode = useAppStore((s) => s.refreshProviderMode); useEffect(() => { @@ -168,6 +317,11 @@ export function SettingsPage() { .then((snapshot) => { if (!cancelled) { setProviderDraft(readProviderDraft(snapshot)); + const localSeconds = typeof snapshot.local.github?.prPollingIntervalSeconds === "number" ? snapshot.local.github.prPollingIntervalSeconds : null; + const effectiveSeconds = + typeof snapshot.effective.github?.prPollingIntervalSeconds === "number" ? snapshot.effective.github.prPollingIntervalSeconds : null; + const seconds = localSeconds ?? effectiveSeconds ?? 25; + setPrPollingIntervalDraft(String(seconds)); } }) .catch((e) => { @@ -179,20 +333,122 @@ export function SettingsPage() { .then((status) => { if (!cancelled) setHostedStatus(status); }) - .catch(() => {}); + .catch(() => { }); window.ade.hosted .getBootstrapConfig() .then((config) => { if (!cancelled) setHostedBootstrapConfig(config); }) - .catch(() => {}); + .catch(() => { }); + + window.ade.github + .getStatus() + .then((status) => { + if (!cancelled) setGithubStatus(status); + }) + .catch(() => { }); return () => { cancelled = true; }; }, []); + useEffect(() => { + let cancelled = false; + + if (!providerDraft || providerDraft.mode !== "hosted") { + setHostedGithubStatus(null); + setHostedGithubEvents([]); + setHostedGithubPollingUntil(null); + return () => { + cancelled = true; + }; + } + + window.ade.hosted.github + .getStatus() + .then((status) => { + if (!cancelled) setHostedGithubStatus(status); + }) + .catch(() => { }); + + window.ade.hosted.github + .listEvents() + .then((res) => { + if (!cancelled) setHostedGithubEvents(res.events ?? []); + }) + .catch(() => { }); + + return () => { + cancelled = true; + }; + }, [providerDraft?.mode]); + + useEffect(() => { + if (hostedGithubPollingUntil == null) return; + let cancelled = false; + let inFlight = false; + + const tick = async () => { + if (cancelled || inFlight) return; + if (Date.now() >= hostedGithubPollingUntil) { + setHostedGithubPollingUntil(null); + return; + } + + inFlight = true; + try { + const status = await window.ade.hosted.github.getStatus(); + if (!cancelled) setHostedGithubStatus(status); + if (status.connected) { + setHostedGithubPollingUntil(null); + setSaveNotice("GitHub App connected."); + } + } catch { + // Ignore polling errors; user can manually refresh. + } finally { + inFlight = false; + } + }; + + const timer = window.setInterval(() => void tick(), 2000); + void tick(); + + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [hostedGithubPollingUntil]); + + useEffect(() => { + if (!providerDraft || providerDraft.mode !== "hosted") return; + if (!hostedGithubStatus?.connected) return; + + let cancelled = false; + let inFlight = false; + + const refreshEvents = async () => { + if (cancelled || inFlight) return; + inFlight = true; + try { + const res = await window.ade.hosted.github.listEvents(); + if (!cancelled) setHostedGithubEvents(res.events ?? []); + } catch { + // ignore + } finally { + inFlight = false; + } + }; + + const timer = window.setInterval(() => void refreshEvents(), 10_000); + void refreshEvents(); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [providerDraft?.mode, hostedGithubStatus?.connected]); + if (loadError) { return ; } @@ -204,6 +460,10 @@ export function SettingsPage() { const refreshProviderDraftAndHostedState = async () => { const snapshot = await window.ade.projectConfig.get(); setProviderDraft(readProviderDraft(snapshot)); + const localSeconds = typeof snapshot.local.github?.prPollingIntervalSeconds === "number" ? snapshot.local.github.prPollingIntervalSeconds : null; + const effectiveSeconds = + typeof snapshot.effective.github?.prPollingIntervalSeconds === "number" ? snapshot.effective.github.prPollingIntervalSeconds : null; + setPrPollingIntervalDraft(String(localSeconds ?? effectiveSeconds ?? 25)); const [status, bootstrap] = await Promise.all([ window.ade.hosted.getStatus().catch(() => null), window.ade.hosted.getBootstrapConfig().catch(() => null) @@ -274,6 +534,50 @@ export function SettingsPage() { } }; + const savePrPollingSettings = async () => { + setActionError(null); + setSaveNotice(null); + + const raw = prPollingIntervalDraft.trim(); + const snapshot = await window.ade.projectConfig.get(); + const currentGithub = isRecord(snapshot.local.github) ? snapshot.local.github : {}; + const nextGithub: Record = { ...currentGithub }; + + if (!raw.length) { + delete nextGithub.prPollingIntervalSeconds; + } else { + const seconds = Number(raw); + if (!Number.isFinite(seconds) || seconds <= 0) { + setActionError("PR polling interval must be a positive number of seconds."); + return; + } + if (seconds < 5 || seconds > 300) { + setActionError("PR polling interval must be between 5 and 300 seconds."); + return; + } + nextGithub.prPollingIntervalSeconds = seconds; + } + + setPrPollingBusy(true); + try { + const nextLocal = { + ...snapshot.local, + ...(Object.keys(nextGithub).length ? { github: nextGithub } : {}) + }; + await window.ade.projectConfig.save({ + shared: snapshot.shared, + local: nextLocal + }); + + await refreshProviderDraftAndHostedState(); + setSaveNotice("PR polling settings saved to .ade/local.yaml."); + } catch (error) { + setActionError(error instanceof Error ? error.message : String(error)); + } finally { + setPrPollingBusy(false); + } + }; + const applyHostedBootstrap = async () => { setActionError(null); setSaveNotice(null); @@ -296,6 +600,15 @@ export function SettingsPage() { return (
+
Theme
+
+ {THEME_IDS.map((id) => ( + setTheme(id)} /> + ))} +
+ +
+
Environment
{saveNotice ? (
@@ -333,9 +646,9 @@ export function SettingsPage() { setProviderDraft((prev) => prev ? { - ...prev, - mode: nextMode - } + ...prev, + mode: nextMode + } : prev ); }} @@ -369,12 +682,12 @@ export function SettingsPage() { setProviderDraft((prev) => prev ? { - ...prev, - hosted: { - ...prev.hosted, - consentGiven: e.target.checked - } + ...prev, + hosted: { + ...prev.hosted, + consentGiven: e.target.checked } + } : prev ) } @@ -390,12 +703,12 @@ export function SettingsPage() { setProviderDraft((prev) => prev ? { - ...prev, - hosted: { - ...prev.hosted, - githubRepoConsent: e.target.checked - } + ...prev, + hosted: { + ...prev.hosted, + githubRepoConsent: e.target.checked } + } : prev ) } @@ -411,12 +724,12 @@ export function SettingsPage() { setProviderDraft((prev) => prev ? { - ...prev, - hosted: { - ...prev.hosted, - uploadTranscripts: e.target.checked - } + ...prev, + hosted: { + ...prev.hosted, + uploadTranscripts: e.target.checked } + } : prev ) } @@ -535,12 +848,12 @@ export function SettingsPage() { setProviderDraft((prev) => prev ? { - ...prev, - hosted: { - ...prev.hosted, - mirrorExcludePatternsText: e.target.value - } + ...prev, + hosted: { + ...prev.hosted, + mirrorExcludePatternsText: e.target.value } + } : prev ) } @@ -564,12 +877,12 @@ export function SettingsPage() { setProviderDraft((prev) => prev ? { - ...prev, - hosted: { - ...prev.hosted, - apiBaseUrl: e.target.value - } + ...prev, + hosted: { + ...prev.hosted, + apiBaseUrl: e.target.value } + } : prev ) } @@ -582,12 +895,12 @@ export function SettingsPage() { setProviderDraft((prev) => prev ? { - ...prev, - hosted: { - ...prev.hosted, - region: e.target.value - } + ...prev, + hosted: { + ...prev.hosted, + region: e.target.value } + } : prev ) } @@ -600,12 +913,12 @@ export function SettingsPage() { setProviderDraft((prev) => prev ? { - ...prev, - hosted: { - ...prev.hosted, - clerkPublishableKey: e.target.value - } + ...prev, + hosted: { + ...prev.hosted, + clerkPublishableKey: e.target.value } + } : prev ) } @@ -618,12 +931,12 @@ export function SettingsPage() { setProviderDraft((prev) => prev ? { - ...prev, - hosted: { - ...prev.hosted, - clerkOauthClientId: e.target.value - } + ...prev, + hosted: { + ...prev.hosted, + clerkOauthClientId: e.target.value } + } : prev ) } @@ -636,12 +949,12 @@ export function SettingsPage() { setProviderDraft((prev) => prev ? { - ...prev, - hosted: { - ...prev.hosted, - clerkIssuer: e.target.value - } + ...prev, + hosted: { + ...prev.hosted, + clerkIssuer: e.target.value } + } : prev ) } @@ -654,12 +967,12 @@ export function SettingsPage() { setProviderDraft((prev) => prev ? { - ...prev, - hosted: { - ...prev.hosted, - clerkFrontendApiUrl: e.target.value - } + ...prev, + hosted: { + ...prev.hosted, + clerkFrontendApiUrl: e.target.value } + } : prev ) } @@ -672,12 +985,12 @@ export function SettingsPage() { setProviderDraft((prev) => prev ? { - ...prev, - hosted: { - ...prev.hosted, - clerkOauthMetadataUrl: e.target.value - } + ...prev, + hosted: { + ...prev.hosted, + clerkOauthMetadataUrl: e.target.value } + } : prev ) } @@ -690,12 +1003,12 @@ export function SettingsPage() { setProviderDraft((prev) => prev ? { - ...prev, - hosted: { - ...prev.hosted, - clerkOauthAuthorizeUrl: e.target.value - } + ...prev, + hosted: { + ...prev.hosted, + clerkOauthAuthorizeUrl: e.target.value } + } : prev ) } @@ -708,12 +1021,12 @@ export function SettingsPage() { setProviderDraft((prev) => prev ? { - ...prev, - hosted: { - ...prev.hosted, - clerkOauthTokenUrl: e.target.value - } + ...prev, + hosted: { + ...prev.hosted, + clerkOauthTokenUrl: e.target.value } + } : prev ) } @@ -726,12 +1039,12 @@ export function SettingsPage() { setProviderDraft((prev) => prev ? { - ...prev, - hosted: { - ...prev.hosted, - clerkOauthRevocationUrl: e.target.value - } + ...prev, + hosted: { + ...prev.hosted, + clerkOauthRevocationUrl: e.target.value } + } : prev ) } @@ -744,12 +1057,12 @@ export function SettingsPage() { setProviderDraft((prev) => prev ? { - ...prev, - hosted: { - ...prev.hosted, - clerkOauthUserInfoUrl: e.target.value - } + ...prev, + hosted: { + ...prev.hosted, + clerkOauthUserInfoUrl: e.target.value } + } : prev ) } @@ -762,12 +1075,12 @@ export function SettingsPage() { setProviderDraft((prev) => prev ? { - ...prev, - hosted: { - ...prev.hosted, - clerkOauthScopes: e.target.value - } + ...prev, + hosted: { + ...prev.hosted, + clerkOauthScopes: e.target.value } + } : prev ) } @@ -781,6 +1094,142 @@ export function SettingsPage() {
) : null} + {providerDraft.mode === "hosted" ? ( +
+
GitHub App (Hosted, Phase 7A)
+
+ Hosted GitHub uses a GitHub App installation per project (no PATs). Click Connect, complete the installation in the browser, then return to ADE. +
+ +
+ + + + + + + {!hostedStatus?.auth.signedIn ? ( +
Sign in first.
+ ) : providerMode !== "hosted" ? ( +
Save provider mode as Hosted first.
+ ) : hostedGithubStatus?.configured === false ? ( +
Server GitHub App not configured.
+ ) : null} +
+ +
+
configured: {hostedGithubStatus ? (hostedGithubStatus.configured ? "yes" : "no") : "unknown"}
+
connected: {hostedGithubStatus ? (hostedGithubStatus.connected ? "yes" : "no") : "unknown"}
+
app slug: {hostedGithubStatus?.appSlug ?? "unknown"}
+
installation: {hostedGithubStatus?.installationId ?? "none"}
+
connected at: {hostedGithubStatus?.connectedAt ?? "never"}
+
+ +
+
+
Recent GitHub webhook events (debug)
+ +
+ +
+
+ {hostedGithubEvents.map((ev) => ( +
+
+
{ev.summary}
+
{ev.createdAt}
+
+
+ {ev.repoFullName ? ev.repoFullName : "unknown repo"} + {ev.prNumber != null ? ` · #${ev.prNumber}` : ""} + {ev.action ? ` · ${ev.action}` : ""} +
+
+ ))} + {!hostedGithubEvents.length ? ( +
No events stored yet.
+ ) : null} +
+
+
+
+ ) : null} + {providerDraft.mode === "byok" ? (
BYOK (ONBOARD-015)
@@ -792,18 +1241,20 @@ export function SettingsPage() { setProviderDraft((prev) => prev ? { - ...prev, - byok: { - ...prev.byok, - provider: e.target.value === "openai" ? "openai" : "anthropic" - } + ...prev, + byok: { + ...prev.byok, + provider: + e.target.value === "openai" ? "openai" : e.target.value === "gemini" ? "gemini" : "anthropic" } + } : prev ) } > + prev ? { - ...prev, - byok: { - ...prev.byok, - model: e.target.value - } + ...prev, + byok: { + ...prev.byok, + model: e.target.value } + } : prev ) } @@ -832,12 +1283,12 @@ export function SettingsPage() { setProviderDraft((prev) => prev ? { - ...prev, - byok: { - ...prev.byok, - apiKey: e.target.value - } + ...prev, + byok: { + ...prev.byok, + apiKey: e.target.value } + } : prev ) } @@ -847,6 +1298,133 @@ export function SettingsPage() {
) : null} +
+
GitHub (Local Token)
+
+ setGithubTokenDraft(e.target.value)} + /> +
+ + + +
+
+
+
token stored: {githubStatus?.tokenStored ? "yes" : "no"}
+
repo: {githubStatus?.repo ? `${githubStatus.repo.owner}/${githubStatus.repo.name}` : "unknown"}
+
user: {githubStatus?.userLogin ?? "unknown"}
+
scopes: {(githubStatus?.scopes ?? []).join(", ") || "unknown"}
+
checked: {githubStatus?.checkedAt ?? "never"}
+
+
+ Token is encrypted using OS secure storage and stored locally under `.ade/`. In Hosted mode, GitHub uses the GitHub App connection instead of this token. +
+
+
PR Polling
+
+ setPrPollingIntervalDraft(e.target.value)} + /> + seconds +
+ + +
+
+
+ Controls background PR refresh and notifications. Default is 25s; higher values reduce GitHub API usage. +
+
+
+ {providerDraft.mode === "cli" ? (
CLI Provider
@@ -858,11 +1436,11 @@ export function SettingsPage() { setProviderDraft((prev) => prev ? { - ...prev, - cli: { - command: e.target.value - } + ...prev, + cli: { + command: e.target.value } + } : prev ) } diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index f9cc4acf5..d3d7593b3 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -1,13 +1,51 @@ -import React, { useMemo, useState } from "react"; -import * as Dialog from "@radix-ui/react-dialog"; -import { Link2, Moon, Play, Plus, Search, Sun } from "lucide-react"; -import { useNavigate } from "react-router-dom"; +import React, { useCallback, useEffect, useState } from "react"; +import { Folder, Plus, Search, X } from "lucide-react"; import { Button } from "../ui/Button"; -import { Chip } from "../ui/Chip"; -import { ProjectSelector } from "./ProjectSelector"; import { useAppStore } from "../../state/appStore"; import { cn } from "../ui/cn"; +type RecentProject = { name: string; rootPath: string }; + +const STORAGE_KEY = "ade.recentProjects"; +const MAX_RECENT = 8; + +function loadRecentProjects(): RecentProject[] { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter((p: unknown): p is RecentProject => + typeof p === "object" && p !== null && typeof (p as RecentProject).name === "string" && typeof (p as RecentProject).rootPath === "string" + ).slice(0, MAX_RECENT); + } catch { + return []; + } +} + +function saveRecentProjects(projects: RecentProject[]) { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(projects.slice(0, MAX_RECENT))); + } catch { + // ignore + } +} + +function addToRecent(project: RecentProject) { + const existing = loadRecentProjects(); + const filtered = existing.filter((p) => p.rootPath !== project.rootPath); + const next = [project, ...filtered].slice(0, MAX_RECENT); + saveRecentProjects(next); + return next; +} + +function removeFromRecent(rootPath: string) { + const existing = loadRecentProjects(); + const next = existing.filter((p) => p.rootPath !== rootPath); + saveRecentProjects(next); + return next; +} + export function TopBar({ onOpenCommandPalette, commandHint @@ -15,271 +53,109 @@ export function TopBar({ onOpenCommandPalette: () => void; commandHint: React.ReactNode; }) { - const baseRef = useAppStore((s) => s.project?.baseRef); const project = useAppStore((s) => s.project); - const lanes = useAppStore((s) => s.lanes); - const selectedLaneId = useAppStore((s) => s.selectedLaneId); - const refreshLanes = useAppStore((s) => s.refreshLanes); - const focusSession = useAppStore((s) => s.focusSession); - const theme = useAppStore((s) => s.theme); - const toggleTheme = useAppStore((s) => s.toggleTheme); - const navigate = useNavigate(); - - const [createOpen, setCreateOpen] = useState(false); - const [laneName, setLaneName] = useState(""); - const [parentLaneId, setParentLaneId] = useState(""); - const [attachOpen, setAttachOpen] = useState(false); - const [attachName, setAttachName] = useState(""); - const [attachPath, setAttachPath] = useState(""); - - const canCreateLane = Boolean(project?.rootPath); - const canStartTerminal = Boolean(selectedLaneId); - - const selectedLaneName = useMemo( - () => lanes.find((l) => l.id === selectedLaneId)?.name ?? null, - [lanes, selectedLaneId] - ); - const selectableParentLanes = useMemo(() => lanes, [lanes]); + const openRepo = useAppStore((s) => s.openRepo); + const [recentProjects, setRecentProjects] = useState(loadRecentProjects); - return ( -
-
-
-
ADE
-
MVP scaffold
-
+ // When project changes, add it to recent list + useEffect(() => { + if (project?.rootPath && project?.displayName) { + const next = addToRecent({ name: project.displayName, rootPath: project.rootPath }); + setRecentProjects(next); + } + }, [project?.rootPath, project?.displayName]); -
+ const handleOpenNew = useCallback(() => { + openRepo().catch(() => {}); + }, [openRepo]); - + const handleSwitchProject = useCallback((rootPath: string) => { + // If it's already the current project, do nothing + if (project?.rootPath === rootPath) return; + // Open the project - openRepo will show file dialog, but we can try to hint + // For now, trigger the dialog. Full path-based switching needs backend support. + openRepo().catch(() => {}); + }, [project?.rootPath, openRepo]); - base: {baseRef ?? "?"} - pull: idle - jobs: 0 - procs: 0 -
+ const handleRemoveTab = useCallback((rootPath: string) => { + // Don't allow removing the current project + if (project?.rootPath === rootPath) return; + const next = removeFromRecent(rootPath); + setRecentProjects(next); + }, [project?.rootPath]); -
- - - - - - - - - -
- Create lane - - - -
+ return ( +
+ {/* Branding */} +
ADE
-
-
Name
- setLaneName(e.target.value)} - placeholder="e.g. feature/auth-refresh" - className="h-10 w-full rounded-lg border border-border bg-card/70 px-3 text-sm outline-none placeholder:text-muted-fg" - autoFocus - /> -
-
Parent lane (optional)
- -
-
+
-
- - + ) : ( + <> + {recentProjects.map((rp) => { + const isCurrent = project?.rootPath === rp.rootPath; + const canClose = !isCurrent; + return ( +
handleSwitchProject(rp.rootPath)} + title={rp.rootPath} > - {parentLaneId ? "Create child lane" : "Create"} - -
- - - - - - - - - - -
- Attach lane - - - -
- -
-
-
Lane name
- setAttachName(e.target.value)} - placeholder="e.g. bugfix/from-other-worktree" - className="h-10 w-full rounded-lg border border-border bg-card/70 px-3 text-sm outline-none placeholder:text-muted-fg" - autoFocus - /> + + {rp.name} + {canClose ? ( + { + e.stopPropagation(); + handleRemoveTab(rp.rootPath); + }} + title="Remove from tabs" + > + + + ) : null}
-
-
Attached path
- setAttachPath(e.target.value)} - placeholder="/absolute/path/to/existing/worktree" - className="h-10 w-full rounded-lg border border-border bg-card/70 px-3 font-mono text-xs outline-none placeholder:text-muted-fg" - /> -
-
- -
- - -
-
-
-
+ ); + })} + + )} - - + +
+ + {/* Command palette */} +
); } diff --git a/apps/desktop/src/renderer/components/conflicts/ConflictFileDiff.tsx b/apps/desktop/src/renderer/components/conflicts/ConflictFileDiff.tsx index 98c21cc33..335c6f991 100644 --- a/apps/desktop/src/renderer/components/conflicts/ConflictFileDiff.tsx +++ b/apps/desktop/src/renderer/components/conflicts/ConflictFileDiff.tsx @@ -1,7 +1,7 @@ import React from "react"; import type { editor as MonacoEditor } from "monaco-editor"; import type { MergeSimulationResult } from "../../../shared/types"; -import { useAppStore } from "../../state/appStore"; +import { useAppStore, type ThemeId } from "../../state/appStore"; import { extensionToLanguage } from "./extensionToLanguage"; let monacoInit: Promise | null = null; @@ -105,6 +105,10 @@ function buildDecorations( return decorations; } +function isDarkTheme(theme: ThemeId): boolean { + return theme === "bloomberg" || theme === "github" || theme === "rainbow" || theme === "pats"; +} + export function ConflictFileDiff({ result, selectedPath, @@ -247,7 +251,7 @@ export function ConflictFileDiff({ React.useEffect(() => { loadMonaco() .then((monaco) => { - monaco.editor.setTheme(theme === "dark" ? "vs-dark" : "vs"); + monaco.editor.setTheme(isDarkTheme(theme) ? "vs-dark" : "vs"); }) .catch(() => { // ignore theme updates in fallback mode diff --git a/apps/desktop/src/renderer/components/files/FilesPage.tsx b/apps/desktop/src/renderer/components/files/FilesPage.tsx index ca408b11d..cc2893be7 100644 --- a/apps/desktop/src/renderer/components/files/FilesPage.tsx +++ b/apps/desktop/src/renderer/components/files/FilesPage.tsx @@ -20,7 +20,7 @@ import { Sparkles, TerminalSquare } from "lucide-react"; -import { useNavigate } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import type { FileTreeNode, FilesQuickOpenItem, @@ -40,6 +40,11 @@ type OpenTab = { isBinary: boolean; }; +type FilesPageNavState = { + openFilePath?: string; + laneId?: string; +}; + type ConflictHunk = { key: string; startLine: number; @@ -208,6 +213,7 @@ function changeStatusClasses(changeStatus: FileTreeNode["changeStatus"]): { dot: export function FilesPage() { const navigate = useNavigate(); + const location = useLocation(); const selectedLaneId = useAppStore((s) => s.selectedLaneId); const [workspaces, setWorkspaces] = useState([]); @@ -217,6 +223,7 @@ export function FilesPage() { const [expanded, setExpanded] = useState>(new Set()); const [selectedNodePath, setSelectedNodePath] = useState(null); const [explorerCollapsed, setExplorerCollapsed] = useState(false); + const pendingOpenRef = useRef<{ filePath: string; laneId: string | null; key: string } | null>(null); const [openTabs, setOpenTabs] = useState([]); const [activeTabPath, setActiveTabPath] = useState(null); @@ -344,6 +351,13 @@ export function FilesPage() { activeTabPathRef.current = activeTabPath; }, [activeTabPath]); + useEffect(() => { + const st = (location.state as FilesPageNavState | null) ?? null; + const openFilePath = st?.openFilePath?.trim(); + if (!openFilePath) return; + pendingOpenRef.current = { key: location.key, filePath: openFilePath, laneId: st?.laneId ?? null }; + }, [location.key, location.state]); + const refreshTree = useCallback(async (parentPath?: string) => { if (!workspaceId) return; try { @@ -413,6 +427,27 @@ export function FilesPage() { } }, [workspaceId]); + useEffect(() => { + const pending = pendingOpenRef.current; + if (!pending) return; + if (!workspaces.length) return; + + const desiredWorkspaceId = + pending.laneId != null + ? workspaces.find((ws) => ws.kind !== "primary" && ws.laneId === pending.laneId)?.id ?? null + : null; + const targetWorkspaceId = desiredWorkspaceId ?? workspaceId; + if (targetWorkspaceId && targetWorkspaceId !== workspaceId) { + switchWorkspace(targetWorkspaceId); + return; + } + if (!workspaceId) return; + + openFile(pending.filePath).catch(() => {}); + pendingOpenRef.current = null; + navigate(location.pathname, { replace: true, state: null }); + }, [workspaces, workspaceId, switchWorkspace, openFile, navigate, location.pathname]); + const closeTab = useCallback((filePath: string) => { setOpenTabs((prev) => { const tab = prev.find((t) => t.path === filePath); diff --git a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx index 78386aaf7..e2b6ec4c8 100644 --- a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx +++ b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx @@ -26,6 +26,7 @@ import { useNavigate } from "react-router-dom"; import type { BatchAssessmentResult, ConflictStatus, + ConflictProposal, GraphFilterState, GraphLayoutPreset, GraphLayoutSnapshot, @@ -35,7 +36,15 @@ import type { GitSyncMode, LaneIcon, LaneSummary, - MergeSimulationResult + MergeMethod, + MergeSimulationResult, + PackSummary, + PrCheck, + PrReview, + PrReviewStatus, + PrState, + PrStatus, + PrSummary } from "../../../shared/types"; import { useAppStore } from "../../state/appStore"; import { Button } from "../ui/Button"; @@ -55,6 +64,22 @@ type GraphNodeData = { highlight: boolean; restackFailed: boolean; restackPulse: boolean; + mergeInProgress: boolean; + mergeDisappearing: boolean; +}; + +type GraphPrOverlay = { + prId: string; + laneId: string; + baseLaneId: string; + number: number; + title: string; + url: string; + state: PrState; + checksStatus: PrStatus["checksStatus"]; + reviewStatus: PrReviewStatus; + lastSyncedAt: string | null; + mergeInProgress: boolean; }; type GraphEdgeData = { @@ -64,6 +89,7 @@ type GraphEdgeData = { stale?: boolean; dimmed?: boolean; highlight?: boolean; + pr?: GraphPrOverlay; }; type BatchStepStatus = "pending" | "running" | "done" | "failed" | "skipped"; @@ -86,6 +112,47 @@ type GraphTextPromptState = { resolve: (value: string | null) => void; }; +type PrDialogState = { + laneId: string; + baseLaneId: string; + baseBranch: string; + title: string; + body: string; + draft: boolean; + loadingDraft: boolean; + creating: boolean; + existingPr: PrSummary | null; + loadingDetails: boolean; + status: PrStatus | null; + checks: PrCheck[]; + reviews: PrReview[]; + mergeMethod: MergeMethod; + merging: boolean; + error: string | null; +}; + +type ConflictPanelState = { + laneAId: string; + laneBId: string; + loading: boolean; + result: MergeSimulationResult | null; + error: string | null; + applyLaneId: string; + proposal: ConflictProposal | null; + proposing: boolean; + applyMode: "unstaged" | "staged" | "commit"; + commitMessage: string; + applying: boolean; +}; + +type IntegrationDialogState = { + laneIds: string[]; + name: string; + busy: boolean; + step: string | null; + error: string | null; +}; + const VIEW_MODES: GraphViewMode[] = ["stack", "risk", "activity", "all"]; const ICON_OPTIONS: Array<{ key: LaneIcon; label: string; icon: React.ReactNode }> = [ { key: null, label: "None", icon: }, @@ -223,6 +290,22 @@ function riskStrokeColor(level: GraphEdgeData["riskLevel"]): string { return "#6b7280"; } +function prOverlayColor(pr: GraphPrOverlay): string { + if (pr.state === "draft") return "#a855f7"; + if (pr.checksStatus === "failing") return "#dc2626"; + if (pr.reviewStatus === "changes_requested") return "#f59e0b"; + if (pr.checksStatus === "passing") return "#16a34a"; + if (pr.checksStatus === "pending") return "#38bdf8"; + return "#6b7280"; +} + +function prCiDotColor(pr: GraphPrOverlay): string { + if (pr.checksStatus === "failing") return "#dc2626"; + if (pr.checksStatus === "passing") return "#16a34a"; + if (pr.checksStatus === "pending") return "#f59e0b"; + return "#6b7280"; +} + function iconGlyph(icon: LaneIcon): React.ReactNode { if (icon === "star") return ; if (icon === "flag") return ; @@ -372,7 +455,9 @@ function GraphLaneNode({ data, selected }: NodeProps>) { data.highlight && "scale-[1.02] shadow-[0_2px_8px_rgba(0,0,0,0.2)]", data.activityBucket === "high" && "shadow-[0_0_18px_rgba(34,197,94,0.2)]", data.restackFailed && "border-red-500 ring-1 ring-red-500/80", - data.restackPulse && "ade-node-failed-pulse" + data.restackPulse && "ade-node-failed-pulse", + data.mergeInProgress && "ade-node-merging", + data.mergeDisappearing && "ade-node-disappear" )} style={{ width: dimensions.width, @@ -389,6 +474,7 @@ function GraphLaneNode({ data, selected }: NodeProps>) { {lane.status.dirty ? "dirty" : "clean"} {lane.status.ahead}↑/{lane.status.behind}↓ {data.status} + {data.mergeInProgress ? merging : null} {data.activeSessions > 0 ? : null}
{lane.tags.length > 0 ? ( @@ -425,7 +511,7 @@ function GraphLaneNode({ data, selected }: NodeProps>) { function RiskEdge(props: EdgeProps>) { const { id, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, markerEnd, data, selected } = props; - const [path] = getBezierPath({ + const [path, labelX, labelY] = getBezierPath({ sourceX, sourceY, targetX, @@ -433,11 +519,24 @@ function RiskEdge(props: EdgeProps>) { sourcePosition: sourcePosition ?? Position.Bottom, targetPosition: targetPosition ?? Position.Top }); - const color = data?.edgeType === "risk" ? riskStrokeColor(data.riskLevel) : data?.edgeType === "stack" ? "#38bdf8" : "#6b7280"; - const width = data?.edgeType === "stack" ? 3 : 1.8; + const pr = data?.pr; + const color = + data?.edgeType === "risk" + ? riskStrokeColor(data.riskLevel) + : pr + ? prOverlayColor(pr) + : data?.edgeType === "stack" + ? "#38bdf8" + : "#6b7280"; + const width = pr && data?.edgeType !== "risk" ? 2.6 : data?.edgeType === "stack" ? 3 : 1.8; const dash = data?.edgeType === "risk" ? "5 3" : undefined; const effectiveWidth = (selected ? width + 1 : width) + (data?.highlight ? 0.5 : 0); const effectiveOpacity = data?.dimmed ? 0.16 : data?.highlight ? 1 : data?.stale ? 0.55 : 0.9; + const badgeColor = pr ? prOverlayColor(pr) : "#6b7280"; + const dotColor = pr ? prCiDotColor(pr) : "#6b7280"; + const badgeText = pr ? `PR #${pr.number}` : ""; + const badgeWidth = Math.max(64, badgeText.length * 6 + 26); + const badgeHeight = 18; return ( >) { strokeDasharray={dash} opacity={effectiveOpacity} /> + {pr ? ( + + + + + {badgeText} + + + ) : null} ); } @@ -464,6 +595,13 @@ function GraphInner() { const project = useAppStore((s) => s.project); const lanes = useAppStore((s) => s.lanes); const refreshLanes = useAppStore((s) => s.refreshLanes); + const [prs, setPrs] = React.useState([]); + const [loadingPrs, setLoadingPrs] = React.useState(true); + + const refreshPrs = React.useCallback(async () => { + const next = await window.ade.prs.refresh(); + setPrs(next); + }, []); const [viewMode, setViewMode] = React.useState("all"); const [graphState, setGraphState] = React.useState(createDefaultState()); @@ -498,7 +636,7 @@ function GraphInner() { overlapFiles: string[]; preview: MergeSimulationResult | null; previewBusy: boolean; - actionMode: "integrate" | "reparent"; + actionMode: "integrate" | "reparent" | "pr"; integratePlan: { sourceLaneId: string; laneId: string; @@ -515,6 +653,11 @@ function GraphInner() { const [activityScoreByLaneId, setActivityScoreByLaneId] = React.useState>({}); const [activeSessionsByLaneId, setActiveSessionsByLaneId] = React.useState>({}); const [lastActivityByLaneId, setLastActivityByLaneId] = React.useState>({}); + const [mergeInProgressByLaneId, setMergeInProgressByLaneId] = React.useState>({}); + const [mergeDisappearingAtByLaneId, setMergeDisappearingAtByLaneId] = React.useState>({}); + const [prDialog, setPrDialog] = React.useState(null); + const [conflictPanel, setConflictPanel] = React.useState(null); + const [integrationDialog, setIntegrationDialog] = React.useState(null); const [edgeHover, setEdgeHover] = React.useState<{ x: number; y: number; label: string } | null>(null); const [dragTrail, setDragTrail] = React.useState<{ laneId: string; from: { x: number; y: number }; to: { x: number; y: number } } | null>(null); const [dropPreview, setDropPreview] = React.useState<{ @@ -534,6 +677,7 @@ function GraphInner() { } | null>(null); const [hoveredNodeId, setHoveredNodeId] = React.useState(null); const [nodeTooltip, setNodeTooltip] = React.useState<{ x: number; y: number; laneId: string } | null>(null); + const [nodeTooltipPack, setNodeTooltipPack] = React.useState(null); const [restackFailedLaneId, setRestackFailedLaneId] = React.useState(null); const [restackFailedPulse, setRestackFailedPulse] = React.useState(false); const [textPrompt, setTextPrompt] = React.useState(null); @@ -644,6 +788,32 @@ function GraphInner() { }, [collapsedLaneIds, lanes]); const laneById = React.useMemo(() => new Map(lanes.map((lane) => [lane.id, lane] as const)), [lanes]); + const primaryLaneId = React.useMemo(() => lanes.find((lane) => lane.laneType === "primary")?.id ?? null, [lanes]); + const laneIdByBranchRef = React.useMemo(() => new Map(lanes.map((lane) => [lane.branchRef, lane.id] as const)), [lanes]); + const prByLaneId = React.useMemo(() => new Map(prs.map((pr) => [pr.laneId, pr] as const)), [prs]); + const prOverlayByPair = React.useMemo(() => { + const map = new Map(); + for (const pr of prs) { + const lane = laneById.get(pr.laneId); + if (!lane) continue; + const baseLaneId = laneIdByBranchRef.get(pr.baseBranch) ?? lane.parentLaneId ?? primaryLaneId; + if (!baseLaneId) continue; + map.set(edgePairKey(baseLaneId, pr.laneId), { + prId: pr.id, + laneId: pr.laneId, + baseLaneId, + number: pr.githubPrNumber, + title: pr.title, + url: pr.githubUrl, + state: pr.state, + checksStatus: pr.checksStatus, + reviewStatus: pr.reviewStatus, + lastSyncedAt: pr.lastSyncedAt ?? null, + mergeInProgress: Boolean(mergeInProgressByLaneId[pr.laneId]) + }); + } + return map; + }, [laneById, laneIdByBranchRef, mergeInProgressByLaneId, primaryLaneId, prs]); const connectedToHoveredNode = React.useMemo(() => { if (!hoveredNodeId) return new Set(); @@ -817,6 +987,33 @@ function GraphInner() { void refreshActivity(); }, [refreshLanes, refreshRiskBatch, refreshActivity]); + React.useEffect(() => { + let cancelled = false; + setLoadingPrs(true); + window.ade.prs + .listAll() + .then((list) => { + if (cancelled) return; + setPrs(list); + }) + .catch(() => {}) + .finally(() => { + if (!cancelled) setLoadingPrs(false); + }); + + const unsub = window.ade.prs.onEvent((event) => { + if (event.type !== "prs-updated") return; + if (cancelled) return; + setPrs(event.prs); + setLoadingPrs(false); + }); + + return () => { + cancelled = true; + unsub(); + }; + }, []); + React.useEffect(() => { if (!project?.rootPath) return; setLoadedGraphState(false); @@ -839,6 +1036,25 @@ function GraphInner() { return () => window.clearTimeout(timer); }, [undoToast]); + React.useEffect(() => { + const laneId = nodeTooltip?.laneId ?? null; + if (!laneId) { + setNodeTooltipPack(null); + return; + } + let cancelled = false; + setNodeTooltipPack(null); + window.ade.packs + .getLanePack(laneId) + .then((pack) => { + if (!cancelled) setNodeTooltipPack(pack); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [nodeTooltip?.laneId]); + React.useEffect(() => { if (!batchStatus?.summary) return; const hasFailure = batchStatus.steps.some((step) => step.status === "failed" || step.status === "skipped"); @@ -954,7 +1170,9 @@ function GraphInner() { lastActivityAt: lastActivityByLaneId[lane.id] ?? null, highlight: Boolean(hoveredNodeId) && connectedToHover, restackFailed: restackFailedLaneId === lane.id, - restackPulse: restackFailedLaneId === lane.id && restackFailedPulse + restackPulse: restackFailedLaneId === lane.id && restackFailedPulse, + mergeInProgress: Boolean(mergeInProgressByLaneId[lane.id]), + mergeDisappearing: Boolean(mergeDisappearingAtByLaneId[lane.id]) }, selected: selectedLaneIds.includes(lane.id), draggable: true @@ -964,6 +1182,16 @@ function GraphInner() { const nextEdges: Array> = []; const primaryLane = lanes.find((lane) => lane.laneType === "primary") ?? null; + const riskPairsWithVisibleEdge = new Set(); + if (viewMode === "all" || viewMode === "risk") { + for (const [key, risk] of riskByPair.entries()) { + if (risk.riskLevel === "none" && risk.overlapCount === 0) continue; + const [laneAId, laneBId] = key.split("::"); + if (!laneAId || !laneBId) continue; + if (hiddenByCollapse.has(laneAId) || hiddenByCollapse.has(laneBId)) continue; + riskPairsWithVisibleEdge.add(key); + } + } const edgeVisualState = (edgeId: string, source: string, target: string) => { const connectedToNodeHover = hoveredNodeId ? source === hoveredNodeId || target === hoveredNodeId : false; const highlightedByEdge = hoveredEdgeId ? hoveredEdgeId === edgeId : false; @@ -981,6 +1209,8 @@ function GraphInner() { if (!primaryLane || lane.id === primaryLane.id) continue; const edgeId = `topology:${primaryLane.id}:${lane.id}`; const visual = edgeVisualState(edgeId, primaryLane.id, lane.id); + const pair = edgePairKey(primaryLane.id, lane.id); + const pr = prOverlayByPair.get(pair); nextEdges.push({ id: edgeId, source: primaryLane.id, @@ -988,7 +1218,7 @@ function GraphInner() { sourceHandle: "source", targetHandle: "target", type: "custom", - data: { edgeType: "topology", ...visual }, + data: { edgeType: "topology", ...visual, ...(pr && !riskPairsWithVisibleEdge.has(pair) ? { pr } : {}) }, markerEnd: { type: MarkerType.ArrowClosed }, animated: false, selected: visual.highlight @@ -998,6 +1228,8 @@ function GraphInner() { if (!lane.parentLaneId || !laneById.has(lane.parentLaneId)) continue; const edgeId = `stack:${lane.parentLaneId}:${lane.id}`; const visual = edgeVisualState(edgeId, lane.parentLaneId, lane.id); + const pair = edgePairKey(lane.parentLaneId, lane.id); + const pr = prOverlayByPair.get(pair); nextEdges.push({ id: edgeId, source: lane.parentLaneId, @@ -1005,7 +1237,7 @@ function GraphInner() { sourceHandle: "source", targetHandle: "target", type: "custom", - data: { edgeType: "stack", ...visual }, + data: { edgeType: "stack", ...visual, ...(pr && !riskPairsWithVisibleEdge.has(pair) ? { pr } : {}) }, markerEnd: { type: MarkerType.ArrowClosed }, selected: visual.highlight }); @@ -1020,6 +1252,7 @@ function GraphInner() { if (hiddenByCollapse.has(laneAId) || hiddenByCollapse.has(laneBId)) continue; const edgeId = `risk:${laneAId}:${laneBId}`; const visual = edgeVisualState(edgeId, laneAId, laneBId); + const pr = prOverlayByPair.get(key); nextEdges.push({ id: edgeId, source: laneAId, @@ -1032,6 +1265,7 @@ function GraphInner() { riskLevel: risk.riskLevel, overlapCount: risk.overlapCount, stale: risk.stale, + ...(pr ? { pr } : {}), ...visual }, selected: visual.highlight @@ -1057,11 +1291,14 @@ function GraphInner() { restackFailedLaneId, restackFailedPulse, riskByPair, + prOverlayByPair, selectedLaneIds, statusByLane, viewMode, hoveredEdgeId, - activityScoreByLaneId + activityScoreByLaneId, + mergeDisappearingAtByLaneId, + mergeInProgressByLaneId ]); const onNodesChange = React.useCallback((changes: Parameters>>[0]) => { @@ -1287,6 +1524,110 @@ function GraphInner() { [getDropIntegratePlan, laneById, lanes, overlapFilesByPair] ); + const openPrDialogForLane = React.useCallback( + (laneId: string, baseLaneId: string) => { + const lane = laneById.get(laneId); + const baseLane = laneById.get(baseLaneId); + if (!lane || !baseLane) return; + + const existing = prByLaneId.get(laneId) ?? null; + const baseBranch = baseLane.branchRef; + + setPrDialog({ + laneId, + baseLaneId, + baseBranch, + title: existing?.title ?? "", + body: "", + draft: existing?.state === "draft", + loadingDraft: !existing, + creating: false, + existingPr: existing, + loadingDetails: Boolean(existing), + status: null, + checks: [], + reviews: [], + mergeMethod: "squash", + merging: false, + error: null + }); + + if (!existing) { + void window.ade.prs + .draftDescription(laneId) + .then((draft) => { + setPrDialog((prev) => (prev && prev.laneId === laneId ? { ...prev, title: draft.title, body: draft.body, loadingDraft: false } : prev)); + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + setPrDialog((prev) => (prev && prev.laneId === laneId ? { ...prev, loadingDraft: false, error: message } : prev)); + }); + return; + } + + void Promise.all([ + window.ade.prs.getStatus(existing.id), + window.ade.prs.getChecks(existing.id), + window.ade.prs.getReviews(existing.id) + ]) + .then(([status, checks, reviews]) => { + setPrDialog((prev) => + prev && prev.laneId === laneId + ? { ...prev, loadingDetails: false, status, checks, reviews } + : prev + ); + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + setPrDialog((prev) => (prev && prev.laneId === laneId ? { ...prev, loadingDetails: false, error: message } : prev)); + }); + }, + [laneById, prByLaneId] + ); + + const openConflictPanelForEdge = React.useCallback( + (laneAId: string, laneBId: string) => { + const laneA = laneById.get(laneAId); + const laneB = laneById.get(laneBId); + const applyLaneId = laneA && laneB && laneA.stackDepth !== laneB.stackDepth + ? (laneA.stackDepth > laneB.stackDepth ? laneAId : laneBId) + : laneAId; + + setConflictPanel({ + laneAId, + laneBId, + loading: true, + result: null, + error: null, + applyLaneId, + proposal: null, + proposing: false, + applyMode: "unstaged", + commitMessage: "", + applying: false + }); + + void window.ade.conflicts + .simulateMerge({ laneAId, laneBId }) + .then((result) => { + setConflictPanel((prev) => + prev && prev.laneAId === laneAId && prev.laneBId === laneBId + ? { ...prev, loading: false, result } + : prev + ); + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + setConflictPanel((prev) => + prev && prev.laneAId === laneAId && prev.laneBId === laneBId + ? { ...prev, loading: false, error: message } + : prev + ); + }); + }, + [laneById] + ); + const onNodeDragStop = React.useCallback( (_event: React.MouseEvent, node: Node) => { nodeDragActiveRef.current = false; @@ -1312,9 +1653,13 @@ function GraphInner() { } const selectedIds = selectedLaneIds.includes(node.id) && selectedLaneIds.length > 1 ? selectedLaneIds : [node.id]; + if (selectedIds.length === 1 && laneById.get(target.id)?.laneType === "primary") { + openPrDialogForLane(node.id, target.id); + return; + } openReparentDialog(node.id, target.id, selectedIds); }, - [findDropTarget, openReparentDialog, reactFlow, saveNodePositions, selectedLaneIds] + [findDropTarget, laneById, openPrDialogForLane, openReparentDialog, reactFlow, saveNodePositions, selectedLaneIds] ); const applyReparent = React.useCallback(async () => { @@ -1336,6 +1681,14 @@ function GraphInner() { return; } + if (reparentDialog.actionMode === "pr") { + const laneId = reparentDialog.laneIds[0]; + if (!laneId) return; + openPrDialogForLane(laneId, reparentDialog.targetLaneId); + setReparentDialog(null); + return; + } + const target = laneById.get(reparentDialog.targetLaneId); if (!target) return; @@ -1378,7 +1731,7 @@ function GraphInner() { }); setReparentDialog(null); await refreshLanes().catch(() => {}); - }, [laneById, refreshLanes, reparentDialog]); + }, [laneById, openPrDialogForLane, refreshLanes, reparentDialog]); const runBatchOperation = React.useCallback( async (operation: "restack" | "push" | "fetch" | "archive" | "delete") => { @@ -2088,7 +2441,24 @@ function GraphInner() { onEdgeClick={(_event, edge) => { const [prefix, laneAId, laneBId] = edge.id.split(":"); if (!laneAId || !laneBId) return; - if (prefix === "risk" || prefix === "stack" || prefix === "topology") { + const data = edge.data; + if (prefix === "risk") { + setEdgeSimulation(null); + setReparentDialog(null); + setContextMenu(null); + openConflictPanelForEdge(laneAId, laneBId); + return; + } + + if (data?.pr) { + setEdgeSimulation(null); + setReparentDialog(null); + setContextMenu(null); + openPrDialogForLane(data.pr.laneId, data.pr.baseLaneId); + return; + } + + if (prefix === "stack" || prefix === "topology") { setReparentDialog(null); setContextMenu(null); setEdgeSimulation({ @@ -2122,11 +2492,28 @@ function GraphInner() { setHoveredEdgeId(edge.id); const data = edge.data; const [_, laneAId, laneBId] = edge.id.split(":"); + const pr = data?.pr ?? null; + const prLines = pr + ? [ + `PR #${pr.number} · ${pr.state} · checks: ${pr.checksStatus} · reviews: ${pr.reviewStatus}`, + pr.title ? pr.title : null, + pr.lastSyncedAt ? `synced ${toRelativeTime(pr.lastSyncedAt)}` : null + ].filter((line): line is string => Boolean(line && line.trim().length)) + : []; if (data?.edgeType === "risk") { + const pair = laneAId && laneBId ? edgePairKey(laneAId, laneBId) : ""; + const overlapFiles = pair ? overlapFilesByPair.get(pair) ?? [] : []; + const fileLines = overlapFiles.slice(0, 6).map((file) => `- ${file}`); + const moreLine = overlapFiles.length > 6 ? `... +${overlapFiles.length - 6} more` : null; setEdgeHover({ x: event.clientX + 12, y: event.clientY + 12, - label: `${data.riskLevel ?? "unknown"} · ${data.overlapCount ?? 0} files${data.stale ? " · stale" : ""}` + label: [ + `${data.riskLevel ?? "unknown"} · ${overlapFiles.length} file${overlapFiles.length === 1 ? "" : "s"}${data.stale ? " · stale" : ""}`, + ...fileLines, + ...(moreLine ? [moreLine] : []), + ...(prLines.length ? ["", ...prLines] : []) + ].join("\n") }); return; } @@ -2134,7 +2521,21 @@ function GraphInner() { setEdgeHover({ x: event.clientX + 12, y: event.clientY + 12, - label: `${laneById.get(laneAId)?.name ?? laneAId} → ${laneById.get(laneBId)?.name ?? laneBId}` + label: [ + `${laneById.get(laneAId)?.name ?? laneAId} → ${laneById.get(laneBId)?.name ?? laneBId}`, + ...(prLines.length ? ["", ...prLines] : []) + ].join("\n") + }); + return; + } + if (data?.edgeType === "topology" && laneAId && laneBId) { + setEdgeHover({ + x: event.clientX + 12, + y: event.clientY + 12, + label: [ + `${laneById.get(laneAId)?.name ?? laneAId} → ${laneById.get(laneBId)?.name ?? laneBId}`, + ...(prLines.length ? ["", ...prLines] : []) + ].join("\n") }); return; } @@ -2393,18 +2794,20 @@ function GraphInner() {
Confirm Lane Drop
- {reparentDialog.integratePlan ? ( + {reparentDialog.integratePlan || reparentDialog.laneIds.length === 1 ? (
- + {reparentDialog.integratePlan ? ( + + ) : null} + {reparentDialog.laneIds.length === 1 ? ( + + ) : null}
) : null}
{reparentDialog.actionMode === "integrate" ? "Integrate keeps stack ancestry unchanged and brings source lane commits into the target lane." + : reparentDialog.actionMode === "pr" + ? "PR opens the pull request workflow for the dragged lane, targeting the drop base." : "Reparent changes stack ancestry. ADE rebases selected lane commits onto the target parent branch."}
{reparentDialog.actionMode === "integrate" && reparentDialog.integratePlan ? ( @@ -2466,6 +2883,8 @@ function GraphInner() {
{reparentDialog.actionMode === "integrate" ? "If merge/rebase conflicts occur, resolve them in the target lane." + : reparentDialog.actionMode === "pr" + ? "This does not change lane ancestry. It opens a PR flow targeting the drop base." : "If conflicts occur during rebase, resolve them in the target lane context."} {reparentDialog.actionMode === "reparent" && laneById.get(reparentDialog.targetLaneId)?.laneType === "primary" ? " Target is Primary: lane will now be based directly on Primary." @@ -2483,32 +2902,384 @@ function GraphInner() { + {reparentDialog.actionMode !== "pr" ? ( + + ) : null} + +
+
+
+ ) : null} + + {prDialog ? ( +
+
+
+
+ {prDialog.existingPr ? `PR #${prDialog.existingPr.githubPrNumber}` : "Create Pull Request"} +
+ +
+
+ {laneById.get(prDialog.laneId)?.name ?? prDialog.laneId} → {laneById.get(prDialog.baseLaneId)?.name ?? prDialog.baseLaneId} (base:{" "} + {prDialog.baseBranch}) +
+ + {prDialog.error ? ( +
+ {prDialog.error} +
+ ) : null} + + {!prDialog.existingPr ? ( +
+ {prDialog.loadingDraft ? ( +
+
+ Drafting description from pack… +
+ ) : null} + +
+ setPrDialog((prev) => (prev ? { ...prev, title: e.target.value } : prev))} + /> + +
+ +