mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 08:18:19 +00:00
feat: add archived run timeline
Squash merge PR #113 after resolving the project changelog conflict with current dev. Local checks passed: Go store/http/gateway/agent/pipeline tests, SQLite-tagged tests, both Go builds, web Vitest, and web build. PR CI run 26705098712 passed release-versioning, go, and web.
This commit is contained in:
1 parent
990fabf94b
commit
269e2618ae
49 files changed
+2791
-145
No files matched your search
+1
-1
@@ -440,7 +440,7 @@ func runGateway() {
|
||||
// Register all RPC methods
|
||||
server.SetLogTee(logTee)
|
||||
server.SetRuntimeLogsHandler(httpapi.NewRuntimeLogsHandler(logTee))
|
||||
pairingMethods, heartbeatMethods, chatMethods, cfgPermsMethods := registerAllMethods(server, agentRouter, pgStores.Sessions, pgStores.Cron, pgStores.Pairing, cfg, cfgPath, workspace, dataDir, msgBus, execApprovalMgr, pgStores.Agents, pgStores.Skills, pgStores.ConfigSecrets, pgStores.Teams, contextFileInterceptor, logTee, pgStores.Heartbeats, pgStores.ConfigPermissions, pgStores.SystemConfigs, pgStores.Tenants, pgStores.SkillTenantCfgs, audioMgr, usageCapSvc)
|
||||
pairingMethods, heartbeatMethods, chatMethods, cfgPermsMethods := registerAllMethods(server, agentRouter, pgStores.Sessions, pgStores.RunTimeline, pgStores.Cron, pgStores.Pairing, cfg, cfgPath, workspace, dataDir, msgBus, execApprovalMgr, pgStores.Agents, pgStores.Skills, pgStores.ConfigSecrets, pgStores.Teams, contextFileInterceptor, logTee, pgStores.Heartbeats, pgStores.ConfigPermissions, pgStores.SystemConfigs, pgStores.Tenants, pgStores.SkillTenantCfgs, audioMgr, usageCapSvc)
|
||||
|
||||
// Phase 3: Agent hooks RPC methods (hooks.list/create/update/delete/toggle/test/history).
|
||||
if hs, ok := pgStores.Hooks.(hooks.HookStore); ok && hs != nil {
|
||||
|
||||
@@ -48,7 +48,7 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir
|
||||
}
|
||||
|
||||
if stores != nil && stores.Tracing != nil {
|
||||
tracesH = httpapi.NewTracesHandler(stores.Tracing)
|
||||
tracesH = httpapi.NewTracesHandler(stores.Tracing, stores.RunTimeline)
|
||||
}
|
||||
|
||||
if stores != nil && stores.MCP != nil {
|
||||
|
||||
@@ -204,6 +204,7 @@ func wireExtras(
|
||||
sharedHookHandlers = handlers
|
||||
slog.Info("agent hooks dispatcher wired", "handlers", "command,http,prompt")
|
||||
}
|
||||
timelineRecorder := agent.NewRunTimelineRecorder(stores.RunTimeline)
|
||||
|
||||
resolver := agent.NewManagedResolver(agent.ResolverDeps{
|
||||
AgentStore: stores.Agents,
|
||||
@@ -299,6 +300,7 @@ func wireExtras(
|
||||
Payload: event,
|
||||
TenantID: event.TenantID,
|
||||
})
|
||||
timelineRecorder.Record(event)
|
||||
},
|
||||
})
|
||||
agentRouter.SetResolver(resolver)
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
|
||||
)
|
||||
|
||||
func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore store.SessionStore, cronStore store.CronStore, pairingStore store.PairingStore, cfg *config.Config, cfgPath, workspace, dataDir string, msgBus *bus.MessageBus, execApprovalMgr *tools.ExecApprovalManager, agentStore store.AgentStore, skillStore store.SkillStore, configSecretsStore store.ConfigSecretsStore, teamStore store.TeamStore, contextFileInterceptor *tools.ContextFileInterceptor, logTee *gateway.LogTee, heartbeatStore store.HeartbeatStore, configPermStore store.ConfigPermissionStore, sysConfigStore store.SystemConfigStore, tenantStore store.TenantStore, skillTenantCfgStore store.SkillTenantConfigStore, audioMgr *audio.Manager, usageCapSvc *usagecaps.Service) (*methods.PairingMethods, *methods.HeartbeatMethods, *methods.ChatMethods, *methods.ConfigPermissionsMethods) {
|
||||
func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore store.SessionStore, runTimeline store.RunTimelineStore, cronStore store.CronStore, pairingStore store.PairingStore, cfg *config.Config, cfgPath, workspace, dataDir string, msgBus *bus.MessageBus, execApprovalMgr *tools.ExecApprovalManager, agentStore store.AgentStore, skillStore store.SkillStore, configSecretsStore store.ConfigSecretsStore, teamStore store.TeamStore, contextFileInterceptor *tools.ContextFileInterceptor, logTee *gateway.LogTee, heartbeatStore store.HeartbeatStore, configPermStore store.ConfigPermissionStore, sysConfigStore store.SystemConfigStore, tenantStore store.TenantStore, skillTenantCfgStore store.SkillTenantConfigStore, audioMgr *audio.Manager, usageCapSvc *usagecaps.Service) (*methods.PairingMethods, *methods.HeartbeatMethods, *methods.ChatMethods, *methods.ConfigPermissionsMethods) {
|
||||
router := server.Router()
|
||||
|
||||
// Phase 1: Core methods
|
||||
@@ -25,6 +25,7 @@ func registerAllMethods(server *gateway.Server, agents *agent.Router, sessStore
|
||||
chatMethods.Register(router)
|
||||
methods.NewAgentsMethods(agents, cfg, cfgPath, workspace, agentStore, contextFileInterceptor, msgBus).Register(router)
|
||||
methods.NewSessionsMethods(sessStore, msgBus, cfg).Register(router)
|
||||
methods.NewRunTimelineMethods(runTimeline, cfg).Register(router)
|
||||
configMethods := methods.NewConfigMethods(cfg, cfgPath, configSecretsStore, msgBus)
|
||||
if sysConfigStore != nil {
|
||||
configMethods.SetSystemConfigSync(func(ctx context.Context, c *config.Config) {
|
||||
|
||||
@@ -1297,6 +1297,7 @@ LLM call tracing and cost analysis.
|
||||
| `GET` | `/v1/traces/follow` | Poll trace changes for one session or agent |
|
||||
| `GET` | `/v1/traces/{traceID}` | Get trace with spans |
|
||||
| `GET` | `/v1/traces/{traceID}/export` | Export trace tree (gzipped JSON) |
|
||||
| `GET` | `/v1/runs/{runID}/timeline` | Get persisted run archive timeline items |
|
||||
|
||||
**Filters:** `agent_id`, `user_id`, `session_key`, `status`, `channel`
|
||||
|
||||
@@ -1314,6 +1315,42 @@ Follow response:
|
||||
}
|
||||
```
|
||||
|
||||
### Run Timeline
|
||||
|
||||
`GET /v1/runs/{runID}/timeline` returns display-safe archive entries for one
|
||||
agent run. Optional query params: `session_key`, `limit` (default 200, max 500),
|
||||
and `offset`. Non-admin callers only receive entries owned by their effective
|
||||
`X-GoClaw-User-Id`.
|
||||
|
||||
Timeline items are ordered by run sequence for `run_id` reads and include only
|
||||
safe previews for tool arguments/results. Raw thinking is not persisted.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"run_id": "run-123",
|
||||
"session_key": "agent:demo:direct:user-1",
|
||||
"items": [{
|
||||
"id": "019e...",
|
||||
"run_id": "run-123",
|
||||
"session_key": "agent:demo:direct:user-1",
|
||||
"seq": 2,
|
||||
"item_type": "tool.call",
|
||||
"status": "running",
|
||||
"title": "web_fetch",
|
||||
"preview": "{\"url\":\"https://example.com\"}",
|
||||
"tool_name": "web_fetch",
|
||||
"tool_call_id": "call_123",
|
||||
"trace_id": "019e...",
|
||||
"span_id": "019e...",
|
||||
"created_at": "2026-05-29T10:00:00Z"
|
||||
}],
|
||||
"limit": 200,
|
||||
"offset": 0
|
||||
}
|
||||
```
|
||||
|
||||
### Costs
|
||||
|
||||
| Method | Path | Description |
|
||||
|
||||
@@ -232,10 +232,56 @@ Delete an agent (admin only).
|
||||
| `sessions.patch` | Update label, model, metadata |
|
||||
| `sessions.delete` | Delete session |
|
||||
| `sessions.reset` | Clear session messages |
|
||||
| `run.timeline.get` | Get archived run/session timeline items |
|
||||
|
||||
**`sessions.list` request:** `{agentId, limit, offset}`
|
||||
**Response:** `{sessions[], total, limit, offset}`
|
||||
|
||||
### `run.timeline.get`
|
||||
|
||||
Fetch display-safe timeline entries captured during agent runs. Pass `runId` for
|
||||
one run, or `sessionKey` for the session archive panel. At least one is
|
||||
required. `limit` defaults to `200` and is capped at `500`; `offset` paginates.
|
||||
Viewer role can read this method. Non-admin callers only receive entries whose
|
||||
`user_id` matches their connected user.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"runId": "run-123",
|
||||
"sessionKey": "agent:demo:direct:user-1",
|
||||
"limit": 100,
|
||||
"offset": 0
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"runId": "run-123",
|
||||
"sessionKey": "agent:demo:direct:user-1",
|
||||
"items": [{
|
||||
"id": "019e...",
|
||||
"run_id": "run-123",
|
||||
"session_key": "agent:demo:direct:user-1",
|
||||
"seq": 1,
|
||||
"item_type": "assistant.message",
|
||||
"status": "completed",
|
||||
"title": "assistant",
|
||||
"preview": "I will check that now.",
|
||||
"created_at": "2026-05-29T10:00:00Z"
|
||||
}],
|
||||
"limit": 100,
|
||||
"offset": 0
|
||||
}
|
||||
```
|
||||
|
||||
Timeline items include `activity`, `assistant.message`, `tool.call`,
|
||||
`tool.result`, and `run.status`. Tool entries store bounded previews only;
|
||||
raw reasoning/thinking is not persisted.
|
||||
|
||||
---
|
||||
|
||||
## 5. Config
|
||||
|
||||
@@ -6,6 +6,33 @@ Significant changes, features, and fixes in reverse chronological order.
|
||||
|
||||
## 2026-05-29
|
||||
|
||||
### Archived run timeline (issue #76)
|
||||
|
||||
Adds persisted run archive timeline entries for session detail review.
|
||||
|
||||
**New**
|
||||
|
||||
- `run_timeline_items` schema for PostgreSQL and SQLite/Lite, with tenant/run
|
||||
sequence uniqueness and session/run indexes.
|
||||
- Best-effort agent event recorder for `activity`, `assistant.message`,
|
||||
`tool.call`, `tool.result`, and `run.status` entries.
|
||||
- HTTP `GET /v1/runs/{runID}/timeline` and WS `run.timeline.get` read APIs,
|
||||
including tenant/user visibility filtering.
|
||||
- Web session detail timeline panel using the WS API and localized labels.
|
||||
|
||||
**Security**
|
||||
|
||||
- Tool arguments/results are persisted as bounded previews only.
|
||||
- Raw thinking/reasoning is not persisted.
|
||||
- Non-admin reads are filtered to the connected/effective user.
|
||||
|
||||
**Tests**
|
||||
|
||||
- Added store coverage for PG and SQLite, recorder tests, HTTP/WS API tests,
|
||||
permission classification coverage, and UI display mapping tests.
|
||||
|
||||
---
|
||||
|
||||
### Shell security group disabled state persistence (issue #75)
|
||||
|
||||
**Fixes**
|
||||
|
||||
@@ -232,10 +232,16 @@ func (l *Loop) recordToolMetric(ctx context.Context, sessionKey, toolName string
|
||||
func makeToolEmitRun(l *Loop, req *RunRequest) func(AgentEvent) {
|
||||
return func(event AgentEvent) {
|
||||
event.RunKind = req.RunKind
|
||||
event.DelegationID = req.DelegationID
|
||||
event.TeamID = req.TeamID
|
||||
event.TeamTaskID = req.TeamTaskID
|
||||
event.ParentAgentID = req.ParentAgentID
|
||||
event.SessionKey = req.SessionKey
|
||||
event.SenderID = req.SenderID
|
||||
event.UserID = req.UserID
|
||||
event.Channel = req.Channel
|
||||
event.ChatID = req.ChatID
|
||||
event.TenantID = l.tenantID
|
||||
l.emit(event)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tools"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/tracing"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
const runTimelinePreviewLimit = 2000
|
||||
|
||||
// RunTimelineRecorder persists display-safe run events without blocking delivery.
|
||||
type RunTimelineRecorder struct {
|
||||
store store.RunTimelineStore
|
||||
timeout time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
nextSeq map[string]int
|
||||
}
|
||||
|
||||
func NewRunTimelineRecorder(timelineStore store.RunTimelineStore) *RunTimelineRecorder {
|
||||
return &RunTimelineRecorder{
|
||||
store: timelineStore,
|
||||
timeout: 2 * time.Second,
|
||||
nextSeq: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RunTimelineRecorder) Record(event AgentEvent) {
|
||||
if r == nil || r.store == nil {
|
||||
return
|
||||
}
|
||||
if event.RunID == "" || event.SessionKey == "" || event.TenantID == uuid.Nil {
|
||||
return
|
||||
}
|
||||
if _, _, ok := timelineKindForEvent(event); !ok {
|
||||
return
|
||||
}
|
||||
seq := r.reserveSeq(event.RunID)
|
||||
item, ok := runTimelineItemFromEvent(event, seq)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if isTerminalRunTimelineEvent(event.Type) {
|
||||
defer r.forgetRun(event.RunID)
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), r.timeout)
|
||||
defer cancel()
|
||||
ctx = store.WithTenantID(ctx, item.TenantID)
|
||||
if err := r.store.AppendRunTimelineItem(ctx, &item); err != nil {
|
||||
slog.Warn("run_timeline.persist_failed", "run_id", event.RunID, "event", event.Type, "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (r *RunTimelineRecorder) reserveSeq(runID string) int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.nextSeq[runID]++
|
||||
return r.nextSeq[runID]
|
||||
}
|
||||
|
||||
func (r *RunTimelineRecorder) forgetRun(runID string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.nextSeq, runID)
|
||||
}
|
||||
|
||||
func isTerminalRunTimelineEvent(eventType string) bool {
|
||||
switch eventType {
|
||||
case protocol.AgentEventRunCompleted, protocol.AgentEventRunFailed, protocol.AgentEventRunCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func runTimelineItemFromEvent(event AgentEvent, seq int) (store.RunTimelineItem, bool) {
|
||||
if event.RunID == "" || event.SessionKey == "" || event.TenantID == uuid.Nil {
|
||||
return store.RunTimelineItem{}, false
|
||||
}
|
||||
itemType, status, ok := timelineKindForEvent(event)
|
||||
if !ok {
|
||||
return store.RunTimelineItem{}, false
|
||||
}
|
||||
metadata := timelineMetadata(event)
|
||||
agentUUID, agentIsUUID := parseOptionalUUID(event.AgentID)
|
||||
if event.AgentID != "" && !agentIsUUID {
|
||||
metadata["agent_key"] = event.AgentID
|
||||
}
|
||||
traceID, spanID := payloadTraceIDs(event.Payload)
|
||||
item := store.RunTimelineItem{
|
||||
TenantID: event.TenantID,
|
||||
RunID: event.RunID,
|
||||
SessionKey: event.SessionKey,
|
||||
AgentID: agentUUID,
|
||||
UserID: event.UserID,
|
||||
Channel: event.Channel,
|
||||
ChatID: event.ChatID,
|
||||
Seq: seq,
|
||||
ItemType: itemType,
|
||||
Status: status,
|
||||
Title: timelineTitle(event),
|
||||
Preview: timelinePreview(event),
|
||||
ToolName: payloadString(event.Payload, "name"),
|
||||
ToolCallID: payloadString(event.Payload, "id"),
|
||||
TraceID: traceID,
|
||||
SpanID: spanID,
|
||||
Metadata: mustJSON(metadata),
|
||||
}
|
||||
return item, true
|
||||
}
|
||||
|
||||
func timelineKindForEvent(event AgentEvent) (string, string, bool) {
|
||||
switch event.Type {
|
||||
case protocol.AgentEventRunStarted:
|
||||
return store.RunTimelineItemTypeRunStatus, store.RunTimelineStatusStarted, true
|
||||
case protocol.AgentEventRunCompleted:
|
||||
return store.RunTimelineItemTypeRunStatus, store.RunTimelineStatusCompleted, true
|
||||
case protocol.AgentEventRunFailed:
|
||||
return store.RunTimelineItemTypeRunStatus, store.RunTimelineStatusFailed, true
|
||||
case protocol.AgentEventRunCancelled:
|
||||
return store.RunTimelineItemTypeRunStatus, store.RunTimelineStatusCancelled, true
|
||||
case protocol.AgentEventActivity:
|
||||
return store.RunTimelineItemTypeActivity, store.RunTimelineStatusRunning, true
|
||||
case protocol.AgentEventBlockReply:
|
||||
return store.RunTimelineItemTypeAssistantMessage, store.RunTimelineStatusCompleted, true
|
||||
case protocol.AgentEventToolCall:
|
||||
return store.RunTimelineItemTypeToolCall, store.RunTimelineStatusRunning, true
|
||||
case protocol.AgentEventToolResult:
|
||||
if payloadBool(event.Payload, "is_error") {
|
||||
return store.RunTimelineItemTypeToolResult, store.RunTimelineStatusFailed, true
|
||||
}
|
||||
return store.RunTimelineItemTypeToolResult, store.RunTimelineStatusCompleted, true
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
|
||||
func timelineTitle(event AgentEvent) string {
|
||||
if name := payloadString(event.Payload, "name"); name != "" {
|
||||
return name
|
||||
}
|
||||
switch event.Type {
|
||||
case protocol.AgentEventRunStarted:
|
||||
return "Run started"
|
||||
case protocol.AgentEventRunCompleted:
|
||||
return "Run completed"
|
||||
case protocol.AgentEventRunFailed:
|
||||
return "Run failed"
|
||||
case protocol.AgentEventRunCancelled:
|
||||
return "Run cancelled"
|
||||
case protocol.AgentEventBlockReply:
|
||||
return "Assistant message"
|
||||
case protocol.AgentEventActivity:
|
||||
return "Activity"
|
||||
default:
|
||||
return event.Type
|
||||
}
|
||||
}
|
||||
|
||||
func timelinePreview(event AgentEvent) string {
|
||||
switch event.Type {
|
||||
case protocol.AgentEventRunStarted:
|
||||
return sanitizeTimelinePreview(payloadString(event.Payload, "message"))
|
||||
case protocol.AgentEventRunCompleted, protocol.AgentEventBlockReply:
|
||||
return sanitizeTimelinePreview(payloadString(event.Payload, "content"))
|
||||
case protocol.AgentEventRunFailed:
|
||||
return sanitizeTimelinePreview(payloadString(event.Payload, "error"))
|
||||
case protocol.AgentEventActivity:
|
||||
return sanitizeTimelinePreview(payloadAnyString(event.Payload))
|
||||
case protocol.AgentEventToolCall:
|
||||
return sanitizeTimelinePreview(payloadJSON(event.Payload, "arguments"))
|
||||
case protocol.AgentEventToolResult:
|
||||
if result := payloadString(event.Payload, "result"); result != "" {
|
||||
return sanitizeTimelinePreview(result)
|
||||
}
|
||||
return sanitizeTimelinePreview(payloadString(event.Payload, "content"))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeTimelinePreview(value string) string {
|
||||
value = strings.TrimSpace(stripThinkingTags(value))
|
||||
value = stripDeliveryFileTokens(value)
|
||||
value = tools.ScrubCredentials(value)
|
||||
return tracing.TruncateMid(value, runTimelinePreviewLimit)
|
||||
}
|
||||
|
||||
var deliveryFileTokenRe = regexp.MustCompile(`([?&])ft=[^)\]'"<>\s&]+`)
|
||||
|
||||
func stripDeliveryFileTokens(value string) string {
|
||||
if !strings.Contains(value, "ft=") {
|
||||
return value
|
||||
}
|
||||
value = deliveryFileTokenRe.ReplaceAllStringFunc(value, func(match string) string {
|
||||
if strings.HasPrefix(match, "&") {
|
||||
return ""
|
||||
}
|
||||
return "?"
|
||||
})
|
||||
value = strings.ReplaceAll(value, "?&", "?")
|
||||
value = strings.ReplaceAll(value, "?)", ")")
|
||||
value = strings.ReplaceAll(value, "?]", "]")
|
||||
value = strings.TrimSuffix(value, "?")
|
||||
return strings.TrimSuffix(value, "&")
|
||||
}
|
||||
|
||||
func timelineMetadata(event AgentEvent) map[string]any {
|
||||
metadata := map[string]any{"event_type": event.Type}
|
||||
if event.RunKind != "" {
|
||||
metadata["run_kind"] = event.RunKind
|
||||
}
|
||||
if event.DelegationID != "" {
|
||||
metadata["delegation_id"] = event.DelegationID
|
||||
}
|
||||
if event.TeamID != "" {
|
||||
metadata["team_id"] = event.TeamID
|
||||
}
|
||||
if event.TeamTaskID != "" {
|
||||
metadata["team_task_id"] = event.TeamTaskID
|
||||
}
|
||||
if event.ParentAgentID != "" {
|
||||
metadata["parent_agent_id"] = event.ParentAgentID
|
||||
}
|
||||
if event.SenderID != "" {
|
||||
metadata["sender_id"] = event.SenderID
|
||||
}
|
||||
if payloadBool(event.Payload, "is_error") {
|
||||
metadata["is_error"] = true
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func payloadString(payload any, key string) string {
|
||||
switch m := payload.(type) {
|
||||
case map[string]string:
|
||||
return m[key]
|
||||
case map[string]any:
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func payloadBool(payload any, key string) bool {
|
||||
if m, ok := payload.(map[string]any); ok {
|
||||
if v, ok := m[key].(bool); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func payloadAnyString(payload any) string {
|
||||
switch v := payload.(type) {
|
||||
case string:
|
||||
return v
|
||||
case map[string]string:
|
||||
if c := v["content"]; c != "" {
|
||||
return c
|
||||
}
|
||||
if m := v["message"]; m != "" {
|
||||
return m
|
||||
}
|
||||
case map[string]any:
|
||||
for _, key := range []string{"content", "message", "status", "step"} {
|
||||
if s, ok := v[key].(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func payloadJSON(payload any, key string) string {
|
||||
if m, ok := payload.(map[string]any); ok {
|
||||
if v, has := m[key]; has {
|
||||
raw, _ := json.Marshal(v)
|
||||
return string(raw)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func payloadTraceIDs(payload any) (*uuid.UUID, *uuid.UUID) {
|
||||
traceID := parsePayloadUUID(payload, "trace_id", "traceId")
|
||||
spanID := parsePayloadUUID(payload, "span_id", "spanId")
|
||||
return traceID, spanID
|
||||
}
|
||||
|
||||
func parsePayloadUUID(payload any, keys ...string) *uuid.UUID {
|
||||
for _, key := range keys {
|
||||
if v := payloadString(payload, key); v != "" {
|
||||
if parsed, err := uuid.Parse(v); err == nil {
|
||||
return &parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseOptionalUUID(value string) (*uuid.UUID, bool) {
|
||||
if value == "" {
|
||||
return nil, false
|
||||
}
|
||||
parsed, err := uuid.Parse(value)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &parsed, true
|
||||
}
|
||||
|
||||
func mustJSON(v any) json.RawMessage {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
type recordingTimelineStore struct {
|
||||
mu sync.Mutex
|
||||
items []store.RunTimelineItem
|
||||
}
|
||||
|
||||
func (s *recordingTimelineStore) AppendRunTimelineItem(_ context.Context, item *store.RunTimelineItem) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.items = append(s.items, *item)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *recordingTimelineStore) ListRunTimelineItems(context.Context, store.RunTimelineListOpts) ([]store.RunTimelineItem, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestRunTimelineItemFromEventScrubsToolArguments(t *testing.T) {
|
||||
tenantID := uuid.Must(uuid.NewV7())
|
||||
item, ok := runTimelineItemFromEvent(AgentEvent{
|
||||
Type: protocol.AgentEventToolCall,
|
||||
AgentID: "default",
|
||||
RunID: "run-1",
|
||||
UserID: "user-1",
|
||||
Channel: "web",
|
||||
ChatID: "chat-1",
|
||||
SessionKey: "session-1",
|
||||
TenantID: tenantID,
|
||||
Payload: map[string]any{
|
||||
"name": "exec_command",
|
||||
"id": "call-1",
|
||||
"arguments": map[string]any{"cmd": "echo sk-abcdefghijklmnopqrstuvwxyz123456"},
|
||||
},
|
||||
}, 7)
|
||||
if !ok {
|
||||
t.Fatal("expected timeline item")
|
||||
}
|
||||
if item.ItemType != store.RunTimelineItemTypeToolCall {
|
||||
t.Fatalf("ItemType = %q", item.ItemType)
|
||||
}
|
||||
if item.Seq != 7 {
|
||||
t.Fatalf("Seq = %d, want 7", item.Seq)
|
||||
}
|
||||
if item.ToolName != "exec_command" || item.ToolCallID != "call-1" {
|
||||
t.Fatalf("tool fields = %q/%q", item.ToolName, item.ToolCallID)
|
||||
}
|
||||
if strings.Contains(item.Preview, "sk-abcdefghijklmnopqrstuvwxyz123456") {
|
||||
t.Fatalf("preview leaked secret: %s", item.Preview)
|
||||
}
|
||||
if !strings.Contains(item.Preview, "[REDACTED]") {
|
||||
t.Fatalf("preview missing redaction: %s", item.Preview)
|
||||
}
|
||||
if item.AgentID != nil {
|
||||
t.Fatalf("AgentID = %v, want nil for agent key", item.AgentID)
|
||||
}
|
||||
if !strings.Contains(string(item.Metadata), `"agent_key":"default"`) {
|
||||
t.Fatalf("metadata missing agent_key: %s", item.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTimelineItemFromEventDropsUnsupportedAndThinking(t *testing.T) {
|
||||
tenantID := uuid.Must(uuid.NewV7())
|
||||
if _, ok := runTimelineItemFromEvent(AgentEvent{
|
||||
Type: protocol.ChatEventThinking,
|
||||
RunID: "run-1",
|
||||
SessionKey: "session-1",
|
||||
TenantID: tenantID,
|
||||
}, 1); ok {
|
||||
t.Fatal("thinking event should not be archived")
|
||||
}
|
||||
|
||||
item, ok := runTimelineItemFromEvent(AgentEvent{
|
||||
Type: protocol.AgentEventRunCompleted,
|
||||
RunID: "run-1",
|
||||
SessionKey: "session-1",
|
||||
TenantID: tenantID,
|
||||
Payload: map[string]any{
|
||||
"content": "visible <thinking>hidden chain</thinking> done",
|
||||
"thinking": "raw hidden chain",
|
||||
},
|
||||
}, 1)
|
||||
if !ok {
|
||||
t.Fatal("expected completed item")
|
||||
}
|
||||
if strings.Contains(item.Preview, "hidden chain") || strings.Contains(item.Preview, "raw hidden") {
|
||||
t.Fatalf("preview leaked thinking: %q", item.Preview)
|
||||
}
|
||||
if item.Preview != "visible done" {
|
||||
t.Fatalf("Preview = %q", item.Preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTimelinePreviewStripsDeliveryFileTokens(t *testing.T) {
|
||||
tenantID := uuid.Must(uuid.NewV7())
|
||||
item, ok := runTimelineItemFromEvent(AgentEvent{
|
||||
Type: protocol.AgentEventRunCompleted,
|
||||
RunID: "run-1",
|
||||
SessionKey: "session-1",
|
||||
TenantID: tenantID,
|
||||
Payload: map[string]any{
|
||||
"content": "See  and /v1/media/b.txt?x=1&ft=stale.456",
|
||||
},
|
||||
}, 1)
|
||||
if !ok {
|
||||
t.Fatal("expected completed item")
|
||||
}
|
||||
if strings.Contains(item.Preview, "ft=") || strings.Contains(item.Preview, "signed.123") || strings.Contains(item.Preview, "stale.456") {
|
||||
t.Fatalf("preview leaked delivery token: %q", item.Preview)
|
||||
}
|
||||
if !strings.Contains(item.Preview, "/v1/files/work/a.png") || !strings.Contains(item.Preview, "/v1/media/b.txt?x=1") {
|
||||
t.Fatalf("preview lost clean file URLs: %q", item.Preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTimelineRecorderOnlyTracksSupportedActiveRuns(t *testing.T) {
|
||||
tenantID := uuid.Must(uuid.NewV7())
|
||||
recorder := NewRunTimelineRecorder(&recordingTimelineStore{})
|
||||
base := AgentEvent{
|
||||
RunID: "run-1",
|
||||
SessionKey: "session-1",
|
||||
TenantID: tenantID,
|
||||
}
|
||||
|
||||
recorder.Record(AgentEvent{Type: protocol.ChatEventThinking, RunID: base.RunID, SessionKey: base.SessionKey, TenantID: base.TenantID})
|
||||
if got := recorderTrackedRuns(recorder); got != 0 {
|
||||
t.Fatalf("tracked runs after unsupported event = %d, want 0", got)
|
||||
}
|
||||
|
||||
recorder.Record(AgentEvent{Type: protocol.AgentEventToolCall, RunID: base.RunID, SessionKey: base.SessionKey, TenantID: base.TenantID})
|
||||
if got := recorderTrackedRuns(recorder); got != 1 {
|
||||
t.Fatalf("tracked runs after supported event = %d, want 1", got)
|
||||
}
|
||||
if seq := recorderSeq(recorder, base.RunID); seq != 1 {
|
||||
t.Fatalf("seq after first supported event = %d, want 1", seq)
|
||||
}
|
||||
|
||||
recorder.Record(AgentEvent{Type: protocol.ChatEventThinking, RunID: base.RunID, SessionKey: base.SessionKey, TenantID: base.TenantID})
|
||||
if seq := recorderSeq(recorder, base.RunID); seq != 1 {
|
||||
t.Fatalf("seq after unsupported event = %d, want 1", seq)
|
||||
}
|
||||
|
||||
recorder.Record(AgentEvent{Type: protocol.AgentEventRunCompleted, RunID: base.RunID, SessionKey: base.SessionKey, TenantID: base.TenantID})
|
||||
if got := recorderTrackedRuns(recorder); got != 0 {
|
||||
t.Fatalf("tracked runs after terminal event = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func recorderTrackedRuns(r *RunTimelineRecorder) int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return len(r.nextSeq)
|
||||
}
|
||||
|
||||
func recorderSeq(r *RunTimelineRecorder, runID string) int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.nextSeq[runID]
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package methods
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
// RunTimelineMethods handles archived run timeline reads.
|
||||
type RunTimelineMethods struct {
|
||||
timeline store.RunTimelineStore
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewRunTimelineMethods(timeline store.RunTimelineStore, cfg *config.Config) *RunTimelineMethods {
|
||||
return &RunTimelineMethods{timeline: timeline, cfg: cfg}
|
||||
}
|
||||
|
||||
func (m *RunTimelineMethods) Register(router *gateway.MethodRouter) {
|
||||
router.Register(protocol.MethodRunTimelineGet, m.handleGet)
|
||||
}
|
||||
|
||||
type runTimelineGetParams struct {
|
||||
RunID string `json:"runId"`
|
||||
SessionKey string `json:"sessionKey"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
func (m *RunTimelineMethods) handleGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
|
||||
locale := store.LocaleFromContext(ctx)
|
||||
if m.timeline == nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRunTimelineUnavailable)))
|
||||
return
|
||||
}
|
||||
var params runTimelineGetParams
|
||||
if req.Params != nil {
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
|
||||
return
|
||||
}
|
||||
}
|
||||
if params.RunID == "" && params.SessionKey == "" {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "runId or sessionKey")))
|
||||
return
|
||||
}
|
||||
if params.Limit <= 0 || params.Limit > 500 {
|
||||
params.Limit = 200
|
||||
}
|
||||
if params.Offset < 0 {
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "offset must be non-negative")))
|
||||
return
|
||||
}
|
||||
items, err := m.timeline.ListRunTimelineItems(ctx, store.RunTimelineListOpts{
|
||||
RunID: params.RunID,
|
||||
SessionKey: params.SessionKey,
|
||||
Limit: params.Limit,
|
||||
Offset: params.Offset,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("run_timeline.get_failed", "run_id", params.RunID, "session_key", params.SessionKey, "error", err)
|
||||
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "run timeline")))
|
||||
return
|
||||
}
|
||||
if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
|
||||
items = filterRunTimelineItemsByUser(items, client.UserID())
|
||||
}
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
|
||||
"runId": params.RunID,
|
||||
"sessionKey": params.SessionKey,
|
||||
"items": items,
|
||||
"limit": params.Limit,
|
||||
"offset": params.Offset,
|
||||
}))
|
||||
}
|
||||
|
||||
func filterRunTimelineItemsByUser(items []store.RunTimelineItem, userID string) []store.RunTimelineItem {
|
||||
if userID == "" {
|
||||
return nil
|
||||
}
|
||||
filtered := items[:0]
|
||||
for _, item := range items {
|
||||
if item.UserID == userID {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package methods
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/gateway"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
type stubRunTimelineStore struct {
|
||||
opts []store.RunTimelineListOpts
|
||||
items []store.RunTimelineItem
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubRunTimelineStore) AppendRunTimelineItem(context.Context, *store.RunTimelineItem) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubRunTimelineStore) ListRunTimelineItems(_ context.Context, opts store.RunTimelineListOpts) ([]store.RunTimelineItem, error) {
|
||||
s.opts = append(s.opts, opts)
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return s.items, nil
|
||||
}
|
||||
|
||||
func TestRunTimelineGetScopesViewerByUser(t *testing.T) {
|
||||
timeline := &stubRunTimelineStore{
|
||||
items: []store.RunTimelineItem{
|
||||
{RunID: "run-1", UserID: "caller", Seq: 1, Preview: "visible"},
|
||||
{RunID: "run-1", UserID: "other", Seq: 2, Preview: "hidden"},
|
||||
},
|
||||
}
|
||||
m := NewRunTimelineMethods(timeline, &config.Config{})
|
||||
tenantID := uuid.Must(uuid.NewV7())
|
||||
client, responses := gateway.NewCapturingTestClient(permissions.RoleViewer, tenantID, "caller", 1)
|
||||
ctx := store.WithTenantID(context.Background(), tenantID)
|
||||
m.handleGet(ctx, client, sessionReqFrame(t, protocol.MethodRunTimelineGet, map[string]any{"runId": "run-1"}))
|
||||
|
||||
resp := readTimelineResponse(t, responses)
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("unexpected error: %+v", resp.Error)
|
||||
}
|
||||
data, ok := resp.Payload.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("payload type = %T", resp.Payload)
|
||||
}
|
||||
rawItems, ok := data["items"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("items type = %T", data["items"])
|
||||
}
|
||||
if len(rawItems) != 1 {
|
||||
t.Fatalf("len(items) = %d, want 1", len(rawItems))
|
||||
}
|
||||
if timeline.opts[0].RunID != "run-1" {
|
||||
t.Fatalf("RunID = %q", timeline.opts[0].RunID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTimelineGetRejectsNegativeOffset(t *testing.T) {
|
||||
timeline := &stubRunTimelineStore{}
|
||||
m := NewRunTimelineMethods(timeline, &config.Config{})
|
||||
tenantID := uuid.Must(uuid.NewV7())
|
||||
client, responses := gateway.NewCapturingTestClient(permissions.RoleViewer, tenantID, "caller", 1)
|
||||
ctx := store.WithTenantID(context.Background(), tenantID)
|
||||
m.handleGet(ctx, client, sessionReqFrame(t, protocol.MethodRunTimelineGet, map[string]any{
|
||||
"runId": "run-1",
|
||||
"offset": -1,
|
||||
}))
|
||||
|
||||
resp := readTimelineResponse(t, responses)
|
||||
if resp.Error == nil || resp.Error.Code != protocol.ErrInvalidRequest {
|
||||
t.Fatalf("error = %+v, want INVALID_REQUEST", resp.Error)
|
||||
}
|
||||
if len(timeline.opts) != 0 {
|
||||
t.Fatalf("store called with opts: %+v", timeline.opts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTimelineGetHidesStoreErrorDetail(t *testing.T) {
|
||||
timeline := &stubRunTimelineStore{err: errors.New("pq: syntax error at or near \"OFFSET -1\"")}
|
||||
m := NewRunTimelineMethods(timeline, &config.Config{})
|
||||
tenantID := uuid.Must(uuid.NewV7())
|
||||
client, responses := gateway.NewCapturingTestClient(permissions.RoleViewer, tenantID, "caller", 1)
|
||||
ctx := store.WithTenantID(context.Background(), tenantID)
|
||||
m.handleGet(ctx, client, sessionReqFrame(t, protocol.MethodRunTimelineGet, map[string]any{"runId": "run-1"}))
|
||||
|
||||
resp := readTimelineResponse(t, responses)
|
||||
if resp.Error == nil || resp.Error.Code != protocol.ErrInternal {
|
||||
t.Fatalf("error = %+v, want INTERNAL_ERROR", resp.Error)
|
||||
}
|
||||
if strings.Contains(resp.Error.Message, "OFFSET -1") || strings.Contains(resp.Error.Message, "pq:") {
|
||||
t.Fatalf("error leaked store detail: %q", resp.Error.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func readTimelineResponse(t *testing.T, ch <-chan []byte) protocol.ResponseFrame {
|
||||
t.Helper()
|
||||
raw := <-ch
|
||||
var resp protocol.ResponseFrame
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/crypto"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
type stubRunTimelineStore struct {
|
||||
opts []store.RunTimelineListOpts
|
||||
items []store.RunTimelineItem
|
||||
}
|
||||
|
||||
func (s *stubRunTimelineStore) AppendRunTimelineItem(context.Context, *store.RunTimelineItem) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubRunTimelineStore) ListRunTimelineItems(_ context.Context, opts store.RunTimelineListOpts) ([]store.RunTimelineItem, error) {
|
||||
s.opts = append(s.opts, opts)
|
||||
return s.items, nil
|
||||
}
|
||||
|
||||
func TestRunTimelineHTTPScopesViewerByUser(t *testing.T) {
|
||||
token := setupTraceReadToken(t, "caller")
|
||||
timeline := &stubRunTimelineStore{
|
||||
items: []store.RunTimelineItem{
|
||||
{RunID: "run-1", UserID: "caller", Seq: 1, Preview: "visible"},
|
||||
{RunID: "run-1", UserID: "other", Seq: 2, Preview: "hidden"},
|
||||
},
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
NewTracesHandler(&mockTracingStore{}, timeline).RegisterRoutes(mux)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/runs/run-1/timeline?session_key=session-1&limit=20", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Items []store.RunTimelineItem `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
if len(body.Items) != 1 || body.Items[0].UserID != "caller" {
|
||||
t.Fatalf("items = %+v", body.Items)
|
||||
}
|
||||
if len(timeline.opts) != 1 {
|
||||
t.Fatalf("List calls = %d, want 1", len(timeline.opts))
|
||||
}
|
||||
if timeline.opts[0].RunID != "run-1" || timeline.opts[0].SessionKey != "session-1" {
|
||||
t.Fatalf("opts = %+v", timeline.opts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTimelineHTTPAdminSeesTenantItems(t *testing.T) {
|
||||
token := "timeline-admin-key"
|
||||
setupTestCache(t, map[string]*store.APIKeyData{
|
||||
crypto.HashAPIKey(token): {ID: uuid.New(), Scopes: []string{"operator.admin"}, OwnerID: "admin"},
|
||||
})
|
||||
timeline := &stubRunTimelineStore{
|
||||
items: []store.RunTimelineItem{{RunID: "run-1", UserID: "other", Seq: 1}},
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
NewTracesHandler(&mockTracingStore{}, timeline).RegisterRoutes(mux)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/runs/run-1/timeline", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
+71
-3
@@ -8,6 +8,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -19,12 +20,17 @@ import (
|
||||
|
||||
// TracesHandler handles LLM trace listing and detail endpoints.
|
||||
type TracesHandler struct {
|
||||
tracing store.TracingStore
|
||||
tracing store.TracingStore
|
||||
runTimeline store.RunTimelineStore
|
||||
}
|
||||
|
||||
// NewTracesHandler creates a handler for trace management endpoints.
|
||||
func NewTracesHandler(tracing store.TracingStore) *TracesHandler {
|
||||
return &TracesHandler{tracing: tracing}
|
||||
func NewTracesHandler(tracing store.TracingStore, timelines ...store.RunTimelineStore) *TracesHandler {
|
||||
h := &TracesHandler{tracing: tracing}
|
||||
if len(timelines) > 0 {
|
||||
h.runTimeline = timelines[0]
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// RegisterRoutes registers trace routes on the given mux.
|
||||
@@ -33,6 +39,7 @@ func (h *TracesHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /v1/traces/follow", h.authMiddleware(h.handleFollow))
|
||||
mux.HandleFunc("GET /v1/traces/{traceID}/export", h.authMiddleware(h.handleExport))
|
||||
mux.HandleFunc("GET /v1/traces/{traceID}", h.authMiddleware(h.handleGet))
|
||||
mux.HandleFunc("GET /v1/runs/{runID}/timeline", h.authMiddleware(h.handleRunTimeline))
|
||||
mux.HandleFunc("GET /v1/costs/summary", h.authMiddleware(h.handleCostSummary))
|
||||
}
|
||||
|
||||
@@ -256,6 +263,67 @@ func (h *TracesHandler) handleCostSummary(w http.ResponseWriter, r *http.Request
|
||||
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
|
||||
}
|
||||
|
||||
func (h *TracesHandler) handleRunTimeline(w http.ResponseWriter, r *http.Request) {
|
||||
locale := store.LocaleFromContext(r.Context())
|
||||
if h.runTimeline == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": i18n.T(locale, i18n.MsgRunTimelineUnavailable)})
|
||||
return
|
||||
}
|
||||
runID := r.PathValue("runID")
|
||||
if strings.TrimSpace(runID) == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "run_id")})
|
||||
return
|
||||
}
|
||||
opts := store.RunTimelineListOpts{
|
||||
RunID: runID,
|
||||
SessionKey: r.URL.Query().Get("session_key"),
|
||||
Limit: 200,
|
||||
}
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
if n > 500 {
|
||||
n = 500
|
||||
}
|
||||
opts.Limit = n
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("offset"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
opts.Offset = n
|
||||
}
|
||||
}
|
||||
items, err := h.runTimeline.ListRunTimelineItems(r.Context(), opts)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
auth := resolveAuth(r)
|
||||
if !permissions.HasMinRole(auth.Role, permissions.RoleAdmin) {
|
||||
callerID := store.UserIDFromContext(r.Context())
|
||||
items = filterTimelineItemsByUser(items, callerID)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"run_id": runID,
|
||||
"session_key": opts.SessionKey,
|
||||
"items": items,
|
||||
"limit": opts.Limit,
|
||||
"offset": opts.Offset,
|
||||
})
|
||||
}
|
||||
|
||||
func filterTimelineItemsByUser(items []store.RunTimelineItem, userID string) []store.RunTimelineItem {
|
||||
if userID == "" {
|
||||
return nil
|
||||
}
|
||||
filtered := items[:0]
|
||||
for _, item := range items {
|
||||
if item.UserID == userID {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// traceExportEntry is a trace with its spans and recursive sub-traces.
|
||||
type traceExportEntry struct {
|
||||
Trace store.TraceData `json:"trace"`
|
||||
|
||||
+23
-22
@@ -59,23 +59,24 @@ func init() {
|
||||
MsgSenderIDRequired: "sender_id is required",
|
||||
|
||||
// HTTP API
|
||||
MsgInvalidAuth: "invalid authentication",
|
||||
MsgMsgsRequired: "messages is required",
|
||||
MsgUserIDHeader: "X-GoClaw-User-Id header is required",
|
||||
MsgFileTooLarge: "file too large or invalid multipart form",
|
||||
MsgMissingFileField: "missing 'file' field",
|
||||
MsgInvalidFilename: "invalid filename",
|
||||
MsgChannelKeyReq: "channel and key are required",
|
||||
MsgMethodNotAllowed: "method not allowed",
|
||||
MsgStreamingNotSupported: "streaming not supported",
|
||||
MsgOwnerOnly: "only owner can %s",
|
||||
MsgNoAccess: "no access to this %s",
|
||||
MsgAlreadySummoning: "agent is already being summoned",
|
||||
MsgSummoningUnavailable: "summoning not available",
|
||||
MsgNoDescription: "agent has no description to resummon from",
|
||||
MsgSummonCancelled: "summon cancelled by user",
|
||||
MsgCannotCancel: "agent is not being summoned",
|
||||
MsgInvalidPath: "invalid path",
|
||||
MsgInvalidAuth: "invalid authentication",
|
||||
MsgMsgsRequired: "messages is required",
|
||||
MsgUserIDHeader: "X-GoClaw-User-Id header is required",
|
||||
MsgFileTooLarge: "file too large or invalid multipart form",
|
||||
MsgMissingFileField: "missing 'file' field",
|
||||
MsgInvalidFilename: "invalid filename",
|
||||
MsgChannelKeyReq: "channel and key are required",
|
||||
MsgMethodNotAllowed: "method not allowed",
|
||||
MsgStreamingNotSupported: "streaming not supported",
|
||||
MsgOwnerOnly: "only owner can %s",
|
||||
MsgNoAccess: "no access to this %s",
|
||||
MsgAlreadySummoning: "agent is already being summoned",
|
||||
MsgSummoningUnavailable: "summoning not available",
|
||||
MsgRunTimelineUnavailable: "run timeline not available",
|
||||
MsgNoDescription: "agent has no description to resummon from",
|
||||
MsgSummonCancelled: "summon cancelled by user",
|
||||
MsgCannotCancel: "agent is not being summoned",
|
||||
MsgInvalidPath: "invalid path",
|
||||
|
||||
// Browser cookies
|
||||
MsgBrowserCookieTooMany: "too many browser cookies in one sync request",
|
||||
@@ -308,11 +309,11 @@ func init() {
|
||||
MsgGrantEnvRevealLimit: "rate limit exceeded for env reveal — try again later",
|
||||
|
||||
// Git credential adapter
|
||||
MsgGitCredHostMismatch: "stored git credential is for %s but command targets %s",
|
||||
MsgGitCredNoMatch: "no git credential configured for host %s",
|
||||
MsgGitCredUnsupportedType: "git credential type %q is not supported",
|
||||
MsgGitCredTokenInvalid: "stored git token is invalid or empty",
|
||||
MsgGitCredTokenControlChar: "stored git token contains forbidden control characters",
|
||||
MsgGitCredHostMismatch: "stored git credential is for %s but command targets %s",
|
||||
MsgGitCredNoMatch: "no git credential configured for host %s",
|
||||
MsgGitCredUnsupportedType: "git credential type %q is not supported",
|
||||
MsgGitCredTokenInvalid: "stored git token is invalid or empty",
|
||||
MsgGitCredTokenControlChar: "stored git token contains forbidden control characters",
|
||||
MsgGitCredHostUserinfoRejected: "git URL with embedded userinfo is rejected as ambiguous",
|
||||
MsgGitCredSSHPassphraseUnsupported: "passphrase-protected SSH keys are not supported; remove the passphrase with `ssh-keygen -p` before saving",
|
||||
MsgGitCredSSHKeyInvalid: "SSH private key invalid: %s",
|
||||
|
||||
+23
-22
@@ -59,23 +59,24 @@ func init() {
|
||||
MsgSenderIDRequired: "sender_id là bắt buộc",
|
||||
|
||||
// HTTP API
|
||||
MsgInvalidAuth: "xác thực không hợp lệ",
|
||||
MsgMsgsRequired: "messages là bắt buộc",
|
||||
MsgUserIDHeader: "header X-GoClaw-User-Id là bắt buộc",
|
||||
MsgFileTooLarge: "tệp quá lớn hoặc form multipart không hợp lệ",
|
||||
MsgMissingFileField: "thiếu trường 'file'",
|
||||
MsgInvalidFilename: "tên tệp không hợp lệ",
|
||||
MsgChannelKeyReq: "channel và key là bắt buộc",
|
||||
MsgMethodNotAllowed: "phương thức không được phép",
|
||||
MsgStreamingNotSupported: "streaming không được hỗ trợ",
|
||||
MsgOwnerOnly: "chỉ chủ sở hữu mới có thể %s",
|
||||
MsgNoAccess: "không có quyền truy cập %s này",
|
||||
MsgAlreadySummoning: "agent đang được triệu hồi",
|
||||
MsgSummoningUnavailable: "triệu hồi không khả dụng",
|
||||
MsgNoDescription: "agent không có mô tả để triệu hồi lại",
|
||||
MsgSummonCancelled: "đã huỷ triệu hồi",
|
||||
MsgCannotCancel: "agent không trong trạng thái đang triệu hồi",
|
||||
MsgInvalidPath: "đường dẫn không hợp lệ",
|
||||
MsgInvalidAuth: "xác thực không hợp lệ",
|
||||
MsgMsgsRequired: "messages là bắt buộc",
|
||||
MsgUserIDHeader: "header X-GoClaw-User-Id là bắt buộc",
|
||||
MsgFileTooLarge: "tệp quá lớn hoặc form multipart không hợp lệ",
|
||||
MsgMissingFileField: "thiếu trường 'file'",
|
||||
MsgInvalidFilename: "tên tệp không hợp lệ",
|
||||
MsgChannelKeyReq: "channel và key là bắt buộc",
|
||||
MsgMethodNotAllowed: "phương thức không được phép",
|
||||
MsgStreamingNotSupported: "streaming không được hỗ trợ",
|
||||
MsgOwnerOnly: "chỉ chủ sở hữu mới có thể %s",
|
||||
MsgNoAccess: "không có quyền truy cập %s này",
|
||||
MsgAlreadySummoning: "agent đang được triệu hồi",
|
||||
MsgSummoningUnavailable: "triệu hồi không khả dụng",
|
||||
MsgRunTimelineUnavailable: "timeline lượt chạy không khả dụng",
|
||||
MsgNoDescription: "agent không có mô tả để triệu hồi lại",
|
||||
MsgSummonCancelled: "đã huỷ triệu hồi",
|
||||
MsgCannotCancel: "agent không trong trạng thái đang triệu hồi",
|
||||
MsgInvalidPath: "đường dẫn không hợp lệ",
|
||||
|
||||
// Browser cookies
|
||||
MsgBrowserCookieTooMany: "quá nhiều cookie trình duyệt trong một yêu cầu đồng bộ",
|
||||
@@ -308,11 +309,11 @@ func init() {
|
||||
MsgGrantEnvRevealLimit: "đã vượt giới hạn yêu cầu xem env — vui lòng thử lại sau",
|
||||
|
||||
// Git credential adapter
|
||||
MsgGitCredHostMismatch: "thông tin xác thực git đã lưu dành cho %s nhưng lệnh đang nhắm tới %s",
|
||||
MsgGitCredNoMatch: "không có thông tin xác thực git cho host %s",
|
||||
MsgGitCredUnsupportedType: "loại thông tin xác thực git %q không được hỗ trợ",
|
||||
MsgGitCredTokenInvalid: "token git đã lưu không hợp lệ hoặc rỗng",
|
||||
MsgGitCredTokenControlChar: "token git đã lưu chứa ký tự điều khiển bị cấm",
|
||||
MsgGitCredHostMismatch: "thông tin xác thực git đã lưu dành cho %s nhưng lệnh đang nhắm tới %s",
|
||||
MsgGitCredNoMatch: "không có thông tin xác thực git cho host %s",
|
||||
MsgGitCredUnsupportedType: "loại thông tin xác thực git %q không được hỗ trợ",
|
||||
MsgGitCredTokenInvalid: "token git đã lưu không hợp lệ hoặc rỗng",
|
||||
MsgGitCredTokenControlChar: "token git đã lưu chứa ký tự điều khiển bị cấm",
|
||||
MsgGitCredHostUserinfoRejected: "URL git chứa userinfo nhúng bị từ chối vì gây nhập nhằng",
|
||||
MsgGitCredSSHPassphraseUnsupported: "khóa SSH có passphrase chưa được hỗ trợ; chạy `ssh-keygen -p` để gỡ passphrase trước khi lưu",
|
||||
MsgGitCredSSHKeyInvalid: "khóa riêng SSH không hợp lệ: %s",
|
||||
|
||||
+23
-22
@@ -59,23 +59,24 @@ func init() {
|
||||
MsgSenderIDRequired: "sender_id 是必填项",
|
||||
|
||||
// HTTP API
|
||||
MsgInvalidAuth: "身份验证无效",
|
||||
MsgMsgsRequired: "messages 是必填项",
|
||||
MsgUserIDHeader: "需要 X-GoClaw-User-Id 请求头",
|
||||
MsgFileTooLarge: "文件过大或 multipart 表单无效",
|
||||
MsgMissingFileField: "缺少 'file' 字段",
|
||||
MsgInvalidFilename: "文件名无效",
|
||||
MsgChannelKeyReq: "channel 和 key 是必填项",
|
||||
MsgMethodNotAllowed: "不允许的请求方法",
|
||||
MsgStreamingNotSupported: "不支持流式传输",
|
||||
MsgOwnerOnly: "只有所有者才能%s",
|
||||
MsgNoAccess: "无权访问此%s",
|
||||
MsgAlreadySummoning: "Agent正在被召唤中",
|
||||
MsgSummoningUnavailable: "召唤功能不可用",
|
||||
MsgNoDescription: "Agent没有可供重新召唤的描述",
|
||||
MsgSummonCancelled: "已取消召唤",
|
||||
MsgCannotCancel: "Agent 未处于召唤状态",
|
||||
MsgInvalidPath: "路径无效",
|
||||
MsgInvalidAuth: "身份验证无效",
|
||||
MsgMsgsRequired: "messages 是必填项",
|
||||
MsgUserIDHeader: "需要 X-GoClaw-User-Id 请求头",
|
||||
MsgFileTooLarge: "文件过大或 multipart 表单无效",
|
||||
MsgMissingFileField: "缺少 'file' 字段",
|
||||
MsgInvalidFilename: "文件名无效",
|
||||
MsgChannelKeyReq: "channel 和 key 是必填项",
|
||||
MsgMethodNotAllowed: "不允许的请求方法",
|
||||
MsgStreamingNotSupported: "不支持流式传输",
|
||||
MsgOwnerOnly: "只有所有者才能%s",
|
||||
MsgNoAccess: "无权访问此%s",
|
||||
MsgAlreadySummoning: "Agent正在被召唤中",
|
||||
MsgSummoningUnavailable: "召唤功能不可用",
|
||||
MsgRunTimelineUnavailable: "运行时间线不可用",
|
||||
MsgNoDescription: "Agent没有可供重新召唤的描述",
|
||||
MsgSummonCancelled: "已取消召唤",
|
||||
MsgCannotCancel: "Agent 未处于召唤状态",
|
||||
MsgInvalidPath: "路径无效",
|
||||
|
||||
// Browser cookies
|
||||
MsgBrowserCookieTooMany: "单次同步请求中的浏览器 Cookie 过多",
|
||||
@@ -308,11 +309,11 @@ func init() {
|
||||
MsgGrantEnvRevealLimit: "env 查看请求超出速率限制,请稍后再试",
|
||||
|
||||
// Git 凭据适配器
|
||||
MsgGitCredHostMismatch: "已存储的 git 凭据属于 %s,但命令目标是 %s",
|
||||
MsgGitCredNoMatch: "没有为主机 %s 配置 git 凭据",
|
||||
MsgGitCredUnsupportedType: "不支持的 git 凭据类型 %q",
|
||||
MsgGitCredTokenInvalid: "已存储的 git token 无效或为空",
|
||||
MsgGitCredTokenControlChar: "已存储的 git token 包含被禁止的控制字符",
|
||||
MsgGitCredHostMismatch: "已存储的 git 凭据属于 %s,但命令目标是 %s",
|
||||
MsgGitCredNoMatch: "没有为主机 %s 配置 git 凭据",
|
||||
MsgGitCredUnsupportedType: "不支持的 git 凭据类型 %q",
|
||||
MsgGitCredTokenInvalid: "已存储的 git token 无效或为空",
|
||||
MsgGitCredTokenControlChar: "已存储的 git token 包含被禁止的控制字符",
|
||||
MsgGitCredHostUserinfoRejected: "git URL 中嵌入的 userinfo 因含义不明被拒绝",
|
||||
MsgGitCredSSHPassphraseUnsupported: "暂不支持带密码短语的 SSH 私钥;请先用 `ssh-keygen -p` 移除密码短语再保存",
|
||||
MsgGitCredSSHKeyInvalid: "SSH 私钥无效:%s",
|
||||
|
||||
+29
-28
@@ -60,23 +60,24 @@ const (
|
||||
MsgSenderIDRequired = "error.sender_id_required" // "sender_id is required"
|
||||
|
||||
// --- HTTP API ---
|
||||
MsgInvalidAuth = "error.invalid_auth" // "invalid authentication"
|
||||
MsgMsgsRequired = "error.messages_required" // "messages is required"
|
||||
MsgUserIDHeader = "error.user_id_header" // "X-GoClaw-User-Id header is required"
|
||||
MsgFileTooLarge = "error.file_too_large" // "file too large or invalid multipart form"
|
||||
MsgMissingFileField = "error.missing_file_field" // "missing 'file' field"
|
||||
MsgInvalidFilename = "error.invalid_filename" // "invalid filename"
|
||||
MsgChannelKeyReq = "error.channel_key_required" // "channel and key are required"
|
||||
MsgMethodNotAllowed = "error.method_not_allowed" // "method not allowed"
|
||||
MsgStreamingNotSupported = "error.streaming_not_supported" // "streaming not supported"
|
||||
MsgOwnerOnly = "error.owner_only" // "only owner can %s"
|
||||
MsgNoAccess = "error.no_access" // "no access to this %s"
|
||||
MsgAlreadySummoning = "error.already_summoning" // "agent is already being summoned"
|
||||
MsgSummoningUnavailable = "error.summoning_unavailable" // "summoning not available"
|
||||
MsgNoDescription = "error.no_description" // "agent has no description to resummon from"
|
||||
MsgSummonCancelled = "info.summon_cancelled" // "summon cancelled by user"
|
||||
MsgCannotCancel = "error.cannot_cancel_summon" // "agent is not being summoned"
|
||||
MsgInvalidPath = "error.invalid_path" // "invalid path"
|
||||
MsgInvalidAuth = "error.invalid_auth" // "invalid authentication"
|
||||
MsgMsgsRequired = "error.messages_required" // "messages is required"
|
||||
MsgUserIDHeader = "error.user_id_header" // "X-GoClaw-User-Id header is required"
|
||||
MsgFileTooLarge = "error.file_too_large" // "file too large or invalid multipart form"
|
||||
MsgMissingFileField = "error.missing_file_field" // "missing 'file' field"
|
||||
MsgInvalidFilename = "error.invalid_filename" // "invalid filename"
|
||||
MsgChannelKeyReq = "error.channel_key_required" // "channel and key are required"
|
||||
MsgMethodNotAllowed = "error.method_not_allowed" // "method not allowed"
|
||||
MsgStreamingNotSupported = "error.streaming_not_supported" // "streaming not supported"
|
||||
MsgOwnerOnly = "error.owner_only" // "only owner can %s"
|
||||
MsgNoAccess = "error.no_access" // "no access to this %s"
|
||||
MsgAlreadySummoning = "error.already_summoning" // "agent is already being summoned"
|
||||
MsgSummoningUnavailable = "error.summoning_unavailable" // "summoning not available"
|
||||
MsgRunTimelineUnavailable = "error.run_timeline_unavailable" // "run timeline not available"
|
||||
MsgNoDescription = "error.no_description" // "agent has no description to resummon from"
|
||||
MsgSummonCancelled = "info.summon_cancelled" // "summon cancelled by user"
|
||||
MsgCannotCancel = "error.cannot_cancel_summon" // "agent is not being summoned"
|
||||
MsgInvalidPath = "error.invalid_path" // "invalid path"
|
||||
|
||||
// --- Browser cookies ---
|
||||
MsgBrowserCookieTooMany = "error.browser_cookie_too_many" // "too many browser cookies in one sync request"
|
||||
@@ -340,16 +341,16 @@ const (
|
||||
MsgGrantEnvRevealLimit = "error.grant_env_reveal_limit" // "rate limit exceeded for env reveal"
|
||||
|
||||
// --- Git credential adapter (Phase 3+) ---
|
||||
MsgGitCredHostMismatch = "error.git_cred_host_mismatch" // "stored credential is for %s but command targets %s"
|
||||
MsgGitCredNoMatch = "error.git_cred_no_match" // "no git credential configured for host %s"
|
||||
MsgGitCredUnsupportedType = "error.git_cred_unsupported_type" // "git credential type %q is not supported"
|
||||
MsgGitCredTokenInvalid = "error.git_cred_token_invalid" // "stored git token is invalid or empty"
|
||||
MsgGitCredTokenControlChar = "error.git_cred_token_control_char" // "stored git token contains forbidden control characters"
|
||||
MsgGitCredHostUserinfoRejected = "error.git_cred_host_userinfo_rejected" // "git URL with embedded userinfo is rejected as ambiguous"
|
||||
MsgGitCredHostMismatch = "error.git_cred_host_mismatch" // "stored credential is for %s but command targets %s"
|
||||
MsgGitCredNoMatch = "error.git_cred_no_match" // "no git credential configured for host %s"
|
||||
MsgGitCredUnsupportedType = "error.git_cred_unsupported_type" // "git credential type %q is not supported"
|
||||
MsgGitCredTokenInvalid = "error.git_cred_token_invalid" // "stored git token is invalid or empty"
|
||||
MsgGitCredTokenControlChar = "error.git_cred_token_control_char" // "stored git token contains forbidden control characters"
|
||||
MsgGitCredHostUserinfoRejected = "error.git_cred_host_userinfo_rejected" // "git URL with embedded userinfo is rejected as ambiguous"
|
||||
MsgGitCredSSHPassphraseUnsupported = "error.git_cred_ssh_passphrase_unsupported" // "passphrase-protected SSH keys not supported in v1"
|
||||
MsgGitCredSSHKeyInvalid = "error.git_cred_ssh_key_invalid" // "SSH private key invalid: %s"
|
||||
MsgGitCredHostScopeRequired = "error.git_cred_host_scope_required" // "host_scope required for credential_type %s"
|
||||
MsgGitCredHostScopeInvalid = "error.git_cred_host_scope_invalid" // "host_scope %q is not a valid hostname"
|
||||
MsgGitCredBlobMissingField = "error.git_cred_blob_missing_field" // "blob missing required field %q"
|
||||
MsgGitCredUnsupportedCredType = "error.git_cred_unsupported_cred_type" // "credential_type %q is not supported"
|
||||
MsgGitCredSSHKeyInvalid = "error.git_cred_ssh_key_invalid" // "SSH private key invalid: %s"
|
||||
MsgGitCredHostScopeRequired = "error.git_cred_host_scope_required" // "host_scope required for credential_type %s"
|
||||
MsgGitCredHostScopeInvalid = "error.git_cred_host_scope_invalid" // "host_scope %q is not a valid hostname"
|
||||
MsgGitCredBlobMissingField = "error.git_cred_blob_missing_field" // "blob missing required field %q"
|
||||
MsgGitCredUnsupportedCredType = "error.git_cred_unsupported_cred_type" // "credential_type %q is not supported"
|
||||
)
|
||||
@@ -367,6 +367,7 @@ func isReadMethod(method string) bool {
|
||||
// Sessions read
|
||||
protocol.MethodSessionsList,
|
||||
protocol.MethodSessionsPreview,
|
||||
protocol.MethodRunTimelineGet,
|
||||
|
||||
// Skills read
|
||||
protocol.MethodSkillsList,
|
||||
|
||||
@@ -33,6 +33,7 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) {
|
||||
Agents: NewPGAgentStore(db),
|
||||
Providers: NewPGProviderStore(db, cfg.EncryptionKey),
|
||||
Tracing: NewPGTracingStore(db),
|
||||
RunTimeline: NewPGRunTimelineStore(db),
|
||||
MCP: NewPGMCPServerStore(db, cfg.EncryptionKey),
|
||||
ChannelInstances: NewPGChannelInstanceStore(db, cfg.EncryptionKey),
|
||||
ConfigSecrets: NewPGConfigSecretsStore(db, cfg.EncryptionKey),
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package pg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// PGRunTimelineStore implements store.RunTimelineStore backed by PostgreSQL.
|
||||
type PGRunTimelineStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewPGRunTimelineStore(db *sql.DB) *PGRunTimelineStore {
|
||||
return &PGRunTimelineStore{db: db}
|
||||
}
|
||||
|
||||
func (s *PGRunTimelineStore) AppendRunTimelineItem(ctx context.Context, item *store.RunTimelineItem) error {
|
||||
if item.ID == uuid.Nil {
|
||||
item.ID = store.GenNewID()
|
||||
}
|
||||
if item.CreatedAt.IsZero() {
|
||||
item.CreatedAt = time.Now()
|
||||
}
|
||||
tenantID := tenantIDForInsert(ctx)
|
||||
item.TenantID = tenantID
|
||||
metadata := item.Metadata
|
||||
if len(metadata) == 0 {
|
||||
metadata = []byte(`{}`)
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO run_timeline_items
|
||||
(id, tenant_id, run_id, session_key, agent_id, user_id, channel, chat_id, seq,
|
||||
item_type, status, title, preview, content, tool_name, tool_call_id, trace_id, span_id,
|
||||
metadata, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9,
|
||||
$10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
|
||||
ON CONFLICT (tenant_id, run_id, seq) DO UPDATE SET
|
||||
session_key = EXCLUDED.session_key,
|
||||
agent_id = EXCLUDED.agent_id,
|
||||
user_id = EXCLUDED.user_id,
|
||||
channel = EXCLUDED.channel,
|
||||
chat_id = EXCLUDED.chat_id,
|
||||
item_type = EXCLUDED.item_type,
|
||||
status = EXCLUDED.status,
|
||||
title = EXCLUDED.title,
|
||||
preview = EXCLUDED.preview,
|
||||
content = '',
|
||||
tool_name = EXCLUDED.tool_name,
|
||||
tool_call_id = EXCLUDED.tool_call_id,
|
||||
trace_id = EXCLUDED.trace_id,
|
||||
span_id = EXCLUDED.span_id,
|
||||
metadata = EXCLUDED.metadata,
|
||||
created_at = EXCLUDED.created_at`,
|
||||
item.ID, tenantID, item.RunID, item.SessionKey, nilUUID(item.AgentID), nilStr(item.UserID),
|
||||
nilStr(item.Channel), nilStr(item.ChatID), item.Seq, item.ItemType, nilStr(item.Status),
|
||||
nilStr(item.Title), nilStr(item.Preview), "", nilStr(item.ToolName), nilStr(item.ToolCallID),
|
||||
nilUUID(item.TraceID), nilUUID(item.SpanID), jsonOrEmpty(metadata), item.CreatedAt,
|
||||
)
|
||||
if err == nil {
|
||||
item.Content = ""
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PGRunTimelineStore) ListRunTimelineItems(ctx context.Context, opts store.RunTimelineListOpts) ([]store.RunTimelineItem, error) {
|
||||
where, args := buildRunTimelineWhere(ctx, opts)
|
||||
limit := opts.Limit
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 200
|
||||
}
|
||||
q := `SELECT id, tenant_id, run_id, session_key, agent_id, user_id, channel, chat_id, seq,
|
||||
item_type, status, title, preview, COALESCE(content, '') AS content, tool_name, tool_call_id,
|
||||
trace_id, span_id, COALESCE(metadata, '{}'::jsonb) AS metadata, created_at
|
||||
FROM run_timeline_items` + where +
|
||||
runTimelineOrderBy(opts) +
|
||||
fmt.Sprintf(" OFFSET %d LIMIT %d", opts.Offset, limit)
|
||||
|
||||
var rows []runTimelineRow
|
||||
if err := pkgSqlxDB.SelectContext(ctx, &rows, q, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]store.RunTimelineItem, len(rows))
|
||||
for i, row := range rows {
|
||||
items[i] = row.toStore()
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func runTimelineOrderBy(opts store.RunTimelineListOpts) string {
|
||||
if opts.RunID != "" {
|
||||
return " ORDER BY seq ASC, created_at ASC"
|
||||
}
|
||||
return " ORDER BY created_at ASC, seq ASC"
|
||||
}
|
||||
|
||||
func buildRunTimelineWhere(ctx context.Context, opts store.RunTimelineListOpts) (string, []any) {
|
||||
var conditions []string
|
||||
var args []any
|
||||
argIdx := 1
|
||||
if !store.IsCrossTenant(ctx) {
|
||||
tenantID := store.TenantIDFromContext(ctx)
|
||||
if tenantID == uuid.Nil {
|
||||
return " WHERE 1=0", nil
|
||||
}
|
||||
conditions = append(conditions, fmt.Sprintf("tenant_id = $%d", argIdx))
|
||||
args = append(args, tenantID)
|
||||
argIdx++
|
||||
}
|
||||
if opts.RunID != "" {
|
||||
conditions = append(conditions, fmt.Sprintf("run_id = $%d", argIdx))
|
||||
args = append(args, opts.RunID)
|
||||
argIdx++
|
||||
}
|
||||
if opts.SessionKey != "" {
|
||||
conditions = append(conditions, fmt.Sprintf("session_key = $%d", argIdx))
|
||||
args = append(args, opts.SessionKey)
|
||||
}
|
||||
if len(conditions) == 0 {
|
||||
return " WHERE 1=0", nil
|
||||
}
|
||||
return " WHERE " + strings.Join(conditions, " AND "), args
|
||||
}
|
||||
|
||||
type runTimelineRow struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
TenantID uuid.UUID `db:"tenant_id"`
|
||||
RunID string `db:"run_id"`
|
||||
SessionKey string `db:"session_key"`
|
||||
AgentID *uuid.UUID `db:"agent_id"`
|
||||
UserID *string `db:"user_id"`
|
||||
Channel *string `db:"channel"`
|
||||
ChatID *string `db:"chat_id"`
|
||||
Seq int `db:"seq"`
|
||||
ItemType string `db:"item_type"`
|
||||
Status *string `db:"status"`
|
||||
Title *string `db:"title"`
|
||||
Preview *string `db:"preview"`
|
||||
Content string `db:"content"`
|
||||
ToolName *string `db:"tool_name"`
|
||||
ToolCallID *string `db:"tool_call_id"`
|
||||
TraceID *uuid.UUID `db:"trace_id"`
|
||||
SpanID *uuid.UUID `db:"span_id"`
|
||||
Metadata json.RawMessage `db:"metadata"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}
|
||||
|
||||
func (r runTimelineRow) toStore() store.RunTimelineItem {
|
||||
return store.RunTimelineItem{
|
||||
ID: r.ID, TenantID: r.TenantID, RunID: r.RunID, SessionKey: r.SessionKey,
|
||||
AgentID: r.AgentID, UserID: derefStr(r.UserID), Channel: derefStr(r.Channel),
|
||||
ChatID: derefStr(r.ChatID), Seq: r.Seq, ItemType: r.ItemType,
|
||||
Status: derefStr(r.Status), Title: derefStr(r.Title), Preview: derefStr(r.Preview),
|
||||
Content: r.Content, ToolName: derefStr(r.ToolName), ToolCallID: derefStr(r.ToolCallID),
|
||||
TraceID: r.TraceID, SpanID: r.SpanID, Metadata: r.Metadata, CreatedAt: r.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package pg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
func TestPGRunTimelineStoreAppendAndListBySeq(t *testing.T) {
|
||||
db := hooksTestDB(t)
|
||||
timeline := NewPGRunTimelineStore(db)
|
||||
tenantID, agentID := seedTenantAndAgent(t, db)
|
||||
ctx := store.WithTenantID(context.Background(), tenantID)
|
||||
|
||||
items := []store.RunTimelineItem{
|
||||
{
|
||||
RunID: "pg-run-1",
|
||||
SessionKey: "agent:default:direct:user-1",
|
||||
AgentID: &agentID,
|
||||
UserID: "user-1",
|
||||
Channel: "web",
|
||||
Seq: 2,
|
||||
ItemType: store.RunTimelineItemTypeToolCall,
|
||||
Status: store.RunTimelineStatusRunning,
|
||||
Title: "exec_command",
|
||||
Preview: `{"cmd":"date"}`,
|
||||
ToolName: "exec_command",
|
||||
ToolCallID: "call-1",
|
||||
Metadata: json.RawMessage(`{"safe":true}`),
|
||||
},
|
||||
{
|
||||
RunID: "pg-run-1",
|
||||
SessionKey: "agent:default:direct:user-1",
|
||||
UserID: "user-1",
|
||||
Channel: "web",
|
||||
Seq: 1,
|
||||
ItemType: store.RunTimelineItemTypeRunStatus,
|
||||
Status: store.RunTimelineStatusStarted,
|
||||
Title: "Run started",
|
||||
Preview: "hello",
|
||||
},
|
||||
}
|
||||
for i := range items {
|
||||
if err := timeline.AppendRunTimelineItem(ctx, &items[i]); err != nil {
|
||||
t.Fatalf("AppendRunTimelineItem(%d): %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := timeline.ListRunTimelineItems(ctx, store.RunTimelineListOpts{RunID: "pg-run-1", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ListRunTimelineItems: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(items) = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].Seq != 1 || got[1].Seq != 2 {
|
||||
t.Fatalf("seq order = [%d,%d], want [1,2]", got[0].Seq, got[1].Seq)
|
||||
}
|
||||
if got[1].Content != "" {
|
||||
t.Fatalf("Content persisted = %q, want empty preview-only archive", got[1].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPGRunTimelineStoreTenantScope(t *testing.T) {
|
||||
db := hooksTestDB(t)
|
||||
timeline := NewPGRunTimelineStore(db)
|
||||
tenantID, _ := seedTenantAndAgent(t, db)
|
||||
ctxA := store.WithTenantID(context.Background(), tenantID)
|
||||
ctxB := store.WithTenantID(context.Background(), uuid.Must(uuid.NewV7()))
|
||||
|
||||
item := store.RunTimelineItem{
|
||||
RunID: "pg-run-scope",
|
||||
SessionKey: "agent:default:direct:user-1",
|
||||
Seq: 1,
|
||||
ItemType: store.RunTimelineItemTypeAssistantMessage,
|
||||
Status: store.RunTimelineStatusCompleted,
|
||||
Title: "Assistant",
|
||||
Preview: "Visible only to tenant A",
|
||||
Content: `{"raw":"must not persist"}`,
|
||||
}
|
||||
if err := timeline.AppendRunTimelineItem(ctxA, &item); err != nil {
|
||||
t.Fatalf("AppendRunTimelineItem: %v", err)
|
||||
}
|
||||
|
||||
gotA, err := timeline.ListRunTimelineItems(ctxA, store.RunTimelineListOpts{RunID: "pg-run-scope"})
|
||||
if err != nil {
|
||||
t.Fatalf("List tenant A: %v", err)
|
||||
}
|
||||
if len(gotA) != 1 {
|
||||
t.Fatalf("tenant A len = %d, want 1", len(gotA))
|
||||
}
|
||||
if gotA[0].Content != "" {
|
||||
t.Fatalf("Content = %q, want empty", gotA[0].Content)
|
||||
}
|
||||
|
||||
gotB, err := timeline.ListRunTimelineItems(ctxB, store.RunTimelineListOpts{RunID: "pg-run-scope"})
|
||||
if err != nil {
|
||||
t.Fatalf("List tenant B: %v", err)
|
||||
}
|
||||
if len(gotB) != 0 {
|
||||
t.Fatalf("tenant B len = %d, want 0", len(gotB))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
RunTimelineItemTypeActivity = "activity"
|
||||
RunTimelineItemTypeAssistantMessage = "assistant.message"
|
||||
RunTimelineItemTypeToolCall = "tool.call"
|
||||
RunTimelineItemTypeToolResult = "tool.result"
|
||||
RunTimelineItemTypeRunStatus = "run.status"
|
||||
)
|
||||
|
||||
const (
|
||||
RunTimelineStatusStarted = "started"
|
||||
RunTimelineStatusRunning = "running"
|
||||
RunTimelineStatusCompleted = "completed"
|
||||
RunTimelineStatusFailed = "failed"
|
||||
RunTimelineStatusCancelled = "cancelled"
|
||||
)
|
||||
|
||||
// RunTimelineItem is a persisted, display-safe archive entry for one agent run.
|
||||
type RunTimelineItem struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
|
||||
RunID string `json:"run_id" db:"run_id"`
|
||||
SessionKey string `json:"session_key" db:"session_key"`
|
||||
AgentID *uuid.UUID `json:"agent_id,omitempty" db:"agent_id"`
|
||||
UserID string `json:"user_id,omitempty" db:"user_id"`
|
||||
Channel string `json:"channel,omitempty" db:"channel"`
|
||||
ChatID string `json:"chat_id,omitempty" db:"chat_id"`
|
||||
Seq int `json:"seq" db:"seq"`
|
||||
ItemType string `json:"item_type" db:"item_type"`
|
||||
Status string `json:"status,omitempty" db:"status"`
|
||||
Title string `json:"title,omitempty" db:"title"`
|
||||
Preview string `json:"preview,omitempty" db:"preview"`
|
||||
Content string `json:"content,omitempty" db:"content"`
|
||||
ToolName string `json:"tool_name,omitempty" db:"tool_name"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty" db:"tool_call_id"`
|
||||
TraceID *uuid.UUID `json:"trace_id,omitempty" db:"trace_id"`
|
||||
SpanID *uuid.UUID `json:"span_id,omitempty" db:"span_id"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty" db:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
}
|
||||
|
||||
// RunTimelineListOpts scopes a timeline read. RunID is preferred; SessionKey is
|
||||
// a fallback for session archive views that need the latest runs in a session.
|
||||
type RunTimelineListOpts struct {
|
||||
RunID string
|
||||
SessionKey string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// RunTimelineStore appends and lists archived agent run timeline entries.
|
||||
type RunTimelineStore interface {
|
||||
AppendRunTimelineItem(ctx context.Context, item *RunTimelineItem) error
|
||||
ListRunTimelineItems(ctx context.Context, opts RunTimelineListOpts) ([]RunTimelineItem, error)
|
||||
}
|
||||
@@ -36,43 +36,44 @@ func NewSQLiteStores(cfg store.StoreConfig) (*store.Stores, error) {
|
||||
}
|
||||
|
||||
sqliteStores := &store.Stores{
|
||||
DB: db,
|
||||
Sessions: NewSQLiteSessionStore(db),
|
||||
Agents: NewSQLiteAgentStore(db),
|
||||
Providers: NewSQLiteProviderStore(db, cfg.EncryptionKey),
|
||||
Tracing: NewSQLiteTracingStore(db),
|
||||
ConfigSecrets: NewSQLiteConfigSecretsStore(db, cfg.EncryptionKey),
|
||||
BuiltinTools: NewSQLiteBuiltinToolStore(db),
|
||||
Heartbeats: NewSQLiteHeartbeatStore(db),
|
||||
Tenants: NewSQLiteTenantStore(db),
|
||||
BuiltinToolTenantCfgs: NewSQLiteBuiltinToolTenantConfigStore(db),
|
||||
SkillTenantCfgs: NewSQLiteSkillTenantConfigStore(db),
|
||||
SystemConfigs: NewSQLiteSystemConfigStore(db),
|
||||
Snapshots: NewSQLiteSnapshotStore(db),
|
||||
Cron: NewSQLiteCronStore(db),
|
||||
ChannelInstances: NewSQLiteChannelInstanceStore(db, cfg.EncryptionKey),
|
||||
Pairing: NewSQLitePairingStore(db),
|
||||
PendingMessages: NewSQLitePendingMessageStore(db),
|
||||
Contacts: NewSQLiteContactStore(db),
|
||||
Teams: NewSQLiteTeamStore(db),
|
||||
Skills: NewSQLiteSkillStore(db, cfg.SkillsStorageDir),
|
||||
MCP: NewSQLiteMCPServerStore(db, cfg.EncryptionKey),
|
||||
Activity: NewSQLiteActivityStore(db),
|
||||
APIKeys: NewSQLiteAPIKeyStore(db),
|
||||
ConfigPermissions: NewSQLiteConfigPermissionStore(db),
|
||||
BrowserCookies: NewSQLiteBrowserCookieStore(db, cfg.EncryptionKey),
|
||||
Memory: NewSQLiteMemoryStore(db),
|
||||
SubagentTasks: NewSQLiteSubagentTaskStore(db),
|
||||
AgentLinks: NewSQLiteAgentLinkStore(db),
|
||||
SecureCLI: secureCLI,
|
||||
SecureCLIGrants: NewSQLiteSecureCLIAgentGrantStore(db, cfg.EncryptionKey),
|
||||
Episodic: NewSQLiteEpisodicStore(db),
|
||||
EvolutionMetrics: NewSQLiteEvolutionMetricsStore(db),
|
||||
EvolutionSuggestions: NewSQLiteEvolutionSuggestionStore(db),
|
||||
KnowledgeGraph: NewSQLiteKnowledgeGraphStore(db),
|
||||
Vault: NewSQLiteVaultStore(db),
|
||||
BitrixPortals: NewSQLiteBitrixPortalStore(db, cfg.EncryptionKey),
|
||||
Hooks: NewSQLiteHookStore(db),
|
||||
DB: db,
|
||||
Sessions: NewSQLiteSessionStore(db),
|
||||
Agents: NewSQLiteAgentStore(db),
|
||||
Providers: NewSQLiteProviderStore(db, cfg.EncryptionKey),
|
||||
Tracing: NewSQLiteTracingStore(db),
|
||||
RunTimeline: NewSQLiteRunTimelineStore(db),
|
||||
ConfigSecrets: NewSQLiteConfigSecretsStore(db, cfg.EncryptionKey),
|
||||
BuiltinTools: NewSQLiteBuiltinToolStore(db),
|
||||
Heartbeats: NewSQLiteHeartbeatStore(db),
|
||||
Tenants: NewSQLiteTenantStore(db),
|
||||
BuiltinToolTenantCfgs: NewSQLiteBuiltinToolTenantConfigStore(db),
|
||||
SkillTenantCfgs: NewSQLiteSkillTenantConfigStore(db),
|
||||
SystemConfigs: NewSQLiteSystemConfigStore(db),
|
||||
Snapshots: NewSQLiteSnapshotStore(db),
|
||||
Cron: NewSQLiteCronStore(db),
|
||||
ChannelInstances: NewSQLiteChannelInstanceStore(db, cfg.EncryptionKey),
|
||||
Pairing: NewSQLitePairingStore(db),
|
||||
PendingMessages: NewSQLitePendingMessageStore(db),
|
||||
Contacts: NewSQLiteContactStore(db),
|
||||
Teams: NewSQLiteTeamStore(db),
|
||||
Skills: NewSQLiteSkillStore(db, cfg.SkillsStorageDir),
|
||||
MCP: NewSQLiteMCPServerStore(db, cfg.EncryptionKey),
|
||||
Activity: NewSQLiteActivityStore(db),
|
||||
APIKeys: NewSQLiteAPIKeyStore(db),
|
||||
ConfigPermissions: NewSQLiteConfigPermissionStore(db),
|
||||
BrowserCookies: NewSQLiteBrowserCookieStore(db, cfg.EncryptionKey),
|
||||
Memory: NewSQLiteMemoryStore(db),
|
||||
SubagentTasks: NewSQLiteSubagentTaskStore(db),
|
||||
AgentLinks: NewSQLiteAgentLinkStore(db),
|
||||
SecureCLI: secureCLI,
|
||||
SecureCLIGrants: NewSQLiteSecureCLIAgentGrantStore(db, cfg.EncryptionKey),
|
||||
Episodic: NewSQLiteEpisodicStore(db),
|
||||
EvolutionMetrics: NewSQLiteEvolutionMetricsStore(db),
|
||||
EvolutionSuggestions: NewSQLiteEvolutionSuggestionStore(db),
|
||||
KnowledgeGraph: NewSQLiteKnowledgeGraphStore(db),
|
||||
Vault: NewSQLiteVaultStore(db),
|
||||
BitrixPortals: NewSQLiteBitrixPortalStore(db, cfg.EncryptionKey),
|
||||
Hooks: NewSQLiteHookStore(db),
|
||||
Webhooks: NewSQLiteWebhookStore(db),
|
||||
WebhookCalls: NewSQLiteWebhookCallStore(db),
|
||||
Workstations: NewSQLiteWorkstationStore(db, cfg.EncryptionKey),
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//go:build sqlite || sqliteonly
|
||||
|
||||
package sqlitestore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// SQLiteRunTimelineStore implements store.RunTimelineStore backed by SQLite.
|
||||
type SQLiteRunTimelineStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewSQLiteRunTimelineStore(db *sql.DB) *SQLiteRunTimelineStore {
|
||||
return &SQLiteRunTimelineStore{db: db}
|
||||
}
|
||||
|
||||
func (s *SQLiteRunTimelineStore) AppendRunTimelineItem(ctx context.Context, item *store.RunTimelineItem) error {
|
||||
if item.ID == uuid.Nil {
|
||||
item.ID = store.GenNewID()
|
||||
}
|
||||
if item.CreatedAt.IsZero() {
|
||||
item.CreatedAt = time.Now()
|
||||
}
|
||||
tenantID := tenantIDForInsert(ctx)
|
||||
item.TenantID = tenantID
|
||||
metadata := item.Metadata
|
||||
if len(metadata) == 0 {
|
||||
metadata = []byte(`{}`)
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO run_timeline_items
|
||||
(id, tenant_id, run_id, session_key, agent_id, user_id, channel, chat_id, seq,
|
||||
item_type, status, title, preview, content, tool_name, tool_call_id, trace_id, span_id,
|
||||
metadata, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (tenant_id, run_id, seq) DO UPDATE SET
|
||||
session_key = excluded.session_key,
|
||||
agent_id = excluded.agent_id,
|
||||
user_id = excluded.user_id,
|
||||
channel = excluded.channel,
|
||||
chat_id = excluded.chat_id,
|
||||
item_type = excluded.item_type,
|
||||
status = excluded.status,
|
||||
title = excluded.title,
|
||||
preview = excluded.preview,
|
||||
content = '',
|
||||
tool_name = excluded.tool_name,
|
||||
tool_call_id = excluded.tool_call_id,
|
||||
trace_id = excluded.trace_id,
|
||||
span_id = excluded.span_id,
|
||||
metadata = excluded.metadata,
|
||||
created_at = excluded.created_at`,
|
||||
item.ID, tenantID, item.RunID, item.SessionKey, nilUUID(item.AgentID), nilStr(item.UserID),
|
||||
nilStr(item.Channel), nilStr(item.ChatID), item.Seq, item.ItemType, nilStr(item.Status),
|
||||
nilStr(item.Title), nilStr(item.Preview), "", nilStr(item.ToolName), nilStr(item.ToolCallID),
|
||||
nilUUID(item.TraceID), nilUUID(item.SpanID), string(metadata), item.CreatedAt,
|
||||
)
|
||||
if err == nil {
|
||||
item.Content = ""
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteRunTimelineStore) ListRunTimelineItems(ctx context.Context, opts store.RunTimelineListOpts) ([]store.RunTimelineItem, error) {
|
||||
where, args := buildRunTimelineWhere(ctx, opts)
|
||||
limit := opts.Limit
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 200
|
||||
}
|
||||
q := `SELECT id, tenant_id, run_id, session_key, agent_id, user_id, channel, chat_id, seq,
|
||||
item_type, status, title, preview, COALESCE(content, '') AS content, tool_name, tool_call_id,
|
||||
trace_id, span_id, COALESCE(metadata, '{}') AS metadata, created_at
|
||||
FROM run_timeline_items` + where +
|
||||
runTimelineOrderBy(opts) +
|
||||
fmt.Sprintf(" LIMIT %d OFFSET %d", limit, opts.Offset)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanRunTimelineRows(rows)
|
||||
}
|
||||
|
||||
func runTimelineOrderBy(opts store.RunTimelineListOpts) string {
|
||||
if opts.RunID != "" {
|
||||
return " ORDER BY seq ASC, created_at ASC"
|
||||
}
|
||||
return " ORDER BY created_at ASC, seq ASC"
|
||||
}
|
||||
|
||||
func buildRunTimelineWhere(ctx context.Context, opts store.RunTimelineListOpts) (string, []any) {
|
||||
var conditions []string
|
||||
var args []any
|
||||
if !store.IsCrossTenant(ctx) {
|
||||
tenantID := store.TenantIDFromContext(ctx)
|
||||
if tenantID == uuid.Nil {
|
||||
return " WHERE 1=0", nil
|
||||
}
|
||||
conditions = append(conditions, "tenant_id = ?")
|
||||
args = append(args, tenantID)
|
||||
}
|
||||
if opts.RunID != "" {
|
||||
conditions = append(conditions, "run_id = ?")
|
||||
args = append(args, opts.RunID)
|
||||
}
|
||||
if opts.SessionKey != "" {
|
||||
conditions = append(conditions, "session_key = ?")
|
||||
args = append(args, opts.SessionKey)
|
||||
}
|
||||
if len(conditions) == 0 {
|
||||
return " WHERE 1=0", nil
|
||||
}
|
||||
return " WHERE " + strings.Join(conditions, " AND "), args
|
||||
}
|
||||
|
||||
func scanRunTimelineRows(rows *sql.Rows) ([]store.RunTimelineItem, error) {
|
||||
var items []store.RunTimelineItem
|
||||
for rows.Next() {
|
||||
var item store.RunTimelineItem
|
||||
var agentID, traceID, spanID *uuid.UUID
|
||||
var userID, channel, chatID, status, title, preview, toolName, toolCallID sql.NullString
|
||||
var metadata string
|
||||
var createdAt sqliteTime
|
||||
if err := rows.Scan(&item.ID, &item.TenantID, &item.RunID, &item.SessionKey, &agentID,
|
||||
&userID, &channel, &chatID, &item.Seq, &item.ItemType, &status, &title, &preview,
|
||||
&item.Content, &toolName, &toolCallID, &traceID, &spanID, &metadata, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.AgentID = agentID
|
||||
item.TraceID = traceID
|
||||
item.SpanID = spanID
|
||||
item.UserID = userID.String
|
||||
item.Channel = channel.String
|
||||
item.ChatID = chatID.String
|
||||
item.Status = status.String
|
||||
item.Title = title.String
|
||||
item.Preview = preview.String
|
||||
item.ToolName = toolName.String
|
||||
item.ToolCallID = toolCallID.String
|
||||
item.Metadata = []byte(metadata)
|
||||
item.CreatedAt = createdAt.Time
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//go:build sqlite || sqliteonly
|
||||
|
||||
package sqlitestore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
func TestSQLiteRunTimelineStoreAppendAndListBySeq(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
if err := EnsureSchema(db); err != nil {
|
||||
t.Fatalf("EnsureSchema: %v", err)
|
||||
}
|
||||
|
||||
timeline := NewSQLiteRunTimelineStore(db)
|
||||
ctx := store.WithTenantID(context.Background(), store.MasterTenantID)
|
||||
agentID := uuid.Must(uuid.NewV7())
|
||||
seedSQLiteRunTimelineAgent(t, db, store.MasterTenantID, agentID)
|
||||
|
||||
items := []store.RunTimelineItem{
|
||||
{
|
||||
RunID: "run-1",
|
||||
SessionKey: "agent:default:direct:user-1",
|
||||
AgentID: &agentID,
|
||||
UserID: "user-1",
|
||||
Channel: "web",
|
||||
Seq: 2,
|
||||
ItemType: store.RunTimelineItemTypeToolCall,
|
||||
Status: store.RunTimelineStatusRunning,
|
||||
Title: "read_file",
|
||||
Preview: `{"path":"/tmp/a.txt"}`,
|
||||
ToolName: "read_file",
|
||||
ToolCallID: "call-1",
|
||||
Metadata: json.RawMessage(`{"safe":true}`),
|
||||
},
|
||||
{
|
||||
RunID: "run-1",
|
||||
SessionKey: "agent:default:direct:user-1",
|
||||
UserID: "user-1",
|
||||
Channel: "web",
|
||||
Seq: 1,
|
||||
ItemType: store.RunTimelineItemTypeRunStatus,
|
||||
Status: store.RunTimelineStatusStarted,
|
||||
Title: "Run started",
|
||||
Preview: "hello",
|
||||
},
|
||||
}
|
||||
for i := range items {
|
||||
if err := timeline.AppendRunTimelineItem(ctx, &items[i]); err != nil {
|
||||
t.Fatalf("AppendRunTimelineItem(%d): %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := timeline.ListRunTimelineItems(ctx, store.RunTimelineListOpts{RunID: "run-1", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ListRunTimelineItems: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len(items) = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].Seq != 1 || got[1].Seq != 2 {
|
||||
t.Fatalf("seq order = [%d,%d], want [1,2]", got[0].Seq, got[1].Seq)
|
||||
}
|
||||
if got[1].Content != "" {
|
||||
t.Fatalf("Content persisted = %q, want empty preview-only archive", got[1].Content)
|
||||
}
|
||||
if got[1].AgentID == nil || *got[1].AgentID != agentID {
|
||||
t.Fatalf("AgentID = %v, want %s", got[1].AgentID, agentID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteRunTimelineStoreTenantScope(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
if err := EnsureSchema(db); err != nil {
|
||||
t.Fatalf("EnsureSchema: %v", err)
|
||||
}
|
||||
|
||||
timeline := NewSQLiteRunTimelineStore(db)
|
||||
tenantA := uuid.Must(uuid.NewV7())
|
||||
tenantB := uuid.Must(uuid.NewV7())
|
||||
seedSQLiteRunTimelineTenant(t, db, tenantA)
|
||||
seedSQLiteRunTimelineTenant(t, db, tenantB)
|
||||
ctxA := store.WithTenantID(context.Background(), tenantA)
|
||||
ctxB := store.WithTenantID(context.Background(), tenantB)
|
||||
|
||||
item := store.RunTimelineItem{
|
||||
RunID: "run-shared",
|
||||
SessionKey: "agent:default:direct:user-1",
|
||||
Seq: 1,
|
||||
ItemType: store.RunTimelineItemTypeAssistantMessage,
|
||||
Status: store.RunTimelineStatusCompleted,
|
||||
Title: "Assistant",
|
||||
Preview: "Visible only to tenant A",
|
||||
Content: `{"raw":"must not persist"}`,
|
||||
}
|
||||
if err := timeline.AppendRunTimelineItem(ctxA, &item); err != nil {
|
||||
t.Fatalf("AppendRunTimelineItem: %v", err)
|
||||
}
|
||||
|
||||
gotA, err := timeline.ListRunTimelineItems(ctxA, store.RunTimelineListOpts{RunID: "run-shared"})
|
||||
if err != nil {
|
||||
t.Fatalf("List tenant A: %v", err)
|
||||
}
|
||||
if len(gotA) != 1 {
|
||||
t.Fatalf("tenant A len = %d, want 1", len(gotA))
|
||||
}
|
||||
if gotA[0].Content != "" {
|
||||
t.Fatalf("Content = %q, want empty", gotA[0].Content)
|
||||
}
|
||||
|
||||
gotB, err := timeline.ListRunTimelineItems(ctxB, store.RunTimelineListOpts{RunID: "run-shared"})
|
||||
if err != nil {
|
||||
t.Fatalf("List tenant B: %v", err)
|
||||
}
|
||||
if len(gotB) != 0 {
|
||||
t.Fatalf("tenant B len = %d, want 0", len(gotB))
|
||||
}
|
||||
|
||||
gotNoTenant, err := timeline.ListRunTimelineItems(context.Background(), store.RunTimelineListOpts{RunID: "run-shared"})
|
||||
if err != nil {
|
||||
t.Fatalf("List no tenant: %v", err)
|
||||
}
|
||||
if len(gotNoTenant) != 0 {
|
||||
t.Fatalf("no-tenant len = %d, want fail-closed 0", len(gotNoTenant))
|
||||
}
|
||||
}
|
||||
|
||||
func seedSQLiteRunTimelineTenant(t *testing.T, db execer, tenantID uuid.UUID) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(
|
||||
`INSERT OR IGNORE INTO tenants (id, name, slug, status)
|
||||
VALUES (?, ?, ?, 'active')`,
|
||||
tenantID, "timeline-test-"+tenantID.String()[:8], "timeline-"+tenantID.String(),
|
||||
); err != nil {
|
||||
t.Fatalf("seed tenant: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedSQLiteRunTimelineAgent(t *testing.T, db execer, tenantID, agentID uuid.UUID) {
|
||||
t.Helper()
|
||||
seedSQLiteRunTimelineTenant(t, db, tenantID)
|
||||
if _, err := db.Exec(
|
||||
`INSERT OR IGNORE INTO agents (id, tenant_id, agent_key, agent_type, status, provider, model, owner_id)
|
||||
VALUES (?, ?, ?, 'predefined', 'active', 'test', 'test-model', 'owner')`,
|
||||
agentID, tenantID, "timeline-agent-"+agentID.String(),
|
||||
); err != nil {
|
||||
t.Fatalf("seed agent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type execer interface {
|
||||
Exec(query string, args ...any) (sql.Result, error)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ var schemaSQL string
|
||||
|
||||
// SchemaVersion is the current SQLite schema version.
|
||||
// Bump this when adding new migration steps below.
|
||||
const SchemaVersion = 42
|
||||
const SchemaVersion = 43
|
||||
|
||||
// migrations maps version → SQL to apply when upgrading FROM that version.
|
||||
// schema.sql always represents the LATEST full schema (for fresh DBs).
|
||||
@@ -772,6 +772,37 @@ CREATE INDEX IF NOT EXISTS idx_browser_cookies_expires_at
|
||||
40: `ALTER TABLE secure_cli_user_credentials ADD COLUMN host_scope TEXT;`,
|
||||
// Version 41 → 42: credential adapter framework — adapter_name on binaries.
|
||||
41: `ALTER TABLE secure_cli_binaries ADD COLUMN adapter_name TEXT;`,
|
||||
// Version 42 → 43: archived agent run timeline.
|
||||
42: `CREATE TABLE IF NOT EXISTS run_timeline_items (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
run_id TEXT NOT NULL,
|
||||
session_key TEXT NOT NULL,
|
||||
agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
|
||||
user_id TEXT,
|
||||
channel TEXT,
|
||||
chat_id TEXT,
|
||||
seq INTEGER NOT NULL,
|
||||
item_type TEXT NOT NULL,
|
||||
status TEXT,
|
||||
title TEXT,
|
||||
preview TEXT,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
tool_name TEXT,
|
||||
tool_call_id TEXT,
|
||||
trace_id TEXT REFERENCES traces(id) ON DELETE SET NULL,
|
||||
span_id TEXT REFERENCES spans(id) ON DELETE SET NULL,
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
UNIQUE (tenant_id, run_id, seq)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_run_timeline_run_seq
|
||||
ON run_timeline_items (tenant_id, run_id, seq);
|
||||
CREATE INDEX IF NOT EXISTS idx_run_timeline_session_time
|
||||
ON run_timeline_items (tenant_id, session_key, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_run_timeline_trace
|
||||
ON run_timeline_items (tenant_id, trace_id)
|
||||
WHERE trace_id IS NOT NULL;`,
|
||||
}
|
||||
|
||||
// addHooksTables is the SQLite incremental migration for schema v19 → v20.
|
||||
|
||||
@@ -591,6 +591,42 @@ CREATE INDEX IF NOT EXISTS idx_traces_team ON traces(team_id, created_at DESC) W
|
||||
CREATE INDEX IF NOT EXISTS idx_traces_tenant ON traces(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_traces_tenant_time ON traces(tenant_id, created_at DESC);
|
||||
|
||||
-- ============================================================
|
||||
-- Table: run_timeline_items
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS run_timeline_items (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
run_id TEXT NOT NULL,
|
||||
session_key TEXT NOT NULL,
|
||||
agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL,
|
||||
user_id TEXT,
|
||||
channel TEXT,
|
||||
chat_id TEXT,
|
||||
seq INTEGER NOT NULL,
|
||||
item_type TEXT NOT NULL,
|
||||
status TEXT,
|
||||
title TEXT,
|
||||
preview TEXT,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
tool_name TEXT,
|
||||
tool_call_id TEXT,
|
||||
trace_id TEXT REFERENCES traces(id) ON DELETE SET NULL,
|
||||
span_id TEXT REFERENCES spans(id) ON DELETE SET NULL,
|
||||
metadata TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
UNIQUE (tenant_id, run_id, seq)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_run_timeline_run_seq
|
||||
ON run_timeline_items (tenant_id, run_id, seq);
|
||||
CREATE INDEX IF NOT EXISTS idx_run_timeline_session_time
|
||||
ON run_timeline_items (tenant_id, session_key, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_run_timeline_trace
|
||||
ON run_timeline_items (tenant_id, trace_id)
|
||||
WHERE trace_id IS NOT NULL;
|
||||
|
||||
-- ============================================================
|
||||
-- Table: spans
|
||||
-- ============================================================
|
||||
|
||||
@@ -13,6 +13,7 @@ type Stores struct {
|
||||
Agents AgentStore
|
||||
Providers ProviderStore
|
||||
Tracing TracingStore
|
||||
RunTimeline RunTimelineStore
|
||||
MCP MCPServerStore
|
||||
ChannelInstances ChannelInstanceStore
|
||||
ConfigSecrets ConfigSecretsStore
|
||||
|
||||
@@ -2,4 +2,4 @@ package upgrade
|
||||
|
||||
// RequiredSchemaVersion is the schema migration version this binary requires.
|
||||
// Bump this whenever adding a new SQL migration file.
|
||||
const RequiredSchemaVersion uint = 73
|
||||
const RequiredSchemaVersion uint = 74
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS run_timeline_items;
|
||||
@@ -0,0 +1,27 @@
|
||||
CREATE TABLE run_timeline_items (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
run_id TEXT NOT NULL,
|
||||
session_key VARCHAR(500) NOT NULL,
|
||||
agent_id UUID REFERENCES agents(id) ON DELETE SET NULL,
|
||||
user_id VARCHAR(255),
|
||||
channel VARCHAR(50),
|
||||
chat_id VARCHAR(255),
|
||||
seq INT NOT NULL,
|
||||
item_type VARCHAR(40) NOT NULL,
|
||||
status VARCHAR(40),
|
||||
title TEXT,
|
||||
preview TEXT,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
tool_name VARCHAR(255),
|
||||
tool_call_id VARCHAR(255),
|
||||
trace_id UUID REFERENCES traces(id) ON DELETE SET NULL,
|
||||
span_id UUID REFERENCES spans(id) ON DELETE SET NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (tenant_id, run_id, seq)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_run_timeline_run_seq ON run_timeline_items(tenant_id, run_id, seq);
|
||||
CREATE INDEX idx_run_timeline_session_time ON run_timeline_items(tenant_id, session_key, created_at DESC);
|
||||
CREATE INDEX idx_run_timeline_trace ON run_timeline_items(tenant_id, trace_id) WHERE trace_id IS NOT NULL;
|
||||
@@ -41,6 +41,7 @@ const (
|
||||
MethodSessionsDelete = "sessions.delete"
|
||||
MethodSessionsReset = "sessions.reset"
|
||||
MethodSessionsCompact = "sessions.compact"
|
||||
MethodRunTimelineGet = "run.timeline.get"
|
||||
|
||||
// System
|
||||
MethodConnect = "connect"
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
phase: 1
|
||||
title: "TDD Contract and Schema"
|
||||
status: complete
|
||||
priority: P1
|
||||
effort: "1d"
|
||||
dependencies: []
|
||||
---
|
||||
|
||||
# Phase 1: TDD Contract and Schema
|
||||
|
||||
## Context Links
|
||||
|
||||
- GitHub issue: `digitopvn/goclaw#76`
|
||||
- Related issue boundary: `digitopvn/goclaw#67`
|
||||
- Current schema: `migrations/000001_init_schema.up.sql`
|
||||
- SQLite schema: `internal/store/sqlitestore/schema.sql`
|
||||
- Trace store reference: `internal/store/tracing_store.go`
|
||||
|
||||
## Overview
|
||||
|
||||
Define the durable run timeline contract and add tests before schema/store code. This phase locks the archive semantics so later implementation does not drift into issue #67 delivery behavior.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- `sessions.messages` is useful for conversation history but not reliable enough for exact live event rhythm.
|
||||
- `traces` and `spans` are diagnostic product data with different retention/debug semantics.
|
||||
- Archive timeline needs its own append-only table because ordering and privacy rules differ.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: define timeline item DTOs for activity, assistant message, tool call, tool result, and run status.
|
||||
- Functional: expose Phase 1 storage as previews only for tool args/results.
|
||||
- Functional: include `run_id`, `session_key`, `agent_id`, `tenant_id`, `seq`, `type`, `status`, timestamps, optional `trace_id`, optional `span_id`.
|
||||
- Non-functional: preserve strict sequence ordering under parallel tool calls.
|
||||
- Non-functional: support both PostgreSQL and SQLite/Lite.
|
||||
- Non-functional: no raw hidden thinking persistence.
|
||||
|
||||
## Architecture
|
||||
|
||||
Use a dedicated append-only product table instead of reconstructing from `sessions.messages` or `spans`.
|
||||
|
||||
Proposed table:
|
||||
|
||||
```text
|
||||
run_timeline_items
|
||||
- id uuid/text primary key
|
||||
- tenant_id uuid/text not null
|
||||
- run_id text not null
|
||||
- session_key text not null
|
||||
- agent_id uuid/text nullable
|
||||
- user_id text nullable
|
||||
- channel text nullable
|
||||
- chat_id text nullable
|
||||
- seq bigint not null
|
||||
- item_type text not null
|
||||
- status text nullable
|
||||
- title text nullable
|
||||
- preview text nullable
|
||||
- content text nullable
|
||||
- tool_name text nullable
|
||||
- tool_call_id text nullable
|
||||
- trace_id uuid/text nullable
|
||||
- span_id uuid/text nullable
|
||||
- metadata json/jsonb nullable
|
||||
- created_at timestamp not null
|
||||
```
|
||||
|
||||
Index:
|
||||
- `(tenant_id, run_id, seq)`
|
||||
- `(tenant_id, session_key, created_at desc)`
|
||||
- optional `(trace_id)` where supported
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Create: `internal/store/run_timeline_store.go`
|
||||
- Create: `internal/store/pg/run_timeline.go`
|
||||
- Create: `internal/store/sqlitestore/run_timeline.go`
|
||||
- Create: `internal/store/pg/run_timeline_test.go`
|
||||
- Create: `internal/store/sqlitestore/run_timeline_test.go`
|
||||
- Create: `migrations/0000XX_run_timeline_items.up.sql`
|
||||
- Create: `migrations/0000XX_run_timeline_items.down.sql`
|
||||
- Modify: `internal/store/sqlitestore/schema.sql`
|
||||
- Modify: `internal/store/sqlitestore/schema.go`
|
||||
- Modify: `internal/upgrade/version.go`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Write store contract tests first:
|
||||
- append items with explicit sequence.
|
||||
- list by `tenant_id + run_id` ordered by `seq`.
|
||||
- reject cross-tenant reads.
|
||||
- persist preview fields but not full tool JSON.
|
||||
2. Write schema migration tests or startup schema assertions for PostgreSQL and SQLite.
|
||||
3. Add store interface and DTO structs.
|
||||
4. Add PostgreSQL migration and implementation.
|
||||
5. Add SQLite full schema and incremental migration. Bump `SchemaVersion`.
|
||||
6. Bump `RequiredSchemaVersion` for PostgreSQL.
|
||||
7. Confirm migration numbering against current `origin/dev` before implementation.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Store tests fail before implementation and pass after.
|
||||
- [ ] Both DB backends expose the same timeline item fields.
|
||||
- [ ] Sequence order is deterministic and not timestamp-dependent.
|
||||
- [ ] Tool args/results have preview fields only in Phase 1.
|
||||
- [ ] SQLite and PostgreSQL schema versions are both updated.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] Add failing PostgreSQL store tests.
|
||||
- [ ] Add failing SQLite store tests.
|
||||
- [ ] Define store interface and DTOs.
|
||||
- [ ] Add PostgreSQL migration.
|
||||
- [ ] Add SQLite full schema and incremental migration.
|
||||
- [ ] Bump both schema version choke points.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Main risk: schema drift between Standard and Lite. Mitigation: add both migrations in the same phase and compile/test `-tags sqliteonly`.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
Timeline rows are tenant-scoped product data. All store list queries must include tenant scope. Phase 1 must not persist full tool argument/result JSON.
|
||||
|
||||
## Next Steps
|
||||
|
||||
Proceed to Phase 2 only after both DB backends pass contract tests.
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
phase: 2
|
||||
title: "Timeline Persistence"
|
||||
status: complete
|
||||
priority: P1
|
||||
effort: "1.5d"
|
||||
dependencies: [1]
|
||||
---
|
||||
|
||||
# Phase 2: Timeline Persistence
|
||||
|
||||
## Context Links
|
||||
|
||||
- Event enrichment: `internal/agent/loop_run.go`
|
||||
- Tool events: `internal/agent/loop_pipeline_tool_callbacks.go`
|
||||
- Block replies: `internal/pipeline/think_stage.go`
|
||||
- Final/block reply dedup reference: `internal/pipeline/observe_stage.go`
|
||||
|
||||
## Overview
|
||||
|
||||
Persist timeline items from existing agent events without changing delivery behavior. This is an event capture layer, not a new chat behavior engine.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- The shared enriched emitter boundary already carries run, user, channel, chat, session, and tenant context.
|
||||
- Parallel tool calls can emit from goroutines, so `seq` must be assigned before async persistence.
|
||||
- `block.reply` already excludes final answers; this helps avoid final duplicate archive items.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: capture `run.started`, `activity`, `block.reply`, `tool.call`, `tool.result`, `run.completed`, `run.failed`, `run.cancelled`.
|
||||
- Functional: link tool timeline items to trace/span IDs where the current code can provide them without invasive rewrites.
|
||||
- Functional: store final answer once; avoid duplicating the last `block.reply` as final answer.
|
||||
- Non-functional: best-effort persistence must not block agent execution if DB write fails.
|
||||
- Non-functional: preserve tenant/session/user/channel context already attached in `AgentEvent`.
|
||||
|
||||
## Architecture
|
||||
|
||||
Introduce a small timeline recorder owned by the agent loop. It receives enriched `AgentEvent` values from the same emitter boundary used by live WS/channel broadcast.
|
||||
|
||||
Ordering:
|
||||
- Maintain per-run atomic sequence counter in the loop/request scope.
|
||||
- Assign `seq` before async persistence.
|
||||
- For parallel tool calls, sequence reflects event emission order, not completion timestamp.
|
||||
|
||||
Privacy:
|
||||
- `tool.call`: title/tool name + safe args preview. No full arguments.
|
||||
- `tool.result`: status + safe result preview. No full result.
|
||||
- `thinking`: redacted `activity` marker only.
|
||||
- `block.reply` and final answer: content stored because user-visible.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Create: `internal/agent/run_timeline_recorder.go`
|
||||
- Create: `internal/agent/run_timeline_recorder_test.go`
|
||||
- Modify: `internal/agent/loop_types.go`
|
||||
- Modify: `internal/agent/loop_run.go`
|
||||
- Modify: `internal/agent/loop_pipeline_callbacks.go`
|
||||
- Modify: `internal/agent/loop_pipeline_tool_callbacks.go`
|
||||
- Modify: `internal/agent/loop_tracing.go` only if span ID linkage can be added narrowly.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Write recorder unit tests first:
|
||||
- event to item mapping.
|
||||
- preview truncation and redaction.
|
||||
- no raw thinking.
|
||||
- final answer dedup when same as last block reply.
|
||||
- tool error status mapping.
|
||||
2. Add a `RunTimelineStore` dependency to loop config/wiring.
|
||||
3. Add recorder creation in `Loop.Run` after run context is known.
|
||||
4. Wrap `emitRun` so each relevant event is recorded and then emitted live.
|
||||
5. Keep recorder DB writes best-effort with bounded timeout/logging.
|
||||
6. Add tests for sequential and parallel tool event ordering.
|
||||
7. Verify no #67 behavior changes: no ack, no message splitting, no channel delivery toggles.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Every supported event type maps to exactly one timeline item shape.
|
||||
- [ ] Tool args/results are persisted as previews only.
|
||||
- [ ] Final answer is not duplicated after a matching block reply.
|
||||
- [ ] Recorder failures do not fail the agent run.
|
||||
- [ ] Parallel tool call and result events stay ordered by assigned `seq`.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] Add recorder mapper tests.
|
||||
- [ ] Add privacy/redaction tests.
|
||||
- [ ] Add sequence ordering tests.
|
||||
- [ ] Wire store dependency into loop config.
|
||||
- [ ] Wrap enriched event emitter with recorder.
|
||||
- [ ] Add best-effort persistence timeout/logging.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Main risk: recording at too many call sites creates inconsistent item shapes. Mitigation: record at the shared enriched emitter boundary and keep payload normalization centralized.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
Use the same sanitizer/redaction approach across tool call and result previews. Never persist raw thinking. Log recorder failures without dumping payloads.
|
||||
|
||||
## Next Steps
|
||||
|
||||
Proceed to Phase 3 after recorder tests prove stable event-to-item mapping.
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
phase: 3
|
||||
title: "HTTP and WS Timeline APIs"
|
||||
status: complete
|
||||
priority: P1
|
||||
effort: "1d"
|
||||
dependencies: [1, 2]
|
||||
---
|
||||
|
||||
# Phase 3: HTTP and WS Timeline APIs
|
||||
|
||||
## Context Links
|
||||
|
||||
- HTTP trace permissions reference: `internal/http/traces.go`
|
||||
- WS chat methods reference: `internal/gateway/methods/chat.go`
|
||||
- API docs: `docs/18-http-api.md`
|
||||
- WS docs: `docs/19-websocket-rpc.md`
|
||||
|
||||
## Overview
|
||||
|
||||
Expose Phase 1 archive timeline through both HTTP and WebSocket RPC. User decision: both APIs are in scope for Phase 1.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- HTTP is useful for direct UI fetch and future export.
|
||||
- WS RPC keeps parity with existing dashboard control-plane patterns.
|
||||
- Both paths must use one DTO mapper to avoid response drift.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: HTTP endpoint returns timeline by run ID.
|
||||
- Functional: WS RPC returns the same DTO by run ID.
|
||||
- Functional: support optional session key guard when provided.
|
||||
- Functional: enforce tenant/user permissions; non-admin users can only access their own run/session timeline.
|
||||
- Functional: include trace ID/span ID references for admin debug, but not full trace content.
|
||||
- Non-functional: response shape must be stable for web UI and future exports.
|
||||
|
||||
## Architecture
|
||||
|
||||
HTTP route recommendation:
|
||||
|
||||
```text
|
||||
GET /v1/runs/{runID}/timeline?session_key=<optional>
|
||||
```
|
||||
|
||||
WS method recommendation:
|
||||
|
||||
```text
|
||||
run.timeline.get
|
||||
params: { runId: string, sessionKey?: string }
|
||||
response: { runId, sessionKey, items: RunTimelineItem[] }
|
||||
```
|
||||
|
||||
DTO should be shared between HTTP and WS methods to avoid drift.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Create: `internal/http/run_timeline.go`
|
||||
- Create: `internal/http/run_timeline_test.go`
|
||||
- Create: `internal/gateway/methods/run_timeline.go`
|
||||
- Create: `internal/gateway/methods/run_timeline_test.go`
|
||||
- Modify: `internal/http/server.go` or route registration file
|
||||
- Modify: `internal/gateway/methods/registry.go` or method registration file
|
||||
- Modify: `pkg/protocol` only if method constants live there
|
||||
- Modify: `ui/web/src/api/protocol.ts`
|
||||
- Modify: `docs/18-http-api.md`
|
||||
- Modify: `docs/19-websocket-rpc.md`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Write HTTP auth/permission tests first:
|
||||
- admin can read tenant run timeline.
|
||||
- owner/user can read own run timeline.
|
||||
- other user receives not found/forbidden without leaking existence.
|
||||
- invalid run ID returns localized bad request.
|
||||
2. Write WS RPC tests with equivalent permission cases.
|
||||
3. Add shared response DTO and mapper.
|
||||
4. Register HTTP route.
|
||||
5. Register WS method.
|
||||
6. Add docs for both APIs.
|
||||
7. Confirm endpoint names do not conflict with existing `/v1/traces` routes.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] HTTP and WS return identical timeline item fields.
|
||||
- [ ] Permission tests cover admin, owner, and unrelated user.
|
||||
- [ ] Trace/span links are IDs only.
|
||||
- [ ] API docs include request and response examples.
|
||||
- [ ] No public share or export API is introduced in Phase 1.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] Add HTTP permission tests.
|
||||
- [ ] Add WS permission tests.
|
||||
- [ ] Add shared DTO mapper.
|
||||
- [ ] Register HTTP route.
|
||||
- [ ] Register WS method.
|
||||
- [ ] Update HTTP and WS docs.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Main risk: leaking run existence across tenants/users. Mitigation: scope all queries by tenant and session/user where available, and mirror trace handler non-admin behavior.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
Return not found for cross-tenant or unauthorized access when possible. Do not include full trace/span payloads, only IDs.
|
||||
|
||||
## Next Steps
|
||||
|
||||
Proceed to Phase 4 after API parity tests pass.
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
phase: 4
|
||||
title: "Session Archive UI"
|
||||
status: complete
|
||||
priority: P2
|
||||
effort: "1.5d"
|
||||
dependencies: [3]
|
||||
---
|
||||
|
||||
# Phase 4: Session Archive UI
|
||||
|
||||
## Context Links
|
||||
|
||||
- Live chat event handling: `ui/web/src/pages/chat/hooks/use-chat-messages.ts`
|
||||
- Tool card reference: `ui/web/src/components/chat/tool-call-card.tsx`
|
||||
- Session detail page: `ui/web/src/pages/sessions/session-detail-page.tsx`
|
||||
- Trace detail page: `ui/web/src/pages/traces/trace-detail-dialog.tsx`
|
||||
|
||||
## Overview
|
||||
|
||||
Add the Phase 1 archive UI under session detail as the recommended first surface. Trace pages link out for admin/debug, but the primary user journey is reviewing a session run archive.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- Session detail is the right Phase 1 home because users review archived conversations there.
|
||||
- Trace detail is a secondary admin/debug path, not the primary archive UX.
|
||||
- Current `ToolCallCard` can leak full args/results on expand, so archive UI needs preview-only rendering.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: session detail shows available runs for the session and opens a Claude-like timeline.
|
||||
- Functional: render assistant messages, activity markers, combined tool cards, and final status in strict order.
|
||||
- Functional: tool cards collapsed by default and show preview only.
|
||||
- Functional: admin/debug users see trace/span links when available.
|
||||
- Non-functional: mobile-friendly, no nested cards, no raw args/results expansion in Phase 1.
|
||||
- Non-functional: preserve current live chat UI behavior.
|
||||
|
||||
## Architecture
|
||||
|
||||
Recommended UI placement:
|
||||
- Primary: `ui/web/src/pages/sessions/session-detail-page.tsx`
|
||||
- Add a run archive drawer/panel or route-scoped section from session detail.
|
||||
- Use current `ToolCallCard` patterns but create archive-safe component that never displays full raw JSON in Phase 1.
|
||||
- Trace detail may include a "View archive timeline" link if `run_id` exists.
|
||||
|
||||
Why this recommendation:
|
||||
- Users looking at old work start from sessions, not traces.
|
||||
- Trace detail is developer/admin diagnostic UI.
|
||||
- Public share page belongs to later phase after privacy model is proven.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Create: `ui/web/src/types/run-timeline.ts`
|
||||
- Create: `ui/web/src/pages/sessions/run-timeline-panel.tsx`
|
||||
- Create: `ui/web/src/pages/sessions/run-timeline-item.tsx`
|
||||
- Create: `ui/web/src/pages/sessions/hooks/use-run-timeline.ts`
|
||||
- Create: `ui/web/src/pages/sessions/__tests__/run-timeline-panel.test.tsx`
|
||||
- Modify: `ui/web/src/pages/sessions/session-detail-page.tsx`
|
||||
- Modify: `ui/web/src/pages/traces/trace-detail-dialog.tsx`
|
||||
- Modify: `ui/web/src/api/protocol.ts`
|
||||
- Modify: `ui/web/src/i18n/locales/{en,vi,zh}/sessions.json`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Write component tests first:
|
||||
- assistant intermediate and final messages render in order.
|
||||
- tool call/result combine into one collapsed card by `tool_call_id`.
|
||||
- preview appears; raw args/results do not.
|
||||
- trace link appears only when item has trace/span IDs and user can see debug affordance.
|
||||
- mobile layout does not depend on fixed wide columns.
|
||||
2. Add API hook using HTTP fetch first; use WS RPC only where current page pattern prefers WS.
|
||||
3. Add run selector/list in session detail using existing session/run data where possible.
|
||||
4. Add timeline panel/drawer.
|
||||
5. Add trace detail link to timeline when `run_id` and `session_key` exist.
|
||||
6. Add i18n keys in all three locale files before wiring UI text.
|
||||
7. Verify no current chat live event rendering regresses.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Session detail can open an archive timeline for a run.
|
||||
- [ ] Timeline order matches backend `seq`.
|
||||
- [ ] Tool previews are collapsed and safe by default.
|
||||
- [ ] Trace/span links are available for admin/debug users.
|
||||
- [ ] UI works on mobile widths without horizontal overflow.
|
||||
- [ ] Existing live chat `ActiveRunZone` still behaves unchanged.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [ ] Add timeline TS types.
|
||||
- [ ] Add timeline fetch hook.
|
||||
- [ ] Add archive-safe timeline item component.
|
||||
- [ ] Add session detail panel/drawer entry.
|
||||
- [ ] Add trace detail link.
|
||||
- [ ] Add i18n keys for en/vi/zh.
|
||||
- [ ] Add component tests.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Main risk: reusing live `ToolCallCard` leaks raw arguments/results. Mitigation: introduce archive-safe rendering that accepts preview fields only.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
Do not render raw payload fields in Phase 1. Treat trace links as debug affordances and hide them when permission data is unavailable.
|
||||
|
||||
## Next Steps
|
||||
|
||||
Proceed to Phase 5 after UI tests and manual mobile review pass.
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
phase: 5
|
||||
title: "Validation and Issue Handoff"
|
||||
status: complete
|
||||
priority: P1
|
||||
effort: "0.5d"
|
||||
dependencies: [1, 2, 3, 4]
|
||||
---
|
||||
|
||||
# Phase 5: Validation and Issue Handoff
|
||||
|
||||
## Context Links
|
||||
|
||||
- Plan overview: `plans/260528-1804-archived-run-timeline/plan.md`
|
||||
- GitHub issue: `digitopvn/goclaw#76`
|
||||
- Related issue boundary: `digitopvn/goclaw#67`
|
||||
|
||||
## Overview
|
||||
|
||||
Run compile/test gates, verify issue #76 acceptance criteria, and update the GitHub issue with the final implementation summary.
|
||||
|
||||
## Key Insights
|
||||
|
||||
- This is a cross-surface feature: store, agent loop, HTTP, WS, and UI all need verification.
|
||||
- Lite/SQLite must be explicitly checked because schema drift can break desktop startup.
|
||||
- #67 behavior must remain absent.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Functional: verify backend persistence, HTTP API, WS RPC, and web UI behavior.
|
||||
- Functional: confirm no #67 behavior was implemented accidentally.
|
||||
- Non-functional: run both PostgreSQL and SQLite/Lite compile gates.
|
||||
- Non-functional: update issue #76 with plan/implementation result and filepath.
|
||||
|
||||
## Architecture
|
||||
|
||||
Validation focuses on product behavior:
|
||||
- timeline stored as product data.
|
||||
- trace/span IDs linked for debug only.
|
||||
- archive UI under session detail.
|
||||
- previews only in Phase 1.
|
||||
|
||||
## Related Code Files
|
||||
|
||||
- Modify: `docs/18-http-api.md`
|
||||
- Modify: `docs/19-websocket-rpc.md`
|
||||
- Modify: `docs/project-changelog.md` if implementation lands.
|
||||
- Read: `plans/260528-1804-archived-run-timeline/plan.md`
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Run focused backend tests:
|
||||
- stores, HTTP handler, WS method, agent recorder.
|
||||
2. Run SQLite-specific tests and build.
|
||||
3. Run full Go compile/vet gates:
|
||||
- `go build ./...`
|
||||
- `go build -tags sqliteonly ./...`
|
||||
- `go vet ./...`
|
||||
4. Run web tests/build:
|
||||
- `cd ui/web && pnpm test -- --run`
|
||||
- `cd ui/web && pnpm build`
|
||||
5. Run `git diff --check`.
|
||||
6. Manually verify a real or fixture run timeline:
|
||||
- block reply appears before tool call/result where emitted.
|
||||
- final answer appears once.
|
||||
- tool preview only.
|
||||
- trace link exists when trace IDs are available.
|
||||
7. Reply to GitHub issue #76 with summary, validation results, and plan/implementation filepath.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [x] All planned acceptance criteria are checked against tests or manual verification.
|
||||
- [x] Both HTTP and WS API paths are verified.
|
||||
- [x] No quick ack or message splitting behavior from #67 is present.
|
||||
- [x] GitHub issue #76 has a concise update with filepath.
|
||||
- [x] Any unresolved questions are listed at the end.
|
||||
|
||||
## Todo List
|
||||
|
||||
- [x] Run focused backend tests.
|
||||
- [x] Run SQLite/Lite tests and build.
|
||||
- [x] Run Go build/vet gates.
|
||||
- [x] Run web tests/build.
|
||||
- [x] Run `git diff --check`.
|
||||
- [x] Manually verify timeline behavior.
|
||||
- [x] Reply to issue #76.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
Main risk: validation misses Lite/SQLite regression. Mitigation: treat `go build -tags sqliteonly ./...` and SQLite store tests as blocking.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
Validation must include permission boundary tests and preview-only assertions. Do not paste sensitive tool payloads into GitHub issue updates.
|
||||
|
||||
## Next Steps
|
||||
|
||||
After implementation validation, ship via normal branch/PR flow and update issue #76 with commit/PR references.
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: "Archived Interleaved Run Timeline"
|
||||
description: "TDD plan for issue #76: persist safe run timeline previews and render a Claude-like session archive, without implementing issue #67 chat delivery heuristics."
|
||||
status: complete
|
||||
priority: P2
|
||||
branch: "codex/issue-76-run-timeline-plan"
|
||||
tags: [timeline, archive, traces, web-ui, tdd, issue-76]
|
||||
blockedBy: []
|
||||
blocks: []
|
||||
created: "2026-05-28T11:04:29.547Z"
|
||||
createdBy: "ck:plan"
|
||||
source: skill
|
||||
---
|
||||
|
||||
# Archived Interleaved Run Timeline
|
||||
|
||||
## Overview
|
||||
|
||||
Implement `digitopvn/goclaw#76`: persist assistant intermediate messages, tool calls, tool results, activity markers, and final run status as an ordered archive timeline. Phase 1 exposes both HTTP and WS RPC fetch APIs, stores safe previews only, and links to traces/spans for admin debug.
|
||||
|
||||
Recommended Phase 1 UI: add the archive inside session detail first, with a per-run timeline panel/drawer and trace links for admin/debug. This is closest to user intent ("what happened in this run?") and avoids building a public share surface before the archive contract is proven.
|
||||
|
||||
Hard boundary with related issue `#67`: this plan does not add quick acknowledgement, multi-message final splitting, channel spam controls, or delivery heuristics. It archives existing structured events; #67 can later reuse the same timeline model for live/channel behavior.
|
||||
|
||||
## Current Code Context
|
||||
|
||||
- Runtime event enrichment: `internal/agent/loop_run.go`
|
||||
- Tool events: `internal/agent/loop_pipeline_tool_callbacks.go`
|
||||
- Intermediate assistant content: `internal/pipeline/think_stage.go`, `internal/pipeline/observe_stage.go`
|
||||
- Existing trace product data: `internal/store/tracing_store.go`, `internal/http/traces.go`
|
||||
- Existing session messages: `sessions.messages` JSONB in `migrations/000001_init_schema.up.sql`
|
||||
- Existing live UI event handling: `ui/web/src/pages/chat/hooks/use-chat-messages.ts`
|
||||
- Existing tool card UI: `ui/web/src/components/chat/tool-call-card.tsx`
|
||||
|
||||
## Phases
|
||||
|
||||
| Phase | Name | Status |
|
||||
|-------|------|--------|
|
||||
| 1 | [TDD Contract and Schema](./phase-01-tdd-contract-and-schema.md) | Complete |
|
||||
| 2 | [Timeline Persistence](./phase-02-timeline-persistence.md) | Complete |
|
||||
| 3 | [HTTP and WS Timeline APIs](./phase-03-http-and-ws-timeline-apis.md) | Complete |
|
||||
| 4 | [Session Archive UI](./phase-04-session-archive-ui.md) | Complete |
|
||||
| 5 | [Validation and Issue Handoff](./phase-05-validation-and-issue-handoff.md) | Complete |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- GitHub issue: `digitopvn/goclaw#76`
|
||||
- Related non-overlap issue: `digitopvn/goclaw#67`
|
||||
- No blocking plan found. Existing pending issue #80 plan targets skills export UI and does not overlap this feature.
|
||||
|
||||
## Data Contract Target
|
||||
|
||||
Timeline item kinds:
|
||||
- `activity`
|
||||
- `assistant.message`
|
||||
- `tool.call`
|
||||
- `tool.result`
|
||||
- `run.status`
|
||||
|
||||
Storage rule:
|
||||
- Store previews only for tool args/results in Phase 1.
|
||||
- Store assistant `block.reply` and final answer content because they are user-visible.
|
||||
- Store `thinking` as redacted marker only; do not persist raw reasoning.
|
||||
- Link to trace/span IDs when available for admin debug.
|
||||
|
||||
## Validation Commands
|
||||
|
||||
Run focused tests first, then full compile gates:
|
||||
|
||||
```bash
|
||||
go test ./internal/store/pg ./internal/store/sqlitestore ./internal/http ./internal/gateway/methods ./internal/agent ./internal/pipeline
|
||||
go test -tags sqliteonly ./internal/store/sqlitestore ./internal/gateway/methods
|
||||
go build ./...
|
||||
go build -tags sqliteonly ./...
|
||||
go vet ./...
|
||||
cd ui/web && pnpm test -- --run
|
||||
cd ui/web && pnpm build
|
||||
git diff --check
|
||||
```
|
||||
@@ -79,6 +79,7 @@ export const Methods = {
|
||||
SESSIONS_PATCH: "sessions.patch",
|
||||
SESSIONS_DELETE: "sessions.delete",
|
||||
SESSIONS_RESET: "sessions.reset",
|
||||
RUN_TIMELINE_GET: "run.timeline.get",
|
||||
|
||||
// Phase 2 - NEEDED
|
||||
SKILLS_LIST: "skills.list",
|
||||
|
||||
@@ -32,7 +32,19 @@
|
||||
"showDetails": "show details",
|
||||
"hide": "hide",
|
||||
"systemMessage": "System Message",
|
||||
"messages": "messages"
|
||||
"messages": "messages",
|
||||
"timeline": {
|
||||
"title": "Run timeline",
|
||||
"loading": "loading",
|
||||
"count": "{{count}} items",
|
||||
"runStatus": "run",
|
||||
"assistant": "assistant",
|
||||
"toolCall": "tool call",
|
||||
"toolResult": "tool result",
|
||||
"trace": "trace",
|
||||
"activity": "activity",
|
||||
"failed": "failed"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"deleted": "Session deleted",
|
||||
|
||||
@@ -32,7 +32,19 @@
|
||||
"showDetails": "xem chi tiết",
|
||||
"hide": "ẩn",
|
||||
"systemMessage": "Tin nhắn hệ thống",
|
||||
"messages": "tin nhắn"
|
||||
"messages": "tin nhắn",
|
||||
"timeline": {
|
||||
"title": "Timeline lượt chạy",
|
||||
"loading": "đang tải",
|
||||
"count": "{{count}} mục",
|
||||
"runStatus": "lượt chạy",
|
||||
"assistant": "assistant",
|
||||
"toolCall": "gọi tool",
|
||||
"toolResult": "kết quả tool",
|
||||
"trace": "trace",
|
||||
"activity": "hoạt động",
|
||||
"failed": "lỗi"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"deleted": "Đã xóa session",
|
||||
|
||||
@@ -32,7 +32,19 @@
|
||||
"showDetails": "显示详情",
|
||||
"hide": "隐藏",
|
||||
"systemMessage": "系统消息",
|
||||
"messages": "条消息"
|
||||
"messages": "条消息",
|
||||
"timeline": {
|
||||
"title": "运行时间线",
|
||||
"loading": "加载中",
|
||||
"count": "{{count}} 项",
|
||||
"runStatus": "运行",
|
||||
"assistant": "助手",
|
||||
"toolCall": "工具调用",
|
||||
"toolResult": "工具结果",
|
||||
"trace": "追踪",
|
||||
"activity": "活动",
|
||||
"failed": "失败"
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
"deleted": "Session已删除",
|
||||
|
||||
@@ -23,6 +23,7 @@ export const queryKeys = {
|
||||
sessions: {
|
||||
all: ["sessions"] as const,
|
||||
list: (params: Record<string, unknown>) => ["sessions", params] as const,
|
||||
timeline: (params: Record<string, unknown>) => ["sessions", "timeline", params] as const,
|
||||
},
|
||||
traces: {
|
||||
all: ["traces"] as const,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Methods } from "@/api/protocol";
|
||||
import { useWs } from "@/hooks/use-ws";
|
||||
import { useAuthStore } from "@/stores/use-auth-store";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { RunTimelineResponse } from "@/types/run-timeline";
|
||||
|
||||
interface UseRunTimelineOptions {
|
||||
runId?: string;
|
||||
sessionKey?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function useRunTimeline({ runId, sessionKey, limit = 100 }: UseRunTimelineOptions) {
|
||||
const ws = useWs();
|
||||
const connected = useAuthStore((s) => s.connected);
|
||||
|
||||
return useQuery({
|
||||
queryKey: queryKeys.sessions.timeline({ runId, sessionKey, limit }),
|
||||
queryFn: async () => {
|
||||
if (!ws.isConnected) return { items: [], limit, offset: 0 } satisfies RunTimelineResponse;
|
||||
return ws.call<RunTimelineResponse>(Methods.RUN_TIMELINE_GET, {
|
||||
runId,
|
||||
sessionKey,
|
||||
limit,
|
||||
});
|
||||
},
|
||||
staleTime: 10_000,
|
||||
enabled: connected && Boolean(runId || sessionKey),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getRunTimelineDisplay } from "./run-timeline-panel";
|
||||
|
||||
describe("getRunTimelineDisplay", () => {
|
||||
it("marks failed items as destructive", () => {
|
||||
const display = getRunTimelineDisplay({ item_type: "tool.result", status: "failed" });
|
||||
expect(display.labelKey).toBe("detail.timeline.failed");
|
||||
expect(display.dotClass).toContain("destructive");
|
||||
});
|
||||
|
||||
it("distinguishes tool call and assistant message items", () => {
|
||||
expect(getRunTimelineDisplay({ item_type: "tool.call", status: "running" }).labelKey).toBe(
|
||||
"detail.timeline.toolCall",
|
||||
);
|
||||
expect(getRunTimelineDisplay({ item_type: "assistant.message", status: "completed" }).labelKey).toBe(
|
||||
"detail.timeline.assistant",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
MessageSquareText,
|
||||
Wrench,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import type { RunTimelineItem, RunTimelineItemType } from "@/types/run-timeline";
|
||||
|
||||
interface RunTimelinePanelProps {
|
||||
items: RunTimelineItem[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function RunTimelinePanel({ items, loading }: RunTimelinePanelProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!loading && items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="border-b px-6 py-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-8 w-full justify-start gap-2 px-0 text-sm"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
<Clock className="h-4 w-4" />
|
||||
<span className="font-medium">{t("detail.timeline.title")}</span>
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{loading ? t("detail.timeline.loading") : t("detail.timeline.count", { count: items.length })}
|
||||
</Badge>
|
||||
</Button>
|
||||
|
||||
{open && (
|
||||
<div className="mt-3 space-y-0">
|
||||
{items.map((item, index) => {
|
||||
const display = getRunTimelineDisplay(item);
|
||||
const Icon = display.icon;
|
||||
return (
|
||||
<div key={item.id || `${item.run_id}-${item.seq}`} className="relative flex gap-3 pb-4 last:pb-0">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full ${display.dotClass}`}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
{index < items.length - 1 && <div className="w-px flex-1 bg-border" />}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="text-2xs">
|
||||
{t(display.labelKey)}
|
||||
</Badge>
|
||||
<span className="truncate text-sm font-medium">{item.title || t(display.labelKey)}</span>
|
||||
{item.status && <span className="text-xs text-muted-foreground">{item.status}</span>}
|
||||
</div>
|
||||
{item.preview && (
|
||||
<pre className="mt-1 max-h-28 overflow-auto whitespace-pre-wrap break-words rounded bg-muted/50 px-2 py-1 text-xs text-muted-foreground">
|
||||
{item.preview}
|
||||
</pre>
|
||||
)}
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{formatDate(item.created_at)}</span>
|
||||
{item.tool_call_id && <span>{item.tool_call_id}</span>}
|
||||
{item.trace_id && (
|
||||
<Button asChild variant="ghost" size="sm" className="h-6 gap-1 px-1 text-xs">
|
||||
<Link to={`/traces/${item.trace_id}`}>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{t("detail.timeline.trace")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{item.run_id && <span>{item.run_id}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function getRunTimelineDisplay(item: Pick<RunTimelineItem, "item_type" | "status">) {
|
||||
if (item.status === "failed") {
|
||||
return {
|
||||
icon: XCircle,
|
||||
dotClass: "bg-destructive/10 text-destructive",
|
||||
labelKey: "detail.timeline.failed",
|
||||
};
|
||||
}
|
||||
if (item.item_type === "tool.call" || item.item_type === "tool.result") {
|
||||
return {
|
||||
icon: Wrench,
|
||||
dotClass: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
labelKey: item.item_type === "tool.call" ? "detail.timeline.toolCall" : "detail.timeline.toolResult",
|
||||
};
|
||||
}
|
||||
if (item.item_type === "assistant.message") {
|
||||
return {
|
||||
icon: MessageSquareText,
|
||||
dotClass: "bg-blue-500/10 text-blue-700 dark:text-blue-300",
|
||||
labelKey: "detail.timeline.assistant",
|
||||
};
|
||||
}
|
||||
if (item.item_type === "activity") {
|
||||
return {
|
||||
icon: Activity,
|
||||
dotClass: "bg-primary/10 text-primary",
|
||||
labelKey: "detail.timeline.activity",
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: item.status === "completed" ? CheckCircle2 : Clock,
|
||||
dotClass: statusDotClass(item.status),
|
||||
labelKey: "detail.timeline.runStatus",
|
||||
};
|
||||
}
|
||||
|
||||
function statusDotClass(status: RunTimelineItem["status"]) {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
|
||||
case "cancelled":
|
||||
return "bg-muted text-muted-foreground";
|
||||
case "running":
|
||||
case "started":
|
||||
return "bg-primary/10 text-primary";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
export type { RunTimelineItemType };
|
||||
@@ -14,6 +14,8 @@ import type { SessionInfo, SessionPreview, Message } from "@/types/session";
|
||||
import type { ChatMessage, AgentEventPayload, ToolStreamEntry } from "@/types/chat";
|
||||
import { messageToTimestamp } from "@/lib/message-utils";
|
||||
import { SystemMessageBlock, SummaryBlock } from "./session-message-blocks";
|
||||
import { useRunTimeline } from "./hooks/use-run-timeline";
|
||||
import { RunTimelinePanel } from "./run-timeline-panel";
|
||||
|
||||
/** Check if a message is an internal system message (subagent results, cron, etc.) */
|
||||
function isSystemMessage(msg: ChatMessage): boolean {
|
||||
@@ -61,6 +63,11 @@ export function SessionDetailPage({
|
||||
const [titleDraft, setTitleDraft] = useState("");
|
||||
|
||||
const parsed = parseSessionKey(session.key);
|
||||
const {
|
||||
data: runTimelineData,
|
||||
isFetching: runTimelineFetching,
|
||||
refetch: refetchRunTimeline,
|
||||
} = useRunTimeline({ sessionKey: session.key, limit: 100 });
|
||||
|
||||
const loadMessages = useCallback(() => {
|
||||
onPreview(session.key)
|
||||
@@ -119,12 +126,14 @@ export function SessionDetailPage({
|
||||
if (!event) return;
|
||||
if (
|
||||
(event.type === "run.completed" || event.type === "run.failed" || event.type === "run.cancelled") &&
|
||||
event.agentId === parsed.agentId
|
||||
event.agentId === parsed.agentId &&
|
||||
(!event.sessionKey || event.sessionKey === session.key)
|
||||
) {
|
||||
debouncedRefresh();
|
||||
void refetchRunTimeline();
|
||||
}
|
||||
},
|
||||
[debouncedRefresh, parsed.agentId],
|
||||
[debouncedRefresh, parsed.agentId, refetchRunTimeline, session.key],
|
||||
);
|
||||
|
||||
useWsEvent(Events.AGENT, handleAgentEvent);
|
||||
@@ -220,6 +229,8 @@ export function SessionDetailPage({
|
||||
<SummaryBlock text={summary} />
|
||||
)}
|
||||
|
||||
<RunTimelinePanel items={runTimelineData?.items ?? []} loading={runTimelineFetching} />
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{loading && messages.length === 0 ? (
|
||||
@@ -271,4 +282,3 @@ export function SessionDetailPage({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
export type RunTimelineItemType =
|
||||
| "activity"
|
||||
| "assistant.message"
|
||||
| "tool.call"
|
||||
| "tool.result"
|
||||
| "run.status";
|
||||
|
||||
export interface RunTimelineItem {
|
||||
id: string;
|
||||
tenant_id?: string;
|
||||
run_id: string;
|
||||
session_key: string;
|
||||
agent_id?: string;
|
||||
user_id?: string;
|
||||
channel?: string;
|
||||
chat_id?: string;
|
||||
seq: number;
|
||||
item_type: RunTimelineItemType;
|
||||
status?: "started" | "running" | "completed" | "failed" | "cancelled" | string;
|
||||
title?: string;
|
||||
preview?: string;
|
||||
tool_name?: string;
|
||||
tool_call_id?: string;
|
||||
trace_id?: string;
|
||||
span_id?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RunTimelineResponse {
|
||||
runId?: string;
|
||||
sessionKey?: string;
|
||||
items: RunTimelineItem[];
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
Reference in new issue
Block a user