diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md index e7a9869fb..c5e9ae423 100644 --- a/registry/coder/modules/agentapi/README.md +++ b/registry/coder/modules/agentapi/README.md @@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI ```tf module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "2.1.1" + version = "2.2.0" agent_id = var.agent_id web_app_slug = local.app_slug @@ -62,6 +62,33 @@ module "agentapi" { } ``` +## State Persistence + +AgentAPI can save and restore conversation state across workspace restarts. +This is disabled by default and requires agentapi binary >= v0.12.0. + +State and PID files are stored in `$HOME//` alongside other +module files (e.g. `$HOME/.claude-module/agentapi-state.json`). + +To enable: + +```tf +module "agentapi" { + # ... other config + enable_state_persistence = true +} +``` + +To override file paths: + +```tf +module "agentapi" { + # ... other config + state_file_path = "/custom/path/state.json" + pid_file_path = "/custom/path/agentapi.pid" +} +``` + ## For module developers For a complete example of how to use this module, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf). diff --git a/registry/coder/modules/agentapi/agentapi.tftest.hcl b/registry/coder/modules/agentapi/agentapi.tftest.hcl new file mode 100644 index 000000000..87404c625 --- /dev/null +++ b/registry/coder/modules/agentapi/agentapi.tftest.hcl @@ -0,0 +1,108 @@ +mock_provider "coder" {} + +variables { + agent_id = "test-agent" + web_app_icon = "/icon/test.svg" + web_app_display_name = "Test" + web_app_slug = "test" + cli_app_display_name = "Test CLI" + cli_app_slug = "test-cli" + start_script = "echo test" + module_dir_name = ".test-module" +} + +run "default_values" { + command = plan + + assert { + condition = var.enable_state_persistence == false + error_message = "enable_state_persistence should default to false" + } + + assert { + condition = var.state_file_path == "" + error_message = "state_file_path should default to empty string" + } + + assert { + condition = var.pid_file_path == "" + error_message = "pid_file_path should default to empty string" + } + + # Verify start script contains state persistence ARG_ vars. + assert { + condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE", coder_script.agentapi.script)) + error_message = "start script should contain ARG_ENABLE_STATE_PERSISTENCE" + } + + assert { + condition = can(regex("ARG_STATE_FILE_PATH", coder_script.agentapi.script)) + error_message = "start script should contain ARG_STATE_FILE_PATH" + } + + assert { + condition = can(regex("ARG_PID_FILE_PATH", coder_script.agentapi.script)) + error_message = "start script should contain ARG_PID_FILE_PATH" + } + + # Verify shutdown script contains PID-related ARG_ vars. + assert { + condition = can(regex("ARG_PID_FILE_PATH", coder_script.agentapi_shutdown.script)) + error_message = "shutdown script should contain ARG_PID_FILE_PATH" + } + + assert { + condition = can(regex("ARG_MODULE_DIR_NAME", coder_script.agentapi_shutdown.script)) + error_message = "shutdown script should contain ARG_MODULE_DIR_NAME" + } + + assert { + condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE", coder_script.agentapi_shutdown.script)) + error_message = "shutdown script should contain ARG_ENABLE_STATE_PERSISTENCE" + } +} + +run "state_persistence_disabled" { + command = plan + + variables { + enable_state_persistence = false + } + + assert { + condition = var.enable_state_persistence == false + error_message = "enable_state_persistence should be false" + } + + # Even when disabled, the ARG_ vars should still be in the script + # (the shell script handles the conditional logic). + assert { + condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE='false'", coder_script.agentapi.script)) + error_message = "start script should contain ARG_ENABLE_STATE_PERSISTENCE='false'" + } +} + +run "custom_paths" { + command = plan + + variables { + state_file_path = "/custom/state.json" + pid_file_path = "/custom/agentapi.pid" + } + + assert { + condition = can(regex("/custom/state.json", coder_script.agentapi.script)) + error_message = "start script should contain custom state_file_path" + } + + assert { + condition = can(regex("/custom/agentapi.pid", coder_script.agentapi.script)) + error_message = "start script should contain custom pid_file_path" + } + + # Verify custom paths also appear in shutdown script. + assert { + condition = can(regex("/custom/agentapi.pid", coder_script.agentapi_shutdown.script)) + error_message = "shutdown script should contain custom pid_file_path" + } +} diff --git a/registry/coder/modules/agentapi/main.test.ts b/registry/coder/modules/agentapi/main.test.ts index 20b47b1a0..cedf840c2 100644 --- a/registry/coder/modules/agentapi/main.test.ts +++ b/registry/coder/modules/agentapi/main.test.ts @@ -258,11 +258,76 @@ describe("agentapi", async () => { expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *"); }); + test("state-persistence-disabled", async () => { + const { id } = await setup({ + moduleVariables: { + enable_state_persistence: "false", + }, + }); + await execModuleScript(id); + await expectAgentAPIStarted(id); + const mockLog = await readFileContainer( + id, + "/home/coder/agentapi-mock.log", + ); + // PID file should always be exported + expect(mockLog).toContain("AGENTAPI_PID_FILE:"); + // State vars should NOT be present when disabled + expect(mockLog).not.toContain("AGENTAPI_STATE_FILE:"); + expect(mockLog).not.toContain("AGENTAPI_SAVE_STATE:"); + expect(mockLog).not.toContain("AGENTAPI_LOAD_STATE:"); + }); + + test("state-persistence-custom-paths", async () => { + const { id } = await setup({ + moduleVariables: { + enable_state_persistence: "true", + state_file_path: "/home/coder/custom/state.json", + pid_file_path: "/home/coder/custom/agentapi.pid", + }, + }); + await execModuleScript(id); + await expectAgentAPIStarted(id); + const mockLog = await readFileContainer( + id, + "/home/coder/agentapi-mock.log", + ); + expect(mockLog).toContain( + "AGENTAPI_STATE_FILE: /home/coder/custom/state.json", + ); + expect(mockLog).toContain( + "AGENTAPI_PID_FILE: /home/coder/custom/agentapi.pid", + ); + }); + + test("state-persistence-default-paths", async () => { + const { id } = await setup({ + moduleVariables: { + enable_state_persistence: "true", + }, + }); + await execModuleScript(id); + await expectAgentAPIStarted(id); + const mockLog = await readFileContainer( + id, + "/home/coder/agentapi-mock.log", + ); + expect(mockLog).toContain( + `AGENTAPI_STATE_FILE: /home/coder/${moduleDirName}/agentapi-state.json`, + ); + expect(mockLog).toContain( + `AGENTAPI_PID_FILE: /home/coder/${moduleDirName}/agentapi.pid`, + ); + expect(mockLog).toContain("AGENTAPI_SAVE_STATE: true"); + expect(mockLog).toContain("AGENTAPI_LOAD_STATE: true"); + }); + describe("shutdown script", async () => { const setupMocks = async ( containerId: string, agentapiPreset: string, httpCode: number = 204, + pidFilePath: string = "", ) => { const agentapiMock = await loadTestFile( import.meta.dir, @@ -285,10 +350,11 @@ describe("agentapi", async () => { content: coderMock, }); + const pidFileEnv = pidFilePath ? `AGENTAPI_PID_FILE=${pidFilePath}` : ""; await execContainer(containerId, [ "bash", "-c", - `PRESET=${agentapiPreset} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`, + `PRESET=${agentapiPreset} ${pidFileEnv} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`, ]); await execContainer(containerId, [ @@ -303,12 +369,25 @@ describe("agentapi", async () => { const runShutdownScript = async ( containerId: string, taskId: string = "test-task", + pidFilePath: string = "", + enableStatePersistence: string = "false", ) => { const shutdownScript = await loadTestFile( import.meta.dir, "../scripts/agentapi-shutdown.sh", ); + const libScript = await loadTestFile( + import.meta.dir, + "../scripts/lib.sh", + ); + + await writeExecutable({ + containerId, + filePath: "/tmp/agentapi-lib.sh", + content: libScript, + }); + await writeExecutable({ containerId, filePath: "/tmp/shutdown.sh", @@ -318,7 +397,7 @@ describe("agentapi", async () => { return await execContainer(containerId, [ "bash", "-c", - `ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`, + `ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 ARG_PID_FILE_PATH=${pidFilePath} ARG_ENABLE_STATE_PERSISTENCE=${enableStatePersistence} CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`, ]); }; @@ -334,6 +413,7 @@ describe("agentapi", async () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Retrieved 5 messages for log snapshot"); expect(result.stdout).toContain("Log snapshot posted successfully"); + expect(result.stdout).not.toContain("Log snapshot capture failed"); const posted = await readFileContainer(id, "/tmp/snapshot-posted.json"); const snapshot = JSON.parse(posted); @@ -409,5 +489,128 @@ describe("agentapi", async () => { "Log snapshot endpoint not supported by this Coder version", ); }); + + test("sends SIGUSR1 before shutdown", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + const pidFile = "/tmp/agentapi-test.pid"; + await setupMocks(id, "normal", 204, pidFile); + const result = await runShutdownScript(id, "test-task", pidFile, "true"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Sending SIGUSR1 to AgentAPI"); + + const sigusr1Log = await readFileContainer(id, "/tmp/sigusr1-received"); + expect(sigusr1Log).toContain("SIGUSR1 received"); + }); + + test("handles missing PID file gracefully", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + await setupMocks(id, "normal"); + // Pass a non-existent PID file path with persistence enabled to + // exercise the SIGUSR1 path with a missing PID. + const result = await runShutdownScript( + id, + "test-task", + "/tmp/nonexistent.pid", + "true", + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Shutdown complete"); + }); + + test("sends SIGTERM even when snapshot fails", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + const pidFile = "/tmp/agentapi-test.pid"; + // HTTP 500 will cause snapshot to fail + await setupMocks(id, "normal", 500, pidFile); + const result = await runShutdownScript(id, "test-task", pidFile, "true"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain( + "Log snapshot capture failed, continuing shutdown", + ); + expect(result.stdout).toContain("Sending SIGTERM to AgentAPI"); + }); + + test("resolves default PID path from MODULE_DIR_NAME", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + // Start mock with PID file at the module_dir_name default location. + const defaultPidPath = `/home/coder/${moduleDirName}/agentapi.pid`; + await setupMocks(id, "normal", 204, defaultPidPath); + // Don't pass pidFilePath - let shutdown script compute it from MODULE_DIR_NAME. + const shutdownScript = await loadTestFile( + import.meta.dir, + "../scripts/agentapi-shutdown.sh", + ); + const libScript = await loadTestFile( + import.meta.dir, + "../scripts/lib.sh", + ); + await writeExecutable({ + containerId: id, + filePath: "/tmp/agentapi-lib.sh", + content: libScript, + }); + await writeExecutable({ + containerId: id, + filePath: "/tmp/shutdown.sh", + content: shutdownScript, + }); + const result = await execContainer(id, [ + "bash", + "-c", + `ARG_TASK_ID=test-task ARG_AGENTAPI_PORT=3284 ARG_MODULE_DIR_NAME=${moduleDirName} ARG_ENABLE_STATE_PERSISTENCE=true CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`, + ]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Sending SIGUSR1 to AgentAPI"); + expect(result.stdout).toContain("Sending SIGTERM to AgentAPI"); + }); + + test("skips SIGUSR1 when no PID file available", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + await setupMocks(id, "normal", 204); + // No pidFilePath and no MODULE_DIR_NAME, so no PID file can be resolved. + const result = await runShutdownScript(id, "test-task", "", "false"); + + expect(result.exitCode).toBe(0); + // Should not send SIGUSR1 or SIGTERM (no PID to signal). + expect(result.stdout).not.toContain("Sending SIGUSR1"); + expect(result.stdout).not.toContain("Sending SIGTERM"); + expect(result.stdout).toContain("Shutdown complete"); + }); + + test("skips SIGUSR1 when state persistence disabled", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + const pidFile = "/tmp/agentapi-test.pid"; + await setupMocks(id, "normal", 204, pidFile); + // PID file exists but state persistence is disabled. + const result = await runShutdownScript(id, "test-task", pidFile, "false"); + + expect(result.exitCode).toBe(0); + // Should NOT send SIGUSR1 (persistence disabled). + expect(result.stdout).not.toContain("Sending SIGUSR1"); + // Should still send SIGTERM (graceful shutdown always happens). + expect(result.stdout).toContain("Sending SIGTERM to AgentAPI"); + }); }); }); diff --git a/registry/coder/modules/agentapi/main.tf b/registry/coder/modules/agentapi/main.tf index 6914be779..8818736d7 100644 --- a/registry/coder/modules/agentapi/main.tf +++ b/registry/coder/modules/agentapi/main.tf @@ -164,6 +164,23 @@ variable "module_dir_name" { description = "Name of the subdirectory in the home directory for module files." } +variable "enable_state_persistence" { + type = bool + description = "Enable AgentAPI conversation state persistence across restarts." + default = false +} + +variable "state_file_path" { + type = string + description = "Path to the AgentAPI state file. Defaults to $HOME//agentapi-state.json." + default = "" +} + +variable "pid_file_path" { + type = string + description = "Path to the AgentAPI PID file. Defaults to $HOME//agentapi.pid." + default = "" +} locals { # we always trim the slash for consistency @@ -182,6 +199,7 @@ locals { agentapi_chat_base_path = var.agentapi_subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${var.web_app_slug}/chat" main_script = file("${path.module}/scripts/main.sh") shutdown_script = file("${path.module}/scripts/agentapi-shutdown.sh") + lib_script = file("${path.module}/scripts/lib.sh") } resource "coder_script" "agentapi" { @@ -195,6 +213,7 @@ resource "coder_script" "agentapi" { echo -n '${base64encode(local.main_script)}' | base64 -d > /tmp/main.sh chmod +x /tmp/main.sh + echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh ARG_MODULE_DIR_NAME='${var.module_dir_name}' \ ARG_WORKDIR="$(echo -n '${base64encode(local.workdir)}' | base64 -d)" \ @@ -209,6 +228,9 @@ resource "coder_script" "agentapi" { ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \ ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \ ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \ + ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \ + ARG_STATE_FILE_PATH='${var.state_file_path}' \ + ARG_PID_FILE_PATH='${var.pid_file_path}' \ /tmp/main.sh EOT run_on_start = true @@ -225,10 +247,14 @@ resource "coder_script" "agentapi_shutdown" { echo -n '${base64encode(local.shutdown_script)}' | base64 -d > /tmp/agentapi-shutdown.sh chmod +x /tmp/agentapi-shutdown.sh + echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \ ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \ ARG_AGENTAPI_PORT='${var.agentapi_port}' \ + ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \ + ARG_MODULE_DIR_NAME='${var.module_dir_name}' \ + ARG_PID_FILE_PATH='${var.pid_file_path}' \ /tmp/agentapi-shutdown.sh EOT } diff --git a/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh b/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh index bbee76282..8de176e44 100644 --- a/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh +++ b/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash # AgentAPI shutdown script. # -# Captures the last 10 messages from AgentAPI and posts them to Coder instance -# as a snapshot. This script is called during workspace shutdown to access -# conversation history for paused tasks. +# Performs a graceful shutdown of AgentAPI: sends SIGUSR1 to trigger state save, +# captures the last 10 messages as a log snapshot posted to the Coder instance, +# then sends SIGTERM for graceful termination. set -euo pipefail @@ -11,6 +11,13 @@ set -euo pipefail readonly TASK_ID="${ARG_TASK_ID:-}" readonly TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}" readonly AGENTAPI_PORT="${ARG_AGENTAPI_PORT:-3284}" +readonly ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}" +readonly MODULE_DIR_NAME="${ARG_MODULE_DIR_NAME:-}" +readonly PID_FILE_PATH="${ARG_PID_FILE_PATH:-${MODULE_DIR_NAME:+$HOME/$MODULE_DIR_NAME/agentapi.pid}}" + +# Source shared utilities (written by the coder_script wrapper). +# shellcheck source=lib.sh +source /tmp/agentapi-lib.sh # Runtime environment variables. readonly CODER_AGENT_URL="${CODER_AGENT_URL:-}" @@ -20,7 +27,7 @@ readonly CODER_AGENT_TOKEN="${CODER_AGENT_TOKEN:-}" readonly MAX_PAYLOAD_SIZE=65536 # 64KB readonly MAX_MESSAGE_CONTENT=57344 # 56KB readonly MAX_MESSAGES=10 -readonly FETCH_TIMEOUT=5 +readonly FETCH_TIMEOUT=10 readonly POST_TIMEOUT=10 log() { @@ -138,44 +145,45 @@ post_task_log_snapshot() { capture_task_log_snapshot() { if [[ -z $TASK_ID ]]; then log "No task ID, skipping log snapshot" - exit 0 + return 0 fi if [[ -z $CODER_AGENT_URL ]]; then error "CODER_AGENT_URL not set, cannot capture log snapshot" - exit 1 + return 1 fi if [[ -z $CODER_AGENT_TOKEN ]]; then error "CODER_AGENT_TOKEN not set, cannot capture log snapshot" - exit 1 + return 1 fi if ! command -v jq > /dev/null 2>&1; then error "jq not found, cannot capture log snapshot" - exit 1 + return 1 fi if ! command -v curl > /dev/null 2>&1; then error "curl not found, cannot capture log snapshot" - exit 1 + return 1 fi + # Not local, must be visible to the EXIT trap after the function returns. tmpdir=$(mktemp -d) - trap 'rm -rf "$tmpdir"' EXIT + trap 'trap - EXIT; rm -rf "$tmpdir"' EXIT local payload_file="${tmpdir}/payload.json" if ! fetch_and_build_messages_payload "$payload_file"; then error "Cannot capture log snapshot without messages" - exit 1 + return 1 fi local message_count message_count=$(jq '.messages | length' < "$payload_file") if ((message_count == 0)); then log "No messages for log snapshot" - exit 0 + return 0 fi log "Retrieved $message_count messages for log snapshot" @@ -183,7 +191,7 @@ capture_task_log_snapshot() { # Ensure payload fits within size limit. if ! truncate_messages_payload_to_size "$payload_file" "$MAX_PAYLOAD_SIZE"; then error "Failed to truncate payload to size limit" - exit 1 + return 1 fi local final_size final_count @@ -193,19 +201,60 @@ capture_task_log_snapshot() { if ! post_task_log_snapshot "$payload_file" "$tmpdir"; then error "Log snapshot capture failed" - exit 1 + return 1 fi } main() { log "Shutting down AgentAPI" + local agentapi_pid= + if [[ -n $PID_FILE_PATH ]]; then + agentapi_pid=$(cat "$PID_FILE_PATH" 2> /dev/null || echo "") + fi + + # State persistence is only enabled when the binary supports it (>= v0.12.0). + # The default SIGUSR1 disposition on Linux is terminate, so sending it to an + # older binary would kill the process. + local state_persistence=0 + if [[ $ENABLE_STATE_PERSISTENCE == true ]] && version_at_least 0.12.0 "$(agentapi_version)"; then + state_persistence=1 + fi + + # Trigger state save via SIGUSR1 (saves without exiting). + if ((state_persistence)) && [[ -n $agentapi_pid ]] && kill -0 "$agentapi_pid" 2> /dev/null; then + log "Sending SIGUSR1 to AgentAPI (pid $agentapi_pid) to save state" + kill -USR1 "$agentapi_pid" || true + # Allow time for state save to complete before proceeding. + sleep 1 + fi + + # Capture log snapshot for task history. if [[ $TASK_LOG_SNAPSHOT == true ]]; then - capture_task_log_snapshot + # Subshell scopes the EXIT trap (tmpdir cleanup) inside + # capture_task_log_snapshot and preserves set -e, which + # || would otherwise disable for the function body. + (capture_task_log_snapshot) || log "Log snapshot capture failed, continuing shutdown" else log "Log snapshot disabled, skipping" fi + # Graceful termination. + if [[ -n $agentapi_pid ]] && kill -0 "$agentapi_pid" 2> /dev/null; then + log "Sending SIGTERM to AgentAPI (pid $agentapi_pid)" + kill -TERM "$agentapi_pid" 2> /dev/null || true + + # Wait for process to exit to guarantee a clean shutdown. + local elapsed=0 + while kill -0 "$agentapi_pid" 2> /dev/null; do + sleep 1 + ((elapsed++)) || true + if ((elapsed % 5 == 0)); then + log "Warning: AgentAPI (pid $agentapi_pid) still running after ${elapsed}s" + fi + done + fi + log "Shutdown complete" } diff --git a/registry/coder/modules/agentapi/scripts/lib.sh b/registry/coder/modules/agentapi/scripts/lib.sh new file mode 100644 index 000000000..20bdef479 --- /dev/null +++ b/registry/coder/modules/agentapi/scripts/lib.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Shared utility functions for agentapi module scripts. + +# version_at_least checks if an actual version meets a minimum requirement. +# Non-semver strings (e.g. "latest", custom builds) always pass. +# Usage: version_at_least +# version_at_least v0.12.0 v0.10.0 # returns 1 (false) +# version_at_least v0.12.0 v0.12.0 # returns 0 (true) +# version_at_least v0.12.0 latest # returns 0 (true) +version_at_least() { + local min="${1#v}" + local actual="${2#v}" + + # Non-semver versions pass through (e.g. "latest", custom builds). + if ! [[ $actual =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + return 0 + fi + + local act_major="${BASH_REMATCH[1]}" + local act_minor="${BASH_REMATCH[2]}" + local act_patch="${BASH_REMATCH[3]}" + + [[ $min =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || return 0 + + local min_major="${BASH_REMATCH[1]}" + local min_minor="${BASH_REMATCH[2]}" + local min_patch="${BASH_REMATCH[3]}" + + # Arithmetic expressions set exit status: 0 (true) if non-zero, 1 (false) if zero. + if ((act_major != min_major)); then + ((act_major > min_major)) + return + fi + if ((act_minor != min_minor)); then + ((act_minor > min_minor)) + return + fi + ((act_patch >= min_patch)) +} + +# agentapi_version returns the installed agentapi binary version (e.g. "0.11.8"). +# Returns empty string if the binary is missing or doesn't support --version. +agentapi_version() { + agentapi --version 2> /dev/null | awk '{print $NF}' +} diff --git a/registry/coder/modules/agentapi/scripts/main.sh b/registry/coder/modules/agentapi/scripts/main.sh index 63e013eb9..928132c8c 100644 --- a/registry/coder/modules/agentapi/scripts/main.sh +++ b/registry/coder/modules/agentapi/scripts/main.sh @@ -16,8 +16,14 @@ AGENTAPI_PORT="$ARG_AGENTAPI_PORT" AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}" TASK_ID="${ARG_TASK_ID:-}" TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}" +ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}" +STATE_FILE_PATH="${ARG_STATE_FILE_PATH:-}" +PID_FILE_PATH="${ARG_PID_FILE_PATH:-}" set +o nounset +# shellcheck source=lib.sh +source /tmp/agentapi-lib.sh + command_exists() { command -v "$1" > /dev/null 2>&1 } @@ -106,5 +112,18 @@ cd "${WORKDIR}" export AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}" # Disable host header check since AgentAPI is proxied by Coder (which does its own validation) export AGENTAPI_ALLOWED_HOSTS="*" +export AGENTAPI_PID_FILE="${PID_FILE_PATH:-$module_path/agentapi.pid}" +# Only set state env vars when persistence is enabled and the binary supports +# it. State persistence requires agentapi >= v0.12.0. +if [ "${ENABLE_STATE_PERSISTENCE}" = "true" ]; then + actual_version=$(agentapi_version) + if version_at_least 0.12.0 "$actual_version"; then + export AGENTAPI_STATE_FILE="${STATE_FILE_PATH:-$module_path/agentapi-state.json}" + export AGENTAPI_SAVE_STATE="true" + export AGENTAPI_LOAD_STATE="true" + else + echo "Warning: State persistence requires agentapi >= v0.12.0 (current: ${actual_version:-unknown}), skipping." + fi +fi nohup "$module_path/scripts/agentapi-start.sh" true "${AGENTAPI_PORT}" &> "$module_path/agentapi-start.log" & "$module_path/scripts/agentapi-wait-for-start.sh" "${AGENTAPI_PORT}" diff --git a/registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js b/registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js index c6b0fb7fe..c53a0757a 100644 --- a/registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js +++ b/registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js @@ -3,8 +3,26 @@ // Usage: MESSAGES='[...]' node agentapi-mock-shutdown.js [port] const http = require("http"); +const fs = require("fs"); const port = process.argv[2] || 3284; +// Write PID file for shutdown script. +if (process.env.AGENTAPI_PID_FILE) { + const path = require("path"); + fs.mkdirSync(path.dirname(process.env.AGENTAPI_PID_FILE), { + recursive: true, + }); + fs.writeFileSync(process.env.AGENTAPI_PID_FILE, String(process.pid)); +} + +// Handle SIGUSR1 (state save signal from shutdown script). +process.on("SIGUSR1", () => { + fs.writeFileSync( + "/tmp/sigusr1-received", + `SIGUSR1 received at ${Date.now()}\n`, + ); +}); + // Parse messages from environment or use default let messages = []; if (process.env.MESSAGES) { diff --git a/registry/coder/modules/agentapi/testdata/agentapi-mock.js b/registry/coder/modules/agentapi/testdata/agentapi-mock.js index 72db716a3..84a88c047 100644 --- a/registry/coder/modules/agentapi/testdata/agentapi-mock.js +++ b/registry/coder/modules/agentapi/testdata/agentapi-mock.js @@ -6,12 +6,41 @@ const args = process.argv.slice(2); const portIdx = args.findIndex((arg) => arg === "--port") + 1; const port = portIdx ? args[portIdx] : 3284; +if (args.includes("--version")) { + console.log("agentapi version 99.99.99"); + process.exit(0); +} + console.log(`starting server on port ${port}`); fs.writeFileSync( "/home/coder/agentapi-mock.log", `AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}`, ); +// Log state persistence env vars. +for (const v of [ + "AGENTAPI_STATE_FILE", + "AGENTAPI_PID_FILE", + "AGENTAPI_SAVE_STATE", + "AGENTAPI_LOAD_STATE", +]) { + if (process.env[v]) { + fs.appendFileSync( + "/home/coder/agentapi-mock.log", + `\n${v}: ${process.env[v]}`, + ); + } +} + +// Write PID file for shutdown script. +if (process.env.AGENTAPI_PID_FILE) { + const path = require("path"); + fs.mkdirSync(path.dirname(process.env.AGENTAPI_PID_FILE), { + recursive: true, + }); + fs.writeFileSync(process.env.AGENTAPI_PID_FILE, String(process.pid)); +} + http .createServer(function (_request, response) { response.writeHead(200);