Merge branch 'main' into fix/dotfiles-fish-compatibility
This commit is contained in:
commit
d20d182cf1
@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI
|
|||||||
```tf
|
```tf
|
||||||
module "agentapi" {
|
module "agentapi" {
|
||||||
source = "registry.coder.com/coder/agentapi/coder"
|
source = "registry.coder.com/coder/agentapi/coder"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
|
|
||||||
agent_id = var.agent_id
|
agent_id = var.agent_id
|
||||||
web_app_slug = local.app_slug
|
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 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).
|
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).
|
||||||
|
|||||||
@ -257,4 +257,157 @@ describe("agentapi", async () => {
|
|||||||
);
|
);
|
||||||
expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *");
|
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,7 +4,7 @@ terraform {
|
|||||||
required_providers {
|
required_providers {
|
||||||
coder = {
|
coder = {
|
||||||
source = "coder/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_workspace_owner" "me" {}
|
||||||
|
|
||||||
|
data "coder_task" "me" {}
|
||||||
|
|
||||||
variable "web_app_order" {
|
variable "web_app_order" {
|
||||||
type = number
|
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)."
|
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
|
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 {
|
locals {
|
||||||
# agentapi_subdomain_false_min_version_expr matches a semantic version >= v0.3.3.
|
# 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
|
# Initial support was added in v0.3.1 but configuration via environment variable
|
||||||
@ -173,6 +181,7 @@ locals {
|
|||||||
// for backward compatibility.
|
// 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"
|
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")
|
main_script = file("${path.module}/scripts/main.sh")
|
||||||
|
shutdown_script = file("${path.module}/scripts/agentapi-shutdown.sh")
|
||||||
}
|
}
|
||||||
|
|
||||||
resource "coder_script" "agentapi" {
|
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_POST_INSTALL_SCRIPT="$(echo -n '${local.encoded_post_install_script}' | base64 -d)" \
|
||||||
ARG_AGENTAPI_PORT='${var.agentapi_port}' \
|
ARG_AGENTAPI_PORT='${var.agentapi_port}' \
|
||||||
ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \
|
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
|
/tmp/main.sh
|
||||||
EOT
|
EOT
|
||||||
run_on_start = true
|
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" {
|
resource "coder_app" "agentapi_web" {
|
||||||
slug = var.web_app_slug
|
slug = var.web_app_slug
|
||||||
display_name = var.web_app_display_name
|
display_name = var.web_app_display_name
|
||||||
|
|||||||
212
registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh
Normal file
212
registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh
Normal file
@ -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 "$@"
|
||||||
@ -14,6 +14,8 @@ WAIT_FOR_START_SCRIPT="$ARG_WAIT_FOR_START_SCRIPT"
|
|||||||
POST_INSTALL_SCRIPT="$ARG_POST_INSTALL_SCRIPT"
|
POST_INSTALL_SCRIPT="$ARG_POST_INSTALL_SCRIPT"
|
||||||
AGENTAPI_PORT="$ARG_AGENTAPI_PORT"
|
AGENTAPI_PORT="$ARG_AGENTAPI_PORT"
|
||||||
AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}"
|
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
|
set +o nounset
|
||||||
|
|
||||||
command_exists() {
|
command_exists() {
|
||||||
@ -23,6 +25,13 @@ command_exists() {
|
|||||||
module_path="$HOME/${MODULE_DIR_NAME}"
|
module_path="$HOME/${MODULE_DIR_NAME}"
|
||||||
mkdir -p "$module_path/scripts"
|
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
|
if [ ! -d "${WORKDIR}" ]; then
|
||||||
echo "Warning: The specified folder '${WORKDIR}' does not exist."
|
echo "Warning: The specified folder '${WORKDIR}' does not exist."
|
||||||
echo "Creating the folder..."
|
echo "Creating the folder..."
|
||||||
|
|||||||
84
registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js
vendored
Normal file
84
registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js
vendored
Normal file
@ -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));
|
||||||
|
});
|
||||||
61
registry/coder/modules/agentapi/testdata/coder-instance-mock.js
vendored
Normal file
61
registry/coder/modules/agentapi/testdata/coder-instance-mock.js
vendored
Normal file
@ -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));
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user