Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions packages/api/src/services/auth-github-login-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { resolvePathFromCwd } from "@effect-template/lib/usecases/path-helpers"
import { autoSyncState } from "@effect-template/lib/usecases/state-repo"
import { ensureStateDotDockerGitRepo } from "@effect-template/lib/usecases/state-repo-github"
import { migrateLegacyOrchLayout } from "@effect-template/lib/usecases/auth-sync"
import { Effect, Runtime } from "effect"
import { Effect, Logger, Runtime } from "effect"
import * as Stream from "effect/Stream"
import { spawn, type ChildProcess } from "node:child_process"

Expand Down Expand Up @@ -188,6 +188,20 @@ const finalizeMessage = (status: string): string =>
? `\nGitHub login completed.\n${githubLoginStreamSuccessMarker}\n`
: `\n${githubLoginStreamErrorMarkerPrefix}${status}\n`

const normalizeCapturedLogLines = (lines: ReadonlyArray<string>): ReadonlyArray<string> =>
lines
.map((line) => line.trim())
.filter((line) => line.length > 0)

export const renderGithubPostLoginOutput = (
lines: ReadonlyArray<string>,
status: string
): string => {
const output = normalizeCapturedLogLines(lines).join("\n")
const logBlock = output.length === 0 ? "" : `\n${output}\n`
return `${logBlock}${finalizeMessage(status)}`
}

const toStreamError = (error: unknown): ApiInternalError | ApiBadRequestError =>
error instanceof ApiBadRequestError || error instanceof ApiInternalError
? error
Expand Down Expand Up @@ -252,17 +266,25 @@ export const streamGithubAuthLogin = (
return
}

const postLoginLogs: Array<string> = []
const logger = Logger.make(({ message }) => {
postLoginLogs.push(String(message))
})

