diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md index 954db1ce3..06897d6b5 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.0.0" + version = "2.1.0" agent_id = var.agent_id web_app_slug = local.app_slug @@ -49,6 +49,19 @@ module "agentapi" { } ``` +## Task log snapshot + +Captures the last 10 messages from AgentAPI when a task workspace stops. This allows viewing conversation history while the task is paused. + +To enable for task workspaces: + +```tf +module "agentapi" { + # ... other config + task_log_snapshot = true # default: true +} +``` + ## 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/main.test.ts b/registry/coder/modules/agentapi/main.test.ts index 713335fd4..20b47b1a0 100644 --- a/registry/coder/modules/agentapi/main.test.ts +++ b/registry/coder/modules/agentapi/main.test.ts @@ -257,4 +257,157 @@ describe("agentapi", async () => { ); expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *"); }); + + describe("shutdown script", async () => { + const setupMocks = async ( + containerId: string, + agentapiPreset: string, + httpCode: number = 204, + ) => { + const agentapiMock = await loadTestFile( + import.meta.dir, + "agentapi-mock-shutdown.js", + ); + const coderMock = await loadTestFile( + import.meta.dir, + "coder-instance-mock.js", + ); + + await writeExecutable({ + containerId, + filePath: "/usr/local/bin/mock-agentapi", + content: agentapiMock, + }); + + await writeExecutable({ + containerId, + filePath: "/usr/local/bin/mock-coder", + content: coderMock, + }); + + await execContainer(containerId, [ + "bash", + "-c", + `PRESET=${agentapiPreset} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`, + ]); + + await execContainer(containerId, [ + "bash", + "-c", + `HTTP_CODE=${httpCode} nohup node /usr/local/bin/mock-coder 18080 > /tmp/mock-coder.log 2>&1 &`, + ]); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + }; + + const runShutdownScript = async ( + containerId: string, + taskId: string = "test-task", + ) => { + const shutdownScript = await loadTestFile( + import.meta.dir, + "../scripts/agentapi-shutdown.sh", + ); + + await writeExecutable({ + containerId, + filePath: "/tmp/shutdown.sh", + content: shutdownScript, + }); + + 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`, + ]); + }; + + test("posts snapshot with normal messages", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + + await setupMocks(id, "normal"); + const result = await runShutdownScript(id); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Retrieved 5 messages for log snapshot"); + expect(result.stdout).toContain("Log snapshot posted successfully"); + + const posted = await readFileContainer(id, "/tmp/snapshot-posted.json"); + const snapshot = JSON.parse(posted); + expect(snapshot.task_id).toBe("test-task"); + expect(snapshot.payload.messages).toHaveLength(5); + expect(snapshot.payload.messages[0].content).toBe("Hello"); + expect(snapshot.payload.messages[4].content).toBe("Great"); + }); + + test("truncates to last 10 messages", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + + await setupMocks(id, "many"); + const result = await runShutdownScript(id); + + expect(result.exitCode).toBe(0); + + const posted = await readFileContainer(id, "/tmp/snapshot-posted.json"); + const snapshot = JSON.parse(posted); + expect(snapshot.task_id).toBe("test-task"); + expect(snapshot.payload.messages).toHaveLength(10); + expect(snapshot.payload.messages[0].content).toBe("Message 6"); + expect(snapshot.payload.messages[9].content).toBe("Message 15"); + }); + + test("truncates huge message content", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + + await setupMocks(id, "huge"); + const result = await runShutdownScript(id); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("truncating final message content"); + + const posted = await readFileContainer(id, "/tmp/snapshot-posted.json"); + const snapshot = JSON.parse(posted); + expect(snapshot.task_id).toBe("test-task"); + expect(snapshot.payload.messages).toHaveLength(1); + expect(snapshot.payload.messages[0].content).toContain( + "[...content truncated", + ); + }); + + test("skips gracefully when TASK_ID is empty", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + + const result = await runShutdownScript(id, ""); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("No task ID, skipping log snapshot"); + }); + + test("handles 404 gracefully for older Coder versions", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + + await setupMocks(id, "normal", 404); + const result = await runShutdownScript(id); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain( + "Log snapshot endpoint not supported by this Coder version", + ); + }); + }); }); diff --git a/registry/coder/modules/agentapi/main.tf b/registry/coder/modules/agentapi/main.tf index 5c3ab9c40..6914be779 100644 --- a/registry/coder/modules/agentapi/main.tf +++ b/registry/coder/modules/agentapi/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 2.12" + version = ">= 2.13" } } } @@ -18,6 +18,8 @@ data "coder_workspace" "me" {} data "coder_workspace_owner" "me" {} +data "coder_task" "me" {} + variable "web_app_order" { type = number description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." @@ -126,6 +128,12 @@ variable "agentapi_port" { default = 3284 } +variable "task_log_snapshot" { + type = bool + description = "Capture last 10 messages when workspace stops for offline viewing while task is paused." + default = true +} + locals { # agentapi_subdomain_false_min_version_expr matches a semantic version >= v0.3.3. # Initial support was added in v0.3.1 but configuration via environment variable @@ -173,6 +181,7 @@ locals { // for backward compatibility. 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") } resource "coder_script" "agentapi" { @@ -198,11 +207,32 @@ resource "coder_script" "agentapi" { ARG_POST_INSTALL_SCRIPT="$(echo -n '${local.encoded_post_install_script}' | base64 -d)" \ ARG_AGENTAPI_PORT='${var.agentapi_port}' \ 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}' \ /tmp/main.sh EOT run_on_start = true } +resource "coder_script" "agentapi_shutdown" { + agent_id = var.agent_id + display_name = "AgentAPI Shutdown" + icon = var.web_app_icon + run_on_stop = true + script = <<-EOT + #!/bin/bash + set -o pipefail + + echo -n '${base64encode(local.shutdown_script)}' | base64 -d > /tmp/agentapi-shutdown.sh + chmod +x /tmp/agentapi-shutdown.sh + + ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \ + ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \ + ARG_AGENTAPI_PORT='${var.agentapi_port}' \ + /tmp/agentapi-shutdown.sh + EOT +} + resource "coder_app" "agentapi_web" { slug = var.web_app_slug display_name = var.web_app_display_name diff --git a/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh b/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh new file mode 100644 index 000000000..bbee76282 --- /dev/null +++ b/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh @@ -0,0 +1,212 @@ +#!/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. + +set -euo pipefail + +# Configuration (set via Terraform interpolation). +readonly TASK_ID="${ARG_TASK_ID:-}" +readonly TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}" +readonly AGENTAPI_PORT="${ARG_AGENTAPI_PORT:-3284}" + +# Runtime environment variables. +readonly CODER_AGENT_URL="${CODER_AGENT_URL:-}" +readonly CODER_AGENT_TOKEN="${CODER_AGENT_TOKEN:-}" + +# Constants. +readonly MAX_PAYLOAD_SIZE=65536 # 64KB +readonly MAX_MESSAGE_CONTENT=57344 # 56KB +readonly MAX_MESSAGES=10 +readonly FETCH_TIMEOUT=5 +readonly POST_TIMEOUT=10 + +log() { + echo "$*" +} + +error() { + echo "Error: $*" >&2 +} + +fetch_and_build_messages_payload() { + local payload_file="$1" + local messages_url="http://localhost:${AGENTAPI_PORT}/messages" + + log "Fetching messages from AgentAPI on port $AGENTAPI_PORT" + + if ! curl -fsSL --max-time "$FETCH_TIMEOUT" "$messages_url" > "$payload_file"; then + error "Failed to fetch messages from AgentAPI (may not be running)" + return 1 + fi + + # Update messages field to keep only last N messages. + if ! jq --argjson n "$MAX_MESSAGES" '.messages |= .[-$n:]' < "$payload_file" > "${payload_file}.tmp"; then + error "Failed to select last $MAX_MESSAGES messages" + return 1 + fi + mv "${payload_file}.tmp" "$payload_file" + + return 0 +} + +truncate_messages_payload_to_size() { + local payload_file="$1" + local max_size="$2" + + while true; do + local size + size=$(wc -c < "$payload_file") + + if ((size <= max_size)); then + break + fi + + local count + count=$(jq '.messages | length' < "$payload_file") + + if ((count == 1)); then + # Down to last message, truncate its content keeping the tail. + log "Payload size $size bytes exceeds limit, truncating final message content" + + # Keep tail of content with truncation indicator, leaving room for JSON + # overhead. + if ! jq --argjson maxlen "$MAX_MESSAGE_CONTENT" '.messages[0].content |= (if length > $maxlen then "[...content truncated, showing last 56KB...]\n\n" + .[-$maxlen:] else . end)' < "$payload_file" > "${payload_file}.tmp"; then + error "Failed to truncate message content" + return 1 + fi + mv "${payload_file}.tmp" "$payload_file" + + # Verify the truncation was sufficient. + size=$(wc -c < "$payload_file") + if ((size > max_size)); then + error "Payload still too large after content truncation, giving up" + return 1 + fi + break + else + # More than one message, remove the oldest. + log "Payload size $size bytes exceeds limit, removing oldest message" + + if ! jq '.messages |= .[1:]' < "$payload_file" > "${payload_file}.tmp"; then + error "Failed to remove oldest message" + return 1 + fi + mv "${payload_file}.tmp" "$payload_file" + fi + done + + return 0 +} + +post_task_log_snapshot() { + local payload_file="$1" + local tmpdir="$2" + + local snapshot_url="${CODER_AGENT_URL}/api/v2/workspaceagents/me/tasks/${TASK_ID}/log-snapshot?format=agentapi" + local response_file="${tmpdir}/response.txt" + + log "Posting log snapshot to Coder instance" + + local http_code + if ! http_code=$(curl -sS -w "%{http_code}" -o "$response_file" \ + --max-time "$POST_TIMEOUT" \ + -X POST "$snapshot_url" \ + -H "Coder-Session-Token: $CODER_AGENT_TOKEN" \ + -H "Content-Type: application/json" \ + --data-binary "@$payload_file"); then + error "Failed to connect to Coder instance (curl failed)" + return 1 + fi + + if [[ $http_code == 204 ]]; then + log "Log snapshot posted successfully" + return 0 + elif [[ $http_code == 404 ]]; then + log "Log snapshot endpoint not supported by this Coder version, skipping" + return 0 + else + local response + response=$(cat "$response_file" 2> /dev/null || echo "") + error "Failed to post log snapshot (HTTP $http_code): $response" + return 1 + fi +} + +capture_task_log_snapshot() { + if [[ -z $TASK_ID ]]; then + log "No task ID, skipping log snapshot" + exit 0 + fi + + if [[ -z $CODER_AGENT_URL ]]; then + error "CODER_AGENT_URL not set, cannot capture log snapshot" + exit 1 + fi + + if [[ -z $CODER_AGENT_TOKEN ]]; then + error "CODER_AGENT_TOKEN not set, cannot capture log snapshot" + exit 1 + fi + + if ! command -v jq > /dev/null 2>&1; then + error "jq not found, cannot capture log snapshot" + exit 1 + fi + + if ! command -v curl > /dev/null 2>&1; then + error "curl not found, cannot capture log snapshot" + exit 1 + fi + + tmpdir=$(mktemp -d) + trap '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 + fi + + local message_count + message_count=$(jq '.messages | length' < "$payload_file") + if ((message_count == 0)); then + log "No messages for log snapshot" + exit 0 + fi + + log "Retrieved $message_count messages for 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 + fi + + local final_size final_count + final_size=$(wc -c < "$payload_file") + final_count=$(jq '.messages | length' < "$payload_file") + log "Log snapshot payload: $final_size bytes, $final_count messages" + + if ! post_task_log_snapshot "$payload_file" "$tmpdir"; then + error "Log snapshot capture failed" + exit 1 + fi +} + +main() { + log "Shutting down AgentAPI" + + if [[ $TASK_LOG_SNAPSHOT == true ]]; then + capture_task_log_snapshot + else + log "Log snapshot disabled, skipping" + fi + + log "Shutdown complete" +} + +main "$@" diff --git a/registry/coder/modules/agentapi/scripts/main.sh b/registry/coder/modules/agentapi/scripts/main.sh index 3875430e6..63e013eb9 100644 --- a/registry/coder/modules/agentapi/scripts/main.sh +++ b/registry/coder/modules/agentapi/scripts/main.sh @@ -14,6 +14,8 @@ WAIT_FOR_START_SCRIPT="$ARG_WAIT_FOR_START_SCRIPT" POST_INSTALL_SCRIPT="$ARG_POST_INSTALL_SCRIPT" 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}" set +o nounset command_exists() { @@ -23,6 +25,13 @@ command_exists() { module_path="$HOME/${MODULE_DIR_NAME}" mkdir -p "$module_path/scripts" +# Check for jq dependency if task log snapshot is enabled. +if [[ $TASK_LOG_SNAPSHOT == true ]] && [[ -n $TASK_ID ]]; then + if ! command_exists jq; then + echo "Warning: jq is not installed. Task log snapshot requires jq to capture conversation history." + echo "Install jq to enable log snapshot functionality when the workspace stops." + fi +fi if [ ! -d "${WORKDIR}" ]; then echo "Warning: The specified folder '${WORKDIR}' does not exist." echo "Creating the folder..." diff --git a/registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js b/registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js new file mode 100644 index 000000000..c6b0fb7fe --- /dev/null +++ b/registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js @@ -0,0 +1,84 @@ +#!/usr/bin/env node +// Mock AgentAPI server for shutdown script tests. +// Usage: MESSAGES='[...]' node agentapi-mock-shutdown.js [port] + +const http = require("http"); +const port = process.argv[2] || 3284; + +// Parse messages from environment or use default +let messages = []; +if (process.env.MESSAGES) { + try { + messages = JSON.parse(process.env.MESSAGES); + } catch (e) { + console.error("Failed to parse MESSAGES env var:", e.message); + process.exit(1); + } +} + +// Presets for common test scenarios +if (process.env.PRESET === "normal") { + messages = [ + { id: 1, type: "input", content: "Hello", time: "2025-01-01T00:00:00Z" }, + { + id: 2, + type: "output", + content: "Hi there", + time: "2025-01-01T00:00:01Z", + }, + { + id: 3, + type: "input", + content: "How are you?", + time: "2025-01-01T00:00:02Z", + }, + { + id: 4, + type: "output", + content: "Good!", + time: "2025-01-01T00:00:03Z", + }, + { id: 5, type: "input", content: "Great", time: "2025-01-01T00:00:04Z" }, + ]; +} else if (process.env.PRESET === "many") { + messages = Array.from({ length: 15 }, (_, i) => ({ + id: i + 1, + type: "input", + content: `Message ${i + 1}`, + time: "2025-01-01T00:00:00Z", + })); +} else if (process.env.PRESET === "huge") { + messages = [ + { + id: 1, + type: "output", + content: "x".repeat(70000), + time: "2025-01-01T00:00:00Z", + }, + ]; +} + +const server = http.createServer((req, res) => { + if (req.url === "/messages") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ messages })); + } else if (req.url === "/status") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "stable" })); + } else { + res.writeHead(404); + res.end(); + } +}); + +server.listen(port, () => { + console.error(`Mock AgentAPI listening on port ${port}`); +}); + +process.on("SIGTERM", () => { + server.close(() => process.exit(0)); +}); + +process.on("SIGINT", () => { + server.close(() => process.exit(0)); +}); diff --git a/registry/coder/modules/agentapi/testdata/coder-instance-mock.js b/registry/coder/modules/agentapi/testdata/coder-instance-mock.js new file mode 100644 index 000000000..6d99215d2 --- /dev/null +++ b/registry/coder/modules/agentapi/testdata/coder-instance-mock.js @@ -0,0 +1,61 @@ +#!/usr/bin/env node +// Mock Coder instance server for shutdown script tests. +// Captures POST requests to /log-snapshot endpoint. + +const http = require("http"); +const fs = require("fs"); +const port = process.argv[2] || 8080; +const outputFile = process.env.OUTPUT_FILE || "/tmp/snapshot-posted.json"; +const httpCode = parseInt(process.env.HTTP_CODE || "204", 10); + +const server = http.createServer((req, res) => { + const url = new URL(req.url, `http://localhost:${port}`); + + // Expected path: /api/v2/workspaceagents/me/tasks/{task_id}/log-snapshot + const pathMatch = url.pathname.match(/\/tasks\/([^\/]+)\/log-snapshot$/); + + if (req.method === "POST" && pathMatch) { + const taskId = pathMatch[1]; + let body = ""; + req.on("data", (chunk) => { + body += chunk.toString(); + }); + + req.on("end", () => { + // Save captured snapshot with task ID for verification + const snapshotData = { + task_id: taskId, + payload: JSON.parse(body), + }; + fs.writeFileSync(outputFile, JSON.stringify(snapshotData, null, 2)); + console.error( + `Captured snapshot for task ${taskId} (${body.length} bytes) to ${outputFile}`, + ); + + // Return configured status code + res.writeHead(httpCode); + res.end(); + }); + + req.on("error", (err) => { + console.error("Request error:", err); + res.writeHead(500); + res.end(); + }); + } else { + res.writeHead(404); + res.end(); + } +}); + +server.listen(port, () => { + console.error(`Mock Coder instance listening on port ${port}`); +}); + +process.on("SIGTERM", () => { + server.close(() => process.exit(0)); +}); + +process.on("SIGINT", () => { + server.close(() => process.exit(0)); +});