diff --git a/CLAUDE.md b/CLAUDE.md index ac3d778..8a8cdca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -233,12 +233,13 @@ The startup script is inline in each template's `template.tf` (e.g. `freeform/te ### Volume Strategy - **Home directory**: Host path `/coder-workspaces/-` → Container `/home/coder` - **Docker cache**: Named volume `coder---dind-cache` → `/var/lib/docker` -- **Isolation**: Each workspace gets separate host directory and Docker volume +- **Homebrew**: Named volume `coder---linuxbrew` → `/home/linuxbrew` — persists the Homebrew Cellar across workspace stop/start, so `brew upgrade`/`brew install` done inside a workspace survives restarts. Unlike the `/home/coder` bind mount, a fresh named volume is auto-populated by Docker from the image's existing `/home/linuxbrew` contents on first mount, so no manual copy step is needed — only an ownership fix (`sudo chown -R coder:coder /home/linuxbrew`) in the startup script. `HOMEBREW_CACHE` is set to `/home/linuxbrew/.cache/Homebrew` (via the Dockerfile) so the cache and Cellar always stay on the same filesystem, avoiding cross-device link (`EXDEV`) errors. +- **Isolation**: Each workspace gets separate host directory and Docker volumes ### Terraform Variables Key template variables (e.g. in `freeform/template.tf`): - `workspace_image_registry` - Docker registry URL (default: `index.docker.io/ddev/coder-ddev`) -- `image_version` - Image tag (default: read from `VERSION` file or `v0.1`) +- `image_version` - Image tag (default: `v0.1`; the Makefile passes `--variable image_version=$(cat VERSION)` at push time, so the root `VERSION` file is the single source of truth — no per-template copies) - `cpu` / `memory` - Resource limits (defaults: 4 cores, 8GB RAM) - `node_version` - Node.js version (default: `24`, informational only — Node is pre-installed in image) - `docker_gid` - Docker group GID (default: `988`) @@ -352,6 +353,6 @@ Additional logs in workspace: - `scripts/coder-delete-workspace-dir.sh` - Sudo wrapper for workspace host dir cleanup (must be installed on server) - `scripts/cleanup-deleted-workspaces.sh` - Manual cleanup for orphaned workspace dirs/volumes - `scripts/workspace-lifecycle-cleanup.sh` - Notifies then deletes idle workspaces; runs via systemd timer on coder.ddev.com (production only, see `scripts/workspace-lifecycle-cleanup.service`/`.timer`, must be installed on server) -- `VERSION` - Image version used by all templates (read automatically by Makefile) +- `VERSION` - Single source of truth for the image version; read by the Makefile and passed to every template as `--variable image_version=...` at push time (no per-template copies) - `openspec/project.md` - Project conventions and constraints - `openspec/AGENTS.md` - OpenSpec workflow instructions diff --git a/Makefile b/Makefile index 7830687..d544682 100644 --- a/Makefile +++ b/Makefile @@ -44,10 +44,8 @@ TEMPLATE_EDIT_freeform := --display-name "DDEV Freeform" \ # Shared recipe for pushing any template (call with template name as argument) define push_template - @echo "Syncing VERSION to $(1)..." - cp VERSION $(1)/VERSION - @echo "Pushing Coder template $(1)..." - coder templates push --directory $(1) $(1) --yes --activate=$(ACTIVATE) $(TEMPLATE_VARS_$(1)) + @echo "Pushing Coder template $(1) (image_version=$(VERSION))..." + coder templates push --directory $(1) $(1) --yes --activate=$(ACTIVATE) --variable image_version=$(VERSION) $(TEMPLATE_VARS_$(1)) @echo "Setting template metadata for $(1)..." coder templates edit $(1) --yes $(TEMPLATE_EDIT_$(1)) @echo "Template $(1) push complete" diff --git a/VERSION b/VERSION index 1811f96..83b4ac5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.4 +v0.5 diff --git a/drupal-contrib/.gitignore b/drupal-contrib/.gitignore new file mode 100644 index 0000000..f1601ee --- /dev/null +++ b/drupal-contrib/.gitignore @@ -0,0 +1,5 @@ +# Vault token file (not committed to Git) +vault-token.txt + +# Terraform working directory +.terraform/ diff --git a/drupal-contrib/VERSION b/drupal-contrib/VERSION deleted file mode 100644 index 1811f96..0000000 --- a/drupal-contrib/VERSION +++ /dev/null @@ -1 +0,0 @@ -v0.4 diff --git a/drupal-contrib/template.tf b/drupal-contrib/template.tf index 847dec5..8891590 100644 --- a/drupal-contrib/template.tf +++ b/drupal-contrib/template.tf @@ -219,7 +219,7 @@ locals { } locals { - image_version = try(trimspace(file("${path.module}/VERSION")), var.image_version) + image_version = var.image_version registry_without_version = replace(var.workspace_image_registry, ":${local.image_version}", "") workspace_image_registry_base = replace(local.registry_without_version, ":latest", "") } @@ -313,6 +313,7 @@ resource "coder_agent" "main" { fi sudo chown coder:coder /home/coder + sudo chown -R coder:coder /home/linuxbrew if [ ! -f "/home/coder/.bashrc" ]; then echo "Initializing home directory..." @@ -1073,6 +1074,15 @@ resource "docker_volume" "coder_dind_cache" { name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}-dind-cache" } +# Persists /home/linuxbrew (Homebrew Cellar) across workspace stop/start so +# `brew upgrade`/`brew install` survives restarts. Not gated by start_count, +# matching coder_dind_cache above. Docker auto-populates a newly-created +# named volume from the image's existing directory contents on first mount, +# so no manual copy-from-image seed step is needed here. +resource "docker_volume" "coder_linuxbrew" { + name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}-linuxbrew" +} + module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" @@ -1188,6 +1198,12 @@ resource "docker_container" "workspace" { target = "/var/lib/docker" } + mounts { + type = "volume" + source = docker_volume.coder_linuxbrew.name + target = "/home/linuxbrew" + } + env = [ "CODER_AGENT_TOKEN=${coder_agent.main.token}", "CODER_WORKSPACE_NAME=${data.coder_workspace.me.name}", diff --git a/drupal-contrib/tests/validate.tftest.hcl b/drupal-contrib/tests/validate.tftest.hcl index 609d7a9..5950625 100644 --- a/drupal-contrib/tests/validate.tftest.hcl +++ b/drupal-contrib/tests/validate.tftest.hcl @@ -78,3 +78,11 @@ run "memory_above_maximum" { } expect_failures = [var.memory] } + +run "linuxbrew_volume_created" { + command = plan + assert { + condition = docker_volume.coder_linuxbrew.name == "coder-testuser-test-workspace-linuxbrew" + error_message = "docker_volume.coder_linuxbrew must be named per the coder---linuxbrew convention" + } +} diff --git a/drupal-core/.gitignore b/drupal-core/.gitignore index b1671c7..f1601ee 100644 --- a/drupal-core/.gitignore +++ b/drupal-core/.gitignore @@ -1,8 +1,5 @@ # Vault token file (not committed to Git) vault-token.txt -# VERSION is copied from root by Makefile before push -VERSION - # Terraform working directory .terraform/ diff --git a/drupal-core/template.tf b/drupal-core/template.tf index 898991b..0dbf358 100644 --- a/drupal-core/template.tf +++ b/drupal-core/template.tf @@ -212,8 +212,8 @@ locals { } locals { - # Read image version from VERSION file if it exists, otherwise use variable default - image_version = try(trimspace(file("${path.module}/VERSION")), var.image_version) + # Set via --variable image_version=$(cat VERSION) by the Makefile at push time. + image_version = var.image_version # Remove any tag (including :latest) if present, but preserve port numbers (e.g., :5050) # Remove common tags from the end of the registry URL @@ -252,7 +252,7 @@ variable "vscode_extensions" { variable "workspace_image_registry" { description = "Docker registry URL for the workspace base image (without tag, version is added automatically)" type = string - # The version tag is appended automatically using the image_version variable or VERSION file + # The version tag is appended automatically using the image_version variable. # DO NOT include :latest or any version tag here - version comes from image_version variable # To use a specific version, override the image_version variable when deploying default = "index.docker.io/ddev/coder-ddev" @@ -262,7 +262,7 @@ variable "workspace_image_registry" { # The image is built and pushed using the Makefile (see root Makefile and VERSION file) # This avoids prevent_destroy issues since the image is not managed by Terraform resource "docker_image" "workspace_image" { - # Always use version tag (never :latest) from the image_version variable or VERSION file + # Always use version tag (never :latest) from the image_version variable. # This ensures consistent image versions and prevents using stale images name = "${local.workspace_image_registry_base}:${local.image_version}" @@ -355,6 +355,7 @@ resource "coder_agent" "main" { # Since the volume comes from the host, it might have host permissions. # We fix this on every startup. sudo chown coder:coder /home/coder + sudo chown -R coder:coder /home/linuxbrew # Copy defaults if empty (first run) if [ ! -f "/home/coder/.bashrc" ]; then @@ -1362,6 +1363,15 @@ resource "docker_volume" "coder_dind_cache" { name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}-dind-cache" } +# Persists /home/linuxbrew (Homebrew Cellar) across workspace stop/start so +# `brew upgrade`/`brew install` survives restarts. Not gated by start_count, +# matching coder_dind_cache above. Docker auto-populates a newly-created +# named volume from the image's existing directory contents on first mount, +# so no manual copy-from-image seed step is needed here. +resource "docker_volume" "coder_linuxbrew" { + name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}-linuxbrew" +} + # VS Code for Web module "vscode-web" { count = data.coder_workspace.me.start_count @@ -1523,6 +1533,12 @@ resource "docker_container" "workspace" { target = "/var/lib/docker" } + mounts { + type = "volume" + source = docker_volume.coder_linuxbrew.name + target = "/home/linuxbrew" + } + # Environment variables env = [ "CODER_AGENT_TOKEN=${coder_agent.main.token}", diff --git a/drupal-core/tests/validate.tftest.hcl b/drupal-core/tests/validate.tftest.hcl index 828b0fb..27c38aa 100644 --- a/drupal-core/tests/validate.tftest.hcl +++ b/drupal-core/tests/validate.tftest.hcl @@ -82,3 +82,14 @@ run "memory_above_maximum" { } expect_failures = [var.memory] } + +run "linuxbrew_volume_created" { + command = plan + variables { + cache_path = "/tmp/mock-cache" + } + assert { + condition = docker_volume.coder_linuxbrew.name == "coder-testuser-test-workspace-linuxbrew" + error_message = "docker_volume.coder_linuxbrew must be named per the coder---linuxbrew convention" + } +} diff --git a/freeform/.gitignore b/freeform/.gitignore index b1671c7..f1601ee 100644 --- a/freeform/.gitignore +++ b/freeform/.gitignore @@ -1,8 +1,5 @@ # Vault token file (not committed to Git) vault-token.txt -# VERSION is copied from root by Makefile before push -VERSION - # Terraform working directory .terraform/ diff --git a/freeform/README.md b/freeform/README.md index 59aedbc..23533b3 100644 --- a/freeform/README.md +++ b/freeform/README.md @@ -89,6 +89,24 @@ ddev restart After `ddev restart`, the post-start hook updates `coder-routes.yaml` to include the Adminer router. Click **Adminer** in the Coder dashboard. +### Claude Code (Remote Control) + +`Enable Claude Code` and `Claude Code: skip permissions` are workspace parameters (per-workspace, not template-wide) — toggle them in the Coder dashboard when creating or updating a workspace, or from the CLI: + +```bash +coder create --template freeform myworkspace --parameter enable_claude_code=true +# or, on an existing workspace: +coder update myworkspace --parameter enable_claude_code=true +``` + +At startup, Claude Code is upgraded via Homebrew and a tmux session named after the workspace is started running `claude --remote-control myworkspace`. **The intended way to use it is not the terminal** — go to [claude.ai/code](https://claude.ai/code) or open the Claude mobile app, and the session shows up by workspace name with a green "online" dot; connect from there. Requires a Claude Pro/Max/Team/Enterprise subscription (API key auth is not supported for Remote Control) and being logged into `api.anthropic.com` (not Bedrock/Vertex/a custom gateway). + +The **Claude Code** app button in the Coder dashboard (and `tmux attach -t myworkspace` manually) is a local fallback — useful for the first interactive login (`/login` inside the session), or for troubleshooting, but not the normal way to interact with the session day-to-day. + +The first connection triggers Claude Code's interactive login — no API key is configured by this template. To skip permission prompts entirely, also pass `--parameter claude_code_skip_permissions=true` (off by default; use with caution). + +> **Known limitation — shared session state.** `enable_claude_code` starts exactly one tmux session per workspace, named after the workspace, running a single `claude` process. Every device/client that attaches (via the "Claude Code" app button or `tmux attach -t `) is attaching to the *same* running session — that's what makes multi-device remote control possible in the first place. The tradeoff: there's no per-client context, so `cd`-ing or `git checkout`-ing from inside that shared session changes it for everyone attached. Do routine repo administration (branch switches, directory navigation) from a separate, ordinary terminal (the Coder web terminal or `coder ssh `) instead — leave the Claude Code tmux session alone for that. + ### Other Add-Ons ```bash diff --git a/freeform/template.tf b/freeform/template.tf index 997b0c4..cea7c3d 100644 --- a/freeform/template.tf +++ b/freeform/template.tf @@ -95,10 +95,32 @@ data "coder_parameter" "vscode_extensions" { } } +data "coder_parameter" "enable_claude_code" { + name = "enable_claude_code" + display_name = "Enable Claude Code" + description = "Upgrade Claude Code via Homebrew at startup and run it in a tmux session named after the workspace, attachable from the dashboard. Interactive OAuth login is required on first attach (no API key is configured by this template)." + type = "bool" + form_type = "switch" + default = "false" + mutable = true + order = 2 +} + +data "coder_parameter" "claude_code_skip_permissions" { + name = "claude_code_skip_permissions" + display_name = "Claude Code: skip permissions" + description = "Append --dangerously-skip-permissions to the Claude Code session. No effect unless Enable Claude Code is also on." + type = "bool" + form_type = "switch" + default = "false" + mutable = true + order = 3 +} + locals { workspace_home = "/home/coder" selected_extensions = jsondecode(data.coder_parameter.vscode_extensions.value) - image_version = try(trimspace(file("${path.module}/VERSION")), var.image_version) + image_version = var.image_version registry_without_version = replace(var.workspace_image_registry, ":${local.image_version}", "") workspace_image_registry_base = replace(local.registry_without_version, ":latest", "") @@ -111,6 +133,12 @@ locals { ? [for s in split(",", local._project_names_raw) : trimspace(s) if trimspace(s) != ""] : [data.coder_workspace.me.name] ) + + # coder_parameter values are always strings; compare rather than tobool() so an + # unrelated mock default (e.g. "[]" in Terraform tests) safely evaluates false + # instead of erroring. + enable_claude_code = data.coder_parameter.enable_claude_code.value == "true" + claude_code_skip_permissions = data.coder_parameter.claude_code_skip_permissions.value == "true" } variable "vscode_extensions" { @@ -206,6 +234,7 @@ resource "coder_agent" "main" { fi sudo chown coder:coder /home/coder + sudo chown -R coder:coder /home/linuxbrew if [ ! -f "/home/coder/.bashrc" ]; then echo "Initializing home directory..." @@ -401,6 +430,35 @@ BASHCOMP echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc fi + %{if local.enable_claude_code~} + # --- Claude Code remote control (enable_claude_code) --------------------- + echo "Setting up Claude Code remote-control session..." + + if command -v brew > /dev/null 2>&1; then + brew update > /dev/null 2>&1 || echo "Warning: brew update failed, continuing with existing formula metadata" + (brew upgrade claude-code || brew install claude-code) || \ + echo "Warning: claude-code install/upgrade via brew failed; continuing with existing version (if any)" + else + echo "Warning: brew not found on PATH; skipping claude-code upgrade" + fi + + CLAUDE_CMD="claude --remote-control $CODER_WORKSPACE_NAME" + %{if local.claude_code_skip_permissions~} + CLAUDE_CMD="claude --remote-control $CODER_WORKSPACE_NAME --dangerously-skip-permissions" + %{endif~} + + if command -v tmux > /dev/null 2>&1 && command -v claude > /dev/null 2>&1; then + if ! tmux has-session -t "$CODER_WORKSPACE_NAME" 2>/dev/null; then + echo "Starting Claude Code tmux session '$CODER_WORKSPACE_NAME'..." + tmux new-session -d -s "$CODER_WORKSPACE_NAME" -c "$HOME" "$CLAUDE_CMD" + else + echo "Claude Code tmux session '$CODER_WORKSPACE_NAME' already running." + fi + else + echo "Warning: tmux or claude not available; skipping Claude Code session startup" + fi + %{endif~} + echo "" echo "=== Setup Complete ===" echo "" @@ -443,6 +501,15 @@ resource "docker_volume" "coder_dind_cache" { name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}-dind-cache" } +# Persists /home/linuxbrew (Homebrew Cellar) across workspace stop/start so +# `brew upgrade`/`brew install` (e.g. for Claude Code) survives restarts. Not +# gated by start_count, matching coder_dind_cache above. Docker auto-populates +# a newly-created named volume from the image's existing directory contents +# on first mount, so no manual copy-from-image seed step is needed here. +resource "docker_volume" "coder_linuxbrew" { + name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}-linuxbrew" +} + module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" @@ -526,6 +593,20 @@ resource "coder_app" "adminer" { } } +# Claude Code remote control (enable_claude_code): opens/attaches the +# workspace's single dedicated tmux session (named after the workspace) +# running `claude`. Shared by every client that attaches — see README for +# the cd/git-checkout caveat. +resource "coder_app" "claude_code" { + for_each = local.enable_claude_code ? toset(["claude-code"]) : toset([]) + agent_id = coder_agent.main.id + slug = "claude-code" + display_name = "Claude Code" + icon = "/icon/terminal.svg" + command = "tmux attach -t $CODER_WORKSPACE_NAME || tmux new -s $CODER_WORKSPACE_NAME claude --remote-control $CODER_WORKSPACE_NAME${local.claude_code_skip_permissions ? " --dangerously-skip-permissions" : ""}" + share = "owner" +} + resource "coder_script" "ddev_shutdown" { agent_id = coder_agent.main.id display_name = "Stop DDEV Projects" @@ -577,6 +658,12 @@ resource "docker_container" "workspace" { target = "/var/lib/docker" } + mounts { + type = "volume" + source = docker_volume.coder_linuxbrew.name + target = "/home/linuxbrew" + } + env = [ "CODER_AGENT_TOKEN=${coder_agent.main.token}", "CODER_WORKSPACE_NAME=${data.coder_workspace.me.name}", diff --git a/freeform/tests/validate.tftest.hcl b/freeform/tests/validate.tftest.hcl index 0edffe8..be82929 100644 --- a/freeform/tests/validate.tftest.hcl +++ b/freeform/tests/validate.tftest.hcl @@ -132,3 +132,67 @@ run "memory_above_maximum" { } expect_failures = [var.memory] } + +run "linuxbrew_volume_created" { + command = plan + assert { + condition = docker_volume.coder_linuxbrew.name == "coder-testuser-test-workspace-linuxbrew" + error_message = "docker_volume.coder_linuxbrew must be named per the coder---linuxbrew convention" + } +} + +run "claude_code_off_by_default" { + command = plan + assert { + condition = length(coder_app.claude_code) == 0 + error_message = "coder_app.claude_code must not be created when enable_claude_code is off" + } +} + +run "claude_code_enabled" { + command = plan + override_data { + target = data.coder_parameter.enable_claude_code + values = { + value = "true" + } + } + assert { + condition = length(coder_app.claude_code) == 1 + error_message = "coder_app.claude_code must be created when enable_claude_code is on" + } +} + +run "claude_code_skip_permissions_off_by_default" { + command = plan + override_data { + target = data.coder_parameter.enable_claude_code + values = { + value = "true" + } + } + assert { + condition = !strcontains(coder_app.claude_code["claude-code"].command, "--dangerously-skip-permissions") + error_message = "claude_code_skip_permissions must default to off" + } +} + +run "claude_code_skip_permissions_enabled" { + command = plan + override_data { + target = data.coder_parameter.enable_claude_code + values = { + value = "true" + } + } + override_data { + target = data.coder_parameter.claude_code_skip_permissions + values = { + value = "true" + } + } + assert { + condition = strcontains(coder_app.claude_code["claude-code"].command, "--dangerously-skip-permissions") + error_message = "claude_code_skip_permissions=true must append --dangerously-skip-permissions" + } +} diff --git a/image/Dockerfile b/image/Dockerfile index 0a29172..cc6c24d 100644 --- a/image/Dockerfile +++ b/image/Dockerfile @@ -165,9 +165,17 @@ RUN printf '#!/usr/bin/env bash\nddev php "$@"\n' > /usr/local/bin/php && \ RUN mkdir -p /home/linuxbrew/.linuxbrew && chown -R coder:coder /home/linuxbrew/ +# Homebrew's cache must live on the same filesystem as the Cellar +# (/home/linuxbrew/.linuxbrew) to avoid cross-device link (EXDEV) errors when +# brew hardlinks a downloaded bottle from cache into the Cellar. At runtime, +# /home/linuxbrew is mounted as a persistent per-workspace volume (see each +# template's template.tf), so this path stays on the same filesystem as the +# Cellar across workspace restarts too. +ENV HOMEBREW_CACHE=/home/linuxbrew/.cache/Homebrew + USER coder RUN /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" -RUN /home/linuxbrew/.linuxbrew/bin/brew install \ +RUN mkdir -p "$HOMEBREW_CACHE" && /home/linuxbrew/.linuxbrew/bin/brew install \ bats-core \ bats-core/bats-core/bats-assert \ bats-core/bats-core/bats-file \ diff --git a/scripts/cleanup-deleted-workspaces.sh b/scripts/cleanup-deleted-workspaces.sh index 852a13b..6b15cd5 100755 --- a/scripts/cleanup-deleted-workspaces.sh +++ b/scripts/cleanup-deleted-workspaces.sh @@ -88,8 +88,10 @@ echo # --- Docker volumes --- orphan_volumes=() while IFS= read -r vol; do - # Volume names look like: coder---dind-cache - if [[ "$vol" =~ ^coder-(.+)-dind-cache$ ]]; then + # Volume names look like: coder---dind-cache or + # coder---linuxbrew. Add any new persistent per-workspace + # volume suffix here too, or orphans of that type will go undetected. + if [[ "$vol" =~ ^coder-(.+)-(dind-cache|linuxbrew)$ ]]; then owner_workspace="${BASH_REMATCH[1]}" if [[ -z "${active_dirs[$owner_workspace]+_}" ]]; then orphan_volumes+=("$vol") diff --git a/scripts/workspace-lifecycle-cleanup.sh b/scripts/workspace-lifecycle-cleanup.sh index 273af37..1fc3d16 100755 --- a/scripts/workspace-lifecycle-cleanup.sh +++ b/scripts/workspace-lifecycle-cleanup.sh @@ -2,12 +2,14 @@ # Notify and delete idle workspaces on coder.ddev.com. # # Why this exists: -# Stopping a workspace does not free its Docker volume — each Sysbox -# workspace keeps a multi-GB dind-cache volume until the workspace is -# deleted (the destroy provisioner in each template removes it). With no -# lifecycle policy, stopped workspaces accumulate forever and slowly fill -# /data on the host. This janitor enforces: notify at N days idle, delete -# 7 days after the notice if the workspace is still untouched. +# Stopping a workspace does not free its Docker volumes — each Sysbox +# workspace keeps a multi-GB dind-cache volume and a linuxbrew volume until +# the workspace is deleted (Terraform destroys them, along with everything +# else in the workspace's resource graph, when the workspace itself is +# deleted). With no lifecycle policy, stopped workspaces accumulate forever +# and slowly fill /data on the host. This janitor enforces: notify at N +# days idle, delete 7 days after the notice if the workspace is still +# untouched. # # Policy (state machine per workspace, keyed by workspace id): # - Not yet notified, age(last_used_at) >= NOTIFY_DAYS