mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-11 20:10:59 +00:00
08a2d95c0c
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)
307 lines
8.7 KiB
Go
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, ¶ms)
|
|
}
|
|
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, ¶ms)
|
|
}
|
|
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, ¶ms)
|
|
}
|
|
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)
|
|
}
|