void runPromiseExit(
finalizeGithubLogin(prepared).pipe(
Effect.provide(Logger.replace(Logger.defaultLogger, logger)),
Effect.matchEffect({
onFailure: (error) =>
Effect.sync(() => {
enqueue(`\nGitHub login finished in browser, but post-login sync failed: ${error.message}\n`)
enqueue(finalizeMessage("post-login"))
enqueue(renderGithubPostLoginOutput([
...postLoginLogs,
`GitHub login finished in browser, but post-login sync failed: ${error.message}`
], "post-login"))
}),
onSuccess: () =>
Effect.sync(() => {
enqueue(finalizeMessage("ok"))
enqueue(renderGithubPostLoginOutput(postLoginLogs, "ok"))
})
})
)
Expand Down
28 changes: 28 additions & 0 deletions packages/api/tests/auth-github-login-stream.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest"

import { renderGithubPostLoginOutput } from "../src/services/auth-github-login-stream.js"

describe("GitHub auth login stream", () => {
it("renders post-login state logs before the success marker", () => {
const output = renderGithubPostLoginOutput([
"Initializing state repository: https://github.com/octocat/.docker-git.git",
"State dir ready: /home/dev/.docker-git"
], "ok")

expect(output).toContain("Initializing state repository")
expect(output).toContain("State dir ready")
expect(output).toContain("GitHub login completed.")
expect(output).toContain("__DOCKER_GIT_GITHUB_LOGIN_STATUS__:ok")
expect(output.indexOf("State dir ready")).toBeLessThan(output.indexOf("GitHub login completed."))
})

it("renders post-login failure details before the failure marker", () => {
const output = renderGithubPostLoginOutput([
"GitHub login finished in browser, but post-login sync failed: git fetch failed"
], "post-login")

expect(output).toContain("post-login sync failed")
expect(output).toContain("__DOCKER_GIT_GITHUB_LOGIN_STATUS__:error:post-login")
expect(output.indexOf("post-login sync failed")).toBeLessThan(output.indexOf("__DOCKER_GIT_GITHUB_LOGIN_STATUS__"))
})
})
104 changes: 21 additions & 83 deletions packages/app/src/docker-git/api-client-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ import * as FsPlatform from "@effect/platform/FileSystem"
import * as PathPlatform from "@effect/platform/Path"
import { Effect } from "effect"

import {
authStreamMarkerExitCode,
type AuthStreamMarkers,
authStreamSucceeded,
authStreamVisibleLines,
codexLoginStreamMarkers,
githubLoginFailureMessage,
githubLoginStreamMarkers,
makeVisibleAuthStreamWriter
} from "../shared/auth-stream-markers.js"
import { request, requestTextStream, requestVoid } from "./api-http.js"
import { asObject, type JsonRequest, type JsonValue } from "./api-json.js"
import type { ControllerRuntime } from "./controller.js"
Expand All @@ -17,75 +27,18 @@ import type {
import { resolvePathFromCwd } from "./frontend-lib/usecases/path-helpers.js"
import type { ApiAuthRequiredError, ApiRequestError } from "./host-errors.js"

type StreamMarkers = {
readonly success: string
readonly errorPrefix: string
}

const codexLoginMarkers: StreamMarkers = {
success: "__DOCKER_GIT_CODEX_LOGIN_STATUS__:ok",
errorPrefix: "__DOCKER_GIT_CODEX_LOGIN_STATUS__:error:"
}

const githubLoginMarkers: StreamMarkers = {
success: "__DOCKER_GIT_GITHUB_LOGIN_STATUS__:ok",
errorPrefix: "__DOCKER_GIT_GITHUB_LOGIN_STATUS__:error:"
}

const isMarkerLine = (line: string, markers: StreamMarkers): boolean =>
line.startsWith(markers.success) || line.startsWith(markers.errorPrefix)

const visibleLines = (output: string, markers: StreamMarkers): ReadonlyArray<string> =>
output
.split(/\r?\n/u)
.map((line) => line.trim())
.filter((line) => line.length > 0 && !isMarkerLine(line, markers))

const markerExitCode = (output: string, markers: StreamMarkers): string | null => {
const failureLine = output
.split(/\r?\n/u)
.find((line) => line.startsWith(markers.errorPrefix))

return failureLine === undefined
? null
: failureLine.slice(markers.errorPrefix.length)
}

const makeVisibleChunkWriter = (markers: StreamMarkers) => {
let pending = ""
const flushVisiblePending = () => {
if (pending.length > 0 && !isMarkerLine(pending, markers)) {
process.stdout.write(pending)
}
}

const writeVisibleChunk = (chunk: string) => {
pending += chunk
const lines = pending.split("\n")
pending = lines.pop() ?? ""

for (const line of lines) {
if (!isMarkerLine(line, markers)) {
process.stdout.write(`${line}\n`)
}
}
}

return { flushVisiblePending, writeVisibleChunk }
}

const codexLoginFailureMessage = (output: string, exitCode: string | null): string => {
if (output.includes("429 Too Many Requests")) {
return "Codex device auth is rate-limited by OpenAI (429 Too Many Requests). Wait a few minutes and retry."
}

const detailedLine = visibleLines(output, codexLoginMarkers)
const detailedLine = authStreamVisibleLines(output, codexLoginStreamMarkers)
.findLast((line) => line.toLowerCase().includes("error"))
if (detailedLine !== undefined) {
return detailedLine
}

const lastLine = visibleLines(output, codexLoginMarkers).at(-1)
const lastLine = authStreamVisibleLines(output, codexLoginStreamMarkers).at(-1)
if (lastLine !== undefined) {
return lastLine
}
Expand All @@ -95,23 +48,6 @@ const codexLoginFailureMessage = (output: string, exitCode: string | null): stri
: `Codex login failed (${exitCode}).`
}

const githubLoginFailureMessage = (output: string, exitCode: string | null): string => {
const detailedLine = visibleLines(output, githubLoginMarkers)
.findLast((line) => line.toLowerCase().includes("failed") || line.toLowerCase().includes("error"))
if (detailedLine !== undefined) {
return detailedLine
}

const lastLine = visibleLines(output, githubLoginMarkers).at(-1)
if (lastLine !== undefined) {
return lastLine
}

return exitCode === null
? "GitHub login stream ended without a completion marker."
: `GitHub login failed (${exitCode}).`
}

const streamFailure = (
method: "POST",
path: string,
Expand All @@ -127,21 +63,23 @@ const streamFailure = (
const requestMarkedAuthStream = (
path: string,
body: JsonRequest,
markers: StreamMarkers,
markers: AuthStreamMarkers,
failureMessage: (output: string, exitCode: string | null) => string
) =>
Effect.gen(function*(_) {
const writer = makeVisibleChunkWriter(markers)
const output = yield* _(requestTextStream("POST", path, body, writer.writeVisibleChunk))
const writer = makeVisibleAuthStreamWriter(markers, (chunk) => {
process.stdout.write(chunk)
})
const output = yield* _(requestTextStream("POST", path, body, writer.writeChunk))
writer.flushVisiblePending()

if (output.includes(markers.success)) {
if (authStreamSucceeded(output, markers)) {
return output
}

return yield* _(
Effect.fail<ApiRequestError>(
streamFailure("POST", path, failureMessage(output, markerExitCode(output, markers)))
streamFailure("POST", path, failureMessage(output, authStreamMarkerExitCode(output, markers)))
)
)
})
Expand All @@ -164,7 +102,7 @@ const githubWebLogin = (
token: null,
scopes: command.scopes
},
githubLoginMarkers,
githubLoginStreamMarkers,
githubLoginFailureMessage
).pipe(
Effect.flatMap(() => request("GET", "/auth/github/status")),
Expand Down Expand Up @@ -193,7 +131,7 @@ export const codexLogin = (command: AuthCodexLoginCommand) =>
requestMarkedAuthStream(
"/auth/codex/login",
{ label: command.label },
codexLoginMarkers,
codexLoginStreamMarkers,
codexLoginFailureMessage
).pipe(Effect.asVoid)

Expand Down
20 changes: 4 additions & 16 deletions packages/app/src/docker-git/api-http.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { FetchHttpClient, HttpBody, HttpClient, HttpClientResponse } from "@effect/platform"
import type { HttpClientResponse } from "@effect/platform"
import { FetchHttpClient, HttpBody, HttpClient } from "@effect/platform"
import type * as HttpClientError from "@effect/platform/HttpClientError"
import { Effect } from "effect"
import * as Stream from "effect/Stream"

import { readHttpResponseTextStream } from "../shared/http-response-stream.js"
import { asObject, asString, type JsonRequest, type JsonValue, parseResponseBody } from "./api-json.js"
import { type ControllerRuntime, ensureControllerReady, resolveApiBaseUrl } from "./controller.js"
import type { ApiAuthRequiredError, ApiRequestError } from "./host-errors.js"
Expand Down Expand Up @@ -202,19 +203,6 @@ export const request = (
export const requestVoid = (method: ApiHttpMethod, path: string, body?: JsonRequest) =>
request(method, path, body).pipe(Effect.asVoid)

const readResponseTextStream = (
response: HttpClientResponse.HttpClientResponse,
onChunk: (chunk: string) => void
) =>
HttpClientResponse.stream(Effect.succeed(response)).pipe(
Stream.decodeText(),
Stream.runFoldEffect("", (output, chunk) =>
Effect.sync(() => {
onChunk(chunk)
return output + chunk
}))
)

export const requestTextStream = (
method: ApiHttpMethod,
path: string,
Expand All @@ -230,5 +218,5 @@ export const requestTextStream = (
return yield* _(Effect.fail(toRequestError(method, path, response.status, parsed)))
}

return yield* _(readResponseTextStream(response, onChunk))
return yield* _(readHttpResponseTextStream(response, onChunk))
}).pipe(Effect.provide(FetchHttpClient.layer), mapTransportError(method, path))
83 changes: 83 additions & 0 deletions packages/app/src/shared/auth-stream-markers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
export type AuthStreamMarkers = {
readonly success: string
readonly errorPrefix: string
}

export const codexLoginStreamMarkers: AuthStreamMarkers = {
success: "__DOCKER_GIT_CODEX_LOGIN_STATUS__:ok",
errorPrefix: "__DOCKER_GIT_CODEX_LOGIN_STATUS__:error:"
}

export const githubLoginStreamMarkers: AuthStreamMarkers = {
success: "__DOCKER_GIT_GITHUB_LOGIN_STATUS__:ok",
errorPrefix: "__DOCKER_GIT_GITHUB_LOGIN_STATUS__:error:"
}

export const isAuthStreamMarkerLine = (line: string, markers: AuthStreamMarkers): boolean =>
line.startsWith(markers.success) || line.startsWith(markers.errorPrefix)

export const authStreamVisibleLines = (
output: string,
markers: AuthStreamMarkers
): ReadonlyArray<string> =>
output
.split(/\r?\n/u)
.map((line) => line.trim())
.filter((line) => line.length > 0 && !isAuthStreamMarkerLine(line, markers))

export const authStreamMarkerExitCode = (output: string, markers: AuthStreamMarkers): string | null => {
const failureLine = output
.split(/\r?\n/u)
.find((line) => line.startsWith(markers.errorPrefix))

return failureLine === undefined
? null
: failureLine.slice(markers.errorPrefix.length)
}

export const authStreamSucceeded = (output: string, markers: AuthStreamMarkers): boolean =>
output.includes(markers.success)

export const githubLoginFailureMessage = (output: string, exitCode: string | null): string => {
const detailedLine = authStreamVisibleLines(output, githubLoginStreamMarkers)
.findLast((line) => line.toLowerCase().includes("failed") || line.toLowerCase().includes("error"))
if (detailedLine !== undefined) {
return detailedLine
}

const lastLine = authStreamVisibleLines(output, githubLoginStreamMarkers).at(-1)
if (lastLine !== undefined) {
return lastLine
}

return exitCode === null
? "GitHub login stream ended without a completion marker."
: `GitHub login failed (${exitCode}).`
}

export const makeVisibleAuthStreamWriter = (
markers: AuthStreamMarkers,
writeVisibleChunk: (chunk: string) => void
) => {
let pending = ""
const flushVisiblePending = () => {
if (pending.length > 0 && !isAuthStreamMarkerLine(pending, markers)) {
writeVisibleChunk(pending)
}
pending = ""
}

const writeChunk = (chunk: string) => {
pending += chunk
const lines = pending.split("\n")
pending = lines.pop() ?? ""

for (const line of lines) {
if (!isAuthStreamMarkerLine(line, markers)) {
writeVisibleChunk(`${line}\n`)
}
}
}

return { flushVisiblePending, writeChunk }
}
16 changes: 16 additions & 0 deletions packages/app/src/shared/http-response-stream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { HttpClientResponse } from "@effect/platform"
import { Effect } from "effect"
import * as Stream from "effect/Stream"

export const readHttpResponseTextStream = (
response: HttpClientResponse.HttpClientResponse,
onChunk: (chunk: string) => void
) =>
HttpClientResponse.stream(Effect.succeed(response)).pipe(
Stream.decodeText(),
Stream.runFoldEffect("", (output, chunk) =>
Effect.sync(() => {
onChunk(chunk)
return output + chunk
}))
)
Loading