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
257 lines
9.7 KiB
Go
257 lines
9.7 KiB
Go
// ABOUTME: Per-session freshness gate for OpenCode-family file-backed
|
|
// ABOUTME: storage sessions, skipping re-parse when the tree is unchanged.
|
|
package sync
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"go.kenn.io/agentsview/internal/parser"
|
|
)
|
|
|
|
// File-backed OpenCode-format sessions fan one session JSON plus its
|
|
// message and part files into a single source whose parse re-reads the
|
|
// whole tree. They are deliberately excluded from the mtime-keyed skip
|
|
// cache (see shouldCacheSkip): the cache keys on a composite max-mtime,
|
|
// which cannot see a child rewritten within one filesystem mtime granule
|
|
// or with a restored timestamp. The consequence was that every full sync
|
|
// and unwatched-root poll re-read and re-parsed every storage session of
|
|
// an idle archive, and every watcher event on a streaming session
|
|
// re-parsed its entire history.
|
|
//
|
|
// The gate restores a cheap answer for the unchanged case using
|
|
// parser.StatOpenCodeStorageSessionState, a per-file (name, size, mtimeNS)
|
|
// signature over exactly the files the parse would read. A session is
|
|
// skipped before fingerprinting only when its current signature equals one
|
|
// captured before a parse whose outcome the archive then absorbed: either
|
|
// every parsed result was dropped as already stored, or the results were
|
|
// confirmed fully written by the batch write path. The remaining blind
|
|
// spot — an in-place child rewrite preserving both size and mtime — is
|
|
// covered on the watcher path by invalidating trust for a session whenever
|
|
// a changed-path event resolves to it, so an event-signaled change always
|
|
// re-verifies by content. Trust is in-memory only: a restart re-verifies
|
|
// every session once, mirroring the shared-SQLite container gate.
|
|
|
|
// openCodeStorageSessionPath returns the discovered file's path when it is
|
|
// a file-backed OpenCode-format storage session JSON under a storage-mode
|
|
// root of its agent, or "" otherwise (SQLite containers and virtual rows
|
|
// included). The root-mode walk mirrors shouldCacheSkip, which excludes
|
|
// these same paths from the mtime skip cache.
|
|
func (e *Engine) openCodeStorageSessionPath(file parser.DiscoveredFile) string {
|
|
if !isOpenCodeFormatStorageAgent(file.Agent) {
|
|
return ""
|
|
}
|
|
if filepath.Base(file.Path) == openCodeFormatDBName(file.Agent) {
|
|
return ""
|
|
}
|
|
if isOpenCodeFormatSQLiteVirtualPath(file.Agent, file.Path) {
|
|
return ""
|
|
}
|
|
for _, dir := range e.agentDirs[file.Agent] {
|
|
if dir == "" {
|
|
continue
|
|
}
|
|
src := resolveOpenCodeFormatSource(file.Agent, dir)
|
|
if src.Mode != parser.OpenCodeSourceStorage {
|
|
continue
|
|
}
|
|
rel, ok := isUnder(dir, file.Path)
|
|
if !ok {
|
|
continue
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
sessionPrefix := "storage/" + filepath.Base(src.SessionRoot) + "/"
|
|
if strings.HasPrefix(rel, sessionPrefix) {
|
|
return file.Path
|
|
}
|
|
return ""
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// storageTrustSnapshot marks a point in a session's invalidation history:
|
|
// the trust epoch and the session's invalidation generation at capture
|
|
// time. A promotion carries the snapshot taken before its parse; if either
|
|
// counter has moved by promotion time, an invalidation landed after the
|
|
// capture and the promotion is discarded rather than resurrecting trust
|
|
// the invalidation just revoked.
|
|
type storageTrustSnapshot struct {
|
|
epoch uint64
|
|
gen uint64
|
|
}
|
|
|
|
// storageTrustSnapshotFor records the session's current invalidation
|
|
// coordinates. It must be taken before the stat signature is captured:
|
|
// an invalidation arriving after the snapshot then reliably blocks the
|
|
// promotion, even when the change it signals is invisible to the stat
|
|
// signature (a same-size, same-mtime child rewrite).
|
|
func (e *Engine) storageTrustSnapshotFor(path string) storageTrustSnapshot {
|
|
e.storageTrustMu.Lock()
|
|
defer e.storageTrustMu.Unlock()
|
|
return storageTrustSnapshot{
|
|
epoch: e.storageTrustEpoch,
|
|
gen: e.storageTrustGens[path],
|
|
}
|
|
}
|
|
|
|
// openCodeStorageSessionGateState captures the session's current stat
|
|
// signature for the gate, along with the invalidation snapshot that any
|
|
// later promotion of this capture must present. It returns ok=false when
|
|
// the file is not a gateable storage session, this run force-parses, or
|
|
// the signature cannot be captured; such sessions never skip and never
|
|
// promote.
|
|
func (e *Engine) openCodeStorageSessionGateState(
|
|
file parser.DiscoveredFile,
|
|
) (string, storageTrustSnapshot, bool) {
|
|
if e.forceParse || file.ForceParse {
|
|
return "", storageTrustSnapshot{}, false
|
|
}
|
|
sessionPath := e.openCodeStorageSessionPath(file)
|
|
if sessionPath == "" {
|
|
return "", storageTrustSnapshot{}, false
|
|
}
|
|
snap := e.storageTrustSnapshotFor(sessionPath)
|
|
state, ok := parser.StatOpenCodeStorageSessionState(sessionPath)
|
|
if !ok {
|
|
return "", storageTrustSnapshot{}, false
|
|
}
|
|
return state, snap, true
|
|
}
|
|
|
|
// openCodeStorageSessionFresh reports whether the session's captured stat
|
|
// signature matches the last verified one, in which case its parse inputs
|
|
// are unchanged and it skips before fingerprinting.
|
|
func (e *Engine) openCodeStorageSessionFresh(path, state string) bool {
|
|
if state == "" {
|
|
return false
|
|
}
|
|
e.storageTrustMu.Lock()
|
|
defer e.storageTrustMu.Unlock()
|
|
trusted, ok := e.trustedStorageSessions[path]
|
|
return ok && trusted == state
|
|
}
|
|
|
|
// promoteOpenCodeStorageSession records a stat signature that was captured
|
|
// before a parse whose outcome the archive has since absorbed (results
|
|
// dropped as already stored, or confirmed written). The
|
|
// capture-before-parse ordering makes overlapping writes safe: a write
|
|
// landing between capture and parse leaves the parsed content newer than
|
|
// the signature, so the next capture mismatches and re-verifies. The
|
|
// snapshot check covers the writes that ordering cannot: an invalidation
|
|
// landing between capture and promotion (a watcher event classified while
|
|
// a full sync was mid-pass) bumps the session's generation, so the stale
|
|
// promotion is dropped and the next pass re-verifies by content.
|
|
func (e *Engine) promoteOpenCodeStorageSession(
|
|
path, state string, snap storageTrustSnapshot,
|
|
) {
|
|
if path == "" || state == "" {
|
|
return
|
|
}
|
|
e.storageTrustMu.Lock()
|
|
defer e.storageTrustMu.Unlock()
|
|
if snap.epoch != e.storageTrustEpoch || snap.gen != e.storageTrustGens[path] {
|
|
return
|
|
}
|
|
if e.trustedStorageSessions == nil {
|
|
e.trustedStorageSessions = make(map[string]string)
|
|
}
|
|
e.trustedStorageSessions[path] = state
|
|
}
|
|
|
|
// stageOpenCodeStorageTrust decides what a finished parse means for the
|
|
// session's gate trust. A complete result set with no exclusions or
|
|
// deferred retries either re-verified the session (every result dropped as
|
|
// already stored — promote immediately) or produced content headed for the
|
|
// write batch (stage the state on the result; the write path promotes it
|
|
// once the batch is confirmed fully written). Anything else — retries,
|
|
// exclusions, policy-suppressed results — invalidates so the next pass
|
|
// re-verifies by content.
|
|
func (e *Engine) stageOpenCodeStorageTrust(
|
|
res *processResult,
|
|
path, state string,
|
|
snap storageTrustSnapshot,
|
|
parsedCount int,
|
|
resultSetComplete bool,
|
|
) {
|
|
clean := resultSetComplete &&
|
|
parsedCount > 0 &&
|
|
len(res.excludedSessionIDs) == 0 &&
|
|
res.retrySessionIDs == nil
|
|
if clean && len(res.results) == 0 {
|
|
e.promoteOpenCodeStorageSession(path, state, snap)
|
|
return
|
|
}
|
|
e.dropOpenCodeStorageTrust(path)
|
|
if clean {
|
|
res.storageTrustPath = path
|
|
res.storageTrustState = state
|
|
res.storageTrustSnap = snap
|
|
}
|
|
}
|
|
|
|
// promoteOpenCodeStorageTrustAfterWrite promotes the staged trust of every
|
|
// session in a written batch, but only when the batch verifiably persisted
|
|
// all of it: any failed, cwd-vetoed, or otherwise unwritten session leaves
|
|
// the whole batch unpromoted (write failures are not attributable to
|
|
// individual sessions), so those sessions re-verify on the next pass.
|
|
func (e *Engine) promoteOpenCodeStorageTrustAfterWrite(
|
|
batch []pendingWrite,
|
|
writtenSessions, failedWrites, cwdFiltered int,
|
|
) {
|
|
if failedWrites > 0 || cwdFiltered > 0 ||
|
|
writtenSessions != len(batch) {
|
|
return
|
|
}
|
|
for _, pw := range batch {
|
|
e.promoteOpenCodeStorageSession(
|
|
pw.storageTrustPath, pw.storageTrustState, pw.storageTrustSnap,
|
|
)
|
|
}
|
|
}
|
|
|
|
// dropOpenCodeStorageTrust removes the session's trusted signature without
|
|
// bumping its invalidation generation. Used by the pass that owns the
|
|
// session's staged promotion (results in flight to the write batch, or an
|
|
// unclean parse), so the drop does not veto that same pass's promotion.
|
|
func (e *Engine) dropOpenCodeStorageTrust(path string) {
|
|
if path == "" {
|
|
return
|
|
}
|
|
e.storageTrustMu.Lock()
|
|
defer e.storageTrustMu.Unlock()
|
|
delete(e.trustedStorageSessions, path)
|
|
}
|
|
|
|
// invalidateOpenCodeStorageSession drops trust for one session and bumps
|
|
// its invalidation generation. Called when a watcher changed-path event
|
|
// resolves to the session: the event says something changed even if the
|
|
// stat signature cannot see it, so the next pass must re-verify by
|
|
// content. The generation bump extends that guarantee across a
|
|
// concurrently running pass — a promotion captured before the event
|
|
// presents a stale snapshot and is discarded, so it cannot restore the
|
|
// trust this invalidation revoked.
|
|
func (e *Engine) invalidateOpenCodeStorageSession(path string) {
|
|
if path == "" {
|
|
return
|
|
}
|
|
e.storageTrustMu.Lock()
|
|
defer e.storageTrustMu.Unlock()
|
|
delete(e.trustedStorageSessions, path)
|
|
if e.storageTrustGens == nil {
|
|
e.storageTrustGens = make(map[string]uint64)
|
|
}
|
|
e.storageTrustGens[path]++
|
|
}
|
|
|
|
// clearTrustedOpenCodeStorageSessions drops every trusted session state
|
|
// and starts a new trust epoch, so promotions staged before the clear are
|
|
// discarded. Called by resync, which rebuilds the archive from scratch and
|
|
// must re-verify every session against it.
|
|
func (e *Engine) clearTrustedOpenCodeStorageSessions() {
|
|
e.storageTrustMu.Lock()
|
|
defer e.storageTrustMu.Unlock()
|
|
e.trustedStorageSessions = nil
|
|
e.storageTrustGens = nil
|
|
e.storageTrustEpoch++
|
|
}
|