| author | |
| committer | |
| log | 533dbdfbdacc82edf06cbe8bf0ff12d4c04edfd4 |
| tree | 97951dc186d82217839b11cd79f5f7fb7d03eaf8 |
| parent | 98eb87f5cd1a448bd4d237242bf87d4bad0a1b31 |
Co-authored-by: codex <codex@users.noreply.github.com>6 files changed, 135 insertions(+), 21 deletions(-)
apps/server/src/codexTextGenerator.ts+7-2| ... | @@ -117,9 +117,11 @@ async function runCodexJson<T>({ | ... | @@ -117,9 +117,11 @@ async function runCodexJson<T>({ |
| 117 | "codex-schema", | 117 | "codex-schema", |
| 118 | JSON.stringify(outputSchemaJson), | 118 | JSON.stringify(outputSchemaJson), |
| 119 | ); | 119 | ); |
| 120 | const outputPath = await writeTempFile("codex-output", ""); | 120 | let outputPath: string | null = null; |
| 121 | 121 | ||
| 122 | try { | 122 | try { |
| 123 | outputPath = await writeTempFile("codex-output", ""); | ||
| 124 | |||
| 123 | await runProcess( | 125 | await runProcess( |
| 124 | "codex", | 126 | "codex", |
| 125 | [ | 127 | [ |
| ... | @@ -154,7 +156,10 @@ async function runCodexJson<T>({ | ... | @@ -154,7 +156,10 @@ async function runCodexJson<T>({ |
| 154 | 156 | ||
| 155 | return parse(parsedJson); | 157 | return parse(parsedJson); |
| 156 | } finally { | 158 | } finally { |
| 157 | await Promise.all([safeUnlink(schemaPath), safeUnlink(outputPath)]); | 159 | await Promise.all([ |
| 160 | safeUnlink(schemaPath), | ||
| 161 | ...(outputPath ? [safeUnlink(outputPath)] : []), | ||
| 162 | ]); | ||
| 158 | } | 163 | } |
| 159 | } | 164 | } |
| 160 | 165 |
apps/server/src/git.test.ts+1-1| ... | @@ -462,7 +462,7 @@ describe("git integration", () => { | ... | @@ -462,7 +462,7 @@ describe("git integration", () => { |
| 462 | await using remote = await makeTmpDir(); | 462 | await using remote = await makeTmpDir(); |
| 463 | await initRepoWithCommit(tmp.path); | 463 | await initRepoWithCommit(tmp.path); |
| 464 | await git(remote.path, "init --bare"); | 464 | await git(remote.path, "init --bare"); |
| 465 | await git(tmp.path, `remote add origin '${remote.path}'`); | 465 | await git(tmp.path, `remote add origin ${JSON.stringify(remote.path)}`); |
| 466 | await createGitBranch({ cwd: tmp.path, branch: "feature/core-push" }); | 466 | await createGitBranch({ cwd: tmp.path, branch: "feature/core-push" }); |
| 467 | await checkoutGitBranch({ cwd: tmp.path, branch: "feature/core-push" }); | 467 | await checkoutGitBranch({ cwd: tmp.path, branch: "feature/core-push" }); |
| 468 | 468 |
apps/server/src/git.ts+10-4| ... | @@ -102,12 +102,15 @@ function commandLabel(args: readonly string[]): string { | ... | @@ -102,12 +102,15 @@ function commandLabel(args: readonly string[]): string { |
| 102 | return `git ${args.join(" ")}`; | 102 | return `git ${args.join(" ")}`; |
| 103 | } | 103 | } |
| 104 | 104 | ||
| 105 | function normalizeGitSpawnError(error: unknown): Error { | 105 | function normalizeGitSpawnError(cwd: string, error: unknown): Error { |
| 106 | if (!(error instanceof Error)) { | 106 | if (!(error instanceof Error)) { |
| 107 | return new Error("Failed to run git command."); | 107 | return new Error("Failed to run git command."); |
| 108 | } | 108 | } |
| 109 | const code = (error as NodeJS.ErrnoException).code; | 109 | const code = (error as NodeJS.ErrnoException).code; |
| 110 | if (code === "ENOENT") { | 110 | if (code === "ENOENT") { |
| 111 | if (!fs.existsSync(cwd)) { | ||
| 112 | return new Error(`Working directory does not exist: ${cwd}`); | ||
| 113 | } | ||
| 111 | return new Error("Git is required but not available on PATH."); | 114 | return new Error("Git is required but not available on PATH."); |
| 112 | } | 115 | } |
| 113 | return new Error(`Failed to run git command: ${error.message}`); | 116 | return new Error(`Failed to run git command: ${error.message}`); |
| ... | @@ -135,10 +138,13 @@ async function runGitOrThrow( | ... | @@ -135,10 +138,13 @@ async function runGitOrThrow( |
| 135 | try { | 138 | try { |
| 136 | result = await runGit(args, cwd, options.timeoutMs); | 139 | result = await runGit(args, cwd, options.timeoutMs); |
| 137 | } catch (error) { | 140 | } catch (error) { |
| 138 | throw normalizeGitSpawnError(error); | 141 | throw normalizeGitSpawnError(cwd, error); |
| 139 | } | 142 | } |
| 140 | 143 | ||
| 141 | if (!options.allowNonZeroExit && (result.code !== 0 || result.timedOut)) { | 144 | if (result.timedOut) { |
| 145 | throw normalizeGitExecutionError(args, result); | ||
| 146 | } | ||
| 147 | if (!options.allowNonZeroExit && result.code !== 0) { | ||
| 142 | throw normalizeGitExecutionError(args, result); | 148 | throw normalizeGitExecutionError(args, result); |
| 143 | } | 149 | } |
| 144 | return result; | 150 | return result; |
| ... | @@ -242,7 +248,7 @@ export class GitCoreService { | ... | @@ -242,7 +248,7 @@ export class GitCoreService { |
| 242 | throw new Error("Cannot push from detached HEAD."); | 248 | throw new Error("Cannot push from detached HEAD."); |
| 243 | } | 249 | } |
| 244 | 250 | ||
| 245 | if (details.hasUpstream && details.aheadCount === 0) { | 251 | if (details.hasUpstream && details.aheadCount === 0 && details.behindCount === 0) { |
| 246 | return { | 252 | return { |
| 247 | status: "skipped_up_to_date", | 253 | status: "skipped_up_to_date", |
| 248 | branch, | 254 | branch, |
apps/server/src/gitManager.ts+22-6| ... | @@ -21,6 +21,8 @@ import { CodexTextGenerator } from "./codexTextGenerator"; | ... | @@ -21,6 +21,8 @@ import { CodexTextGenerator } from "./codexTextGenerator"; |
| 21 | import { GitCoreService } from "./git"; | 21 | import { GitCoreService } from "./git"; |
| 22 | import { type ProcessRunOptions, runProcess } from "./processRunner"; | 22 | import { type ProcessRunOptions, runProcess } from "./processRunner"; |
| 23 | 23 | ||
| 24 | const GH_CLI_TIMEOUT_MS = 30_000; | ||
| 25 | |||
| 24 | interface GitManagerDeps { | 26 | interface GitManagerDeps { |
| 25 | runProcess?: ( | 27 | runProcess?: ( |
| 26 | command: string, | 28 | command: string, |
| ... | @@ -107,10 +109,21 @@ function sanitizeCommitMessage( | ... | @@ -107,10 +109,21 @@ function sanitizeCommitMessage( |
| 107 | 109 | ||
| 108 | function extractBranchFromRef(ref: string): string { | 110 | function extractBranchFromRef(ref: string): string { |
| 109 | const normalized = ref.trim(); | 111 | const normalized = ref.trim(); |
| 110 | const afterSlash = normalized.includes("/") | 112 | |
| 111 | ? normalized.slice(normalized.lastIndexOf("/") + 1) | 113 | if (normalized.startsWith("refs/remotes/")) { |
| 112 | : normalized; | 114 | const withoutPrefix = normalized.slice("refs/remotes/".length); |
| 113 | return afterSlash.trim(); | 115 | const firstSlash = withoutPrefix.indexOf("/"); |
| 116 | if (firstSlash === -1) { | ||
| 117 | return withoutPrefix.trim(); | ||
| 118 | } | ||
| 119 | return withoutPrefix.slice(firstSlash + 1).trim(); | ||
| 120 | } | ||
| 121 | |||
| 122 | const firstSlash = normalized.indexOf("/"); | ||
| 123 | if (firstSlash === -1) { | ||
| 124 | return normalized; | ||
| 125 | } | ||
| 126 | return normalized.slice(firstSlash + 1).trim(); | ||
| 114 | } | 127 | } |
| 115 | 128 | ||
| 116 | function asCommandNotFound( | 129 | function asCommandNotFound( |
| ... | @@ -401,7 +414,7 @@ export class GitManager { | ... | @@ -401,7 +414,7 @@ export class GitManager { |
| 401 | 414 | ||
| 402 | private async runGh(cwd: string, args: readonly string[]): Promise<void> { | 415 | private async runGh(cwd: string, args: readonly string[]): Promise<void> { |
| 403 | try { | 416 | try { |
| 404 | await this.run("gh", args, { cwd }); | 417 | await this.run("gh", args, { cwd, timeoutMs: GH_CLI_TIMEOUT_MS }); |
| 405 | } catch (error) { | 418 | } catch (error) { |
| 406 | throw ( | 419 | throw ( |
| 407 | asCommandNotFound("gh", error) ?? | 420 | asCommandNotFound("gh", error) ?? |
| ... | @@ -413,7 +426,10 @@ export class GitManager { | ... | @@ -413,7 +426,10 @@ export class GitManager { |
| 413 | 426 | ||
| 414 | private async runGhStdout(cwd: string, args: readonly string[]): Promise<string> { | 427 | private async runGhStdout(cwd: string, args: readonly string[]): Promise<string> { |
| 415 | try { | 428 | try { |
| 416 | const result = await this.run("gh", args, { cwd }); | 429 | const result = await this.run("gh", args, { |
| 430 | cwd, | ||
| 431 | timeoutMs: GH_CLI_TIMEOUT_MS, | ||
| 432 | }); | ||
| 417 | return result.stdout; | 433 | return result.stdout; |
| 418 | } catch (error) { | 434 | } catch (error) { |
| 419 | throw ( | 435 | throw ( |
apps/server/src/processRunner.ts+79-4| ... | @@ -6,6 +6,7 @@ export interface ProcessRunOptions { | ... | @@ -6,6 +6,7 @@ export interface ProcessRunOptions { |
| 6 | env?: NodeJS.ProcessEnv | undefined; | 6 | env?: NodeJS.ProcessEnv | undefined; |
| 7 | stdin?: string | undefined; | 7 | stdin?: string | undefined; |
| 8 | allowNonZeroExit?: boolean | undefined; | 8 | allowNonZeroExit?: boolean | undefined; |
| 9 | maxBufferBytes?: number | undefined; | ||
| 9 | } | 10 | } |
| 10 | 11 | ||
| 11 | export interface ProcessRunResult { | 12 | export interface ProcessRunResult { |
| ... | @@ -52,12 +53,39 @@ function normalizeExitError( | ... | @@ -52,12 +53,39 @@ function normalizeExitError( |
| 52 | return new Error(`${commandLabel(command, args)} ${reason}.${detail}`); | 53 | return new Error(`${commandLabel(command, args)} ${reason}.${detail}`); |
| 53 | } | 54 | } |
| 54 | 55 | ||
| 56 | function normalizeStdinError( | ||
| 57 | command: string, | ||
| 58 | args: readonly string[], | ||
| 59 | error: unknown, | ||
| 60 | ): Error { | ||
| 61 | if (!(error instanceof Error)) { | ||
| 62 | return new Error(`Failed to write stdin for ${commandLabel(command, args)}.`); | ||
| 63 | } | ||
| 64 | return new Error( | ||
| 65 | `Failed to write stdin for ${commandLabel(command, args)}: ${error.message}`, | ||
| 66 | ); | ||
| 67 | } | ||
| 68 | |||
| 69 | function normalizeBufferError( | ||
| 70 | command: string, | ||
| 71 | args: readonly string[], | ||
| 72 | stream: "stdout" | "stderr", | ||
| 73 | maxBufferBytes: number, | ||
| 74 | ): Error { | ||
| 75 | return new Error( | ||
| 76 | `${commandLabel(command, args)} exceeded ${stream} buffer limit (${maxBufferBytes} bytes).`, | ||
| 77 | ); | ||
| 78 | } | ||
| 79 | |||
| 80 | const DEFAULT_MAX_BUFFER_BYTES = 8 * 1024 * 1024; | ||
| 81 | |||
| 55 | export async function runProcess( | 82 | export async function runProcess( |
| 56 | command: string, | 83 | command: string, |
| 57 | args: readonly string[], | 84 | args: readonly string[], |
| 58 | options: ProcessRunOptions = {}, | 85 | options: ProcessRunOptions = {}, |
| 59 | ): Promise<ProcessRunResult> { | 86 | ): Promise<ProcessRunResult> { |
| 60 | const timeoutMs = options.timeoutMs ?? 60_000; | 87 | const timeoutMs = options.timeoutMs ?? 60_000; |
| 88 | const maxBufferBytes = options.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES; | ||
| 61 | 89 | ||
| 62 | return new Promise<ProcessRunResult>((resolve, reject) => { | 90 | return new Promise<ProcessRunResult>((resolve, reject) => { |
| 63 | const child = spawn(command, args, { | 91 | const child = spawn(command, args, { |
| ... | @@ -68,6 +96,8 @@ export async function runProcess( | ... | @@ -68,6 +96,8 @@ export async function runProcess( |
| 68 | 96 | ||
| 69 | let stdout = ""; | 97 | let stdout = ""; |
| 70 | let stderr = ""; | 98 | let stderr = ""; |
| 99 | let stdoutBytes = 0; | ||
| 100 | let stderrBytes = 0; | ||
| 71 | let timedOut = false; | 101 | let timedOut = false; |
| 72 | let settled = false; | 102 | let settled = false; |
| 73 | let forceKillTimer: ReturnType<typeof setTimeout> | null = null; | 103 | let forceKillTimer: ReturnType<typeof setTimeout> | null = null; |
| ... | @@ -90,12 +120,47 @@ export async function runProcess( | ... | @@ -90,12 +120,47 @@ export async function runProcess( |
| 90 | callback(); | 120 | callback(); |
| 91 | }; | 121 | }; |
| 92 | 122 | ||
| 123 | const fail = (error: Error): void => { | ||
| 124 | child.kill("SIGTERM"); | ||
| 125 | finalize(() => { | ||
| 126 | reject(error); | ||
| 127 | }); | ||
| 128 | }; | ||
| 129 | |||
| 130 | const appendOutput = ( | ||
| 131 | stream: "stdout" | "stderr", | ||
| 132 | chunk: Buffer | string, | ||
| 133 | ): Error | null => { | ||
| 134 | const text = chunk.toString(); | ||
| 135 | const byteLength = Buffer.byteLength(text); | ||
| 136 | if (stream === "stdout") { | ||
| 137 | stdout += text; | ||
| 138 | stdoutBytes += byteLength; | ||
| 139 | if (stdoutBytes > maxBufferBytes) { | ||
| 140 | return normalizeBufferError(command, args, "stdout", maxBufferBytes); | ||
| 141 | } | ||
| 142 | } else { | ||
| 143 | stderr += text; | ||
| 144 | stderrBytes += byteLength; | ||
| 145 | if (stderrBytes > maxBufferBytes) { | ||
| 146 | return normalizeBufferError(command, args, "stderr", maxBufferBytes); | ||
| 147 | } | ||
| 148 | } | ||
| 149 | return null; | ||
| 150 | }; | ||
| 151 | |||
| 93 | child.stdout.on("data", (chunk: Buffer | string) => { | 152 | child.stdout.on("data", (chunk: Buffer | string) => { |
| 94 | stdout += chunk.toString(); | 153 | const error = appendOutput("stdout", chunk); |
| 154 | if (error) { | ||
| 155 | fail(error); | ||
| 156 | } | ||
| 95 | }); | 157 | }); |
| 96 | 158 | ||
| 97 | child.stderr.on("data", (chunk: Buffer | string) => { | 159 | child.stderr.on("data", (chunk: Buffer | string) => { |
| 98 | stderr += chunk.toString(); | 160 | const error = appendOutput("stderr", chunk); |
| 161 | if (error) { | ||
| 162 | fail(error); | ||
| 163 | } | ||
| 99 | }); | 164 | }); |
| 100 | 165 | ||
| 101 | child.once("error", (error) => { | 166 | child.once("error", (error) => { |
| ... | @@ -125,10 +190,20 @@ export async function runProcess( | ... | @@ -125,10 +190,20 @@ export async function runProcess( |
| 125 | }); | 190 | }); |
| 126 | }); | 191 | }); |
| 127 | 192 | ||
| 193 | child.stdin.once("error", (error) => { | ||
| 194 | fail(normalizeStdinError(command, args, error)); | ||
| 195 | }); | ||
| 196 | |||
| 128 | if (options.stdin !== undefined) { | 197 | if (options.stdin !== undefined) { |
| 129 | child.stdin.write(options.stdin); | 198 | child.stdin.write(options.stdin, (error) => { |
| 199 | if (error) { | ||
| 200 | fail(normalizeStdinError(command, args, error)); | ||
| 201 | return; | ||
| 202 | } | ||
| 203 | child.stdin.end(); | ||
| 204 | }); | ||
| 205 | return; | ||
| 130 | } | 206 | } |
| 131 | child.stdin.end(); | 207 | child.stdin.end(); |
| 132 | }); | 208 | }); |
| 133 | } | 209 | } |
| 134 |
apps/web/src/components/ChatView.tsx+16-4| ... | @@ -196,6 +196,7 @@ export default function ChatView() { | ... | @@ -196,6 +196,7 @@ export default function ChatView() { |
| 196 | const modelMenuRef = useRef<HTMLDivElement>(null); | 196 | const modelMenuRef = useRef<HTMLDivElement>(null); |
| 197 | const editorMenuRef = useRef<HTMLDivElement>(null); | 197 | const editorMenuRef = useRef<HTMLDivElement>(null); |
| 198 | const gitMenuRef = useRef<HTMLDivElement>(null); | 198 | const gitMenuRef = useRef<HTMLDivElement>(null); |
| 199 | const latestGitCwdRef = useRef<string | null>(null); | ||
| 199 | 200 | ||
| 200 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); | 201 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); |
| 201 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); | 202 | const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); |
| ... | @@ -302,19 +303,27 @@ export default function ChatView() { | ... | @@ -302,19 +303,27 @@ export default function ChatView() { |
| 302 | !api || !gitCwd || !gitStatus || isGitActionRunning; | 303 | !api || !gitCwd || !gitStatus || isGitActionRunning; |
| 303 | const pushActionDisabled = gitActionsDisabled || gitStatus.branch === null; | 304 | const pushActionDisabled = gitActionsDisabled || gitStatus.branch === null; |
| 304 | 305 | ||
| 306 | useEffect(() => { | ||
| 307 | latestGitCwdRef.current = gitCwd; | ||
| 308 | }, [gitCwd]); | ||
| 309 | |||
| 305 | const refreshGitStatus = useCallback(async () => { | 310 | const refreshGitStatus = useCallback(async () => { |
| 306 | if (!api || !gitCwd) { | 311 | const requestCwd = gitCwd; |
| 312 | if (!api || !requestCwd) { | ||
| 307 | setGitStatus(null); | 313 | setGitStatus(null); |
| 308 | return; | 314 | return; |
| 309 | } | 315 | } |
| 310 | 316 | ||
| 311 | const nextStatus = await api.git.status({ cwd: gitCwd }); | 317 | const nextStatus = await api.git.status({ cwd: requestCwd }); |
| 318 | if (latestGitCwdRef.current !== requestCwd) return; | ||
| 312 | setGitStatus(nextStatus); | 319 | setGitStatus(nextStatus); |
| 320 | setGitActionError(null); | ||
| 313 | }, [api, gitCwd]); | 321 | }, [api, gitCwd]); |
| 314 | 322 | ||
| 315 | const runGitAction = useCallback( | 323 | const runGitAction = useCallback( |
| 316 | async (action: GitStackedAction) => { | 324 | async (action: GitStackedAction) => { |
| 317 | if (!api || !gitCwd) return; | 325 | if (!api || !gitCwd) return; |
| 326 | const actionCwd = gitCwd; | ||
| 318 | 327 | ||
| 319 | setIsGitMenuOpen(false); | 328 | setIsGitMenuOpen(false); |
| 320 | setIsGitActionRunning(true); | 329 | setIsGitActionRunning(true); |
| ... | @@ -322,7 +331,7 @@ export default function ChatView() { | ... | @@ -322,7 +331,7 @@ export default function ChatView() { |
| 322 | setGitActionNotice(null); | 331 | setGitActionNotice(null); |
| 323 | try { | 332 | try { |
| 324 | const result = await api.git.runStackedAction({ | 333 | const result = await api.git.runStackedAction({ |
| 325 | cwd: gitCwd, | 334 | cwd: actionCwd, |
| 326 | action, | 335 | action, |
| 327 | }); | 336 | }); |
| 328 | setGitActionNotice(summarizeGitActionResult(result)); | 337 | setGitActionNotice(summarizeGitActionResult(result)); |
| ... | @@ -333,7 +342,9 @@ export default function ChatView() { | ... | @@ -333,7 +342,9 @@ export default function ChatView() { |
| 333 | } finally { | 342 | } finally { |
| 334 | setIsGitActionRunning(false); | 343 | setIsGitActionRunning(false); |
| 335 | try { | 344 | try { |
| 336 | await refreshGitStatus(); | 345 | if (latestGitCwdRef.current === actionCwd) { |
| 346 | await refreshGitStatus(); | ||
| 347 | } | ||
| 337 | } catch { | 348 | } catch { |
| 338 | setGitStatus(null); | 349 | setGitStatus(null); |
| 339 | } | 350 | } |
| ... | @@ -404,6 +415,7 @@ export default function ChatView() { | ... | @@ -404,6 +415,7 @@ export default function ChatView() { |
| 404 | const nextStatus = await api.git.status({ cwd: gitCwd }); | 415 | const nextStatus = await api.git.status({ cwd: gitCwd }); |
| 405 | if (!cancelled) { | 416 | if (!cancelled) { |
| 406 | setGitStatus(nextStatus); | 417 | setGitStatus(nextStatus); |
| 418 | setGitActionError(null); | ||
| 407 | } | 419 | } |
| 408 | } catch (error) { | 420 | } catch (error) { |
| 409 | if (!cancelled) { | 421 | if (!cancelled) { |