diff --git a/bun.lock b/bun.lock index 28f293ed..8fad0eec 100644 --- a/bun.lock +++ b/bun.lock @@ -41,7 +41,7 @@ }, "packages/app": { "name": "@prover-coder-ai/docker-git", - "version": "1.1.47", + "version": "1.1.50", "bin": { "docker-git": "dist/src/docker-git/main.js", }, @@ -110,7 +110,7 @@ }, "packages/docker-git-session-sync": { "name": "@prover-coder-ai/docker-git-session-sync", - "version": "1.0.50", + "version": "1.0.53", "bin": { "docker-git-session-sync": "dist/docker-git-session-sync.js", }, @@ -232,6 +232,9 @@ "unrs-resolver", "@parcel/watcher", ], + "overrides": { + "react": "19.2.7", + }, "packages": { "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "7.28.5", "js-tokens": "4.0.0", "picocolors": "1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], @@ -1885,12 +1888,6 @@ "@eslint/js/eslint": ["eslint@10.1.0", "", { "dependencies": { "@eslint-community/eslint-utils": "4.9.1", "@eslint-community/regexpp": "4.12.2", "@eslint/config-array": "0.23.3", "@eslint/config-helpers": "0.5.3", "@eslint/core": "1.1.1", "@eslint/plugin-kit": "0.6.1", "@humanfs/node": "0.16.7", "@humanwhocodes/module-importer": "1.0.1", "@humanwhocodes/retry": "0.4.3", "@types/estree": "1.0.8", "ajv": "6.14.0", "cross-spawn": "7.0.6", "debug": "4.4.3", "escape-string-regexp": "4.0.0", "eslint-scope": "9.1.2", "eslint-visitor-keys": "5.0.1", "espree": "11.2.0", "esquery": "1.7.0", "esutils": "2.0.3", "fast-deep-equal": "3.1.3", "file-entry-cache": "8.0.0", "find-up": "5.0.0", "glob-parent": "6.0.2", "ignore": "5.3.2", "imurmurhash": "0.1.4", "is-glob": "4.0.3", "json-stable-stringify-without-jsonify": "1.0.1", "minimatch": "10.2.4", "natural-compare": "1.4.0", "optionator": "0.9.4" }, "optionalDependencies": { "jiti": "2.6.1" }, "bin": { "eslint": "bin/eslint.js" } }, "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA=="], - "@gridland/bun/react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], - - "@gridland/utils/react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], - - "@gridland/web/react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], - "@inquirer/external-editor/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "0.2.0", "emoji-regex": "9.2.2", "strip-ansi": "7.1.2" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], @@ -2029,8 +2026,6 @@ "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - "react-reconciler/react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], - "read-pkg/type-fest": ["type-fest@0.6.0", "", {}, "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg=="], "read-pkg-up/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "5.0.0", "path-exists": "4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], diff --git a/package.json b/package.json index 36f45516..b75a268f 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/packages/api/src/services/project-port-proxy.ts b/packages/api/src/services/project-port-proxy.ts index b63b61cb..fa4527be 100644 --- a/packages/api/src/services/project-port-proxy.ts +++ b/packages/api/src/services/project-port-proxy.ts @@ -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, @@ -134,21 +134,21 @@ const fetchUpstream = ( const resolveProxyProjectId = ( target: ProjectPortProxyPath -): Effect.Effect => { +): Effect.Effect => { 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 }) } diff --git a/packages/api/src/services/projects.ts b/packages/api/src/services/projects.ts index a20c001a..58b77baf 100644 --- a/packages/api/src/services/projects.ts +++ b/packages/api/src/services/projects.ts @@ -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" @@ -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 +// 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])) @@ -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) { @@ -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}` }))) @@ -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)) + readProjectItemsForInventory().pipe( + Effect.map((projects) => projects.map((project) => dbProjectDetails(project))) ) export const applyAllProjects = (activeOnly: boolean) => @@ -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)) diff --git a/packages/api/tests/api-console-routes.test.ts b/packages/api/tests/api-console-routes.test.ts index b3bea75f..b17faea1 100644 --- a/packages/api/tests/api-console-routes.test.ts +++ b/packages/api/tests/api-console-routes.test.ts @@ -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" @@ -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 + +/** + * 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 = ( + use: (tempDir: string) => Effect.Effect +): Effect.Effect> => + 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 = ( + key: string, + value: string | undefined, + effect: Effect.Effect +): Effect.Effect => + 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)) + ) + +const readHealthResponse = (response: Response): Effect.Effect => + 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*(_) { @@ -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))) }) diff --git a/packages/api/tests/projects.test.ts b/packages/api/tests/projects.test.ts index 32345064..5483527a 100644 --- a/packages/api/tests/projects.test.ts +++ b/packages/api/tests/projects.test.ts @@ -1,3 +1,4 @@ +import type * as CommandExecutor from "@effect/platform/CommandExecutor" import * as FileSystem from "@effect/platform/FileSystem" import type { PlatformError } from "@effect/platform/Error" import * as Path from "@effect/platform/Path" @@ -6,6 +7,9 @@ import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" import * as Scope from "effect/Scope" +import { runCommandCapture } from "@effect-template/lib/shell/command-runner" +import { CommandFailedError } from "@effect-template/lib/shell/errors" + import type { ApiEvent } from "../src/api/contracts.js" import { ApiConflictError, ApiInternalError } from "../src/api/errors.js" import { resolveManagedAuthorizedKeysContents } from "../src/services/project-authorized-keys.js" @@ -113,6 +117,119 @@ const waitForEvents = ( return yield* _(waitForEvents(projectId, predicate, attempts - 1)) }) +const gitEnv: Readonly> = { + DOCKER_GIT_SKIP_POST_PUSH_ACTION: "1", + GIT_AUTHOR_EMAIL: "docker-git@test", + GIT_AUTHOR_NAME: "docker-git", + GIT_COMMITTER_EMAIL: "docker-git@test", + GIT_COMMITTER_NAME: "docker-git", + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "core.hooksPath", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_VALUE_0: ".git/hooks", + GIT_TERMINAL_PROMPT: "0" +} + +/** + * Runs a git command with the deterministic test git environment. + * + * @param cwd - Working directory for the git process. + * @param args - Git CLI arguments passed without shell interpolation. + * @returns Effect that yields captured stdout or fails with a typed command/platform error. + * + * @pure false + * @effect CommandExecutor service for process execution + * @complexity O(1) process spawn plus O(git operation) + */ +const runGit = ( + cwd: string, + args: ReadonlyArray +): Effect.Effect => + runCommandCapture( + { cwd, command: "git", args, env: gitEnv }, + [0], + (exitCode) => new CommandFailedError({ command: `git ${args[0] ?? ""}`, exitCode }) + ) + +/** + * Runs a shell script with the deterministic test git environment. + * + * @param cwd - Working directory for the shell process. + * @param script - POSIX shell script to execute. + * @returns Effect that yields captured stdout or fails with a typed command/platform error. + * + * @pure false + * @effect CommandExecutor service for process execution + * @complexity O(1) process spawn plus O(script) + */ +const runShell = ( + cwd: string, + script: string +): Effect.Effect => + runCommandCapture( + { cwd, command: "sh", args: ["-c", script], env: gitEnv }, + [0], + (exitCode) => new CommandFailedError({ command: "sh -c", exitCode }) + ) + +/** + * Creates a bare state remote and seeds an initial main branch commit. + * + * @param root - Directory that will contain the remote and seed repositories. + * @returns Effect that yields the bare remote path. + * + * @pure false + * @effect Path and CommandExecutor services for path construction and git processes + * @complexity O(1) filesystem paths plus O(git init + commit + push) + */ +const makeStateRemote = ( + root: string +): Effect.Effect => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const remotePath = path.join(root, "remote.git") + const seedPath = path.join(root, "seed") + + yield* _( + runShell( + root, + `git init --bare --initial-branch=main "${remotePath}" 2>/dev/null || git init --bare "${remotePath}"` + ) + ) + yield* _( + runShell( + root, + `git init --initial-branch=main "${seedPath}" 2>/dev/null || git init "${seedPath}"` + ) + ) + yield* _(runGit(seedPath, ["remote", "add", "origin", remotePath])) + yield* _(runShell(seedPath, "printf '# docker-git state\\n' > README.md")) + yield* _(runGit(seedPath, ["add", "-A"])) + yield* _(runGit(seedPath, ["commit", "-m", "initial state"])) + yield* _(runGit(seedPath, ["push", "origin", "HEAD:refs/heads/main"])) + + return remotePath + }) + +/** + * Clones a seeded state remote into a target directory. + * + * @param root - Working directory for the clone command. + * @param remoteUrl - Remote repository URL or path. + * @param target - Target clone directory. + * @returns Effect that yields captured git stdout or fails with a typed command/platform error. + * + * @pure false + * @effect CommandExecutor service for git process execution + * @complexity O(1) process spawn plus O(repository size) + */ +const cloneStateRemote = ( + root: string, + remoteUrl: string, + target: string +): Effect.Effect => + runGit(root, ["clone", remoteUrl, target]) + describe("projects service", () => { it.effect("seeds host SSH keys into the controller managed authorized_keys file", () => withTempDir((root) => @@ -262,7 +379,7 @@ describe("projects service", () => { }) ).pipe(Effect.provide(NodeContext.layer))) - it.effect("lists lightweight project summaries while getProject returns project details", () => + it.effect("lists project details without Docker access for TUI selection", () => withTempDir((root) => Effect.gen(function*(_) { const path = yield* _(Path.Path) @@ -303,16 +420,17 @@ describe("projects service", () => { expect(projects).toHaveLength(1) expect(projects[0]).toMatchObject({ id: projectId, + projectDir: projectId, status: "unknown", statusLabel: "unknown", sshSessions: 0, startedAtIso: null, - startedAtEpochMs: null + startedAtEpochMs: null, + sshCommand: details.sshCommand, + authorizedKeysPath: details.authorizedKeysPath, + envGlobalPath: details.envGlobalPath, + codexHome: details.codexHome }) - expect(projects[0]).not.toHaveProperty("sshCommand") - expect(projects[0]).not.toHaveProperty("authorizedKeysPath") - expect(projects[0]).not.toHaveProperty("envGlobalPath") - expect(projects[0]).not.toHaveProperty("codexHome") expect(details).toMatchObject({ id: projectId, projectDir: projectId, @@ -326,6 +444,142 @@ describe("projects service", () => { }) ).pipe(Effect.provide(NodeContext.layer))) + it.effect("refreshes the state remote before listing projects", () => + withTempDir((root) => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const remoteUrl = yield* _(makeStateRemote(root)) + const controllerRoot = path.join(root, "controller-state") + const pusherRoot = path.join(root, "pusher-state") + const remoteProjectId = path.join(pusherRoot, "remote-owner", "remote-only") + + yield* _(cloneStateRemote(root, remoteUrl, controllerRoot)) + yield* _(cloneStateRemote(root, remoteUrl, pusherRoot)) + + yield* _( + withProjectsRoot( + pusherRoot, + withWorkingDirectory( + root, + createProjectFromRequest({ + repoUrl: "https://git.example.test/remote-owner/remote-only.git", + repoRef: "main", + outDir: remoteProjectId, + skipGithubAuth: true, + up: false + }) + ) + ) + ) + + const staleProjects = yield* _( + withEnvVar( + "DOCKER_GIT_STATE_AUTO_PULL", + "false", + withProjectsRoot(controllerRoot, withWorkingDirectory(root, listProjects())) + ) + ) + expect(staleProjects).toHaveLength(0) + + const refreshedProjects = yield* _( + withProjectsRoot(controllerRoot, withWorkingDirectory(root, listProjects())) + ) + + expect(refreshedProjects).toHaveLength(1) + expect(refreshedProjects[0]).toMatchObject({ + displayName: "remote-owner/remote-only", + id: path.join(controllerRoot, "remote-owner", "remote-only"), + repoUrl: "https://git.example.test/remote-owner/remote-only.git" + }) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("respects DOCKER_GIT_STATE_AUTO_PULL=false for project inventory reads", () => + withTempDir((root) => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const remoteUrl = yield* _(makeStateRemote(root)) + const controllerRoot = path.join(root, "controller-state") + const pusherRoot = path.join(root, "pusher-state") + + yield* _(cloneStateRemote(root, remoteUrl, controllerRoot)) + yield* _(cloneStateRemote(root, remoteUrl, pusherRoot)) + yield* _( + withProjectsRoot( + pusherRoot, + withWorkingDirectory( + root, + createProjectFromRequest({ + repoUrl: "https://git.example.test/remote-owner/disabled-pull.git", + repoRef: "main", + outDir: path.join(pusherRoot, "remote-owner", "disabled-pull"), + skipGithubAuth: true, + up: false + }) + ) + ) + ) + + const projects = yield* _( + withEnvVar( + "DOCKER_GIT_STATE_AUTO_PULL", + "false", + withProjectsRoot(controllerRoot, withWorkingDirectory(root, listProjects())) + ) + ) + + expect(projects).toHaveLength(0) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("lists web absolute and shell relative project output directories from one root", () => + withTempDir((root) => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const projectsRoot = path.join(root, ".docker-git") + const webProjectId = path.join(projectsRoot, "web-owner", "absolute") + const shellProjectId = path.join(projectsRoot, "shell-owner", "relative") + + yield* _( + withProjectsRoot( + projectsRoot, + withWorkingDirectory( + root, + createProjectFromRequest({ + repoUrl: "https://git.example.test/web-owner/absolute.git", + repoRef: "main", + outDir: webProjectId, + skipGithubAuth: true, + up: false + }) + ) + ) + ) + yield* _( + withProjectsRoot( + projectsRoot, + withWorkingDirectory( + root, + createProjectFromRequest({ + repoUrl: "https://git.example.test/shell-owner/relative.git", + repoRef: "main", + outDir: ".docker-git/shell-owner/relative", + skipGithubAuth: true, + up: false + }) + ) + ) + ) + + const projects = yield* _( + withProjectsRoot(projectsRoot, withWorkingDirectory(root, listProjects())) + ) + const projectIds = projects.map((project) => project.id).toSorted() + + expect(projectIds).toEqual([shellProjectId, webProjectId].toSorted()) + }) + ).pipe(Effect.provide(NodeContext.layer))) + it.effect("lists persisted launch metadata from .docker-git without Docker access", () => withTempDir((root) => Effect.gen(function*(_) { diff --git a/packages/app/src/docker-git/controller-compose.ts b/packages/app/src/docker-git/controller-compose.ts index 35807f39..bb02853c 100644 --- a/packages/app/src/docker-git/controller-compose.ts +++ b/packages/app/src/docker-git/controller-compose.ts @@ -23,6 +23,23 @@ export type ControllerComposeFiles = { readonly runtimeOverlayPath: string | null } +export const controllerComposeProjectName = "docker-git" + +// CHANGE: pin the controller compose project name across checkout directories +// WHY: fixed controller container_name must be recreated by the same compose project, not by cwd-derived names +// QUOTE(ТЗ): "container name \"/docker-git-api\" is already in use" +// REF: user-message-2026-06-06-controller-compose-conflict +// SOURCE: n/a +// FORMAT THEOREM: forall cwd: compose_project(controller_bootstrap(cwd)) = "docker-git" +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: controller bootstrap compose commands use one global project name +// COMPLEXITY: O(1) +export const controllerComposeProjectArgs: ReadonlyArray = [ + "--project-name", + controllerComposeProjectName +] + const skillerSubmodulePath = "third_party/skiller-desktop-skills-manager" const skillerPackagePath = `${skillerSubmodulePath}/package.json` diff --git a/packages/app/src/docker-git/controller-docker.ts b/packages/app/src/docker-git/controller-docker.ts index 29bd8b5e..4b5fdc9e 100644 --- a/packages/app/src/docker-git/controller-docker.ts +++ b/packages/app/src/docker-git/controller-docker.ts @@ -4,16 +4,9 @@ import type * as FileSystem from "@effect/platform/FileSystem" import type * as Path from "@effect/platform/Path" import { Effect } from "effect" -import { composeFilesToArgs, prepareControllerRevision, resolveControllerComposeFiles } from "./controller-compose.js" -import { readCurrentContainerName } from "./controller-hostname.js" -import { - runCommandCaptureWithFailureOutput, - runCommandExitCode, - runCommandExitCodeStreaming, - runCommandWithCapturedOutput -} from "./frontend-lib/shell/command-runner.js" - +import * as ControllerCompose from "./controller-compose.js" import { type DockerProbeOutcome, renderDockerAccessDeniedMessage } from "./controller-docker-diagnostics.js" +import { readCurrentContainerName } from "./controller-hostname.js" import { type DockerNetworkIps, parseDockerNetworkIps, @@ -21,6 +14,12 @@ import { uniqueStrings } from "./controller-reachability.js" import { parseControllerRevisionEnvOutput } from "./controller-revision.js" +import { + runCommandCaptureWithFailureOutput, + runCommandExitCode, + runCommandExitCodeStreaming, + runCommandWithCapturedOutput +} from "./frontend-lib/shell/command-runner.js" import type { ControllerBootstrapError } from "./host-errors.js" export { @@ -32,10 +31,7 @@ export { } from "./controller-compose.js" export { parseControllerDockerRuntime } from "./controller-runtime.js" -export type ControllerRuntime = - | CommandExecutor.CommandExecutor - | FileSystem.FileSystem - | Path.Path +export type ControllerRuntime = CommandExecutor.CommandExecutor | FileSystem.FileSystem | Path.Path export const controllerContainerName = process.env["DOCKER_GIT_API_CONTAINER_NAME"]?.trim() || "docker-git-api" @@ -383,10 +379,10 @@ export const runCompose = ( ): Effect.Effect => Effect.gen(function*(_) { const dockerCommand = yield* _(resolveDockerCommand()) - const composeFiles = yield* _(resolveControllerComposeFiles()) const invocation = buildDockerInvocation(dockerCommand, [ "compose", - ...composeFilesToArgs(composeFiles), + ...ControllerCompose.controllerComposeProjectArgs, + ...ControllerCompose.composeFilesToArgs(yield* _(ControllerCompose.resolveControllerComposeFiles())), ...args ]) const exitCode = yield* _( @@ -440,7 +436,7 @@ export const inspectControllerRevision = (): Effect.Effect< ) export const prepareLocalControllerRevision = (): Effect.Effect => - prepareControllerRevision() + ControllerCompose.prepareControllerRevision() export const inspectContainerNetworks = ( containerName: string diff --git a/packages/app/src/docker-git/controller-image-revision.ts b/packages/app/src/docker-git/controller-image-revision.ts index 56508a26..f95d49a3 100644 --- a/packages/app/src/docker-git/controller-image-revision.ts +++ b/packages/app/src/docker-git/controller-image-revision.ts @@ -1,6 +1,10 @@ import { Effect } from "effect" -import { composeFilesToArgs, resolveControllerComposeFiles } from "./controller-compose.js" +import { + composeFilesToArgs, + controllerComposeProjectArgs, + resolveControllerComposeFiles +} from "./controller-compose.js" import { type ControllerRuntime, runDockerCapture, runDockerCaptureWithFailureOutput } from "./controller-docker.js" import { parseControllerRevisionLabelOutput } from "./controller-revision.js" import type { ControllerBootstrapError } from "./host-errors.js" @@ -131,6 +135,7 @@ const inspectControllerComposeImageName = (): Effect.Effect< runDockerCapture( [ "compose", + ...controllerComposeProjectArgs, ...composeFilesToArgs(composeFiles), "config", "--images" diff --git a/packages/app/tests/docker-git/api-project-codec.test.ts b/packages/app/tests/docker-git/api-project-codec.test.ts index 1c4af45f..cceee0cb 100644 --- a/packages/app/tests/docker-git/api-project-codec.test.ts +++ b/packages/app/tests/docker-git/api-project-codec.test.ts @@ -1,8 +1,42 @@ import { describe, expect, it } from "@effect/vitest" -import { decodeCreateProjectAccepted } from "../../src/docker-git/api-project-codec.js" +import { decodeCreateProjectAccepted, decodeProjectDetails } from "../../src/docker-git/api-project-codec.js" describe("api project codec", () => { + it("decodes detailed project payloads returned by the project list endpoint", () => { + const details = decodeProjectDetails({ + authorizedKeysExists: true, + authorizedKeysPath: "/home/dev/.docker-git/authorized_keys", + codexAuthPath: "/home/dev/.docker-git/.orch/auth/codex", + codexHome: "/home/dev/.codex", + containerName: "dg-docker-git-issue-372", + displayName: "provercoderai/docker-git", + envGlobalPath: "/home/dev/.docker-git/.orch/env/global.env", + envProjectPath: "/home/dev/.docker-git/provercoderai/docker-git/issue-372/.env", + id: "/home/dev/.docker-git/provercoderai/docker-git/issue-372", + projectDir: "/home/dev/.docker-git/provercoderai/docker-git/issue-372", + repoRef: "issue-372", + repoUrl: "https://github.com/ProverCoderAI/docker-git.git", + serviceName: "app", + sshCommand: "ssh dev@127.0.0.1 -p 2222", + sshPort: 2222, + sshSessions: 0, + sshUser: "dev", + startedAtEpochMs: 1_780_738_345_268, + startedAtIso: "2026-06-06T09:32:25.268Z", + status: "running", + statusLabel: "last known: running", + targetDir: "/workspace" + }) + + expect(details).toMatchObject({ + projectDir: "/home/dev/.docker-git/provercoderai/docker-git/issue-372", + repoRef: "issue-372", + sshCommand: "ssh dev@127.0.0.1 -p 2222", + status: "running" + }) + }) + it("decodes async create accepted responses", () => { const accepted = decodeCreateProjectAccepted({ accepted: true, diff --git a/packages/app/tests/docker-git/controller-compose.test.ts b/packages/app/tests/docker-git/controller-compose.test.ts index 04bd40cb..a8f0467d 100644 --- a/packages/app/tests/docker-git/controller-compose.test.ts +++ b/packages/app/tests/docker-git/controller-compose.test.ts @@ -8,11 +8,13 @@ import * as fc from "fast-check" import { resolveControllerRuntimeOverlayPath } from "../../src/docker-git/controller-compose-runtime.js" import { controllerBuildSkillerEnvKey, + controllerComposeProjectName, controllerGpuModeEnvKey, ensureSkillerSubmoduleInitialized, prepareControllerRevision, resolveControllerComposeFiles } from "../../src/docker-git/controller-compose.js" +import { runCompose } from "../../src/docker-git/controller-docker.js" import { controllerRevisionEnvKey } from "../../src/docker-git/controller-revision.js" import { controllerDockerRuntimeEnvKey } from "../../src/docker-git/controller-runtime.js" import type { TestCommandResult } from "./fixtures/command-executor.js" @@ -205,6 +207,33 @@ const assertControllerComposeProperty = (property: fc.IAsyncProper }) describe("controller compose preparation", () => { + it.effect("runs controller compose under the stable controller project name", () => { + const startedCommands: Array = [] + + return withMinimalControllerRoot(() => + Effect.gen(function*(_) { + yield* _( + withControllerEnv([ + [controllerBuildSkillerEnvKey, "0"], + [controllerDockerRuntimeEnvKey, undefined], + [controllerGpuModeEnvKey, undefined] + ]) + ) + yield* _( + runCompose(["up", "-d"]).pipe( + Effect.provide(recordedCommandExecutorLayer(startedCommands, emptyCommandResult)) + ) + ) + + const composeCommand = startedCommands.find((command) => + command.startsWith(`docker compose --project-name ${controllerComposeProjectName} -f `) + ) + expect(composeCommand).toBeDefined() + expect(composeCommand?.endsWith(" up -d")).toBe(true) + }) + ).pipe(Effect.provide(NodeContext.layer)) + }) + it.effect("does not initialize the Skiller submodule when package metadata already exists", () => { const startedCommands: Array = [] diff --git a/packages/app/tests/docker-git/controller-image-revision.test.ts b/packages/app/tests/docker-git/controller-image-revision.test.ts index 5cb082ea..466149f7 100644 --- a/packages/app/tests/docker-git/controller-image-revision.test.ts +++ b/packages/app/tests/docker-git/controller-image-revision.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "@effect/vitest" import { Effect, Either } from "effect" import * as fc from "fast-check" +import { controllerComposeProjectArgs } from "../../src/docker-git/controller-compose.js" import { inspectControllerImageRevision } from "../../src/docker-git/controller-image-revision.js" import type { ControllerBootstrapError } from "../../src/docker-git/host-errors.js" import { @@ -189,6 +190,29 @@ const expectRevisionFailureMessage = ( ) describe("controller image revision", () => { + it.effect("resolves compose images under the stable controller project name", () => { + const composeImageCommands: Array> = [] + + return inspectRevisionWithCommandHandler((command) => { + if (command.command === "docker" && command.args.includes("--images")) { + composeImageCommands.push(command.args) + return { exitCode: 0, stderr: "", stdout: "app-api:latest\n" } + } + if (command.command === "docker" && command.args.includes("image") && command.args.includes("inspect")) { + return { exitCode: 0, stderr: "", stdout: " rev123 \n" } + } + return emptyCommandResult + }).pipe( + Effect.map((revision) => { + expect(revision).toBe("rev123") + expect(composeImageCommands[0]?.slice(0, 1 + controllerComposeProjectArgs.length)).toEqual([ + "compose", + ...controllerComposeProjectArgs + ]) + }) + ) + }) + it.effect("falls back to null for non-reusable compose image output cardinalities", () => Effect.tryPromise({ catch: (cause) => cause, diff --git a/packages/app/tests/docker-git/gridland-react-singleton.test.ts b/packages/app/tests/docker-git/gridland-react-singleton.test.ts new file mode 100644 index 00000000..076fa822 --- /dev/null +++ b/packages/app/tests/docker-git/gridland-react-singleton.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import rootPackage from "../../../../package.json" with { type: "json" } +import appPackage from "../../package.json" with { type: "json" } + +describe("Gridland React singleton contract", () => { + it.effect("pins React across workspace dependencies for the Gridland renderer", () => + Effect.sync(() => { + expect(rootPackage.overrides.react).toBe(appPackage.dependencies.react.replace(/^\^/u, "")) + })) +}) diff --git a/packages/lib/src/usecases/state-repo.ts b/packages/lib/src/usecases/state-repo.ts index be1c4662..c58bcc69 100644 --- a/packages/lib/src/usecases/state-repo.ts +++ b/packages/lib/src/usecases/state-repo.ts @@ -38,6 +38,7 @@ import { import type { GitAuthEnv } from "./state-repo/github-auth.js" import { resolveGithubToken } from "./state-repo/github-auth.js" import { ensureStateGitignore } from "./state-repo/gitignore.js" +import { withStateGitLock } from "./state-repo/lock.js" type StateRepoEnv = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor const resolveStateRoot = (path: Path.Path, cwd: string): string => path.resolve(defaultProjectsRoot(cwd)) @@ -60,7 +61,7 @@ export const statePath: Effect.Effect = Effect.g yield* _(Effect.log(root)) }).pipe(Effect.asVoid) -export const stateSync = ( +const stateSyncRaw = ( message: string | null ): Effect.Effect => Effect.gen(function*(_) { @@ -102,7 +103,9 @@ export const stateSync = ( ) }).pipe(Effect.asVoid) -export const autoSyncState = (message: string): Effect.Effect => +export const stateSync = (message: string | null) => withStateGitLock(stateSyncRaw(message)) + +const autoSyncStateRaw = (message: string): Effect.Effect => Effect.gen(function*(_) { const path = yield* _(Path.Path) const root = resolveStateRoot(path, process.cwd()) @@ -117,7 +120,7 @@ export const autoSyncState = (message: string): Effect.Effect 0 ? isTruthyEnv(strictValue) : false - const effect = stateSync(message) + const effect = stateSyncRaw(message) if (strict) { yield* _(effect) return @@ -153,9 +156,14 @@ export const autoSyncState = (message: string): Effect.Effect // INVARIANT: never fails — errors are logged as warnings; does not block CLI execution // COMPLEXITY: O(1) network round-trip -export const autoPullState: Effect.Effect = Effect.gen(function*(_) { +const autoPullStateRaw: Effect.Effect = Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) const path = yield* _(Path.Path) const root = resolveStateRoot(path, process.cwd()) + const rootExists = yield* _(fs.exists(root)) + if (!rootExists) { + return + } const repoOk = yield* _(isGitRepo(root)) if (!repoOk) { return @@ -182,6 +190,10 @@ export const autoPullState: Effect.Effect = Effect.ge Effect.asVoid ) +export const autoSyncState = (message: string) => withStateGitLock(autoSyncStateRaw(message)) + +export const autoPullState: Effect.Effect = withStateGitLock(autoPullStateRaw) + // Internal pull that takes an already-resolved root, reusing auth logic from pull-push. const statePullInternal = ( root: string @@ -304,7 +316,7 @@ const checkoutBranchBestEffort = ( yield* _(Effect.logWarning(`git checkout -B ${repoRef} failed (exit ${checkoutExit})`)) }) -export const stateInit = ( +const stateInitRaw = ( input: StateInitInput ): Effect.Effect => { const doInit = (env: GitAuthEnv) => @@ -327,5 +339,7 @@ export const stateInit = ( return selectStateInitEffect(input.repoUrl, token, doInit) } +export const stateInit = (input: StateInitInput) => withStateGitLock(stateInitRaw(input)) + export { stateCommit, stateStatus } from "./state-repo/local-ops.js" export { statePull, statePush } from "./state-repo/pull-push.js" diff --git a/packages/lib/src/usecases/state-repo/local-ops.ts b/packages/lib/src/usecases/state-repo/local-ops.ts index 724495f3..f0be4f35 100644 --- a/packages/lib/src/usecases/state-repo/local-ops.ts +++ b/packages/lib/src/usecases/state-repo/local-ops.ts @@ -7,6 +7,7 @@ import type { CommandFailedError } from "../../shell/errors.js" import { defaultProjectsRoot } from "../menu-helpers.js" import { git, gitBaseEnv, gitCapture, gitExitCode, successExitCode } from "./git-commands.js" import { ensureStateGitignore } from "./gitignore.js" +import { withStateGitLock } from "./lock.js" type StateRepoEnv = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor @@ -24,14 +25,16 @@ const ensureStateIgnoreAndUntrackCaches = ( yield* _(git(root, ["rm", "-r", "--cached", "--ignore-unmatch", ...managedRepositoryCachePaths], gitBaseEnv)) }).pipe(Effect.asVoid) -export const stateStatus = Effect.gen(function*(_) { +const stateStatusRaw = Effect.gen(function*(_) { const path = yield* _(Path.Path) const root = resolveStateRoot(path, process.cwd()) const output = yield* _(gitCapture(root, ["status", "-sb", "--porcelain=v1"], gitBaseEnv)) yield* _(Effect.log(output.trim().length > 0 ? output.trimEnd() : "(clean)")) }).pipe(Effect.asVoid) -export const stateCommit = ( +export const stateStatus = withStateGitLock(stateStatusRaw) + +const stateCommitRaw = ( message: string ): Effect.Effect< void, @@ -51,3 +54,11 @@ export const stateCommit = ( } yield* _(git(root, ["commit", "-m", message], gitBaseEnv)) }).pipe(Effect.asVoid) + +export const stateCommit = ( + message: string +): Effect.Effect< + void, + CommandFailedError | PlatformError, + FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor +> => withStateGitLock(stateCommitRaw(message)) diff --git a/packages/lib/src/usecases/state-repo/lock.ts b/packages/lib/src/usecases/state-repo/lock.ts new file mode 100644 index 00000000..6db4e048 --- /dev/null +++ b/packages/lib/src/usecases/state-repo/lock.ts @@ -0,0 +1,31 @@ +import { Effect } from "effect" + +const stateGitLock = Effect.unsafeMakeSemaphore(1) + +/** + * Serializes git operations against the shared `.docker-git` working tree. + * + * @param effect - State git operation to run under the process-local lock. + * @returns The same effect guarded by a single permit semaphore. + * + * @pure false + * @effect Semaphore coordination for state repository shell effects. + * @invariant At most one guarded state git effect runs in this process. + * @precondition Effect must not already hold this lock. + * @postcondition Success/failure value is preserved. + * @complexity O(effect) + * @throws Never - failures remain in the Effect error channel. + */ +// CHANGE: serialize state repository git effects +// WHY: inventory auto-pull and create auto-sync can otherwise race on one git working tree +// QUOTE(ТЗ): "project not synchronized" +// REF: issue-372 +// SOURCE: https://github.com/ProverCoderAI/docker-git/issues/372 +// FORMAT THEOREM: forall a,b in StateGitOps: overlap(guard(a), guard(b)) = false +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: a single process-local permit protects the shared state repo +// COMPLEXITY: O(effect) +export const withStateGitLock = ( + effect: Effect.Effect +): Effect.Effect => effect.pipe(stateGitLock.withPermits(1)) diff --git a/packages/lib/src/usecases/state-repo/pull-push.ts b/packages/lib/src/usecases/state-repo/pull-push.ts index 25d04014..88133ee8 100644 --- a/packages/lib/src/usecases/state-repo/pull-push.ts +++ b/packages/lib/src/usecases/state-repo/pull-push.ts @@ -9,13 +9,17 @@ import { git, gitBaseEnv, gitCapture, gitExitCode, successExitCode } from "./git import { resolveStateGithubContext, withGithubAuthHintOnFailure } from "./github-auth-state.js" import { isGithubHttpsRemote, withGithubAskpassEnv } from "./github-auth.js" import { isGitlabHttpsRemote, resolveGitlabToken, withGitlabAskpassEnv } from "./gitlab-auth.js" +import { withStateGitLock } from "./lock.js" const resolveStateRoot = (path: Path.Path, cwd: string): string => path.resolve(defaultProjectsRoot(cwd)) -export const statePull: Effect.Effect< +type StateRepoRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor +type StateRepoError = CommandFailedError | PlatformError + +const statePullRaw: Effect.Effect< void, - CommandFailedError | PlatformError, - FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor + StateRepoError, + StateRepoRuntime > = Effect.gen(function*(_) { const fs = yield* _(FileSystem.FileSystem) const path = yield* _(Path.Path) @@ -52,10 +56,16 @@ export const statePull: Effect.Effect< yield* _(withGithubAuthHintOnFailure(effect, auth.authHintNeeded)) }).pipe(Effect.asVoid) -export const statePush: Effect.Effect< +export const statePull: Effect.Effect< void, - CommandFailedError | PlatformError, - FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor + StateRepoError, + StateRepoRuntime +> = withStateGitLock(statePullRaw) + +const statePushRaw: Effect.Effect< + void, + StateRepoError, + StateRepoRuntime > = Effect.gen(function*(_) { const fs = yield* _(FileSystem.FileSystem) const path = yield* _(Path.Path) @@ -100,3 +110,9 @@ export const statePush: Effect.Effect< })() yield* _(withGithubAuthHintOnFailure(effect, auth.authHintNeeded)) }).pipe(Effect.asVoid) + +export const statePush: Effect.Effect< + void, + StateRepoError, + StateRepoRuntime +> = withStateGitLock(statePushRaw)