Files
casdoor/object/openclaw_session_graph.go

805 lines
23 KiB
Go

// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/casdoor/casdoor/util"
)
type OpenClawSessionGraph struct {
Nodes []*OpenClawSessionGraphNode `json:"nodes"`
Edges []*OpenClawSessionGraphEdge `json:"edges"`
Stats OpenClawSessionGraphStats `json:"stats"`
}
type OpenClawSessionGraphNode struct {
ID string `json:"id"`
ParentID string `json:"parentId,omitempty"`
OriginalParentID string `json:"originalParentId,omitempty"`
EntryID string `json:"entryId,omitempty"`
ToolCallID string `json:"toolCallId,omitempty"`
Kind string `json:"kind"`
Timestamp string `json:"timestamp"`
Summary string `json:"summary"`
Tool string `json:"tool,omitempty"`
Query string `json:"query,omitempty"`
URL string `json:"url,omitempty"`
Path string `json:"path,omitempty"`
OK *bool `json:"ok,omitempty"`
Error string `json:"error,omitempty"`
Text string `json:"text,omitempty"`
IsAnchor bool `json:"isAnchor"`
}
type OpenClawSessionGraphEdge struct {
Source string `json:"source"`
Target string `json:"target"`
}
type OpenClawSessionGraphStats struct {
TotalNodes int `json:"totalNodes"`
TaskCount int `json:"taskCount"`
ToolCallCount int `json:"toolCallCount"`
ToolResultCount int `json:"toolResultCount"`
FinalCount int `json:"finalCount"`
FailedCount int `json:"failedCount"`
}
type openClawSessionGraphBuilder struct {
graph *OpenClawSessionGraph
nodes map[string]*OpenClawSessionGraphNode
}
type openClawSessionGraphRecord struct {
Entry *Entry
Payload openClawBehaviorPayload
}
type openClawAssistantStepGroup struct {
ParentID string
Timestamp string
ToolNames []string
Text string
}
func GetOpenClawSessionGraph(id string) (*OpenClawSessionGraph, error) {
entry, err := GetEntry(id)
if err != nil {
return nil, err
}
if entry == nil {
return nil, nil
}
if strings.TrimSpace(entry.Type) != "session" {
return nil, fmt.Errorf("entry %s is not an OpenClaw session entry", id)
}
provider, err := GetProvider(util.GetId(entry.Owner, entry.Provider))
if err != nil {
return nil, err
}
if provider != nil && !isOpenClawLogProvider(provider) {
return nil, fmt.Errorf("entry %s is not an OpenClaw session entry", id)
}
anchorPayload, err := parseOpenClawSessionGraphPayload(entry)
if err != nil {
return nil, fmt.Errorf("failed to parse anchor entry %s: %w", entry.Name, err)
}
records, err := collectOpenClawSessionGraphRecords(entry, anchorPayload)
if err != nil {
return nil, fmt.Errorf("failed to load OpenClaw session entries from database: %w", err)
}
return buildOpenClawSessionGraphFromEntries(anchorPayload, entry.Name, records), nil
}
func parseOpenClawSessionGraphPayload(entry *Entry) (openClawBehaviorPayload, error) {
if entry == nil {
return openClawBehaviorPayload{}, fmt.Errorf("entry is nil")
}
message := strings.TrimSpace(entry.Message)
if message == "" {
return openClawBehaviorPayload{}, fmt.Errorf("message is empty")
}
var payload openClawBehaviorPayload
if err := json.Unmarshal([]byte(message), &payload); err != nil {
return openClawBehaviorPayload{}, err
}
payload.SessionID = strings.TrimSpace(payload.SessionID)
payload.EntryID = strings.TrimSpace(payload.EntryID)
payload.ToolCallID = strings.TrimSpace(payload.ToolCallID)
payload.ParentID = strings.TrimSpace(payload.ParentID)
payload.Kind = strings.TrimSpace(payload.Kind)
payload.Summary = strings.TrimSpace(payload.Summary)
payload.Tool = strings.TrimSpace(payload.Tool)
payload.Query = strings.TrimSpace(payload.Query)
payload.URL = strings.TrimSpace(payload.URL)
payload.Path = strings.TrimSpace(payload.Path)
payload.Error = strings.TrimSpace(payload.Error)
payload.AssistantText = strings.TrimSpace(payload.AssistantText)
payload.Text = strings.TrimSpace(payload.Text)
payload.Timestamp = strings.TrimSpace(firstNonEmpty(payload.Timestamp, entry.CreatedTime))
if payload.SessionID == "" {
return openClawBehaviorPayload{}, fmt.Errorf("sessionId is empty")
}
if payload.EntryID == "" {
return openClawBehaviorPayload{}, fmt.Errorf("entryId is empty")
}
if payload.Kind == "" {
return openClawBehaviorPayload{}, fmt.Errorf("kind is empty")
}
return payload, nil
}
func collectOpenClawSessionGraphRecords(anchorEntry *Entry, anchorPayload openClawBehaviorPayload) ([]openClawSessionGraphRecord, error) {
if anchorEntry == nil {
return nil, fmt.Errorf("anchor entry is nil")
}
entries := []*Entry{}
query := ormer.Engine.Where("owner = ? and type = ?", anchorEntry.Owner, "session")
if providerName := strings.TrimSpace(anchorEntry.Provider); providerName != "" {
query = query.And("provider = ?", providerName)
}
if err := query.
Asc("created_time").
Asc("name").
Find(&entries); err != nil {
return nil, err
}
return filterOpenClawSessionGraphRecords(anchorEntry, anchorPayload, entries), nil
}
func filterOpenClawSessionGraphRecords(anchorEntry *Entry, anchorPayload openClawBehaviorPayload, entries []*Entry) []openClawSessionGraphRecord {
targetSessionID := strings.TrimSpace(anchorPayload.SessionID)
records := make([]openClawSessionGraphRecord, 0, len(entries)+1)
hasAnchor := false
for _, candidate := range entries {
if candidate == nil {
continue
}
payload, err := parseOpenClawSessionGraphPayload(candidate)
if err != nil {
continue
}
if payload.SessionID != targetSessionID {
continue
}
records = append(records, openClawSessionGraphRecord{
Entry: candidate,
Payload: payload,
})
if candidate.Owner == anchorEntry.Owner && candidate.Name == anchorEntry.Name {
hasAnchor = true
}
}
if !hasAnchor && anchorEntry != nil {
records = append(records, openClawSessionGraphRecord{
Entry: anchorEntry,
Payload: anchorPayload,
})
}
sort.SliceStable(records, func(i, j int) bool {
leftPayload := records[i].Payload
rightPayload := records[j].Payload
leftTimestamp := strings.TrimSpace(firstNonEmpty(leftPayload.Timestamp, records[i].Entry.CreatedTime))
rightTimestamp := strings.TrimSpace(firstNonEmpty(rightPayload.Timestamp, records[j].Entry.CreatedTime))
if timestampOrder := compareOpenClawGraphTimestamps(leftTimestamp, rightTimestamp); timestampOrder != 0 {
return timestampOrder < 0
}
return records[i].Entry.Name < records[j].Entry.Name
})
return records
}
func buildOpenClawSessionGraphFromEntries(anchorPayload openClawBehaviorPayload, anchorEntryName string, records []openClawSessionGraphRecord) *OpenClawSessionGraph {
builder := newOpenClawSessionGraphBuilder()
nodeIDsByEntryName := map[string][]string{}
assistantGroups := map[string]*openClawAssistantStepGroup{}
toolCallNodesByAssistant := map[string][]*OpenClawSessionGraphNode{}
toolCallNodeIDByToolCallID := map[string]string{}
allToolCallNodes := []*OpenClawSessionGraphNode{}
toolResultRecords := []openClawSessionGraphRecord{}
for _, record := range records {
payload := record.Payload
switch payload.Kind {
case "task":
builder.addNode(&OpenClawSessionGraphNode{
ID: payload.EntryID,
ParentID: payload.ParentID,
EntryID: payload.EntryID,
Kind: "task",
Timestamp: payload.Timestamp,
Summary: payload.Summary,
Text: payload.Text,
})
appendGraphNodeEntryName(nodeIDsByEntryName, record.Entry, payload.EntryID)
case "tool_call":
nodeID := buildStoredToolCallNodeID(record.Entry, payload)
builder.addNode(&OpenClawSessionGraphNode{
ID: nodeID,
ParentID: payload.EntryID,
EntryID: payload.EntryID,
ToolCallID: payload.ToolCallID,
Kind: "tool_call",
Timestamp: payload.Timestamp,
Summary: payload.Summary,
Tool: payload.Tool,
Query: payload.Query,
URL: payload.URL,
Path: payload.Path,
Text: payload.Text,
})
storedNode := builder.nodes[nodeID]
appendGraphNodeEntryName(nodeIDsByEntryName, record.Entry, nodeID)
if storedNode != nil {
toolCallNodesByAssistant[payload.EntryID] = append(toolCallNodesByAssistant[payload.EntryID], storedNode)
allToolCallNodes = append(allToolCallNodes, storedNode)
}
if payload.ToolCallID != "" && toolCallNodeIDByToolCallID[payload.ToolCallID] == "" {
toolCallNodeIDByToolCallID[payload.ToolCallID] = nodeID
}
group := assistantGroups[payload.EntryID]
if group == nil {
group = &openClawAssistantStepGroup{
ParentID: payload.ParentID,
Timestamp: payload.Timestamp,
}
assistantGroups[payload.EntryID] = group
}
group.ParentID = firstNonEmpty(group.ParentID, payload.ParentID)
group.Timestamp = chooseEarlierTimestamp(group.Timestamp, payload.Timestamp)
group.ToolNames = append(group.ToolNames, payload.Tool)
group.Text = firstNonEmpty(group.Text, payload.AssistantText)
case "tool_result":
toolResultRecords = append(toolResultRecords, record)
case "final":
builder.addNode(&OpenClawSessionGraphNode{
ID: payload.EntryID,
ParentID: payload.ParentID,
EntryID: payload.EntryID,
Kind: "final",
Timestamp: payload.Timestamp,
Summary: payload.Summary,
Text: payload.Text,
})
appendGraphNodeEntryName(nodeIDsByEntryName, record.Entry, payload.EntryID)
}
}
assistantIDs := make([]string, 0, len(assistantGroups))
for entryID := range assistantGroups {
assistantIDs = append(assistantIDs, entryID)
}
sort.Strings(assistantIDs)
for _, assistantID := range assistantIDs {
group := assistantGroups[assistantID]
builder.addNode(&OpenClawSessionGraphNode{
ID: assistantID,
ParentID: strings.TrimSpace(group.ParentID),
EntryID: assistantID,
Kind: "assistant_step",
Timestamp: strings.TrimSpace(group.Timestamp),
Summary: buildAssistantStepSummary(group.ToolNames),
Text: strings.TrimSpace(group.Text),
})
}
for _, record := range toolResultRecords {
payload := record.Payload
parentID := strings.TrimSpace(payload.ParentID)
originalParentID := ""
if payload.ToolCallID != "" {
if matchedNodeID := strings.TrimSpace(toolCallNodeIDByToolCallID[payload.ToolCallID]); matchedNodeID != "" {
originalParentID = parentID
parentID = matchedNodeID
}
}
if parentID == strings.TrimSpace(payload.ParentID) {
if matchedNodeID := matchToolResultToolCallNodeID(payload, toolCallNodesByAssistant[payload.ParentID], allToolCallNodes); matchedNodeID != "" && matchedNodeID != parentID {
originalParentID = parentID
parentID = matchedNodeID
}
}
builder.addNode(&OpenClawSessionGraphNode{
ID: payload.EntryID,
ParentID: parentID,
OriginalParentID: originalParentID,
EntryID: payload.EntryID,
ToolCallID: payload.ToolCallID,
Kind: "tool_result",
Timestamp: payload.Timestamp,
Summary: payload.Summary,
Tool: payload.Tool,
Query: payload.Query,
URL: payload.URL,
Path: payload.Path,
OK: cloneBoolPointer(payload.OK),
Error: payload.Error,
Text: payload.Text,
})
appendGraphNodeEntryName(nodeIDsByEntryName, record.Entry, payload.EntryID)
}
markStoredGraphAnchor(builder, anchorPayload, anchorEntryName, nodeIDsByEntryName)
return builder.finalize()
}
func appendGraphNodeEntryName(index map[string][]string, entry *Entry, nodeID string) {
if index == nil || entry == nil {
return
}
entryName := strings.TrimSpace(entry.Name)
nodeID = strings.TrimSpace(nodeID)
if entryName == "" || nodeID == "" {
return
}
for _, existingNodeID := range index[entryName] {
if existingNodeID == nodeID {
return
}
}
index[entryName] = append(index[entryName], nodeID)
}
func matchToolResultToolCallNodeID(payload openClawBehaviorPayload, assistantToolCalls []*OpenClawSessionGraphNode, allToolCalls []*OpenClawSessionGraphNode) string {
if matchedNodeID := chooseMatchingToolCallNodeID(payload, assistantToolCalls); matchedNodeID != "" {
return matchedNodeID
}
if len(assistantToolCalls) != len(allToolCalls) {
return chooseMatchingToolCallNodeID(payload, allToolCalls)
}
return ""
}
func chooseMatchingToolCallNodeID(payload openClawBehaviorPayload, candidates []*OpenClawSessionGraphNode) string {
filtered := make([]*OpenClawSessionGraphNode, 0, len(candidates))
seenNodeIDs := map[string]struct{}{}
for _, candidate := range candidates {
if candidate == nil || candidate.Kind != "tool_call" {
continue
}
if _, ok := seenNodeIDs[candidate.ID]; ok {
continue
}
seenNodeIDs[candidate.ID] = struct{}{}
filtered = append(filtered, candidate)
}
if len(filtered) == 0 {
return ""
}
if len(filtered) == 1 {
return filtered[0].ID
}
filtered = refineToolCallCandidates(filtered, payload.Query, func(node *OpenClawSessionGraphNode) string { return node.Query })
if len(filtered) == 1 {
return filtered[0].ID
}
filtered = refineToolCallCandidates(filtered, payload.URL, func(node *OpenClawSessionGraphNode) string { return node.URL })
if len(filtered) == 1 {
return filtered[0].ID
}
filtered = refineToolCallCandidates(filtered, payload.Path, func(node *OpenClawSessionGraphNode) string { return node.Path })
if len(filtered) == 1 {
return filtered[0].ID
}
filtered = refineToolCallCandidates(filtered, payload.Tool, func(node *OpenClawSessionGraphNode) string { return node.Tool })
if len(filtered) == 1 {
return filtered[0].ID
}
return ""
}
func refineToolCallCandidates(candidates []*OpenClawSessionGraphNode, expected string, selector func(node *OpenClawSessionGraphNode) string) []*OpenClawSessionGraphNode {
expected = strings.TrimSpace(expected)
if expected == "" {
return candidates
}
filtered := make([]*OpenClawSessionGraphNode, 0, len(candidates))
for _, candidate := range candidates {
if strings.TrimSpace(selector(candidate)) == expected {
filtered = append(filtered, candidate)
}
}
if len(filtered) == 0 {
return candidates
}
return filtered
}
func markStoredGraphAnchor(builder *openClawSessionGraphBuilder, anchorPayload openClawBehaviorPayload, anchorEntryName string, nodeIDsByEntryName map[string][]string) {
anchorNodeID := ""
if nodeIDs := nodeIDsByEntryName[strings.TrimSpace(anchorEntryName)]; len(nodeIDs) == 1 {
anchorNodeID = nodeIDs[0]
}
if anchorNodeID == "" {
switch anchorPayload.Kind {
case "tool_call":
candidates := []string{}
for _, node := range builder.nodes {
if !toolCallPayloadMatchesNode(anchorPayload, node) {
continue
}
candidates = append(candidates, node.ID)
}
switch len(candidates) {
case 1:
anchorNodeID = candidates[0]
default:
anchorNodeID = anchorPayload.EntryID
}
default:
if node := builder.nodes[anchorPayload.EntryID]; node != nil && node.Kind == anchorPayload.Kind {
anchorNodeID = node.ID
}
}
}
if anchorNode := builder.nodes[anchorNodeID]; anchorNode != nil {
anchorNode.IsAnchor = true
}
}
func buildStoredToolCallNodeID(entry *Entry, payload openClawBehaviorPayload) string {
if payload.ToolCallID != "" {
return fmt.Sprintf("tool_call:%s", payload.ToolCallID)
}
if entry != nil && strings.TrimSpace(entry.Name) != "" {
return fmt.Sprintf("tool_call_row:%s", strings.TrimSpace(entry.Name))
}
return fmt.Sprintf("tool_call:%s", strings.TrimSpace(payload.EntryID))
}
func newOpenClawSessionGraphBuilder() *openClawSessionGraphBuilder {
return &openClawSessionGraphBuilder{
graph: &OpenClawSessionGraph{
Nodes: []*OpenClawSessionGraphNode{},
Edges: []*OpenClawSessionGraphEdge{},
},
nodes: map[string]*OpenClawSessionGraphNode{},
}
}
func (b *openClawSessionGraphBuilder) addNode(node *OpenClawSessionGraphNode) {
if b == nil || node == nil {
return
}
node.ID = strings.TrimSpace(node.ID)
if node.ID == "" {
return
}
if existing := b.nodes[node.ID]; existing != nil {
mergeOpenClawGraphNode(existing, node)
return
}
cloned := *node
cloned.ParentID = strings.TrimSpace(cloned.ParentID)
cloned.OriginalParentID = strings.TrimSpace(cloned.OriginalParentID)
cloned.EntryID = strings.TrimSpace(cloned.EntryID)
cloned.ToolCallID = strings.TrimSpace(cloned.ToolCallID)
cloned.Kind = strings.TrimSpace(cloned.Kind)
cloned.Timestamp = strings.TrimSpace(cloned.Timestamp)
cloned.Summary = strings.TrimSpace(cloned.Summary)
cloned.Tool = strings.TrimSpace(cloned.Tool)
cloned.Query = strings.TrimSpace(cloned.Query)
cloned.URL = strings.TrimSpace(cloned.URL)
cloned.Path = strings.TrimSpace(cloned.Path)
cloned.Error = strings.TrimSpace(cloned.Error)
cloned.Text = strings.TrimSpace(cloned.Text)
cloned.OK = cloneBoolPointer(cloned.OK)
b.nodes[cloned.ID] = &cloned
}
func (b *openClawSessionGraphBuilder) finalize() *OpenClawSessionGraph {
if b == nil || b.graph == nil {
return nil
}
nodeIDs := make([]string, 0, len(b.nodes))
for id := range b.nodes {
nodeIDs = append(nodeIDs, id)
}
sort.Slice(nodeIDs, func(i, j int) bool {
left := b.nodes[nodeIDs[i]]
right := b.nodes[nodeIDs[j]]
return compareGraphNodes(left, right) < 0
})
b.graph.Nodes = make([]*OpenClawSessionGraphNode, 0, len(nodeIDs))
b.graph.Stats = OpenClawSessionGraphStats{}
for _, id := range nodeIDs {
node := b.nodes[id]
b.graph.Nodes = append(b.graph.Nodes, node)
updateOpenClawSessionGraphStats(&b.graph.Stats, node)
}
edgeKeys := map[string]struct{}{}
b.graph.Edges = []*OpenClawSessionGraphEdge{}
for _, node := range b.graph.Nodes {
if node.ParentID == "" || b.nodes[node.ParentID] == nil {
continue
}
key := fmt.Sprintf("%s->%s", node.ParentID, node.ID)
if _, ok := edgeKeys[key]; ok {
continue
}
edgeKeys[key] = struct{}{}
b.graph.Edges = append(b.graph.Edges, &OpenClawSessionGraphEdge{
Source: node.ParentID,
Target: node.ID,
})
}
sort.Slice(b.graph.Edges, func(i, j int) bool {
left := b.graph.Edges[i]
right := b.graph.Edges[j]
if left.Source != right.Source {
return left.Source < right.Source
}
return left.Target < right.Target
})
return b.graph
}
func mergeOpenClawGraphNode(current, next *OpenClawSessionGraphNode) {
if current == nil || next == nil {
return
}
current.ParentID = firstNonEmpty(current.ParentID, next.ParentID)
current.OriginalParentID = firstNonEmpty(current.OriginalParentID, next.OriginalParentID)
current.EntryID = firstNonEmpty(current.EntryID, next.EntryID)
current.ToolCallID = firstNonEmpty(current.ToolCallID, next.ToolCallID)
current.Kind = firstNonEmpty(current.Kind, next.Kind)
current.Timestamp = chooseEarlierTimestamp(current.Timestamp, next.Timestamp)
current.Summary = firstNonEmpty(current.Summary, next.Summary)
current.Tool = firstNonEmpty(current.Tool, next.Tool)
current.Query = firstNonEmpty(current.Query, next.Query)
current.URL = firstNonEmpty(current.URL, next.URL)
current.Path = firstNonEmpty(current.Path, next.Path)
current.Error = firstNonEmpty(current.Error, next.Error)
current.Text = firstNonEmpty(current.Text, next.Text)
current.OK = mergeBoolPointers(current.OK, next.OK)
current.IsAnchor = current.IsAnchor || next.IsAnchor
}
func updateOpenClawSessionGraphStats(stats *OpenClawSessionGraphStats, node *OpenClawSessionGraphNode) {
if stats == nil || node == nil {
return
}
stats.TotalNodes++
switch node.Kind {
case "task":
stats.TaskCount++
case "tool_call":
stats.ToolCallCount++
case "tool_result":
stats.ToolResultCount++
if node.OK != nil && !*node.OK {
stats.FailedCount++
}
case "final":
stats.FinalCount++
}
}
func buildAssistantStepSummary(toolNames []string) string {
deduped := []string{}
seen := map[string]struct{}{}
for _, toolName := range toolNames {
toolName = strings.TrimSpace(toolName)
if toolName == "" {
continue
}
if _, ok := seen[toolName]; ok {
continue
}
seen[toolName] = struct{}{}
deduped = append(deduped, toolName)
}
if len(toolNames) == 0 {
return "assistant step"
}
if len(deduped) == 0 {
return fmt.Sprintf("%d tool calls", len(toolNames))
}
if len(deduped) <= 3 {
return fmt.Sprintf("%d tool calls: %s", len(toolNames), strings.Join(deduped, ", "))
}
return fmt.Sprintf("%d tool calls: %s, ...", len(toolNames), strings.Join(deduped[:3], ", "))
}
func toolCallPayloadMatchesNode(payload openClawBehaviorPayload, node *OpenClawSessionGraphNode) bool {
if node == nil || node.Kind != "tool_call" {
return false
}
if payload.ToolCallID != "" {
return strings.TrimSpace(node.ToolCallID) == strings.TrimSpace(payload.ToolCallID)
}
if strings.TrimSpace(node.EntryID) != strings.TrimSpace(payload.EntryID) {
return false
}
fields := []struct {
payload string
node string
}{
{payload.Tool, node.Tool},
{payload.Query, node.Query},
{payload.URL, node.URL},
{payload.Path, node.Path},
{payload.Text, node.Text},
}
matchedField := false
for _, field := range fields {
left := strings.TrimSpace(field.payload)
if left == "" {
continue
}
matchedField = true
if left != strings.TrimSpace(field.node) {
return false
}
}
return matchedField
}
func compareGraphNodes(left, right *OpenClawSessionGraphNode) int {
leftTimestamp := ""
rightTimestamp := ""
leftID := ""
rightID := ""
if left != nil {
leftTimestamp = left.Timestamp
leftID = left.ID
}
if right != nil {
rightTimestamp = right.Timestamp
rightID = right.ID
}
if timestampOrder := compareOpenClawGraphTimestamps(leftTimestamp, rightTimestamp); timestampOrder != 0 {
return timestampOrder
}
if leftID < rightID {
return -1
}
if leftID > rightID {
return 1
}
return 0
}
func chooseEarlierTimestamp(current, next string) string {
current = strings.TrimSpace(current)
next = strings.TrimSpace(next)
if current == "" {
return next
}
if next == "" {
return current
}
if compareOpenClawGraphTimestamps(next, current) < 0 {
return next
}
return current
}
func compareOpenClawGraphTimestamps(left, right string) int {
left = strings.TrimSpace(left)
right = strings.TrimSpace(right)
leftUnixNano, leftOK := parseOpenClawGraphTimestamp(left)
rightUnixNano, rightOK := parseOpenClawGraphTimestamp(right)
if leftOK && rightOK {
if leftUnixNano < rightUnixNano {
return -1
}
if leftUnixNano > rightUnixNano {
return 1
}
return 0
}
if left < right {
return -1
}
if left > right {
return 1
}
return 0
}
func parseOpenClawGraphTimestamp(timestamp string) (_ int64, ok bool) {
timestamp = strings.TrimSpace(timestamp)
if timestamp == "" {
return 0, false
}
defer func() {
if recover() != nil {
ok = false
}
}()
return util.String2Time(timestamp).UnixNano(), true
}
func mergeBoolPointers(current, next *bool) *bool {
if next == nil {
return current
}
if current == nil {
return cloneBoolPointer(next)
}
value := *current && *next
return &value
}
func cloneBoolPointer(value *bool) *bool {
if value == nil {
return nil
}
cloned := *value
return &cloned
}