ZDDC/zddc/internal/archive/watcher.go
ZDDC 9fce18cd45 feat: lockstep release infra + cascade/.archive fixes + profile perf + page redesign
Four entangled change-sets from one session, committed together because
their file-level overlap (build.sh, docs, embedded/, watcher.go, …) makes
post-hoc separation noisy:

* fix(archive): nested-party + folder-type cascade
  transmittalIsUnderVisibleParty short-circuited on the first matched
  party segment, only checking the immediately-next segment for a
  folder-type marker. Paths like BM/sub/Issued/<txn> bypassed the Issued
  toggle entirely. Replaced with isUnderHiddenFolderType (full-path) +
  any-segment party match. Eight new Playwright cases pin the contract
  in tests/archive-cascade.spec.js.

* refactor(zddc-server): scope .archive index by project
  archive.Index now buckets by top-level segment
  (.ByProject[<project>].ByTracking[<tracking>]). Resolve and AllEntries
  take a project parameter; handler extracts it from contextPath's first
  segment. /.archive/ at root returns 404 — stable refs must be
  project-rooted. Within-project (tracking, rev) collisions emit a WARN
  with both paths. Cross-project tracking-number duplicates no longer
  collide.

* perf(zddc-server): lazy-load expensive bits of the profile page
  serveProfilePage now ships a minimal shell: Email, EmailHeader,
  IsSuperAdmin (root .zddc only). Visible projects + admin subtrees +
  editable scaffolds populate client-side via /.profile/access. Subtree-
  admin scaffolds live in <template id="tmpl-subtree-admin">; pure
  non-admins receive no live admin form. ScanZddcFiles now memoized,
  invalidated on .zddc events by the watcher and writer helpers.

* feat: lockstep release + redesigned releases page
  sh build.sh --release [version|alpha|beta] is the canonical lockstep
  cut: every tool (5 HTML + zddc-server) bumps to the same coordinated
  version. zddc-server binaries now committed under website/releases/
  with the same cascade chain as HTML tools (no more Codeberg release-
  asset publication). zddc/release.sh deprecated (kept as a guard);
  shared/publish-codeberg-release.sh removed.

  Releases page redesigned as an action-first install guide: hero +
  version dropdown that rewires every download link, channel chips for
  always-visible alpha/beta access (state-aware labels: "tracks stable"
  vs "active dev"), Path A (zddc-server with platform auto-detect from
  UA), Path B (5 standalone tool HTMLs), version-pinning empowerment
  narrative (drop-a-copy vs .zddc apps: cascade), channels explainer.

  Channel-link verifier asserts every <tool>_{stable,beta,alpha}.html
  resolves at the end of every build. Bootstrap-friendly: zddc-server
  artifact checks skip until the first lockstep cut anchors the chain.

Tests: 167 Playwright + all Go packages green.
Docs: CLAUDE.md, AGENTS.md, ARCHITECTURE.md, zddc/README.md updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:11:38 -05:00

136 lines
3 KiB
Go

package archive
import (
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
"github.com/fsnotify/fsnotify"
)
// Watcher watches fsRoot for filesystem changes and updates the archive index
// and the zddc ACL policy cache.
type Watcher struct {
fsRoot string
idx *Index
watcher *fsnotify.Watcher
// Debounce: pending dir → timer
mu sync.Mutex
pending map[string]*time.Timer
}
// NewWatcher creates a new Watcher. Call Start to begin watching.
func NewWatcher(fsRoot string, idx *Index) (*Watcher, error) {
w, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
return &Watcher{
fsRoot: fsRoot,
idx: idx,
watcher: w,
pending: make(map[string]*time.Timer),
}, nil
}
// Start begins watching and blocks until ctx is cancelled.
// It registers the entire directory tree under fsRoot.
func (w *Watcher) Start(ctx context.Context) error {
// Walk and register all directories
if err := filepath.WalkDir(w.fsRoot, func(path string, d os.DirEntry, err error) error {
if err != nil || !d.IsDir() {
return nil
}
return w.watcher.Add(path)
}); err != nil {
return err
}
go func() {
defer w.watcher.Close()
for {
select {
case <-ctx.Done():
return
case event, ok := <-w.watcher.Events:
if !ok {
return
}
w.handleEvent(event)
case err, ok := <-w.watcher.Errors:
if !ok {
return
}
slog.Warn("fsnotify error", "err", err)
}
}
}()
<-ctx.Done()
return nil
}
func (w *Watcher) handleEvent(event fsnotify.Event) {
path := event.Name
base := filepath.Base(path)
// New directory created — register it
if event.Has(fsnotify.Create) {
if info, err := os.Stat(path); err == nil && info.IsDir() {
_ = w.watcher.Add(path)
}
}
// .zddc file changed — invalidate ACL policy cache for the chain rooted
// here AND the global scan cache (which lists every directory containing
// a .zddc file). The scan cache is fsRoot-keyed and only changes when
// .zddc files are added/removed, but distinguishing modify from
// create/remove in fsnotify is fragile so we just always invalidate.
if base == ".zddc" {
dir := filepath.Dir(path)
zddc.InvalidateCache(dir)
zddc.InvalidateScanCache()
return
}
// Skip dot-files
if strings.HasPrefix(base, ".") {
return
}
// For transmittal folder events, schedule a debounced index update
dirPath := filepath.Dir(path)
dirName := filepath.Base(dirPath)
if transmittalFolderRE.MatchString(dirName) {
w.scheduleIndexUpdate(dirPath)
}
}
// scheduleIndexUpdate debounces an index update for dirPath (2-second delay).
func (w *Watcher) scheduleIndexUpdate(dirPath string) {
w.mu.Lock()
defer w.mu.Unlock()
if t, ok := w.pending[dirPath]; ok {
t.Reset(2 * time.Second)
return
}
w.pending[dirPath] = time.AfterFunc(2*time.Second, func() {
w.mu.Lock()
delete(w.pending, dirPath)
w.mu.Unlock()
if err := w.idx.UpdateFromDir(w.fsRoot, dirPath); err != nil {
slog.Warn("archive index update failed", "dir", dirPath, "err", err)
}
})
}