164 lines
4.2 KiB
Go
164 lines
4.2 KiB
Go
// chat.go — Chat log storage and streaming.
|
|
//
|
|
// Two logs are maintained:
|
|
// - rawLog: JSONL format (one Block per line) for programmatic access
|
|
// - textLog: Rendered plain text for human reading
|
|
//
|
|
// Both are append-only buffers with overflow handling.
|
|
|
|
package agent
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"ollie/format"
|
|
)
|
|
|
|
const maxLogBytes = 16 * 1024 * 1024
|
|
|
|
// NextBlockID returns the next deterministic block ID and increments the counter.
|
|
// Block IDs are sha256(sessionID + agentID + counter)[:8].
|
|
func (ag *Agent) NextBlockID() string {
|
|
n := atomic.AddUint64(&ag.blockCounter, 1) - 1
|
|
h := sha256.New()
|
|
h.Write([]byte(ag.sessionID))
|
|
h.Write([]byte(":"))
|
|
h.Write([]byte(ag.id))
|
|
h.Write([]byte(":"))
|
|
h.Write([]byte(fmt.Sprintf("%d", n)))
|
|
return hex.EncodeToString(h.Sum(nil))[:8]
|
|
}
|
|
|
|
// AppendBlock writes a block to both logs and notifies stream readers.
|
|
func (ag *Agent) AppendBlock(b format.Block) {
|
|
raw := format.MarshalBlock(b)
|
|
text := []byte(format.RenderBlock(b))
|
|
|
|
ag.chatMu.Lock()
|
|
ag.rawLog = appendWithOverflow(ag.rawLog, raw, maxLogBytes, &ag.rawStart)
|
|
ag.textLog = appendWithOverflow(ag.textLog, text, maxLogBytes, &ag.textStart)
|
|
ag.chatVers++
|
|
ag.chatMu.Unlock()
|
|
|
|
ag.chatCond.Broadcast()
|
|
ag.chatSignalMu.Lock()
|
|
close(ag.chatSignalCh)
|
|
ag.chatSignalCh = make(chan struct{})
|
|
ag.chatSignalMu.Unlock()
|
|
}
|
|
|
|
func appendWithOverflow(log, data []byte, max int, start *int) []byte {
|
|
log = append(log, data...)
|
|
if len(log) > max {
|
|
drop := len(log) - max
|
|
log = append([]byte(nil), log[drop:]...)
|
|
*start += drop
|
|
}
|
|
return log
|
|
}
|
|
|
|
// ChatMu returns the chat log mutex for external locking (streaming).
|
|
func (ag *Agent) ChatMu() *sync.RWMutex { return &ag.chatMu }
|
|
|
|
// ChatCond returns the condvar for blocking chat readers.
|
|
func (ag *Agent) ChatCond() *sync.Cond { return ag.chatCond }
|
|
|
|
// ChatSignal returns the current chat signal channel (closed on new chat data).
|
|
func (ag *Agent) ChatSignal() <-chan struct{} {
|
|
ag.chatSignalMu.Lock()
|
|
ch := ag.chatSignalCh
|
|
ag.chatSignalMu.Unlock()
|
|
return ch
|
|
}
|
|
|
|
// RawLog returns the JSONL log bytes (caller must hold ChatMu.RLock).
|
|
func (ag *Agent) RawLog() []byte { return ag.rawLog }
|
|
|
|
// TextLog returns the rendered text log bytes (caller must hold ChatMu.RLock).
|
|
func (ag *Agent) TextLog() []byte { return ag.textLog }
|
|
|
|
// RawLogRead returns new JSONL data since the given offset.
|
|
func (ag *Agent) RawLogRead(base string) ([]byte, string, error) {
|
|
return ag.logRead(base, &ag.rawLog, &ag.rawStart)
|
|
}
|
|
|
|
// TextLogRead returns new rendered text since the given offset.
|
|
func (ag *Agent) TextLogRead(base string) ([]byte, string, error) {
|
|
return ag.logRead(base, &ag.textLog, &ag.textStart)
|
|
}
|
|
|
|
func (ag *Agent) logRead(base string, log *[]byte, start *int) ([]byte, string, error) {
|
|
var offset int
|
|
if base != "" {
|
|
fmt.Sscanf(base, "%d", &offset)
|
|
} else {
|
|
// Empty base = start from beginning (for replay)
|
|
ag.chatMu.RLock()
|
|
offset = *start
|
|
ag.chatMu.RUnlock()
|
|
}
|
|
ag.chatMu.RLock()
|
|
if offset < *start {
|
|
offset = *start
|
|
}
|
|
localOffset := offset - *start
|
|
if len(*log) <= localOffset {
|
|
ag.chatMu.RUnlock()
|
|
return nil, fmt.Sprintf("%d", offset), nil
|
|
}
|
|
data := make([]byte, len(*log)-localOffset)
|
|
copy(data, (*log)[localOffset:])
|
|
newOffset := *start + len(*log)
|
|
ag.chatMu.RUnlock()
|
|
return data, fmt.Sprintf("%d", newOffset), nil
|
|
}
|
|
|
|
// BlockByID returns the block with the given ID from the raw log.
|
|
func (ag *Agent) BlockByID(blockID string) (format.Block, bool) {
|
|
if blockID == "" {
|
|
return format.Block{}, false
|
|
}
|
|
ag.chatMu.RLock()
|
|
raw := ag.rawLog
|
|
ag.chatMu.RUnlock()
|
|
|
|
// Scan JSONL lines for matching ID
|
|
for _, line := range bytes.Split(raw, []byte("\n")) {
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
b, err := format.UnmarshalBlock(line)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if b.ID == blockID && !b.Partial {
|
|
return b, true
|
|
}
|
|
}
|
|
return format.Block{}, false
|
|
}
|
|
|
|
// --- Plan methods ---
|
|
|
|
// Plan returns a copy of the plan.
|
|
func (ag *Agent) Plan() []byte {
|
|
ag.chatMu.RLock()
|
|
p := make([]byte, len(ag.plan))
|
|
copy(p, ag.plan)
|
|
ag.chatMu.RUnlock()
|
|
return p
|
|
}
|
|
|
|
// SetPlan replaces the plan.
|
|
func (ag *Agent) SetPlan(data []byte) {
|
|
ag.chatMu.Lock()
|
|
ag.plan = make([]byte, len(data))
|
|
copy(ag.plan, data)
|
|
ag.chatMu.Unlock()
|
|
}
|