Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions registry/coder/modules/agentapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
153 changes: 153 additions & 0 deletions registry/coder/modules/agentapi/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
});
});
});
32 changes: 31 additions & 1 deletion registry/coder/modules/agentapi/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ terraform {
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.12"
version = ">= 2.13"
}
}
}
Expand All @@ -18,6 +18,8 @@ data "coder_workspace" "me" {}

data "coder_workspace_owner" "me" {}

data "coder_task" "me" {}
Comment on lines 18 to +21

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bump provider version for coder_task data source

This module now declares data "coder_task" "me" {} but still allows coder provider >= 2.12. Tasks usage elsewhere in the repo pins coder to >= 2.13 (see registry/coder-labs/templates/tasks-docker/main.tf) alongside coder_task, which suggests the data source was introduced after 2.12. If a user is still on 2.12 (currently permitted by this module), Terraform will error with an unknown data source. Consider bumping required_providers.coder.version to >= 2.13 (or gating the data source) so non-task users on 2.12 aren’t broken.

Useful? React with 👍 / 👎.


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)."
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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" {
Expand All @@ -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
Expand Down
Loading