summaryrefslogtreecommitdiff
path: root/internal/engine/session.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 00:57:03 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-16 00:57:03 +0200
commitd6a280d87a274abc8d9cab9956d2af1c845f83d1 (patch)
tree3b3790c3ddb906147ccc35fd7a284efdf616f8c8 /internal/engine/session.go
parent86e55e31f6905ae997619aa095706674e2ffe623 (diff)
downloadkrino-d6a280d87a274abc8d9cab9956d2af1c845f83d1.tar.gz
krino-d6a280d87a274abc8d9cab9956d2af1c845f83d1.zip
the engine owns a run: lock, log, run id, claims
Diffstat (limited to 'internal/engine/session.go')
-rw-r--r--internal/engine/session.go147
1 files changed, 147 insertions, 0 deletions
diff --git a/internal/engine/session.go b/internal/engine/session.go
new file mode 100644
index 0000000..8d52027
--- /dev/null
+++ b/internal/engine/session.go
@@ -0,0 +1,147 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package engine
+
+import (
+ "context"
+ "fmt"
+
+ "krino/internal/journal"
+ "krino/internal/lock"
+ "krino/internal/plan"
+)
+
+// Session is one run of krino over one or more directories: the log it
+// writes, the run id every entry carries, and the claims its directories
+// share. Both the command line and the GUI go through it, so the order a
+// directory is locked, planned, applied and released in - and what a later
+// directory may still claim - is written once (GUI design §1.3).
+//
+// A dry session opens no log and takes no run id: journal.Open creates the
+// state directory and an empty krino.log merely by being called, and a dry
+// run must not (spec §11).
+type Session struct {
+ e *Engine
+ j *journal.Writer
+ run string
+ claims *plan.Claims
+ dry bool
+}
+
+// NewSession starts a run. A real one opens the log; the caller closes the
+// session when the run is over.
+func (e *Engine) NewSession(dry bool) (*Session, error) {
+ s := &Session{e: e, claims: plan.NewClaims(), dry: dry}
+ if dry {
+ return s, nil
+ }
+ j, err := journal.Open(e.Config.LogFile())
+ if err != nil {
+ return nil, err
+ }
+ s.j, s.run = j, journal.NewRunID(e.Now())
+ return s, nil
+}
+
+// Run is the run id every entry of this session carries; "" for a dry one.
+func (s *Session) Run() string { return s.run }
+
+// Journal is the log this session writes, nil for a dry one.
+func (s *Session) Journal() *journal.Writer { return s.j }
+
+// Lock takes d's lock (spec §3, §11). wait blocks until the holder is gone,
+// or until ctx is cancelled; without it a held lock returns lock.ErrHeld at
+// once, so a cron job never piles up behind a stuck run. The caller
+// releases it.
+func (s *Session) Lock(ctx context.Context, d *Dir, wait bool) (*lock.Lock, error) {
+ return lock.Acquire(ctx, s.e.Config.LockFile(d.Name), wait)
+}
+
+// LockDirs takes the locks of several directories, in the order given, and
+// releases every one it took if any of them cannot be had - so an undo
+// spanning directories never holds half of them (spec §10).
+func (s *Session) LockDirs(ctx context.Context, names []string, wait bool) ([]*lock.Lock, error) {
+ var held []*lock.Lock
+ for _, name := range names {
+ l, err := lock.Acquire(ctx, s.e.Config.LockFile(name), wait)
+ if err != nil {
+ for _, h := range held {
+ h.Release()
+ }
+ return nil, fmt.Errorf("%s: %w", name, err)
+ }
+ held = append(held, l)
+ }
+ return held, nil
+}
+
+// Plan builds d's plan with the run's claims.
+func (s *Session) Plan(ctx context.Context, d *Dir) (*DirPlan, error) {
+ return s.e.Plan(ctx, d, s.claims)
+}
+
+// Apply carries out the approved files of dp and logs the run's steps. A
+// real run applies each directory before the next is planned, so afterwards
+// the disk is the truth for the next one: only the paths this directory's
+// files ended up at stay claimed, which keeps a later (on-conflict
+// overwrite) from displacing this run's own result (spec §7.4). A dry
+// session keeps every claim, since it applies nothing.
+func (s *Session) Apply(ctx context.Context, dp *DirPlan, approved map[string]bool) (*ApplyResult, error) {
+ res, err := s.e.Apply(ctx, dp, approved, s.j, s.run)
+ if !s.dry {
+ s.claims = plan.NewClaims()
+ for _, p := range landedAt(res) {
+ s.claims.Claim(p)
+ }
+ }
+ return res, err
+}
+
+// landedAt is where res's files ended up: each copy, and the last place a
+// move or rename put a file - not a path it passed through and left, and
+// nothing at all for a file deleted for good.
+func landedAt(res *ApplyResult) []string {
+ if res == nil {
+ return nil
+ }
+ var out []string
+ for _, fr := range res.Files {
+ final := ""
+ for _, sr := range fr.Steps {
+ switch {
+ case sr.Status != "ok":
+ case sr.Step.Kind == plan.DeletePermanent:
+ final = ""
+ case sr.Dst == "":
+ case sr.Step.Kind == plan.Copy:
+ out = append(out, sr.Dst)
+ default:
+ final = sr.Dst
+ }
+ }
+ if final != "" {
+ out = append(out, final)
+ }
+ }
+ return out
+}
+
+// PlanUndo builds the reversal of runID (spec §10).
+func (s *Session) PlanUndo(runID string) (*UndoPlan, error) {
+ return s.e.PlanUndo(runID)
+}
+
+// ApplyUndo carries out up and logs it under this session's run id.
+func (s *Session) ApplyUndo(ctx context.Context, up *UndoPlan) (*ApplyResult, error) {
+ return s.e.ApplyUndo(ctx, up, s.j, s.run)
+}
+
+// Close closes the log. A dry session has nothing to close.
+func (s *Session) Close() error {
+ if s.j == nil {
+ return nil
+ }
+ err := s.j.Close()
+ s.j = nil
+ return err
+}