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:
Duy /zuey/ authored and GitHub committed 2026-05-31 13:22:07 +07:00
1 parent 990fabf94b
commit 269e2618ae
49 files changed
+2791 -145

No files matched your search

+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+2
View File
@@ -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)
+2 -1
View File
@@ -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) {
+37
View File
@@ -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 |
+46
View File
@@ -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
+27
View File
@@ -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)
}
}
+334
View File
@@ -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 ![a](/v1/files/work/a.png?ft=signed.123) 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]
}
+94
View File
@@ -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, &params); 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
}
+85
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
)
+1
View File
@@ -367,6 +367,7 @@ func isReadMethod(method string) bool {
// Sessions read
protocol.MethodSessionsList,
protocol.MethodSessionsPreview,
protocol.MethodRunTimelineGet,
// Skills read
protocol.MethodSkillsList,
+1
View File
@@ -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),
+164
View File
@@ -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,
}
}
+107
View File
@@ -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))
}
}
+64
View File
@@ -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)
}
+38 -37
View File
@@ -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),
+155
View File
@@ -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)
}
+32 -1
View File
@@ -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.
+36
View File
@@ -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
-- ============================================================
+1
View File
@@ -13,6 +13,7 @@ type Stores struct {
Agents AgentStore
Providers ProviderStore
Tracing TracingStore
RunTimeline RunTimelineStore
MCP MCPServerStore
ChannelInstances ChannelInstanceStore
ConfigSecrets ConfigSecretsStore
+1 -1
View File
@@ -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;
+1
View File
@@ -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
```
+1
View File
@@ -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",
+13 -1
View File
@@ -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",
+13 -1
View File
@@ -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",
+13 -1
View File
@@ -32,7 +32,19 @@
"showDetails": "显示详情",
"hide": "隐藏",
"systemMessage": "系统消息",
"messages": "条消息"
"messages": "条消息",
"timeline": {
"title": "运行时间线",
"loading": "加载中",
"count": "{{count}} 项",
"runStatus": "运行",
"assistant": "助手",
"toolCall": "工具调用",
"toolResult": "工具结果",
"trace": "追踪",
"activity": "活动",
"failed": "失败"
}
},
"toast": {
"deleted": "Session已删除",
+1
View File
@@ -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>
);
}
+36
View File
@@ -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;
}