f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
344 lines
12 KiB
Go
344 lines
12 KiB
Go
// ABOUTME: Container-level freshness gate for OpenCode-family shared
|
|
// ABOUTME: SQLite databases, skipping per-session re-parse on idle syncs.
|
|
package sync
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"go.kenn.io/agentsview/internal/parser"
|
|
)
|
|
|
|
// The OpenCode-family providers fan one shared SQLite database into one
|
|
// virtual source per session row. Per-session freshness cannot be decided
|
|
// before parsing (a message or part row can change without bumping the
|
|
// session's time_updated, which is why dropUnchangedSharedSQLiteResults
|
|
// compares content fingerprints after the parse), so a periodic sync of an
|
|
// untouched archive used to re-open and re-parse every session on every
|
|
// pass. The gate restores an O(1) answer for the common idle case: when the
|
|
// container file provably has not changed since a pass that verified every
|
|
// one of its sessions — as decided by parser.SQLiteContainerState, which
|
|
// rests on SQLite's own write markers rather than timestamp precision —
|
|
// none of its sessions can have changed either, and they all skip before
|
|
// fingerprinting.
|
|
|
|
// openCodeFamilySQLiteAgents lists the agents whose sessions live in a
|
|
// shared OpenCode-format SQLite container.
|
|
var openCodeFamilySQLiteAgents = []parser.AgentType{
|
|
parser.AgentOpenCode,
|
|
parser.AgentKilo,
|
|
parser.AgentMiMoCode,
|
|
parser.AgentIcodemate,
|
|
}
|
|
|
|
// sqliteContainerSourceForFile maps a discovered file to its shared SQLite
|
|
// container path and session ID, or ok=false when the file is not an
|
|
// OpenCode-family SQLite virtual source (storage-mode JSON sessions
|
|
// included).
|
|
func sqliteContainerSourceForFile(
|
|
file parser.DiscoveredFile,
|
|
) (dbPath, sessionID string, ok bool) {
|
|
dbName := openCodeFormatDBName(file.Agent)
|
|
if dbName == "" {
|
|
return "", "", false
|
|
}
|
|
return parser.ParseVirtualSourcePathForBase(file.Path, dbName)
|
|
}
|
|
|
|
// sqliteContainerPathForResultPath maps a processed result path back to its
|
|
// container. Result paths arrive without an agent, so every family DB name
|
|
// is tried.
|
|
func sqliteContainerPathForResultPath(path string) string {
|
|
for _, agent := range openCodeFamilySQLiteAgents {
|
|
dbPath, _, ok := parser.ParseVirtualSourcePathForBase(
|
|
path, openCodeFormatDBName(agent),
|
|
)
|
|
if ok {
|
|
return dbPath
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// trustedSQLiteContainer is a container's state at the end of the last pass
|
|
// that verified every one of its discovered sessions, together with exactly
|
|
// which session IDs that pass discovered. The set matters in hybrid roots:
|
|
// discovery drops SQLite rows shadowed by a same-ID storage JSON, so the
|
|
// discoverable row set can grow — a storage JSON removed while the DB is
|
|
// untouched exposes its row — without the container state changing. A
|
|
// source may therefore gate-skip only when its session ID was part of the
|
|
// verified set; a newly exposed row misses the set and parses.
|
|
type trustedSQLiteContainer struct {
|
|
state parser.SQLiteContainerState
|
|
sessions map[string]struct{}
|
|
}
|
|
|
|
// sqliteContainerPass tracks one sync pass's view of every OpenCode-family
|
|
// SQLite container it discovered. captured and sessions are written once
|
|
// before workers start and are read-only afterwards; completed and failed
|
|
// are touched only by the single collectAndBatch goroutine, so no locking
|
|
// is needed during the pass.
|
|
type sqliteContainerPass struct {
|
|
captured map[string]parser.SQLiteContainerState
|
|
discovered map[string]int
|
|
sessions map[string]map[string]struct{}
|
|
completed map[string]int
|
|
failed map[string]bool
|
|
poisoned bool
|
|
}
|
|
|
|
// captureSQLiteContainerStates snapshots every configured OpenCode-family
|
|
// SQLite container's state. It must run BEFORE discovery lists any session
|
|
// rows: promotion may only trust a state that is at least as old as the
|
|
// discovered session set, otherwise a session written between the listing
|
|
// and a later capture would be promoted away and gate-skipped without ever
|
|
// being parsed. Containers whose state cannot be read are simply absent
|
|
// from the map and never promoted.
|
|
func (e *Engine) captureSQLiteContainerStates() map[string]parser.SQLiteContainerState {
|
|
if e.forceParse {
|
|
return nil
|
|
}
|
|
states := make(map[string]parser.SQLiteContainerState)
|
|
for _, agent := range openCodeFamilySQLiteAgents {
|
|
for _, dir := range e.agentDirs[agent] {
|
|
if dir == "" || strings.HasPrefix(dir, "s3://") {
|
|
continue
|
|
}
|
|
src := resolveOpenCodeFormatSource(agent, filepath.Clean(dir))
|
|
if src.DBPath == "" {
|
|
continue
|
|
}
|
|
if state, ok := parser.StatSQLiteContainerState(src.DBPath); ok {
|
|
states[src.DBPath] = state
|
|
}
|
|
}
|
|
}
|
|
return states
|
|
}
|
|
|
|
// beginSQLiteContainerPass starts a pass's gate bookkeeping from the
|
|
// discovered files and the pre-discovery container captures. files must be
|
|
// the pre-filter discovery set: promotion requires seeing a completion for
|
|
// every discovered session, so an mtime-cutoff or scope filter that drops
|
|
// sessions from processing keeps the container untrusted. A discovered
|
|
// container with no pre-discovery capture is marked failed and can neither
|
|
// gate-skip nor be promoted this pass.
|
|
//
|
|
// It runs AFTER discovery, so each captured container is re-stat'ed here
|
|
// and compared against its pre-discovery capture. A mismatch means the
|
|
// container changed inside the capture-discovery window: the discovered
|
|
// session set may already include that change, so gating against the
|
|
// pre-discovery state would skip it while it still matches the trusted
|
|
// state. Such containers are failed for the pass — no skips, no promotion
|
|
// — and the next pass re-verifies them by content.
|
|
func (e *Engine) beginSQLiteContainerPass(
|
|
files []parser.DiscoveredFile,
|
|
preStates map[string]parser.SQLiteContainerState,
|
|
) {
|
|
if e.forceParse {
|
|
e.containerMu.Lock()
|
|
e.containerPass = nil
|
|
e.containerMu.Unlock()
|
|
return
|
|
}
|
|
var pass *sqliteContainerPass
|
|
for _, file := range files {
|
|
dbPath, sessionID, ok := sqliteContainerSourceForFile(file)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if pass == nil {
|
|
pass = &sqliteContainerPass{
|
|
captured: make(map[string]parser.SQLiteContainerState),
|
|
discovered: make(map[string]int),
|
|
sessions: make(map[string]map[string]struct{}),
|
|
completed: make(map[string]int),
|
|
failed: make(map[string]bool),
|
|
}
|
|
}
|
|
pass.discovered[dbPath]++
|
|
if pass.sessions[dbPath] == nil {
|
|
pass.sessions[dbPath] = make(map[string]struct{})
|
|
}
|
|
pass.sessions[dbPath][sessionID] = struct{}{}
|
|
if _, seen := pass.captured[dbPath]; seen || pass.failed[dbPath] {
|
|
continue
|
|
}
|
|
if state, ok := preStates[dbPath]; ok {
|
|
pass.captured[dbPath] = state
|
|
} else {
|
|
pass.failed[dbPath] = true
|
|
}
|
|
}
|
|
if pass != nil {
|
|
for dbPath, pre := range pass.captured {
|
|
if post, ok := parser.StatSQLiteContainerState(dbPath); ok &&
|
|
post == pre {
|
|
continue
|
|
}
|
|
delete(pass.captured, dbPath)
|
|
pass.failed[dbPath] = true
|
|
}
|
|
}
|
|
e.containerMu.Lock()
|
|
e.containerPass = pass
|
|
e.containerMu.Unlock()
|
|
}
|
|
|
|
// sqliteContainerSourceFresh reports whether a discovered file belongs to a
|
|
// container whose current state matches the last fully verified state AND
|
|
// whose session ID was part of that verified pass, in which case the
|
|
// session is unchanged and skips before fingerprinting. The membership
|
|
// check covers hybrid roots, where the discoverable row set can grow (a
|
|
// removed storage JSON stops shadowing its same-ID row) without the
|
|
// container state changing; such a row was never verified and must parse.
|
|
func (e *Engine) sqliteContainerSourceFresh(file parser.DiscoveredFile) bool {
|
|
if e.forceParse || file.ForceParse {
|
|
return false
|
|
}
|
|
dbPath, sessionID, ok := sqliteContainerSourceForFile(file)
|
|
if !ok {
|
|
return false
|
|
}
|
|
e.containerMu.Lock()
|
|
defer e.containerMu.Unlock()
|
|
if e.containerPass == nil {
|
|
return false
|
|
}
|
|
current, ok := e.containerPass.captured[dbPath]
|
|
if !ok {
|
|
return false
|
|
}
|
|
trusted, ok := e.trustedSQLiteContainers[dbPath]
|
|
if !ok || current != trusted.state {
|
|
return false
|
|
}
|
|
_, verified := trusted.sessions[sessionID]
|
|
return verified
|
|
}
|
|
|
|
// noteSQLiteContainerResult records a processed file's outcome for
|
|
// promotion bookkeeping. Skips count as completions: a skipped session was
|
|
// either gate-skipped against an already-trusted state or individually
|
|
// verified fresh.
|
|
func (e *Engine) noteSQLiteContainerResult(path string, ok bool) {
|
|
e.containerMu.Lock()
|
|
defer e.containerMu.Unlock()
|
|
pass := e.containerPass
|
|
if pass == nil {
|
|
return
|
|
}
|
|
dbPath := sqliteContainerPathForResultPath(path)
|
|
if dbPath == "" {
|
|
return
|
|
}
|
|
if ok {
|
|
pass.completed[dbPath]++
|
|
} else {
|
|
pass.failed[dbPath] = true
|
|
}
|
|
}
|
|
|
|
// poisonSQLiteContainerPass blocks every promotion for the current pass.
|
|
// Used when a batched DB write fails, because batch failures cannot be
|
|
// attributed to individual sessions.
|
|
func (e *Engine) poisonSQLiteContainerPass() {
|
|
e.containerMu.Lock()
|
|
defer e.containerMu.Unlock()
|
|
if e.containerPass != nil {
|
|
e.containerPass.poisoned = true
|
|
}
|
|
}
|
|
|
|
// finishSQLiteContainerPass promotes the pass's captured container states
|
|
// to trusted for every container whose discovered sessions all completed
|
|
// without errors, retries, or write failures. incomplete marks passes that
|
|
// must never promote (aborted, cancelled, or discovery failures whose
|
|
// provider cannot be attributed).
|
|
//
|
|
// fullDiscovery marks passes whose discovery covered every configured
|
|
// root (full syncs, as opposed to changed-path or scoped-root passes).
|
|
// Such a pass is authoritative for which rows are discoverable, so a
|
|
// trusted container it discovered no sources for — fully shadowed by
|
|
// storage JSONs, or gone — loses its trusted entry: the entry's session
|
|
// set is no longer being maintained, and stale membership would otherwise
|
|
// gate-skip a row re-exposed later by a storage removal that leaves the
|
|
// DB untouched.
|
|
func (e *Engine) finishSQLiteContainerPass(incomplete, fullDiscovery bool) {
|
|
e.containerMu.Lock()
|
|
defer e.containerMu.Unlock()
|
|
pass := e.containerPass
|
|
e.containerPass = nil
|
|
if incomplete {
|
|
return
|
|
}
|
|
if fullDiscovery {
|
|
for dbPath := range e.trustedSQLiteContainers {
|
|
if pass == nil || pass.discovered[dbPath] == 0 {
|
|
delete(e.trustedSQLiteContainers, dbPath)
|
|
}
|
|
}
|
|
}
|
|
if pass == nil || pass.poisoned {
|
|
return
|
|
}
|
|
for dbPath, state := range pass.captured {
|
|
if pass.failed[dbPath] {
|
|
continue
|
|
}
|
|
if pass.completed[dbPath] != pass.discovered[dbPath] {
|
|
continue
|
|
}
|
|
if e.trustedSQLiteContainers == nil {
|
|
e.trustedSQLiteContainers =
|
|
make(map[string]trustedSQLiteContainer)
|
|
}
|
|
e.trustedSQLiteContainers[dbPath] = trustedSQLiteContainer{
|
|
state: state,
|
|
sessions: pass.sessions[dbPath],
|
|
}
|
|
}
|
|
}
|
|
|
|
// dropTrustedSQLiteContainerSessionForStorage removes a processed storage
|
|
// session's ID from its root's trusted container membership. From the
|
|
// moment a file-backed storage session is processed, the archive's
|
|
// canonical copy for that ID is the storage one, so a same-ID SQLite row —
|
|
// even under an unchanged container state — no longer matches what its
|
|
// membership verified. Without this, a storage JSON that arrives and
|
|
// disappears entirely between full passes (both legs via watcher
|
|
// changed-path syncs, which never re-promote containers) would leave the
|
|
// stale membership in place, and the re-exposed row would gate-skip
|
|
// forever while the archive kept the interim storage copy. The next fully
|
|
// verified pass re-adds the ID once the row is actually re-verified.
|
|
func (e *Engine) dropTrustedSQLiteContainerSessionForStorage(
|
|
agent parser.AgentType, sessionPath string,
|
|
) {
|
|
// sessionPath is root/storage/<sessionSubdir>/<project>/<id>.json,
|
|
// mirroring the root derivation in StatOpenCodeStorageSessionState.
|
|
sessionID := strings.TrimSuffix(filepath.Base(sessionPath), ".json")
|
|
if sessionID == "" {
|
|
return
|
|
}
|
|
root := filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(sessionPath))))
|
|
src := resolveOpenCodeFormatSource(agent, root)
|
|
if src.DBPath == "" {
|
|
return
|
|
}
|
|
e.containerMu.Lock()
|
|
defer e.containerMu.Unlock()
|
|
if trusted, ok := e.trustedSQLiteContainers[src.DBPath]; ok {
|
|
delete(trusted.sessions, sessionID)
|
|
}
|
|
}
|
|
|
|
// clearTrustedSQLiteContainers drops every trusted container state. Called
|
|
// by resync, which rebuilds the archive from scratch and must re-verify
|
|
// every session against it.
|
|
func (e *Engine) clearTrustedSQLiteContainers() {
|
|
e.containerMu.Lock()
|
|
defer e.containerMu.Unlock()
|
|
e.trustedSQLiteContainers = nil
|
|
e.containerPass = nil
|
|
}
|