Skip to content

Commit b54828d

Browse files
authored
fix(api): refresh state before project inventory reads (#380)
* 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. * fix(app): pin controller compose project name * fix(app): dedupe react for gridland tui * fix(api): return project details in inventory list * fix(api): address project inventory review notes * fix(api): refresh state before port proxy project lookup
1 parent 17600f1 commit b54828d

17 files changed

Lines changed: 644 additions & 57 deletions

bun.lock

Lines changed: 5 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@
6161
"node-pty",
6262
"unrs-resolver"
6363
],
64+
"overrides": {
65+
"react": "19.2.7"
66+
},
6467
"repository": {
6568
"type": "git",
6669
"url": "git+https://github.com/ProverCoderAI/docker-git.git"

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import * as Stream from "effect/Stream"
1010

1111
import { ApiBadRequestError, ApiConflictError, ApiInternalError, ApiNotFoundError } from "../api/errors.js"
1212
import { listProjectPortForwards } from "./project-port-forwards.js"
13-
import { listProjects } from "./projects.js"
13+
import { readProjectItemsForInventory } 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 | ApiInternalError | ApiNotFoundError, 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* _(readProjectItemsForInventory())
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+
export 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) => dbProjectDetails(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: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
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 type * as ParseResult from "effect/ParseResult"
10+
import * as Schema from "effect/Schema"
11+
import * as Scope from "effect/Scope"
612

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

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

25+
const HealthResponseSchema = Schema.Struct({
26+
cwd: Schema.String,
27+
ok: Schema.Boolean,
28+
projectsRoot: Schema.String,
29+
revision: Schema.NullOr(Schema.String)
30+
})
31+
32+
type HealthResponse = Schema.Schema.Type<typeof HealthResponseSchema>
33+
34+
/**
35+
* Creates a scoped temporary directory and provides its path to the supplied effect.
36+
*
37+
* @param use - Effect factory that receives the temporary directory path.
38+
* @returns Effect that yields the factory result and finalizes the temporary directory scope.
39+
*
40+
* @pure false
41+
* @effect FileSystem service for scoped directory allocation and cleanup
42+
* @invariant the temporary directory lifetime is bounded by the returned Effect scope
43+
* @precondition FileSystem service is available in the environment
44+
* @postcondition the temporary directory scope is finalized after success or failure
45+
* @complexity O(1) allocation; cleanup is O(n) in created filesystem entries
46+
* @throws Never - filesystem and user errors are represented in the Effect error channel
47+
*/
48+
const withTempDir = <A, E, R>(
49+
use: (tempDir: string) => Effect.Effect<A, E, R>
50+
): Effect.Effect<A, E | PlatformError, FileSystem.FileSystem | Exclude<R, Scope.Scope>> =>
51+
Effect.scoped(
52+
Effect.gen(function*(_) {
53+
const fs = yield* _(FileSystem.FileSystem)
54+
const tempDir = yield* _(
55+
fs.makeTempDirectoryScoped({
56+
prefix: "docker-git-api-routes-"
57+
})
58+
)
59+
return yield* _(use(tempDir))
60+
})
61+
)
62+
63+
/**
64+
* Temporarily sets or unsets an environment variable for the duration of an effect.
65+
*
66+
* @param key - Environment variable name to modify.
67+
* @param value - Temporary value, or undefined to remove the variable.
68+
* @param effect - Effect evaluated while the temporary environment binding is active.
69+
* @returns Effect that yields the supplied effect result and restores the previous binding.
70+
*
71+
* @pure false
72+
* @effect process environment mutation inside acquire/release
73+
* @invariant the previous environment value is restored exactly once during finalization
74+
* @precondition key is a non-empty environment variable name accepted by the runtime
75+
* @postcondition process.env[key] equals its previous value after the scope finalizes
76+
* @complexity O(1) time and space
77+
* @throws Never - user effect errors are represented in the Effect error channel
78+
*/
79+
const withEnvVar = <A, E, R>(
80+
key: string,
81+
value: string | undefined,
82+
effect: Effect.Effect<A, E, R>
83+
): Effect.Effect<A, E, R> =>
84+
Effect.scoped(
85+
Effect.acquireRelease(
86+
Effect.sync(() => {
87+
const previous = process.env[key]
88+
if (value === undefined) {
89+
delete process.env[key]
90+
} else {
91+
process.env[key] = value
92+
}
93+
return previous
94+
}),
95+
(previous) =>
96+
Effect.sync(() => {
97+
if (previous === undefined) {
98+
delete process.env[key]
99+
} else {
100+
process.env[key] = previous
101+
}
102+
})
103+
).pipe(Effect.flatMap(() => effect))
104+
)
105+
106+
const readHealthResponse = (response: Response): Effect.Effect<HealthResponse, Error | ParseResult.ParseError> =>
107+
Effect.tryPromise({
108+
try: () => response.json(),
109+
catch: (cause) => new Error(String(cause))
110+
}).pipe(Effect.flatMap(Schema.decodeUnknown(HealthResponseSchema)))
111+
19112
describe("api console routes", () => {
20113
it.effect("does not serve the legacy built-in API console", () =>
21114
Effect.gen(function*(_) {
@@ -26,4 +119,19 @@ describe("api console routes", () => {
26119
expect(response.status).toBe(404)
27120
}
28121
}))
122+
123+
it.effect("reports the same configured projects root used by inventory reads", () =>
124+
withTempDir((root) =>
125+
Effect.gen(function*(_) {
126+
const path = yield* _(Path.Path)
127+
const projectsRoot = path.join(root, ".docker-git")
128+
const response = yield* _(
129+
withEnvVar("DOCKER_GIT_PROJECTS_ROOT", projectsRoot, requestApiRoute("/health"))
130+
)
131+
const payload = yield* _(readHealthResponse(response))
132+
133+
expect(response.status).toBe(200)
134+
expect(payload.projectsRoot).toBe(projectsRoot)
135+
})
136+
).pipe(Effect.provide(NodeContext.layer)))
29137
})

0 commit comments

Comments
 (0)