Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/auto-delete-closed-source.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@prover-coder-ai/docker-git": minor
---

Add opt-in automatic deletion of containers whose originating GitHub issue or pull request has been closed. Enable with `DOCKER_GIT_AUTO_DELETE_CLOSED=1` (scan interval configurable via `DOCKER_GIT_AUTO_DELETE_SCAN_INTERVAL_SECONDS`, default 300s). Deletion is conservative: it never runs for open/unknown source states, nor while an agent or live interactive session is active.
10 changes: 10 additions & 0 deletions packages/api/src/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { makeRouter } from "./http.js"
import { initializeAgentState } from "./services/agents.js"
import { attachAuthTerminalWebSocketServer } from "./services/auth-terminal-sessions.js"
import { initializeFederationState, startOutboxPolling } from "./services/federation.js"
import { resolveProjectAutoDeleteConfig, startProjectAutoDeleteLoop } from "./services/project-auto-delete.js"
import { resolveProjectAutoSuspendConfig, startProjectAutoSuspendLoop } from "./services/project-auto-suspend.js"
import { attachProjectBrowserWebSocketServer } from "./services/project-browser.js"
import { attachProjectDatabaseWebSocketServer } from "./services/project-databases.js"
Expand Down Expand Up @@ -63,6 +64,7 @@ export const program = (() => {

const pollingInterval = parseInt(process.env["DOCKER_GIT_OUTBOX_POLLING_INTERVAL_MS"] ?? "5000", 10)
const autoSuspendConfig = resolveProjectAutoSuspendConfig()
const autoDeleteConfig = resolveProjectAutoDeleteConfig()

return Effect.scoped(
Console.log(`docker-git api boot port=${port}`).pipe(
Expand All @@ -82,6 +84,14 @@ export const program = (() => {
Effect.zipRight(
Effect.fork(startProjectAutoSuspendLoop(autoSuspendConfig).pipe(Effect.provide(NodeContext.layer)))
),
Effect.zipRight(
Console.log(
`docker-git auto-delete (closed issue/PR) enabled=${autoDeleteConfig.enabled} scanMs=${autoDeleteConfig.scanIntervalMs}`
)
),
Effect.zipRight(
Effect.fork(startProjectAutoDeleteLoop(autoDeleteConfig).pipe(Effect.provide(NodeContext.layer)))
),
Effect.zipRight(Layer.launch(Layer.provide(app, serverLayer)))
)
)
Expand Down
47 changes: 47 additions & 0 deletions packages/api/src/services/project-activity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { ProjectItem } from "@effect-template/lib/usecases/projects"
import { Effect } from "effect"

import { activeAgents } from "./container-tasks-core.js"
import { readContainerTaskSnapshot } from "./container-tasks.js"
import { hasLiveProjectBrowserSession } from "./project-browser.js"
import { hasLiveProjectSkillerSession } from "./skiller.js"
import { hasLiveProjectTerminalSession } from "./terminal-sessions.js"

// CHANGE: share project activity predicates across auto-suspend and auto-delete loops
// WHY: both loops must agree on what "work in progress" means; avoid duplicate logic
// REF: issue-117
// PURITY: SHELL
// INVARIANT: failures resolve to "no active agent" so callers stay conservative

/**
* Whether an agent is currently working inside the project's container.
*
* @pure false
* @effect FileSystem (task snapshot)
* @invariant snapshot read failures resolve to `false`
* @complexity O(tasks)
*/
export const projectHasActiveAgent = (
project: ProjectItem
) =>
readContainerTaskSnapshot(project.projectDir, false).pipe(
Effect.map((snapshot) =>
activeAgents(snapshot.agents).length > 0 || snapshot.tasks.some((task) => task.kind === "agent")
),
Effect.catchAll(() => Effect.succeed(false))
)
Comment on lines +27 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Не маскируйте сбой чтения снапшота как false для признака активного агента.

На Line 31 ошибка чтения состояния контейнера принудительно превращается в false. В контуре авто-удаления это даёт ложный вывод «агента нет» и может привести к удалению проекта при недоступности проверки. Для безопасного поведения ошибку нужно пробрасывать вверх (или явно трактовать как keep/unknown), чтобы оценка проекта завершалась без удаления.

💡 Proposed fix
 export const projectHasActiveAgent = (
   project: ProjectItem
 ) =>
   readContainerTaskSnapshot(project.projectDir, false).pipe(
-    Effect.map((snapshot) =>
-      activeAgents(snapshot.agents).length > 0 || snapshot.tasks.some((task) => task.kind === "agent")
-    ),
-    Effect.catchAll(() => Effect.succeed(false))
+    Effect.map(
+      (snapshot) =>
+        activeAgents(snapshot.agents).length > 0 || snapshot.tasks.some((task) => task.kind === "agent")
+    )
   )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/api/src/services/project-activity.ts` around lines 27 - 32, The code
masks readContainerTaskSnapshot failures by catching all errors and returning
false (via Effect.catchAll(() => Effect.succeed(false))), which makes the caller
think "no active agent" and can cause unsafe auto-deletion; change the behavior
in the pipeline that uses readContainerTaskSnapshot and activeAgents so errors
are propagated instead of swallowed — remove or modify the Effect.catchAll on
the readContainerTaskSnapshot pipeline (or replace it with an explicit
failure/unknown/keep result) so that failures from readContainerTaskSnapshot
surface to the caller (or return an explicit "unknown"/"keep" sentinel) and let
the higher-level evaluator decide rather than forcing false.


/**
* Whether a live interactive session (ssh/terminal/browser/skiller) is attached.
*
* @pure false (reads in-memory session registries)
* @complexity O(1)
*/
export const projectHasLiveInteractiveSession = (
project: ProjectItem,
sshSessions: number
): boolean =>
sshSessions > 0 ||
hasLiveProjectTerminalSession(project.projectDir) ||
hasLiveProjectBrowserSession(project.projectDir) ||
hasLiveProjectSkillerSession(project.projectDir)
169 changes: 169 additions & 0 deletions packages/api/src/services/project-auto-delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { deleteDockerGitProject, listProjectItems, parseProjectSourceRef } from "@effect-template/lib"
import type { ProjectItem } from "@effect-template/lib/usecases/projects"
import { fetchProjectSourceState } from "@effect-template/lib/usecases/project-source-state"
import { ensureGhAuthImage, ghAuthRoot } from "@effect-template/lib/usecases/github-auth-image"
import { defaultProjectsRoot, resolvePathFromCwd } from "@effect-template/lib/usecases/path-helpers"
import { withFsPathContext } from "@effect-template/lib/usecases/runtime"
import { resolveGithubToken } from "@effect-template/lib/usecases/state-repo/github-auth"
import { Duration, Effect, Schedule } from "effect"

import { decideProjectClosedSourceAction } from "./project-closed-source-policy.js"
import { projectHasActiveAgent, projectHasLiveInteractiveSession } from "./project-activity.js"
import { loadProjectRuntimeByProject, runtimeForProject } from "./project-runtime.js"

// CHANGE: automatically delete containers whose originating issue or PR has been closed
// WHY: closed issues/PRs leave behind unused environments that pile up over time
// QUOTE(ТЗ): "Сделать возможность автоматического удаления контейнера issues или PR которого уже закрылся"
// REF: issue-117
// SOURCE: https://github.com/ProverCoderAI/docker-git/issues/117
// PURITY: SHELL
// INVARIANT: disabled by default; only ever deletes projects with a closed source and no live work

export type ProjectAutoDeleteConfig = {
readonly enabled: boolean
readonly scanIntervalMs: number
}

const secondMs = 1_000
const defaultScanIntervalSeconds = 300

const parsePositiveIntegerEnv = (
key: string,
defaultValue: number
): number => {
const parsed = Number.parseInt(process.env[key] ?? "", 10)
return Number.isFinite(parsed) && parsed > 0 ? parsed : defaultValue
}

const parseEnabledEnv = (
key: string,
defaultValue: boolean
): boolean => {
const raw = process.env[key]?.trim().toLowerCase()
if (raw === undefined || raw.length === 0) {
return defaultValue
}
return raw === "1" || raw === "true" || raw === "on" || raw === "yes"
}

// CHANGE: opt-in by default — deletion is irreversible, so require explicit enablement
// WHY: an unexpected auto-delete must never surprise a user; they must turn it on knowingly
// REF: issue-117
export const resolveProjectAutoDeleteConfig = (): ProjectAutoDeleteConfig => ({
enabled: parseEnabledEnv("DOCKER_GIT_AUTO_DELETE_CLOSED", false),
scanIntervalMs:
parsePositiveIntegerEnv("DOCKER_GIT_AUTO_DELETE_SCAN_INTERVAL_SECONDS", defaultScanIntervalSeconds) * secondMs
})

type AutoDeleteRuntime = {
readonly cwd: string
readonly ghAuthHostPath: string
readonly token: string
}

const evaluateProject = (
runtime: AutoDeleteRuntime,
project: ProjectItem,
sshSessions: number
) =>
Effect.gen(function*(_) {
const ref = parseProjectSourceRef(project.repoUrl, project.repoRef)
if (ref === null) {
return
}

const sourceState = yield* _(
fetchProjectSourceState(runtime.cwd, runtime.ghAuthHostPath, runtime.token, ref).pipe(
Effect.catchAll(() => Effect.succeed("unknown" as const))
)
)

const hasActiveAgent = yield* _(projectHasActiveAgent(project))
const hasLiveInteractiveSession = projectHasLiveInteractiveSession(project, sshSessions)

const decision = decideProjectClosedSourceAction({ sourceState, hasActiveAgent, hasLiveInteractiveSession })
if (decision._tag === "Keep") {
return
}

yield* _(
Effect.log(
`[auto-delete] Removing ${project.containerName}: ${ref.provider} ${ref.kind} #${ref.number} is closed`
)
)
yield* _(
deleteDockerGitProject({
projectDir: project.projectDir,
repoUrl: project.repoUrl,
containerName: project.containerName,
serviceName: project.serviceName
})
)
}).pipe(
Effect.catchAll((error) =>
Effect.logWarning(
`[auto-delete] Failed to evaluate ${project.containerName}: ${
error instanceof Error ? error.message : String(error)
}`
)
)
)

