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
15 changes: 5 additions & 10 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@
"node-pty",
"unrs-resolver"
],
"overrides": {
"react": "19.2.7"
},
"repository": {
"type": "git",
"url": "git+https://github.com/ProverCoderAI/docker-git.git"
Expand Down
10 changes: 5 additions & 5 deletions packages/api/src/services/project-port-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import * as Stream from "effect/Stream"

import { ApiBadRequestError, ApiConflictError, ApiInternalError, ApiNotFoundError } from "../api/errors.js"
import { listProjectPortForwards } from "./project-port-forwards.js"
import { listProjects } from "./projects.js"
import { readProjectItemsForInventory } from "./projects.js"
import {
normalizeForwardedPrefix,
parseLinuxDefaultGatewayIp,
Expand Down Expand Up @@ -134,21 +134,21 @@ const fetchUpstream = (

const resolveProxyProjectId = (
target: ProjectPortProxyPath
): Effect.Effect<string, ApiConflictError | ApiNotFoundError, ListProjectsContext> => {
): Effect.Effect<string, ApiConflictError | ApiInternalError | ApiNotFoundError, ListProjectsContext> => {
if (target._tag === "ProjectId") {
return Effect.succeed(target.projectId)
}

return Effect.gen(function*(_) {
const projects = yield* _(listProjects())
const matches = projects.filter((project) => projectShortKey(project.id) === target.projectKey)
const projects = yield* _(readProjectItemsForInventory())
const matches = projects.filter((project) => projectShortKey(project.projectDir) === target.projectKey)
if (matches.length === 0) {
return yield* _(Effect.fail(new ApiNotFoundError({ message: `Project key not found: ${target.projectKey}` })))
}
if (matches.length > 1) {
return yield* _(Effect.fail(new ApiConflictError({ message: `Project key is ambiguous: ${target.projectKey}` })))
}
return matches[0]!.id
return matches[0]!.projectDir
})
}

Expand Down
48 changes: 43 additions & 5 deletions packages/api/src/services/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { runCommandCapture } from "@effect-template/lib/shell/command-runner"
import { CommandFailedError } from "@effect-template/lib/shell/errors"
import { defaultProjectsRoot, resolvePathFromCwd } from "@effect-template/lib/usecases/path-helpers"
import { deleteDockerGitProject } from "@effect-template/lib/usecases/projects"
import { autoPullState } from "@effect-template/lib/usecases/state-repo"
import type { RawOptions } from "@effect-template/lib/core/command-options"
import type { CreateCommand as LibCreateCommand } from "@effect-template/lib/core/domain"
import type { ProjectItem } from "@effect-template/lib/usecases/projects"
Expand Down Expand Up @@ -216,6 +217,43 @@ const toProjectDetails = (

const dbProjectDetails = (project: ProjectItem): ProjectDetails => toProjectDetails(project, dbProjectSummary(project))

const toProjectInventoryReadError = (error: unknown): ApiInternalError =>
new ApiInternalError({
message: `Failed to read docker-git project inventory: ${String(error)}`,
cause: error
})

/**
* Refreshes controller project inventory from the shared state repository.
*
* @returns Effect that completes after best-effort state auto-pull.
*
* @pure false
* @effect FileSystem, Path, CommandExecutor through autoPullState.
* @invariant Respects DOCKER_GIT_STATE_AUTO_PULL=false and never fails.
* @precondition Controller state root may or may not be a git repository.
* @postcondition If auto-pull is enabled and succeeds, local inventory includes latest remote state.
* @complexity O(1) git remote round-trip when enabled.
* @throws Never - failures are logged by autoPullState.
*/
// CHANGE: refresh shared state before project inventory reads
// WHY: shell and web both read `/projects`; stale controller state caused issue #372
// QUOTE(ТЗ): "project not synchronized"
// REF: issue-372
// SOURCE: https://github.com/ProverCoderAI/docker-git/issues/372
// FORMAT THEOREM: remote_has(p) and pull_enabled -> p in inventory_after_refresh
// PURITY: SHELL
// EFFECT: Effect<void, never, FileSystem | Path | CommandExecutor>
// INVARIANT: refresh failure cannot masquerade as an empty project list
// COMPLEXITY: O(git pull)
export const refreshProjectStateForInventory = () => autoPullState

export const readProjectItemsForInventory = () =>
refreshProjectStateForInventory().pipe(
Effect.zipRight(listProjectItems),
Effect.mapError(toProjectInventoryReadError)
)

const runtimeProjectDetails = (project: ProjectItem) =>
Effect.gen(function*(_) {
const runtimeByProject = yield* _(loadProjectRuntimeByProject([project]))
Expand Down Expand Up @@ -255,7 +293,7 @@ const findProjectById = (projectId: string) =>
Effect.gen(function*(_) {
const path = yield* _(Path.Path)
const aliases = projectIdAliases(path, projectId)
const projects = yield* _(listProjectItems)
const projects = yield* _(readProjectItemsForInventory())
const project = projects.find((item) => item.projectDir === projectId)
?? projects.find((item) => aliases.has(item.projectDir) || aliases.has(path.resolve(item.projectDir)))
if (project) {
Expand All @@ -268,7 +306,7 @@ export const getProjectItemById = (projectId: string) => findProjectById(project

const findProjectByKey = (projectKey: string) =>
Effect.gen(function*(_) {
const projects = yield* _(listProjectItems)
const projects = yield* _(readProjectItemsForInventory())
const matches = projects.filter((item) => projectShortKey(item.projectDir) === projectKey)
if (matches.length === 0) {
return yield* _(Effect.fail(new ApiNotFoundError({ message: `Project key not found: ${projectKey}` })))
Expand Down Expand Up @@ -587,9 +625,8 @@ const startCreateProjectJob = (
})

export const listProjects = () =>
listProjectItems.pipe(
Effect.map((projects) => projects.map((project) => dbProjectSummary(project))),
Effect.catchAll(() => Effect.succeed([] as ReadonlyArray<ProjectSummary>))
readProjectItemsForInventory().pipe(
Effect.map((projects) => projects.map((project) => dbProjectDetails(project)))
)

export const applyAllProjects = (activeOnly: boolean) =>
Expand Down Expand Up @@ -650,6 +687,7 @@ export const createProjectFromRequest = (
request: CreateProjectRequest
) =>
Effect.gen(function*(_) {
yield* _(refreshProjectStateForInventory())
const prepared = yield* _(prepareCreateProjectRequest(request).pipe(Effect.mapError(toProjectApiError)))
if (request.async === true) {
return yield* _(startCreateProjectJob(prepared))
Expand Down
108 changes: 108 additions & 0 deletions packages/api/tests/api-console-routes.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import * as HttpApp from "@effect/platform/HttpApp"
import * as HttpRouter from "@effect/platform/HttpRouter"
import * as FileSystem from "@effect/platform/FileSystem"
import * as Path from "@effect/platform/Path"
import type { PlatformError } from "@effect/platform/Error"
import { NodeContext } from "@effect/platform-node"
import { describe, expect, it } from "@effect/vitest"
import { Effect } from "effect"
import type * as ParseResult from "effect/ParseResult"
import * as Schema from "effect/Schema"
import * as Scope from "effect/Scope"

import { makeRouter } from "../src/http.js"

Expand All @@ -16,6 +22,93 @@ const requestApiRoute = (path: string) =>
catch: (cause) => new Error(String(cause))
})

const HealthResponseSchema = Schema.Struct({
cwd: Schema.String,
ok: Schema.Boolean,
projectsRoot: Schema.String,
revision: Schema.NullOr(Schema.String)
})

type HealthResponse = Schema.Schema.Type<typeof HealthResponseSchema>

/**
* Creates a scoped temporary directory and provides its path to the supplied effect.
*
* @param use - Effect factory that receives the temporary directory path.
* @returns Effect that yields the factory result and finalizes the temporary directory scope.
*
* @pure false
* @effect FileSystem service for scoped directory allocation and cleanup
* @invariant the temporary directory lifetime is bounded by the returned Effect scope
* @precondition FileSystem service is available in the environment
* @postcondition the temporary directory scope is finalized after success or failure
* @complexity O(1) allocation; cleanup is O(n) in created filesystem entries
* @throws Never - filesystem and user errors are represented in the Effect error channel
*/
const withTempDir = <A, E, R>(
use: (tempDir: string) => Effect.Effect<A, E, R>
): Effect.Effect<A, E | PlatformError, FileSystem.FileSystem | Exclude<R, Scope.Scope>> =>
Effect.scoped(
Effect.gen(function*(_) {
const fs = yield* _(FileSystem.FileSystem)
const tempDir = yield* _(
fs.makeTempDirectoryScoped({
prefix: "docker-git-api-routes-"
})
)
return yield* _(use(tempDir))
})
)

/**
* Temporarily sets or unsets an environment variable for the duration of an effect.
*
* @param key - Environment variable name to modify.
* @param value - Temporary value, or undefined to remove the variable.
* @param effect - Effect evaluated while the temporary environment binding is active.
* @returns Effect that yields the supplied effect result and restores the previous binding.
*
* @pure false
* @effect process environment mutation inside acquire/release
* @invariant the previous environment value is restored exactly once during finalization
* @precondition key is a non-empty environment variable name accepted by the runtime
* @postcondition process.env[key] equals its previous value after the scope finalizes
* @complexity O(1) time and space
* @throws Never - user effect errors are represented in the Effect error channel
*/
const withEnvVar = <A, E, R>(
key: string,
value: string | undefined,
effect: Effect.Effect<A, E, R>
): Effect.Effect<A, E, R> =>
Effect.scoped(
Effect.acquireRelease(
Effect.sync(() => {
const previous = process.env[key]
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
return previous
}),
(previous) =>
Effect.sync(() => {
if (previous === undefined) {
delete process.env[key]
} else {
process.env[key] = previous
}
})
).pipe(Effect.flatMap(() => effect))
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const readHealthResponse = (response: Response): Effect.Effect<HealthResponse, Error | ParseResult.ParseError> =>
Effect.tryPromise({
try: () => response.json(),
catch: (cause) => new Error(String(cause))
}).pipe(Effect.flatMap(Schema.decodeUnknown(HealthResponseSchema)))

describe("api console routes", () => {
it.effect("does not serve the legacy built-in API console", () =>
Effect.gen(function*(_) {
Expand All @@ -26,4 +119,19 @@ describe("api console routes", () => {
expect(response.status).toBe(404)
}
}))

it.effect("reports the same configured projects root used by inventory reads", () =>
withTempDir((root) =>
Effect.gen(function*(_) {
const path = yield* _(Path.Path)
const projectsRoot = path.join(root, ".docker-git")
const response = yield* _(
withEnvVar("DOCKER_GIT_PROJECTS_ROOT", projectsRoot, requestApiRoute("/health"))
)
const payload = yield* _(readHealthResponse(response))

expect(response.status).toBe(200)
expect(payload.projectsRoot).toBe(projectsRoot)
})
).pipe(Effect.provide(NodeContext.layer)))
})
Loading
Loading