Skip to content

Commit a9ca6cc

Browse files
committed
fix(api): refresh state before project inventory reads
Serialize state repository git operations with a process-local lock. Add regression coverage for remote inventory refresh, disabled auto-pull, mixed web/shell output directories, and health projectsRoot reporting.
1 parent b4426a3 commit a9ca6cc

9 files changed

Lines changed: 413 additions & 24 deletions

File tree

packages/api/src/http.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -806,7 +806,7 @@ export const makeRouter = () => {
806806
"/health",
807807
Effect.gen(function*(_) {
808808
const cwd = yield* _(resolveWorkspaceRoot(process.cwd()).pipe(Effect.orElseSucceed(() => process.cwd())))
809-
const projectsRoot = defaultProjectsRoot(cwd)
809+
const projectsRoot = defaultProjectsRoot(process.cwd())
810810
return yield* _(jsonResponse({ ok: true, revision: controllerRevision, cwd, projectsRoot }, 200))
811811
}).pipe(Effect.catchAll(errorResponse))
812812
),

packages/api/src/services/project-port-proxy.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ import * as HttpServerRequest from "@effect/platform/HttpServerRequest"
44
import * as HttpServerError from "@effect/platform/HttpServerError"
55
import * as HttpServerResponse from "@effect/platform/HttpServerResponse"
66
import type { PlatformError } from "@effect/platform/Error"
7+
import { listProjectItems } from "@effect-template/lib"
78
import type { ListProjectsContext } from "@effect-template/lib/usecases/projects-list"
89
import { Effect } from "effect"
910
import * as Stream from "effect/Stream"
1011