export const scanProjectAutoDelete = (
config: ProjectAutoDeleteConfig
) =>
Effect.gen(function*(_) {
if (!config.enabled) {
return
}

const projects = yield* _(listProjectItems)
const sourceProjects = projects.filter((project) => parseProjectSourceRef(project.repoUrl, project.repoRef) !== null)
if (sourceProjects.length === 0) {
return
}

const runtime = yield* _(
withFsPathContext(({ cwd, fs, path }) =>
Effect.gen(function*(_) {
const root = path.resolve(defaultProjectsRoot(cwd))
const token = yield* _(resolveGithubToken(fs, path, root))
if (token === null) {
return null
}
const ghAuthHostPath = resolvePathFromCwd(path, cwd, ghAuthRoot)
yield* _(ensureGhAuthImage(fs, path, cwd, "gh api"))
return { cwd, ghAuthHostPath, token } satisfies AutoDeleteRuntime
})
)
)

if (runtime === null) {
yield* _(Effect.logWarning("[auto-delete] No GitHub token available; skipping closed-source cleanup scan"))
return
}

const runtimeByProject = yield* _(loadProjectRuntimeByProject(sourceProjects))
yield* _(
Effect.forEach(
sourceProjects,
(project) => evaluateProject(runtime, project, runtimeForProject(runtimeByProject, project).sshSessions),
{ concurrency: 2, discard: true }
)
)
}).pipe(
Effect.catchAll((error) =>
Effect.logWarning(
`[auto-delete] Scan failed: ${error instanceof Error ? error.message : String(error)}`
)
)
)

