329 lines
9.4 KiB
Go
329 lines
9.4 KiB
Go
package runtime
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
|
|
settingsstore "github.com/kandev/kandev/internal/agent/settings/store"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/office/agents"
|
|
"github.com/kandev/kandev/internal/office/models"
|
|
"github.com/kandev/kandev/internal/office/repository/sqlite"
|
|
)
|
|
|
|
type handlerHarness struct {
|
|
router *gin.Engine
|
|
token string
|
|
comments *handlerCommentWriter
|
|
status *recordingTaskStatusUpdater
|
|
runEvents *recordingRunEvents
|
|
agentSvc *agents.AgentService
|
|
repository *sqlite.Repository
|
|
}
|
|
|
|
type handlerCommentWriter struct {
|
|
comments []*models.TaskComment
|
|
}
|
|
|
|
func (r *handlerCommentWriter) CreateComment(_ context.Context, comment *models.TaskComment) error {
|
|
r.comments = append(r.comments, comment)
|
|
return nil
|
|
}
|
|
|
|
type recordingTaskStatusUpdater struct {
|
|
updates []TaskStatusUpdate
|
|
}
|
|
|
|
func (r *recordingTaskStatusUpdater) UpdateTaskStatusAsAgent(_ context.Context, update TaskStatusUpdate) error {
|
|
r.updates = append(r.updates, update)
|
|
return nil
|
|
}
|
|
|
|
type handlerTaskCreator struct{}
|
|
|
|
func (r *handlerTaskCreator) CreateOfficeSubtaskAsAgent(
|
|
_ context.Context,
|
|
_ string,
|
|
_ string,
|
|
_ string,
|
|
_ string,
|
|
_ string,
|
|
) (string, error) {
|
|
return "task-created", nil
|
|
}
|
|
|
|
type recordingRunEvent struct {
|
|
runID string
|
|
eventType string
|
|
level string
|
|
payload map[string]interface{}
|
|
}
|
|
|
|
type recordingRunEvents struct {
|
|
events []recordingRunEvent
|
|
}
|
|
|
|
func (r *recordingRunEvents) AppendRunEvent(
|
|
_ context.Context,
|
|
runID string,
|
|
eventType string,
|
|
level string,
|
|
payload map[string]interface{},
|
|
) {
|
|
r.events = append(r.events, recordingRunEvent{
|
|
runID: runID,
|
|
eventType: eventType,
|
|
level: level,
|
|
payload: payload,
|
|
})
|
|
}
|
|
|
|
func TestRuntimeHandler_PostCommentUsesRuntimeToken(t *testing.T) {
|
|
h := newRuntimeHandlerHarness(t, Capabilities{
|
|
CanPostComments: true,
|
|
}.WithTaskScope("task-1"))
|
|
|
|
resp := h.request(t, http.MethodPost, "/runtime/comments", map[string]string{
|
|
"body": "runtime comment",
|
|
})
|
|
|
|
if resp.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want %d; body=%s", resp.Code, http.StatusCreated, resp.Body.String())
|
|
}
|
|
if len(h.comments.comments) != 1 {
|
|
t.Fatalf("comments = %d, want 1", len(h.comments.comments))
|
|
}
|
|
comment := h.comments.comments[0]
|
|
if comment.TaskID != "task-1" || comment.AuthorID != "agent-1" || comment.AuthorType != "agent" {
|
|
t.Fatalf("comment identity = %#v", comment)
|
|
}
|
|
assertActionRunEvent(t, h.runEvents, "post_comment", "task", "task-1")
|
|
}
|
|
|
|
func TestRuntimeHandler_WriteAndReadMemoryUsesRuntimeNamespace(t *testing.T) {
|
|
h := newRuntimeHandlerHarness(t, Capabilities{
|
|
CanReadMemory: true,
|
|
CanWriteMemory: true,
|
|
}.WithTaskScope("task-1"))
|
|
|
|
path := "/runtime/memory/workspaces/ws-1/memory/agents/agent-1/knowledge/runtime-note"
|
|
put := h.request(t, http.MethodPut, path, map[string]string{"content": "remember this"})
|
|
if put.Code != http.StatusOK {
|
|
t.Fatalf("put status = %d, want %d; body=%s", put.Code, http.StatusOK, put.Body.String())
|
|
}
|
|
|
|
get := h.request(t, http.MethodGet, path, nil)
|
|
if get.Code != http.StatusOK {
|
|
t.Fatalf("get status = %d, want %d; body=%s", get.Code, http.StatusOK, get.Body.String())
|
|
}
|
|
var body struct {
|
|
Memory models.AgentMemory `json:"memory"`
|
|
}
|
|
if err := json.Unmarshal(get.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode memory: %v", err)
|
|
}
|
|
if body.Memory.Content != "remember this" || body.Memory.Layer != "knowledge" {
|
|
t.Fatalf("unexpected memory: %+v", body.Memory)
|
|
}
|
|
assertActionRunEvent(t, h.runEvents, "write_memory", "memory",
|
|
"/workspaces/ws-1/memory/agents/agent-1/knowledge/runtime-note")
|
|
}
|
|
|
|
func TestRuntimeHandler_CreateAgentBindsSnakeCasePayload(t *testing.T) {
|
|
h := newRuntimeHandlerHarness(t, Capabilities{
|
|
CanCreateAgents: true,
|
|
}.WithTaskScope("task-1"))
|
|
|
|
resp := h.request(t, http.MethodPost, "/runtime/agents", map[string]interface{}{
|
|
"name": "Runtime Worker",
|
|
"role": "worker",
|
|
"desired_skills": `["runtime-skill"]`,
|
|
"executor_preference": `{"type":"local_pc"}`,
|
|
"max_concurrent_sessions": 2,
|
|
})
|
|
if resp.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want %d; body=%s", resp.Code, http.StatusCreated, resp.Body.String())
|
|
}
|
|
var body struct {
|
|
Agent models.AgentInstance `json:"agent"`
|
|
}
|
|
if err := json.Unmarshal(resp.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode agent: %v", err)
|
|
}
|
|
if body.Agent.DesiredSkills != `["runtime-skill"]` ||
|
|
body.Agent.ExecutorPreference != `{"type":"local_pc"}` ||
|
|
body.Agent.MaxConcurrentSessions != 2 {
|
|
t.Fatalf("snake-case fields did not bind: %+v", body.Agent)
|
|
}
|
|
assertActionRunEvent(t, h.runEvents, "create_agent", "agent", body.Agent.ID)
|
|
}
|
|
|
|
func TestRuntimeHandler_DeniesOutOfScopeStatusUpdateAndLogsRunEvent(t *testing.T) {
|
|
h := newRuntimeHandlerHarness(t, Capabilities{
|
|
CanUpdateTaskStatus: true,
|
|
}.WithTaskScope("task-1"))
|
|
|
|
resp := h.request(t, http.MethodPost, "/runtime/tasks/task-2/status", map[string]string{
|
|
"status": "in_review",
|
|
})
|
|
|
|
if resp.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want %d; body=%s", resp.Code, http.StatusForbidden, resp.Body.String())
|
|
}
|
|
if len(h.status.updates) != 0 {
|
|
t.Fatalf("status updates = %d, want 0", len(h.status.updates))
|
|
}
|
|
assertDeniedRunEvent(t, h.runEvents, "update_task_status", "task", "task-2")
|
|
}
|
|
|
|
func TestRuntimeHandler_DeniesMissingCapabilityAndLogsRunEvent(t *testing.T) {
|
|
h := newRuntimeHandlerHarness(t, Capabilities{}.WithTaskScope("task-1"))
|
|
|
|
resp := h.request(t, http.MethodPost, "/runtime/tasks/task-1/status", map[string]string{
|
|
"status": "in_review",
|
|
})
|
|
|
|
if resp.Code != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want %d; body=%s", resp.Code, http.StatusForbidden, resp.Body.String())
|
|
}
|
|
if len(h.status.updates) != 0 {
|
|
t.Fatalf("status updates = %d, want 0", len(h.status.updates))
|
|
}
|
|
assertDeniedRunEvent(t, h.runEvents, "update_task_status", "task", "task-1")
|
|
}
|
|
|
|
func newRuntimeHandlerHarness(t *testing.T, caps Capabilities) *handlerHarness {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
db, err := sqlx.Open("sqlite3", ":memory:")
|
|
if err != nil {
|
|
t.Fatalf("open sqlite: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
if _, _, err := settingsstore.Provide(db, db, nil); err != nil {
|
|
t.Fatalf("settings store init: %v", err)
|
|
}
|
|
repo, err := sqlite.NewWithDB(db, db, nil)
|
|
if err != nil {
|
|
t.Fatalf("new repo: %v", err)
|
|
}
|
|
agentSvc := agents.NewAgentService(repo, logger.Default(), nil)
|
|
agentSvc.SetAuth(agents.NewAgentAuth("runtime-handler-test-key"))
|
|
agent := &models.AgentInstance{
|
|
ID: "agent-1",
|
|
WorkspaceID: "ws-1",
|
|
Name: "CEO",
|
|
Role: models.AgentRoleCEO,
|
|
}
|
|
if err := repo.CreateAgentInstance(context.Background(), agent); err != nil {
|
|
t.Fatalf("create agent: %v", err)
|
|
}
|
|
capabilityJSON, err := MarshalCapabilities(caps)
|
|
if err != nil {
|
|
t.Fatalf("marshal capabilities: %v", err)
|
|
}
|
|
token, err := agentSvc.MintRuntimeJWT("agent-1", "task-1", "ws-1", "run-1", "sess-1", capabilityJSON)
|
|
if err != nil {
|
|
t.Fatalf("mint runtime token: %v", err)
|
|
}
|
|
comments := &handlerCommentWriter{}
|
|
status := &recordingTaskStatusUpdater{}
|
|
runEvents := &recordingRunEvents{}
|
|
router := gin.New()
|
|
RegisterRoutes(router.Group(""), NewHandler(
|
|
agentSvc,
|
|
NewActions(ActionDependencies{
|
|
Comments: comments,
|
|
Tasks: &handlerTaskCreator{},
|
|
TaskStatus: status,
|
|
Agents: agentSvc,
|
|
}),
|
|
nil,
|
|
runEvents,
|
|
))
|
|
return &handlerHarness{
|
|
router: router,
|
|
token: token,
|
|
comments: comments,
|
|
status: status,
|
|
runEvents: runEvents,
|
|
agentSvc: agentSvc,
|
|
repository: repo,
|
|
}
|
|
}
|
|
|
|
func (h *handlerHarness) request(
|
|
t *testing.T,
|
|
method string,
|
|
path string,
|
|
payload interface{},
|
|
) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
t.Fatalf("marshal payload: %v", err)
|
|
}
|
|
req := httptest.NewRequest(method, path, bytes.NewReader(body))
|
|
req.Header.Set("Authorization", "Bearer "+h.token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp := httptest.NewRecorder()
|
|
h.router.ServeHTTP(resp, req)
|
|
return resp
|
|
}
|
|
|
|
func assertActionRunEvent(
|
|
t *testing.T,
|
|
runEvents *recordingRunEvents,
|
|
action string,
|
|
targetType string,
|
|
targetID string,
|
|
) {
|
|
t.Helper()
|
|
if len(runEvents.events) != 1 {
|
|
t.Fatalf("run events = %d, want 1", len(runEvents.events))
|
|
}
|
|
event := runEvents.events[0]
|
|
if event.runID != "run-1" || event.eventType != "runtime.action" || event.level != "info" {
|
|
t.Fatalf("event identity = %#v", event)
|
|
}
|
|
if event.payload["action"] != action ||
|
|
event.payload["target_type"] != targetType ||
|
|
event.payload["target_id"] != targetID ||
|
|
event.payload["agent_id"] != "agent-1" ||
|
|
event.payload["session_id"] != "sess-1" {
|
|
t.Fatalf("event payload = %#v", event.payload)
|
|
}
|
|
}
|
|
|
|
func assertDeniedRunEvent(
|
|
t *testing.T,
|
|
runEvents *recordingRunEvents,
|
|
action string,
|
|
targetType string,
|
|
targetID string,
|
|
) {
|
|
t.Helper()
|
|
if len(runEvents.events) != 1 {
|
|
t.Fatalf("run events = %d, want 1", len(runEvents.events))
|
|
}
|
|
event := runEvents.events[0]
|
|
if event.runID != "run-1" || event.eventType != "runtime.denied" || event.level != "warn" {
|
|
t.Fatalf("event identity = %#v", event)
|
|
}
|
|
if event.payload["action"] != action ||
|
|
event.payload["target_type"] != targetType ||
|
|
event.payload["target_id"] != targetID ||
|
|
event.payload["agent_id"] != "agent-1" ||
|
|
event.payload["session_id"] != "sess-1" {
|
|
t.Fatalf("event payload = %#v", event.payload)
|
|
}
|
|
}
|