Files
goclaw/internal/gateway/methods/agents_files.go
T
viettranx 08a2d95c0c feat: agent heartbeat system — periodic proactive check-ins (#245)
Phase 1 (Core):
- Migration 000022: agent_heartbeats, heartbeat_run_logs, agent_config_permissions tables
- HeartbeatStore + ConfigPermissionStore interfaces with PG implementations
- HeartbeatTicker: background poll → active hours filter → queue-aware skip → run → smart suppression → deliver/log
- Heartbeat tool: status/get/set/toggle/set_checklist/get_checklist/test/logs actions
- Permission check with wildcard scope matching + TTL cache (60s)
- RPC methods: heartbeat.get/set/toggle/test/logs/checklist.get/checklist.set
- HEARTBEAT.md routed via context file interceptor (read/write for both open + predefined agents)
- Session keys: agent:{id}:heartbeat or agent:{id}💓{ts} (isolated)
- PromptMinimal for heartbeat sessions (like cron/subagent)
- Event broadcasting + cache invalidation via bus (heartbeat + config_perms)
- Gateway wiring: ticker init, event wiring, graceful shutdown

Phase 2 (Integration):
- wakeMode: CronPayload.WakeHeartbeat triggers heartbeat after cron job completes
- Queue-aware: Scheduler.HasActiveSessionsForAgent() skips busy agents
- Stagger: deterministic FNV offset spreads heartbeats across interval
- lightContext: RunRequest.LightContext skips context files, only injects checklist
- System prompt distinguishes cron (user-scheduled tasks) vs heartbeat (autonomous monitoring)
2026-03-18 13:11:44 +07:00

307 lines
8.7 KiB
Go

package methods
import (
"context"
"encoding/json"
"os"
"path/filepath"
"slices"
"github.com/nextlevelbuilder/goclaw/internal/bootstrap"
"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"
)
// allowedAgentFiles is the list of files exposed via agents.files.* RPCs.
// TOOLS.md excluded — not applicable.
var allowedAgentFiles = []string{
bootstrap.AgentsFile, bootstrap.SoulFile, bootstrap.IdentityFile,
bootstrap.UserFile, bootstrap.UserPredefinedFile, bootstrap.BootstrapFile, bootstrap.MemoryJSONFile,
bootstrap.HeartbeatFile,
}
// --- agents.files.list ---
// Matching TS src/gateway/server-methods/agents.ts:399-422
func (m *AgentsMethods) handleFilesList(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params agentParams
if req.Params != nil {
json.Unmarshal(req.Params, &params)
}
if params.AgentID == "" {
params.AgentID = "default"
}
if m.agentStore != nil {
// --- DB-backed: list from store ---
ctx := context.Background()
ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
return
}
dbFiles, err := m.agentStore.GetAgentContextFiles(ctx, ag.ID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "files")))
return
}
// Build a map for quick lookup
dbMap := make(map[string]store.AgentContextFileData, len(dbFiles))
for _, f := range dbFiles {
dbMap[f.FileName] = f
}
files := make([]map[string]any, 0, len(allowedAgentFiles))
for _, name := range allowedAgentFiles {
if f, ok := dbMap[name]; ok {
files = append(files, map[string]any{
"name": name,
"missing": false,
"size": len(f.Content),
})
} else {
files = append(files, map[string]any{
"name": name,
"missing": true,
})
}
}
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"agentId": params.AgentID,
"files": files,
}))
return
}
// --- Fallback: filesystem ---
ws := m.resolveWorkspace(params.AgentID)
files := make([]map[string]any, 0, len(allowedAgentFiles))
for _, name := range allowedAgentFiles {
p := filepath.Join(ws, name)
info, err := os.Stat(p)
if err != nil {
files = append(files, map[string]any{
"name": name,
"path": p,
"missing": true,
})
} else {
files = append(files, map[string]any{
"name": name,
"path": p,
"missing": false,
"size": info.Size(),
"updatedAtMs": info.ModTime().UnixMilli(),
})
}
}
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"agentId": params.AgentID,
"workspace": ws,
"files": files,
}))
}
// --- agents.files.get ---
// Matching TS src/gateway/server-methods/agents.ts:423-473
func (m *AgentsMethods) handleFilesGet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
AgentID string `json:"agentId"`
Name string `json:"name"`
}
if req.Params != nil {
json.Unmarshal(req.Params, &params)
}
if params.AgentID == "" {
params.AgentID = "default"
}
if params.Name == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name")))
return
}
if !isAllowedFile(params.Name) {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "file not allowed: "+params.Name)))
return
}
if m.agentStore != nil {
// --- DB-backed: read from store ---
ctx := context.Background()
ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
return
}
dbFiles, err := m.agentStore.GetAgentContextFiles(ctx, ag.ID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "files")))
return
}
for _, f := range dbFiles {
if f.FileName == params.Name {
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"agentId": params.AgentID,
"file": map[string]any{
"name": params.Name,
"missing": false,
"size": len(f.Content),
"content": f.Content,
},
}))
return
}
}
// File not found in DB
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"agentId": params.AgentID,
"file": map[string]any{
"name": params.Name,
"missing": true,
},
}))
return
}
// --- Fallback: filesystem ---
ws := m.resolveWorkspace(params.AgentID)
p := filepath.Join(ws, params.Name)
info, err := os.Stat(p)
if err != nil {
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"agentId": params.AgentID,
"workspace": ws,
"file": map[string]any{
"name": params.Name,
"path": p,
"missing": true,
},
}))
return
}
content, _ := os.ReadFile(p)
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"agentId": params.AgentID,
"workspace": ws,
"file": map[string]any{
"name": params.Name,
"path": p,
"missing": false,
"size": info.Size(),
"updatedAtMs": info.ModTime().UnixMilli(),
"content": string(content),
},
}))
}
// --- agents.files.set ---
// Matching TS src/gateway/server-methods/agents.ts:474-515
func (m *AgentsMethods) handleFilesSet(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params struct {
AgentID string `json:"agentId"`
Name string `json:"name"`
Content string `json:"content"`
}
if req.Params != nil {
json.Unmarshal(req.Params, &params)
}
if params.AgentID == "" {
params.AgentID = "default"
}
if params.Name == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name")))
return
}
if !isAllowedFile(params.Name) {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "file not allowed: "+params.Name)))
return
}
if m.agentStore != nil {
// --- DB-backed: write to store ---
ctx := context.Background()
ag, err := m.agentStore.GetByKey(ctx, params.AgentID)
if err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgAgentNotFound, params.AgentID)))
return
}
if err := m.agentStore.SetAgentContextFile(ctx, ag.ID, params.Name, params.Content); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToSave, "file", err.Error())))
return
}
// Invalidate both caches so the new content is served immediately
// without waiting for the ContextFileInterceptor's 5-minute TTL to expire.
m.agents.InvalidateAgent(params.AgentID)
if m.interceptor != nil {
m.interceptor.InvalidateAgent(ag.ID)
}
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"agentId": params.AgentID,
"file": map[string]any{
"name": params.Name,
"missing": false,
"size": len(params.Content),
"content": params.Content,
},
}))
return
}
// --- Fallback: filesystem ---
ws := m.resolveWorkspace(params.AgentID)
os.MkdirAll(ws, 0755)
p := filepath.Join(ws, params.Name)
if err := os.WriteFile(p, []byte(params.Content), 0644); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToSave, "file", err.Error())))
return
}
info, _ := os.Stat(p)
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"agentId": params.AgentID,
"workspace": ws,
"file": map[string]any{
"name": params.Name,
"path": p,
"missing": false,
"size": info.Size(),
"updatedAtMs": info.ModTime().UnixMilli(),
"content": params.Content,
},
}))
}
// --- Helpers ---
func (m *AgentsMethods) resolveWorkspace(agentID string) string {
if spec, ok := m.cfg.Agents.List[agentID]; ok && spec.Workspace != "" {
return config.ExpandHome(spec.Workspace)
}
return config.ExpandHome(m.cfg.Agents.Defaults.Workspace)
}
func isAllowedFile(name string) bool {
return slices.Contains(allowedAgentFiles, name)
}