export const startProjectAutoDeleteLoop = (
config: ProjectAutoDeleteConfig
) =>
config.enabled
? scanProjectAutoDelete(config).pipe(
Effect.repeat(Schedule.addDelay(Schedule.forever, () => Duration.millis(config.scanIntervalMs)))
)
: Effect.log("docker-git auto-delete (closed issue/PR) disabled.")
30 changes: 1 addition & 29 deletions packages/api/src/services/project-auto-suspend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,10 @@ import { listProjectItems, readProjectRuntimeState, recordProjectRuntimeActivity
import type { ProjectItem } from "@effect-template/lib/usecases/projects"
import { Duration, Effect, Match, Schedule } from "effect"

import { activeAgents } from "./container-tasks-core.js"
import { readContainerTaskSnapshot } from "./container-tasks.js"
import { hasLiveProjectBrowserSession } from "./project-browser.js"
import { projectHasActiveAgent, projectHasLiveInteractiveSession } from "./project-activity.js"
import { decideProjectIdleAction } from "./project-idle-policy.js"
import { applyProjectResourceProfile, suspendProjectRuntime } from "./project-lifecycle-resources.js"
import { loadProjectRuntimeByProject, runtimeForProject } from "./project-runtime.js"
import { hasLiveProjectSkillerSession } from "./skiller.js"
import { hasLiveProjectTerminalSession } from "./terminal-sessions.js"

export type ProjectAutoSuspendConfig = {
readonly enabled: boolean
Expand Down Expand Up @@ -63,30 +59,6 @@ export const resolveProjectAutoSuspendConfig = (): ProjectAutoSuspendConfig => (
)
})

