752 lines
26 KiB
Go
752 lines
26 KiB
Go
package testharness
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/kandev/kandev/internal/events"
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
"github.com/kandev/kandev/internal/office/agents"
|
|
officemodels "github.com/kandev/kandev/internal/office/models"
|
|
officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite"
|
|
)
|
|
|
|
type seedCommentRequest struct {
|
|
TaskID string `json:"task_id"`
|
|
AuthorType string `json:"author_type"`
|
|
AuthorID string `json:"author_id"`
|
|
Body string `json:"body"`
|
|
Source string `json:"source,omitempty"`
|
|
CreatedAt *string `json:"created_at,omitempty"`
|
|
}
|
|
|
|
type mintRuntimeTokenRequest struct {
|
|
AgentProfileID string `json:"agent_profile_id"`
|
|
TaskID string `json:"task_id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
RunID string `json:"run_id"`
|
|
SessionID string `json:"session_id"`
|
|
Capabilities string `json:"capabilities"`
|
|
}
|
|
|
|
func mintRuntimeTokenHandler(agentSvc *agents.AgentService, log *logger.Logger) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var req mintRuntimeTokenRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON: " + err.Error()})
|
|
return
|
|
}
|
|
if req.AgentProfileID == "" || req.WorkspaceID == "" || req.RunID == "" {
|
|
c.JSON(http.StatusBadRequest,
|
|
gin.H{"error": "agent_profile_id, workspace_id, run_id are required"})
|
|
return
|
|
}
|
|
token, err := agentSvc.MintRuntimeJWT(
|
|
req.AgentProfileID,
|
|
req.TaskID,
|
|
req.WorkspaceID,
|
|
req.RunID,
|
|
req.SessionID,
|
|
req.Capabilities,
|
|
)
|
|
if err != nil {
|
|
log.Error("test harness: mint runtime token failed", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"token": token})
|
|
}
|
|
}
|
|
|
|
// seedCommentHandler creates a TaskComment row directly so E2E tests can
|
|
// drive comment-rendering scenarios without launching an agent.
|
|
// Specifically: agent session-bridged comments need author_type="agent",
|
|
// source="session", and an author_id matching a real agent instance —
|
|
// the production createComment route hardcodes user/user.
|
|
func seedCommentHandler(
|
|
repo *officesqlite.Repository,
|
|
eventBus bus.EventBus,
|
|
log *logger.Logger,
|
|
) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var req seedCommentRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON: " + err.Error()})
|
|
return
|
|
}
|
|
if req.TaskID == "" || req.AuthorType == "" || req.AuthorID == "" || req.Body == "" {
|
|
c.JSON(http.StatusBadRequest,
|
|
gin.H{"error": "task_id, author_type, author_id, body are required"})
|
|
return
|
|
}
|
|
comment := &officemodels.TaskComment{
|
|
ID: uuid.New().String(),
|
|
TaskID: req.TaskID,
|
|
AuthorType: req.AuthorType,
|
|
AuthorID: req.AuthorID,
|
|
Body: req.Body,
|
|
Source: req.Source,
|
|
}
|
|
if req.CreatedAt != nil && *req.CreatedAt != "" {
|
|
t, err := time.Parse(time.RFC3339, *req.CreatedAt)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "created_at must be RFC3339"})
|
|
return
|
|
}
|
|
comment.CreatedAt = t.UTC()
|
|
}
|
|
ctx := c.Request.Context()
|
|
if err := repo.CreateTaskComment(ctx, comment); err != nil {
|
|
log.Error("test harness: create comment failed", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
publishCommentCreated(ctx, eventBus, comment, log)
|
|
c.JSON(http.StatusOK, gin.H{"comment_id": comment.ID})
|
|
}
|
|
}
|
|
|
|
func publishCommentCreated(
|
|
ctx context.Context,
|
|
eventBus bus.EventBus,
|
|
comment *officemodels.TaskComment,
|
|
log *logger.Logger,
|
|
) {
|
|
if eventBus == nil {
|
|
return
|
|
}
|
|
data := map[string]interface{}{
|
|
"comment_id": comment.ID,
|
|
"task_id": comment.TaskID,
|
|
"author_type": comment.AuthorType,
|
|
"author_id": comment.AuthorID,
|
|
"body": comment.Body,
|
|
"source": comment.Source,
|
|
"created_at": comment.CreatedAt.Format(time.RFC3339),
|
|
}
|
|
if err := eventBus.Publish(
|
|
ctx,
|
|
events.OfficeCommentCreated,
|
|
bus.NewEvent(events.OfficeCommentCreated, "e2e-mock", data),
|
|
); err != nil {
|
|
log.Warn("test harness: publish comment created failed", zap.Error(err))
|
|
}
|
|
}
|
|
|
|
type seedAgentFailureRequest struct {
|
|
TaskID string `json:"task_id"`
|
|
AgentProfileID string `json:"agent_profile_id"`
|
|
ErrorMessage string `json:"error_message"`
|
|
}
|
|
|
|
// seedAgentFailureHandler enqueues a run, marks it failed via the
|
|
// production HandleAgentFailure path, and returns the run id.
|
|
// Lets E2E exercise the FailureService (counter, auto-pause, inbox)
|
|
// without launching an agent.
|
|
func seedAgentFailureHandler(
|
|
repo *officesqlite.Repository,
|
|
eventBus bus.EventBus,
|
|
log *logger.Logger,
|
|
) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var req seedAgentFailureRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON: " + err.Error()})
|
|
return
|
|
}
|
|
if req.TaskID == "" || req.AgentProfileID == "" {
|
|
c.JSON(http.StatusBadRequest,
|
|
gin.H{"error": "task_id and agent_profile_id are required"})
|
|
return
|
|
}
|
|
if req.ErrorMessage == "" {
|
|
req.ErrorMessage = "seeded failure"
|
|
}
|
|
ctx := c.Request.Context()
|
|
// Insert the run directly so we don't need to plumb the
|
|
// office service into the testharness; the row is enough to
|
|
// drive HandleAgentFailure → MarkRunFailed +
|
|
// IncrementAgentConsecutiveFailures.
|
|
run := &officemodels.Run{
|
|
ID: uuid.New().String(),
|
|
AgentProfileID: req.AgentProfileID,
|
|
Reason: "task_assigned",
|
|
Payload: `{"task_id":"` + req.TaskID + `"}`,
|
|
Status: "claimed",
|
|
CoalescedCount: 1,
|
|
RequestedAt: time.Now().UTC().Add(-1 * time.Minute),
|
|
}
|
|
if err := repo.CreateRun(ctx, run); err != nil {
|
|
log.Error("test harness: create run failed", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if err := repo.MarkRunFailed(ctx, run.ID, req.ErrorMessage); err != nil {
|
|
log.Error("test harness: mark run failed", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
// Update the agent counter and possibly auto-pause.
|
|
count, err := repo.IncrementAgentConsecutiveFailures(ctx, req.AgentProfileID)
|
|
if err != nil {
|
|
log.Warn("test harness: increment counter failed", zap.Error(err))
|
|
}
|
|
threshold, err := repo.GetEffectiveFailureThreshold(ctx, req.AgentProfileID)
|
|
if err != nil || threshold <= 0 {
|
|
threshold = officesqlite.DefaultAgentFailureThreshold
|
|
}
|
|
if count >= threshold {
|
|
pauseReason := "Auto-paused: " +
|
|
strconv.Itoa(count) + " consecutive failures. Last error: " + req.ErrorMessage
|
|
if err := repo.UpdateAgentStatusFields(ctx, req.AgentProfileID, "paused", pauseReason); err != nil {
|
|
log.Warn("test harness: set pause reason failed", zap.Error(err))
|
|
}
|
|
}
|
|
_ = eventBus
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"run_id": run.ID,
|
|
"consecutive_failures": count,
|
|
"threshold": threshold,
|
|
})
|
|
}
|
|
}
|
|
|
|
type seedRunRequest struct {
|
|
AgentProfileID string `json:"agent_profile_id"`
|
|
Reason string `json:"reason,omitempty"`
|
|
Status string `json:"status,omitempty"`
|
|
TaskID string `json:"task_id,omitempty"`
|
|
CommentID string `json:"comment_id,omitempty"`
|
|
// RoutineID populates `payload.routine_id` so seeded routine_dispatch
|
|
// rows round-trip into the office run summary DTO and surface the
|
|
// Linked-column deeplink in the runs UI.
|
|
RoutineID string `json:"routine_id,omitempty"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
Capabilities string `json:"capabilities,omitempty"`
|
|
InputSnapshot string `json:"input_snapshot,omitempty"`
|
|
ErrorMessage string `json:"error_message,omitempty"`
|
|
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
|
RequestedAt *string `json:"requested_at,omitempty"`
|
|
// ScheduledRetryAt keeps a seeded queued run out of the live dispatcher
|
|
// until the E2E test explicitly changes its status.
|
|
ScheduledRetryAt *string `json:"scheduled_retry_at,omitempty"`
|
|
ClaimedAt *string `json:"claimed_at,omitempty"`
|
|
FinishedAt *string `json:"finished_at,omitempty"`
|
|
}
|
|
|
|
// seedRunHandler creates an office_runs row directly so the
|
|
// /office/agents/:id/runs paginated list and the /runs/:runId detail
|
|
// page can be exercised without launching an agent. Defaults Reason
|
|
// to task_assigned and Status to finished so a single field
|
|
// (agent_profile_id) is enough for happy-path tests.
|
|
func seedRunHandler(repo *officesqlite.Repository, log *logger.Logger) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
req, ok := decodeSeedRunRequest(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
runID, ok := createSeededRun(c, repo, log, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"run_id": runID})
|
|
}
|
|
}
|
|
|
|
// decodeSeedRunRequest binds the JSON body, validates required fields, and
|
|
// applies reason/status defaults. On invalid input it writes the 400 response
|
|
// and returns ok=false; callers must abort.
|
|
func decodeSeedRunRequest(c *gin.Context) (seedRunRequest, bool) {
|
|
var req seedRunRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON: " + err.Error()})
|
|
return seedRunRequest{}, false
|
|
}
|
|
if req.AgentProfileID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "agent_profile_id is required"})
|
|
return seedRunRequest{}, false
|
|
}
|
|
if req.Reason == "" {
|
|
req.Reason = "task_assigned"
|
|
}
|
|
if req.Status == "" {
|
|
req.Status = "finished"
|
|
}
|
|
return req, true
|
|
}
|
|
|
|
// createSeededRun runs the full seeded-run pipeline: insert, optional
|
|
// runtime-snapshot update, optional requested_at backfill, and final
|
|
// status flip. Returns the new run ID and ok=true on success; on failure
|
|
// it writes the error response and returns ok=false.
|
|
func createSeededRun(
|
|
c *gin.Context, repo *officesqlite.Repository, log *logger.Logger, req seedRunRequest,
|
|
) (string, bool) {
|
|
ctx := c.Request.Context()
|
|
var scheduledRetryAt *time.Time
|
|
if req.ScheduledRetryAt != nil && *req.ScheduledRetryAt != "" {
|
|
t, err := time.Parse(time.RFC3339, *req.ScheduledRetryAt)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "scheduled_retry_at must be RFC3339"})
|
|
return "", false
|
|
}
|
|
t = t.UTC()
|
|
scheduledRetryAt = &t
|
|
}
|
|
var idemPtr *string
|
|
if req.IdempotencyKey != "" {
|
|
v := req.IdempotencyKey
|
|
idemPtr = &v
|
|
}
|
|
run := &officemodels.Run{
|
|
ID: uuid.New().String(),
|
|
AgentProfileID: req.AgentProfileID,
|
|
Reason: req.Reason,
|
|
Payload: buildSeededRunPayload(req.TaskID, req.SessionID, req.CommentID, req.RoutineID),
|
|
Status: "queued",
|
|
CoalescedCount: 1,
|
|
IdempotencyKey: idemPtr,
|
|
ScheduledRetryAt: scheduledRetryAt,
|
|
ErrorMessage: req.ErrorMessage,
|
|
}
|
|
if err := repo.CreateRun(ctx, run); err != nil {
|
|
log.Error("test harness: create run failed", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return "", false
|
|
}
|
|
if req.Capabilities != "" || req.InputSnapshot != "" || req.SessionID != "" {
|
|
caps := req.Capabilities
|
|
if caps == "" {
|
|
caps = "{}"
|
|
}
|
|
input := req.InputSnapshot
|
|
if input == "" {
|
|
input = "{}"
|
|
}
|
|
if err := repo.UpdateRunRuntimeSnapshot(ctx, run.ID, caps, input, req.SessionID); err != nil {
|
|
log.Warn("test harness: update runtime snapshot", zap.Error(err))
|
|
}
|
|
}
|
|
// Force the requested_at if explicitly provided so tests can build
|
|
// deterministic ordered pages — CreateRun stamps time.Now() otherwise.
|
|
if req.RequestedAt != nil && *req.RequestedAt != "" {
|
|
t, err := time.Parse(time.RFC3339, *req.RequestedAt)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "requested_at must be RFC3339"})
|
|
return "", false
|
|
}
|
|
if err := updateSeededRunRequestedAt(ctx, repo, run.ID, t.UTC()); err != nil {
|
|
log.Warn("test harness: backfill requested_at", zap.Error(err))
|
|
}
|
|
}
|
|
// Finalise status by hand so we can land non-queued rows
|
|
// (claimed/finished/failed/cancelled) directly.
|
|
if req.Status != "queued" {
|
|
if err := updateSeededRunStatus(ctx, repo, run.ID, req.Status, req.ClaimedAt, req.FinishedAt); err != nil {
|
|
log.Warn("test harness: finalise status", zap.Error(err))
|
|
}
|
|
}
|
|
return run.ID, true
|
|
}
|
|
|
|
// patchRunRequest mirrors PATCH /api/v1/_test/runs/:id. Both fields
|
|
// are optional; status is required for the route to do anything.
|
|
type patchRunRequest struct {
|
|
Status string `json:"status,omitempty"`
|
|
ErrorMessage string `json:"error_message,omitempty"`
|
|
}
|
|
|
|
// patchRunHandler updates an existing seeded run's status (and
|
|
// optionally error_message) and publishes the matching
|
|
// OfficeRunProcessed event so the WS pipeline mirrors production.
|
|
// Used by E2E specs to drive a Queued comment badge through Working
|
|
// and Failed without going through the full agent lifecycle.
|
|
func patchRunHandler(
|
|
repo *officesqlite.Repository, eventBus bus.EventBus, log *logger.Logger,
|
|
) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
runID := c.Param("id")
|
|
if runID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "run id required"})
|
|
return
|
|
}
|
|
var req patchRunRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON: " + err.Error()})
|
|
return
|
|
}
|
|
if req.Status == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "status is required"})
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
if err := updateSeededRunStatus(ctx, repo, runID, req.Status, nil, nil); err != nil {
|
|
log.Error("test harness: patch run status failed", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if req.ErrorMessage != "" {
|
|
if err := repo.SetRunErrorMessageForTest(ctx, runID, req.ErrorMessage); err != nil {
|
|
log.Warn("test harness: patch run error_message failed", zap.Error(err))
|
|
}
|
|
}
|
|
publishHarnessRunProcessed(ctx, eventBus, repo, runID, req.Status, log)
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
}
|
|
|
|
// publishHarnessRunProcessed re-fetches the row so the published
|
|
// payload carries the same {agent, task, comment, reason} shape as
|
|
// the production Service.publishRunProcessed path.
|
|
func publishHarnessRunProcessed(
|
|
ctx context.Context,
|
|
eventBus bus.EventBus,
|
|
repo *officesqlite.Repository,
|
|
runID, status string,
|
|
log *logger.Logger,
|
|
) {
|
|
if eventBus == nil {
|
|
return
|
|
}
|
|
run, err := repo.GetRunByID(ctx, runID)
|
|
if err != nil {
|
|
log.Debug("test harness: get run for publish failed", zap.Error(err))
|
|
}
|
|
data := map[string]interface{}{
|
|
"run_id": runID,
|
|
"status": status,
|
|
}
|
|
if run != nil {
|
|
parsed := harnessParseRunPayload(run.Payload)
|
|
data["agent_profile_id"] = run.AgentProfileID
|
|
data["reason"] = run.Reason
|
|
data["task_id"] = parsed["task_id"]
|
|
data["comment_id"] = parsed["comment_id"]
|
|
if run.ErrorMessage != "" {
|
|
data["error_message"] = run.ErrorMessage
|
|
}
|
|
}
|
|
event := bus.NewEvent(events.OfficeRunProcessed, "test-harness", data)
|
|
if pErr := eventBus.Publish(ctx, events.OfficeRunProcessed, event); pErr != nil {
|
|
log.Warn("test harness: publish run processed failed", zap.Error(pErr))
|
|
}
|
|
}
|
|
|
|
// harnessParseRunPayload is a tiny duplicate of service.ParseRunPayload
|
|
// kept here to avoid a cross-package import (testharness imports the
|
|
// office repo only). It parses the small task_id/comment_id payload
|
|
// the harness writes via buildSeededRunPayload.
|
|
func harnessParseRunPayload(payloadJSON string) map[string]string {
|
|
out := map[string]string{}
|
|
if payloadJSON == "" || payloadJSON == "{}" {
|
|
return out
|
|
}
|
|
var raw map[string]interface{}
|
|
if err := json.Unmarshal([]byte(payloadJSON), &raw); err != nil {
|
|
return out
|
|
}
|
|
for _, k := range []string{"task_id", "session_id", "comment_id"} {
|
|
if v, ok := raw[k].(string); ok {
|
|
out[k] = v
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// buildSeededRunPayload assembles the JSON payload for a seeded run.
|
|
// All fields are optional; an empty payload becomes "{}" so the
|
|
// run-detail json_extract calls stay safe.
|
|
func buildSeededRunPayload(taskID, sessionID, commentID, routineID string) string {
|
|
if taskID == "" && sessionID == "" && commentID == "" && routineID == "" {
|
|
return "{}"
|
|
}
|
|
parts := map[string]string{}
|
|
if taskID != "" {
|
|
parts["task_id"] = taskID
|
|
}
|
|
if sessionID != "" {
|
|
parts["session_id"] = sessionID
|
|
}
|
|
if commentID != "" {
|
|
parts["comment_id"] = commentID
|
|
}
|
|
if routineID != "" {
|
|
parts["routine_id"] = routineID
|
|
}
|
|
// Keys are stable so the test harness output is deterministic.
|
|
out := "{"
|
|
first := true
|
|
for _, k := range []string{"task_id", "session_id", "comment_id", "routine_id"} {
|
|
v, ok := parts[k]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if !first {
|
|
out += ","
|
|
}
|
|
out += `"` + k + `":"` + v + `"`
|
|
first = false
|
|
}
|
|
out += "}"
|
|
return out
|
|
}
|
|
|
|
func updateSeededRunRequestedAt(
|
|
ctx context.Context, repo *officesqlite.Repository, runID string, ts time.Time,
|
|
) error {
|
|
return repo.SetRunRequestedAtForTest(ctx, runID, ts)
|
|
}
|
|
|
|
func updateSeededRunStatus(
|
|
ctx context.Context, repo *officesqlite.Repository,
|
|
runID, status string, claimedAt, finishedAt *string,
|
|
) error {
|
|
var claimed, finished *time.Time
|
|
if claimedAt != nil && *claimedAt != "" {
|
|
t, err := time.Parse(time.RFC3339, *claimedAt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
v := t.UTC()
|
|
claimed = &v
|
|
}
|
|
if finishedAt != nil && *finishedAt != "" {
|
|
t, err := time.Parse(time.RFC3339, *finishedAt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
v := t.UTC()
|
|
finished = &v
|
|
}
|
|
return repo.SetRunStatusForTest(ctx, runID, status, claimed, finished)
|
|
}
|
|
|
|
type seedRunEventRequest struct {
|
|
RunID string `json:"run_id"`
|
|
EventType string `json:"event_type"`
|
|
Level string `json:"level,omitempty"`
|
|
Payload string `json:"payload,omitempty"`
|
|
}
|
|
|
|
// seedRunEventHandler appends a synthetic office_run_events row and
|
|
// publishes the matching event-bus notification so the WS gateway's
|
|
// run-event broadcaster fans the row out to subscribed clients —
|
|
// mirrors the production AppendRunEvent service path. Used by the
|
|
// run-detail E2E spec to verify the events log renders the seeded
|
|
// structured events without a page reload.
|
|
func seedRunEventHandler(
|
|
repo *officesqlite.Repository, eventBus bus.EventBus, log *logger.Logger,
|
|
) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var req seedRunEventRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON: " + err.Error()})
|
|
return
|
|
}
|
|
if req.RunID == "" || req.EventType == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id and event_type are required"})
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
evt, err := repo.AppendRunEvent(ctx, req.RunID, req.EventType, req.Level, req.Payload)
|
|
if err != nil {
|
|
log.Error("test harness: append run event failed", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if eventBus != nil && evt != nil {
|
|
subject := events.BuildOfficeRunEventSubject(req.RunID)
|
|
busEvent := bus.NewEvent(subject, "test-harness", map[string]interface{}{
|
|
"run_id": evt.RunID,
|
|
"event": map[string]interface{}{
|
|
"seq": evt.Seq,
|
|
"event_type": evt.EventType,
|
|
"level": evt.Level,
|
|
"payload": evt.Payload,
|
|
"created_at": evt.CreatedAt.UTC().Format(time.RFC3339Nano),
|
|
},
|
|
})
|
|
if pErr := eventBus.Publish(ctx, subject, busEvent); pErr != nil {
|
|
log.Warn("test harness: publish run event failed", zap.Error(pErr))
|
|
}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
}
|
|
|
|
type seedRunSkillSnapshotRequest struct {
|
|
RunID string `json:"run_id"`
|
|
SkillID string `json:"skill_id"`
|
|
Version string `json:"version,omitempty"`
|
|
ContentHash string `json:"content_hash,omitempty"`
|
|
MaterializedPath string `json:"materialized_path,omitempty"`
|
|
}
|
|
|
|
func seedRunSkillSnapshotHandler(repo *officesqlite.Repository, log *logger.Logger) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var req seedRunSkillSnapshotRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON: " + err.Error()})
|
|
return
|
|
}
|
|
if req.RunID == "" || req.SkillID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "run_id and skill_id are required"})
|
|
return
|
|
}
|
|
snapshot := officemodels.RunSkillSnapshot{
|
|
RunID: req.RunID,
|
|
SkillID: req.SkillID,
|
|
Version: req.Version,
|
|
ContentHash: req.ContentHash,
|
|
MaterializedPath: req.MaterializedPath,
|
|
}
|
|
if err := repo.CreateRunSkillSnapshots(c.Request.Context(), []officemodels.RunSkillSnapshot{snapshot}); err != nil {
|
|
log.Error("test harness: seed run skill snapshot failed", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
}
|
|
|
|
// seedCostEventRequest seeds an office_cost_events row directly.
|
|
// Used by the agent dashboard E2E spec to drive the costs section
|
|
// (aggregate + per-run rollup) without launching an agent. TokensOut is a
|
|
// pointer so a test can omit it to seed the "never measured" NULL shape
|
|
// (docs/specs/office/requirements/costs.md) instead of always writing a measured 0.
|
|
type seedCostEventRequest struct {
|
|
AgentProfileID string `json:"agent_profile_id"`
|
|
TaskID string `json:"task_id"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
TokensIn int64 `json:"tokens_in"`
|
|
TokensOut *int64 `json:"tokens_out,omitempty"`
|
|
TokensCachedIn int64 `json:"tokens_cached_in"`
|
|
CostSubcents int64 `json:"cost_subcents"`
|
|
Estimated bool `json:"estimated,omitempty"`
|
|
OccurredAt *string `json:"occurred_at,omitempty"`
|
|
}
|
|
|
|
// seedCostEventHandler appends a synthetic office_cost_events row.
|
|
// Tests can drive the agent dashboard's costs surface (aggregate +
|
|
// per-run rollup) by seeding rows tied to the agent + a task it
|
|
// claimed.
|
|
func seedCostEventHandler(repo *officesqlite.Repository, log *logger.Logger) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var req seedCostEventRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON: " + err.Error()})
|
|
return
|
|
}
|
|
if req.AgentProfileID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "agent_profile_id is required"})
|
|
return
|
|
}
|
|
occurred := time.Now().UTC()
|
|
if req.OccurredAt != nil && *req.OccurredAt != "" {
|
|
t, err := time.Parse(time.RFC3339, *req.OccurredAt)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "occurred_at must be RFC3339"})
|
|
return
|
|
}
|
|
occurred = t.UTC()
|
|
}
|
|
ctx := c.Request.Context()
|
|
contractVersion := officemodels.CostContractVersion
|
|
event := &officemodels.CostEvent{
|
|
ID: uuid.New().String(),
|
|
SessionID: req.SessionID,
|
|
TaskID: req.TaskID,
|
|
AgentProfileID: req.AgentProfileID,
|
|
Model: "test-model",
|
|
Provider: "test",
|
|
TokensIn: req.TokensIn,
|
|
TokensCachedIn: req.TokensCachedIn,
|
|
TokensOut: req.TokensOut,
|
|
CostSubcents: req.CostSubcents,
|
|
Estimated: req.Estimated,
|
|
CostContractVersion: &contractVersion,
|
|
OccurredAt: occurred,
|
|
}
|
|
if err := repo.CreateCostEvent(ctx, event); err != nil {
|
|
log.Error("test harness: create cost event failed", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"id": event.ID})
|
|
}
|
|
}
|
|
|
|
// seedActivityRequest seeds an office_activity_log row directly. The
|
|
// agent dashboard summary aggregates these to drive the per-day
|
|
// priority + status charts and the recent-tasks list, so the E2E
|
|
// spec needs to write them without going through the production
|
|
// mutation paths.
|
|
type seedActivityRequest struct {
|
|
WorkspaceID string `json:"workspace_id"`
|
|
ActorType string `json:"actor_type"`
|
|
ActorID string `json:"actor_id"`
|
|
Action string `json:"action"`
|
|
TargetType string `json:"target_type"`
|
|
TargetID string `json:"target_id"`
|
|
Details string `json:"details,omitempty"`
|
|
RunID string `json:"run_id,omitempty"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
CreatedAt *string `json:"created_at,omitempty"`
|
|
}
|
|
|
|
// seedActivityHandler creates a single office_activity_log row.
|
|
// All inputs are written verbatim — callers control workspace, actor,
|
|
// action, target, and timestamp.
|
|
func seedActivityHandler(repo *officesqlite.Repository, log *logger.Logger) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
var req seedActivityRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON: " + err.Error()})
|
|
return
|
|
}
|
|
if req.WorkspaceID == "" || req.ActorType == "" || req.ActorID == "" || req.Action == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id, actor_type, actor_id, action are required"})
|
|
return
|
|
}
|
|
details := req.Details
|
|
if details == "" {
|
|
details = "{}"
|
|
}
|
|
entry := &officemodels.ActivityEntry{
|
|
ID: uuid.New().String(),
|
|
WorkspaceID: req.WorkspaceID,
|
|
ActorType: officemodels.ActivityActorType(req.ActorType),
|
|
ActorID: req.ActorID,
|
|
Action: officemodels.ActivityAction(req.Action),
|
|
TargetType: officemodels.ActivityTargetType(req.TargetType),
|
|
TargetID: req.TargetID,
|
|
Details: details,
|
|
RunID: req.RunID,
|
|
SessionID: req.SessionID,
|
|
}
|
|
if req.CreatedAt != nil && *req.CreatedAt != "" {
|
|
t, err := time.Parse(time.RFC3339, *req.CreatedAt)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "created_at must be RFC3339"})
|
|
return
|
|
}
|
|
entry.CreatedAt = t.UTC()
|
|
}
|
|
ctx := c.Request.Context()
|
|
if err := repo.CreateActivityEntry(ctx, entry); err != nil {
|
|
log.Error("test harness: create activity entry failed", zap.Error(err))
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"id": entry.ID})
|
|
}
|
|
}
|