From d3e42b04730c0b1441aca62368d90e022a9e32b0 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 27 Jan 2026 15:25:51 +0000 Subject: [PATCH 1/5] feat(coder/modules/agentapi): add log snapshot capture on shutdown Captures the last 10 messages from AgentAPI when task workspaces stop, allowing users to view conversation history while the task is paused. The shutdown script fetches messages, builds a payload with last 10 messages, truncates to 64KB if needed (removes old messages first, then truncates content of the last message), and posts to the log snapshot endpoint. Gracefully handles non-task workspaces (skips), older Coder versions without the endpoint (logs and continues), and empty message sets. Enabled by default via task_log_snapshot variable. Task ID is automatically resolved from data.coder_task when available. Updates coder/internal#1257 --- registry/coder/modules/agentapi/README.md | 13 ++ registry/coder/modules/agentapi/main.test.ts | 153 +++++++++++++ registry/coder/modules/agentapi/main.tf | 29 +++ .../agentapi/scripts/agentapi-shutdown.sh | 211 ++++++++++++++++++ .../testdata/agentapi-mock-shutdown.js | 84 +++++++ .../agentapi/testdata/coder-instance-mock.js | 61 +++++ 6 files changed, 551 insertions(+) create mode 100644 registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh create mode 100644 registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js create mode 100644 registry/coder/modules/agentapi/testdata/coder-instance-mock.js diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md index 954db1ce3..556bbae60 100644 --- a/registry/coder/modules/agentapi/README.md +++ b/registry/coder/modules/agentapi/README.md @@ -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 + 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..b1ac8b093 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 &`, + ]); + + await execContainer(containerId, [ + "bash", + "-c", + `HTTP_CODE=${httpCode} nohup node /usr/local/bin/mock-coder 8080 &`, + ]); + + 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:8080 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.stderr).toContain("Retrieved 5 messages for log snapshot"); + expect(result.stderr).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.stderr).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.stderr).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.stderr).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..7ab75262c 100644 --- a/registry/coder/modules/agentapi/main.tf +++ b/registry/coder/modules/agentapi/main.tf @@ -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" { @@ -203,6 +212,26 @@ resource "coder_script" "agentapi" { 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 + start_blocks_login = false + 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..d7592a288 --- /dev/null +++ b/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh @@ -0,0 +1,211 @@ +#!/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 "$*" >&2 +} + +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 + + if ! jq --argjson n "$MAX_MESSAGES" '{messages: .[-$n:]}' < "$payload_file" > "${payload_file}.tmp"; then + error "Failed to build payload structure" + 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" + 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/testdata/agentapi-mock-shutdown.js b/registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js new file mode 100644 index 000000000..cb400ca6d --- /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)); +}); From 754897e8996fb918aebd2bcc2f94bd9185862059 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 28 Jan 2026 12:11:39 +0000 Subject: [PATCH 2/5] add jq check on start --- registry/coder/modules/agentapi/README.md | 2 +- registry/coder/modules/agentapi/main.tf | 13 +++++++------ .../modules/agentapi/scripts/agentapi-shutdown.sh | 2 +- registry/coder/modules/agentapi/scripts/main.sh | 9 +++++++++ 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md index 556bbae60..6a046baf6 100644 --- a/registry/coder/modules/agentapi/README.md +++ b/registry/coder/modules/agentapi/README.md @@ -58,7 +58,7 @@ To enable for task workspaces: ```tf module "agentapi" { # ... other config - log_snapshot = true # default: true + task_log_snapshot = true # default: true } ``` diff --git a/registry/coder/modules/agentapi/main.tf b/registry/coder/modules/agentapi/main.tf index 7ab75262c..b0f84e7d9 100644 --- a/registry/coder/modules/agentapi/main.tf +++ b/registry/coder/modules/agentapi/main.tf @@ -207,18 +207,19 @@ 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 - start_blocks_login = false - script = <<-EOT + 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 diff --git a/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh b/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh index d7592a288..e6cd4c515 100644 --- a/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh +++ b/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh @@ -28,7 +28,7 @@ log() { } error() { - echo "ERROR: $*" >&2 + echo "Error: $*" >&2 } fetch_and_build_messages_payload() { 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..." From 8bdfa80ab40279d3233c610eb180314dbb8d4ad5 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 28 Jan 2026 13:34:02 +0000 Subject: [PATCH 3/5] fix jq and format --- registry/coder/modules/agentapi/main.test.ts | 6 +++--- .../coder/modules/agentapi/scripts/agentapi-shutdown.sh | 7 ++++--- .../modules/agentapi/testdata/agentapi-mock-shutdown.js | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/registry/coder/modules/agentapi/main.test.ts b/registry/coder/modules/agentapi/main.test.ts index b1ac8b093..eb440c174 100644 --- a/registry/coder/modules/agentapi/main.test.ts +++ b/registry/coder/modules/agentapi/main.test.ts @@ -288,13 +288,13 @@ describe("agentapi", async () => { await execContainer(containerId, [ "bash", "-c", - `PRESET=${agentapiPreset} nohup node /usr/local/bin/mock-agentapi 3284 &`, + `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 8080 &`, + `HTTP_CODE=${httpCode} nohup node /usr/local/bin/mock-coder 18080 > /tmp/mock-coder.log 2>&1 &`, ]); await new Promise((resolve) => setTimeout(resolve, 1000)); @@ -318,7 +318,7 @@ describe("agentapi", async () => { return await execContainer(containerId, [ "bash", "-c", - `ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 CODER_AGENT_URL=http://localhost:8080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`, + `ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`, ]); }; diff --git a/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh b/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh index e6cd4c515..6aa02e9a2 100644 --- a/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh +++ b/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh @@ -42,8 +42,9 @@ fetch_and_build_messages_payload() { return 1 fi - if ! jq --argjson n "$MAX_MESSAGES" '{messages: .[-$n:]}' < "$payload_file" > "${payload_file}.tmp"; then - error "Failed to build payload structure" + # 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" @@ -104,7 +105,7 @@ 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" + 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" diff --git a/registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js b/registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js index cb400ca6d..c6b0fb7fe 100644 --- a/registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js +++ b/registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js @@ -61,7 +61,7 @@ if (process.env.PRESET === "normal") { const server = http.createServer((req, res) => { if (req.url === "/messages") { res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify(messages)); + res.end(JSON.stringify({ messages })); } else if (req.url === "/status") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "stable" })); From f72792ccbc2ffa35170e77f6efffe5b8d671fb40 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 28 Jan 2026 13:35:08 +0000 Subject: [PATCH 4/5] log as info --- registry/coder/modules/agentapi/main.test.ts | 10 +++++----- .../modules/agentapi/scripts/agentapi-shutdown.sh | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/registry/coder/modules/agentapi/main.test.ts b/registry/coder/modules/agentapi/main.test.ts index eb440c174..20b47b1a0 100644 --- a/registry/coder/modules/agentapi/main.test.ts +++ b/registry/coder/modules/agentapi/main.test.ts @@ -332,8 +332,8 @@ describe("agentapi", async () => { const result = await runShutdownScript(id); expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("Retrieved 5 messages for log snapshot"); - expect(result.stderr).toContain("Log snapshot posted successfully"); + 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); @@ -372,7 +372,7 @@ describe("agentapi", async () => { const result = await runShutdownScript(id); expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("truncating final message content"); + expect(result.stdout).toContain("truncating final message content"); const posted = await readFileContainer(id, "/tmp/snapshot-posted.json"); const snapshot = JSON.parse(posted); @@ -392,7 +392,7 @@ describe("agentapi", async () => { const result = await runShutdownScript(id, ""); expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("No task ID, skipping log snapshot"); + expect(result.stdout).toContain("No task ID, skipping log snapshot"); }); test("handles 404 gracefully for older Coder versions", async () => { @@ -405,7 +405,7 @@ describe("agentapi", async () => { const result = await runShutdownScript(id); expect(result.exitCode).toBe(0); - expect(result.stderr).toContain( + expect(result.stdout).toContain( "Log snapshot endpoint not supported by this Coder version", ); }); diff --git a/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh b/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh index 6aa02e9a2..bbee76282 100644 --- a/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh +++ b/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh @@ -24,7 +24,7 @@ readonly FETCH_TIMEOUT=5 readonly POST_TIMEOUT=10 log() { - echo "$*" >&2 + echo "$*" } error() { From ac8354da25f98bee20ff519f0c0eab37d2e722b8 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 28 Jan 2026 13:53:52 +0000 Subject: [PATCH 5/5] bump coder/coder min version --- registry/coder/modules/agentapi/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registry/coder/modules/agentapi/main.tf b/registry/coder/modules/agentapi/main.tf index b0f84e7d9..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" } } }