const snapshotHasAgentTask = (
project: ProjectItem
) =>
readContainerTaskSnapshot(project.projectDir, false).pipe(
Effect.map((snapshot) =>
activeAgents(snapshot.agents).length > 0 || snapshot.tasks.some((task) => task.kind === "agent")
),
Effect.catchAll(() => Effect.succeed(false))
)

const projectHasActiveAgent = (
project: ProjectItem
) =>
snapshotHasAgentTask(project)

const projectHasLiveInteractiveSession = (
project: ProjectItem,
sshSessions: number
): boolean =>
sshSessions > 0 ||
hasLiveProjectTerminalSession(project.projectDir) ||
hasLiveProjectBrowserSession(project.projectDir) ||
hasLiveProjectSkillerSession(project.projectDir)

const runProjectIdleDecision = (
project: ProjectItem,
config: ProjectAutoSuspendConfig,
Expand Down
50 changes: 50 additions & 0 deletions packages/api/src/services/project-closed-source-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { type ProjectSourceState, shouldDeleteForSourceState } from "@effect-template/lib"

// CHANGE: decide whether a project whose issue/PR is closed may be auto-deleted
// WHY: deletion is destructive, so it must never race with active work
// QUOTE(ТЗ): "Сделать возможность автоматического удаления контейнера issues или PR которого уже закрылся"
// REF: issue-117
// SOURCE: https://github.com/ProverCoderAI/docker-git/issues/117
// PURITY: CORE
// EFFECT: n/a
// INVARIANT: Delete is returned only when the source is closed and no work is in progress
// COMPLEXITY: O(1)

export type ProjectClosedSourcePolicyInput = {
readonly sourceState: ProjectSourceState
readonly hasActiveAgent: boolean
readonly hasLiveInteractiveSession: boolean
}

export type ProjectClosedSourceDecision =
| { readonly _tag: "Keep"; readonly reason: "source-open-or-unknown" | "active-agent" | "live-interactive-session" }
| { readonly _tag: "Delete" }

/**
* Decide whether a project should be deleted because its issue/PR is closed.
*
* A project is deleted only when all of the following hold:
* - its originating issue or pull request is definitively `closed`,
* - no agent is currently working inside it, and
* - no live interactive session (terminal/browser/skiller/ssh) is attached.
*
* Anything else keeps the project, with a reason explaining why.
*
* @pure true
* @invariant result._tag = "Delete" → input.sourceState = "closed" ∧ ¬hasActiveAgent ∧ ¬hasLiveInteractiveSession
* @complexity O(1)
*/
export const decideProjectClosedSourceAction = (
input: ProjectClosedSourcePolicyInput
): ProjectClosedSourceDecision => {
if (!shouldDeleteForSourceState(input.sourceState)) {
return { _tag: "Keep", reason: "source-open-or-unknown" }
}
if (input.hasActiveAgent) {
return { _tag: "Keep", reason: "active-agent" }
}
if (input.hasLiveInteractiveSession) {
return { _tag: "Keep", reason: "live-interactive-session" }
}
return { _tag: "Delete" }
}
Loading
Loading