mirror of
https://github.com/coder/registry.git
synced 2026-06-03 13:08:14 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2169fb00ee |
@@ -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/<module_dir_name>/` 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).
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/<module_dir_name>/agentapi-state.json."
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "pid_file_path" {
|
||||
type = string
|
||||
description = "Path to the AgentAPI PID file. Defaults to $HOME/<module_dir_name>/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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <minimum> <actual>
|
||||
# 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}'
|
||||
}
|
||||
@@ -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}"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user