1112
import { ApiBadRequestError, ApiConflictError, ApiInternalError, ApiNotFoundError } from "../api/errors.js"
1213
import { listProjectPortForwards } from "./project-port-forwards.js"
13-
import { listProjects } from "./projects.js"
1414
import {
1515
normalizeForwardedPrefix,
1616
parseLinuxDefaultGatewayIp,
@@ -134,21 +134,21 @@ const fetchUpstream = (
134134

135135
const resolveProxyProjectId = (
136136
target: ProjectPortProxyPath
137-
): Effect.Effect<string, ApiConflictError | ApiNotFoundError, ListProjectsContext> => {
137+
): Effect.Effect<string, ApiConflictError | ApiNotFoundError | PlatformError, ListProjectsContext> => {
138138
if (target._tag === "ProjectId") {
139139
return Effect.succeed(target.projectId)
140140
}
141141

142142
return Effect.gen(function*(_) {
143-
const projects = yield* _(listProjects())
144-
const matches = projects.filter((project) => projectShortKey(project.id) === target.projectKey)
143+
const projects = yield* _(listProjectItems)
144+
const matches = projects.filter((project) => projectShortKey(project.projectDir) === target.projectKey)
145145
if (matches.length === 0) {
146146
return yield* _(Effect.fail(new ApiNotFoundError({ message: `Project key not found: ${target.projectKey}` })))
147147
}
148148
if (matches.length > 1) {
149149
return yield* _(Effect.fail(new ApiConflictError({ message: `Project key is ambiguous: ${target.projectKey}` })))
150150
}
151-
return matches[0]!.id
151+
return matches[0]!.projectDir
152152
})
153153
}
154154

packages/api/src/services/projects.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { runCommandCapture } from "@effect-template/lib/shell/command-runner"
2020
import { CommandFailedError } from "@effect-template/lib/shell/errors"
2121
import { defaultProjectsRoot, resolvePathFromCwd } from "@effect-template/lib/usecases/path-helpers"
2222
import { deleteDockerGitProject } from "@effect-template/lib/usecases/projects"
23+
import { autoPullState } from "@effect-template/lib/usecases/state-repo"
2324
import type { RawOptions } from "@effect-template/lib/core/command-options"
2425
import type { CreateCommand as LibCreateCommand } from "@effect-template/lib/core/domain"
2526
import type { ProjectItem } from "@effect-template/lib/usecases/projects"
@@ -216,6 +217,43 @@ const toProjectDetails = (
216217

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

220+
const toProjectInventoryReadError = (error: unknown): ApiInternalError =>
221+
new ApiInternalError({
222+
message: `Failed to read docker-git project inventory: ${String(error)}`,
223+
cause: error
224+
})
225+
226+
/**
227+
* Refreshes controller project inventory from the shared state repository.
228+
*
229+
* @returns Effect that completes after best-effort state auto-pull.
230+
*
231+
* @pure false
232+
* @effect FileSystem, Path, CommandExecutor through autoPullState.
233+
* @invariant Respects DOCKER_GIT_STATE_AUTO_PULL=false and never fails.
234+
* @precondition Controller state root may or may not be a git repository.
235+
* @postcondition If auto-pull is enabled and succeeds, local inventory includes latest remote state.
236+
* @complexity O(1) git remote round-trip when enabled.
237+
* @throws Never - failures are logged by autoPullState.
238+
*/
239+
// CHANGE: refresh shared state before project inventory reads
240+
// WHY: shell and web both read `/projects`; stale controller state caused issue #372
241+
// QUOTE(ТЗ): "project not synchronized"
242+
// REF: issue-372
243+
// SOURCE: https://github.com/ProverCoderAI/docker-git/issues/372
244+
// FORMAT THEOREM: remote_has(p) and pull_enabled -> p in inventory_after_refresh
245+
// PURITY: SHELL
246+
// EFFECT: Effect<void, never, FileSystem | Path | CommandExecutor>
247+
// INVARIANT: refresh failure cannot masquerade as an empty project list
248+
// COMPLEXITY: O(git pull)
249+
export const refreshProjectStateForInventory = () => autoPullState
250+
251+
const readProjectItemsForInventory = () =>
252+
refreshProjectStateForInventory().pipe(
253+
Effect.zipRight(listProjectItems),
254+
Effect.mapError(toProjectInventoryReadError)
255+
)
256+
219257
const runtimeProjectDetails = (project: ProjectItem) =>
220258
Effect.gen(function*(_) {
221259
const runtimeByProject = yield* _(loadProjectRuntimeByProject([project]))
@@ -255,7 +293,7 @@ const findProjectById = (projectId: string) =>
255293
Effect.gen(function*(_) {
256294
const path = yield* _(Path.Path)
257295
const aliases = projectIdAliases(path, projectId)
258-
const projects = yield* _(listProjectItems)
296+
const projects = yield* _(readProjectItemsForInventory())
259297
const project = projects.find((item) => item.projectDir === projectId)
260298
?? projects.find((item) => aliases.has(item.projectDir) || aliases.has(path.resolve(item.projectDir)))
261299
if (project) {
@@ -268,7 +306,7 @@ export const getProjectItemById = (projectId: string) => findProjectById(project
268306

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

589627
export const listProjects = () =>
590-
listProjectItems.pipe(
591-
Effect.map((projects) => projects.map((project) => dbProjectSummary(project))),
592-
Effect.catchAll(() => Effect.succeed([] as ReadonlyArray<ProjectSummary>))
628+
readProjectItemsForInventory().pipe(
629+
Effect.map((projects) => projects.map((project) => dbProjectSummary(project)))
593630
)
594631

595632
export const applyAllProjects = (activeOnly: boolean) =>
@@ -650,6 +687,7 @@ export const createProjectFromRequest = (
650687
request: CreateProjectRequest
651688
) =>
652689
Effect.gen(function*(_) {
690+
yield* _(refreshProjectStateForInventory())
653691
const prepared = yield* _(prepareCreateProjectRequest(request).pipe(Effect.mapError(toProjectApiError)))
654692
if (request.async === true) {
655693
return yield* _(startCreateProjectJob(prepared))

packages/api/tests/api-console-routes.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import * as HttpApp from "@effect/platform/HttpApp"
22
import * as HttpRouter from "@effect/platform/HttpRouter"
3+
import * as FileSystem from "@effect/platform/FileSystem"
4+
import * as Path from "@effect/platform/Path"
5+
import type { PlatformError } from "@effect/platform/Error"
36
import { NodeContext } from "@effect/platform-node"
47
import { describe, expect, it } from "@effect/vitest"
58
import { Effect } from "effect"
9+
import * as Scope from "effect/Scope"
610

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

@@ -16,6 +20,57 @@ const requestApiRoute = (path: string) =>
1620
catch: (cause) => new Error(String(cause))
1721
})
1822

23+
const withTempDir = <A, E, R>(
24+
use: (tempDir: string) => Effect.Effect<A, E, R>
25+
): Effect.Effect<A, E | PlatformError, FileSystem.FileSystem | Exclude<R, Scope.Scope>> =>
26+
Effect.scoped(
27+
Effect.gen(function*(_) {
28+
const fs = yield* _(FileSystem.FileSystem)
29+
const tempDir = yield* _(
30+
fs.makeTempDirectoryScoped({
31+
prefix: "docker-git-api-routes-"
32+
})
33+
)
34+
return yield* _(use(tempDir))
35+
})
36+
)
37+
38+
const withEnvVar = <A, E, R>(
39+
key: string,
40+
value: string | undefined,
41+
effect: Effect.Effect<A, E, R>
42+
): Effect.Effect<A, E, R> =>
43+
Effect.scoped(
44+
Effect.acquireRelease(
45+
Effect.sync(() => {
46+
const previous = process.env[key]
47+
if (value === undefined) {
48+
delete process.env[key]
49+
} else {
50+
process.env[key] = value
51+
}
52+
return previous
53+
}),
54+
(previous) =>
55+
Effect.sync(() => {
56+
if (previous === undefined) {
57+
delete process.env[key]
58+
} else {
59+
process.env[key] = previous
60+
}
61+
})
62+
).pipe(Effect.flatMap(() => effect))
63+
)
64+
65+
const readResponseJson = (response: Response) =>
66+
Effect.tryPromise({
67+
try: () => response.json(),
68+
catch: (cause) => new Error(String(cause))
69+
})
70+
71+
const objectOrNull = (value: unknown): object | null =>
72+
typeof value === "object" && value !== null && !Array.isArray(value) ? value : null
73+
1974
describe("api console routes", () => {
2075
it.effect("does not serve the legacy built-in API console", () =>
2176
Effect.gen(function*(_) {
@@ -26,4 +81,21 @@ describe("api console routes", () => {
2681
expect(response.status).toBe(404)
2782
}
2883
}))
84+
85+
it.effect("reports the same configured projects root used by inventory reads", () =>
86+
withTempDir((root) =>
87+
Effect.gen(function*(_) {
88+
const path = yield* _(Path.Path)
89+
const projectsRoot = path.join(root, ".docker-git")
90+
const response = yield* _(
91+
withEnvVar("DOCKER_GIT_PROJECTS_ROOT", projectsRoot, requestApiRoute("/health"))
92+
)
93+
const payload = yield* _(readResponseJson(response))
94+
const objectPayload = objectOrNull(payload)
95+
96+
expect(response.status).toBe(200)
97+
expect(objectPayload).not.toBeNull()
98+
expect(Reflect.get(objectPayload ?? {}, "projectsRoot")).toBe(projectsRoot)
99+
})
100+
).pipe(Effect.provide(NodeContext.layer)))
29101
})

0 commit comments

Comments
 (0)