From 49cf0aa562de15c60e10dc3f3a77a3332631a85a Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 15:59:17 +0200 Subject: [PATCH] node-power: the power seat, and code for its moments placed by its holder (hq ADR 0211) A module that needs code after waking wrote into the service manager's sleep units; it now contributes shell code for a named moment, which derives a dependency on node-power. --- .../contribution_dependencies_test.go | 21 ++++++++++++++ internal/catalogue/environment_into.go | 29 +++++++++++++++---- internal/catalogue/seats.go | 4 +++ internal/catalogue/seats_test.go | 7 +++-- 4 files changed, 52 insertions(+), 9 deletions(-) diff --git a/internal/catalogue/contribution_dependencies_test.go b/internal/catalogue/contribution_dependencies_test.go index 5c93242..a1dc1b1 100644 --- a/internal/catalogue/contribution_dependencies_test.go +++ b/internal/catalogue/contribution_dependencies_test.go @@ -98,3 +98,24 @@ func TestTwoModulesDeclaringOnePackageAreRefusedBeforeAnythingIsRecorded(t *test t.Errorf("an unrelated assignment is refused for a collision already there: %v", err) } } + +func TestCodeForAPowerMomentDependsOnThePowerSeat(t *testing.T) { + for _, moment := range powerMoments { + m := mod("laptop", nil, nil, nil) + m.Shell = []ShellCode{{For: moment, Slot: "normal", Code: "true"}} + if got := DependsOn(m); !reflect.DeepEqual(got, []string{PowerSeat}) { + t.Errorf("code for %s depends on %v, want %s", moment, got, PowerSeat) + } + if p := m.shellProblems(); len(p) != 0 { + t.Errorf("code for %s is refused: %v", moment, p) + } + } + m := mod("laptop", nil, nil, nil) + m.Shell = []ShellCode{{For: "after-lunch", Slot: "normal", Code: "true"}} + if p := m.shellProblems(); len(p) == 0 { + t.Error("code for a moment that does not exist was accepted") + } + if s, ok := SeatNamed(PowerSeat); !ok || s.Scope != ScopeNode { + t.Errorf("%s is not a node seat of the mesh's own: %+v %v", PowerSeat, s, ok) + } +} diff --git a/internal/catalogue/environment_into.go b/internal/catalogue/environment_into.go index 13216da..d457cd8 100644 --- a/internal/catalogue/environment_into.go +++ b/internal/catalogue/environment_into.go @@ -29,6 +29,8 @@ import ( const ( EnvironmentSeat = "node-environment" LoginShellSeat = "node-login-shell" + // PowerSeat is the seat whose holder places code for the power moments (novox/hq ADR 0211). + PowerSeat = "node-power" ) // Where an environment entry on PATH goes: before the account's existing PATH, or after it. @@ -73,11 +75,15 @@ var ( // code the session's start runs, `xresources` X resources merged at its start. Placed by the // display server's holder, as a shell's slots are placed by the login shell's. sessionFiles = []string{"xinitrc", "xresources"} + // powerMoments are the moments of a machine's power a module may run code at (novox/hq ADR + // 0211 §3): POSIX code run as root by node-power's holder, in module order, each piece bounded. + powerMoments = []string{"after-boot", "before-sleep", "after-wake", "before-shutdown", "on-mains", "on-battery"} ) // contributionTargets is every name a contribution's `for` may take. func contributionTargets() []string { - return append(append([]string(nil), knownShells...), sessionFiles...) + out := append(append([]string(nil), knownShells...), sessionFiles...) + return append(out, powerMoments...) } // placerOf is the seat whose holder places a contribution for this target (novox/hq ADR 0204, @@ -86,6 +92,9 @@ func placerOf(target string) string { if oneOf(sessionFiles, target) { return DisplayServerSeat } + if oneOf(powerMoments, target) { + return PowerSeat + } return LoginShellSeat } @@ -191,8 +200,9 @@ func (m Manifest) shellProblems() []string { for i, c := range m.Shell { if !oneOf(contributionTargets(), c.For) { problems = append(problems, fmt.Sprintf( - "%s's shell code %d is for %q; the shells are %s, and the session's files %s", - m.Module, i+1, c.For, strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "))) + "%s's shell code %d is for %q; the shells are %s, the session's files %s, and the power "+ + "moments %s", m.Module, i+1, c.For, strings.Join(knownShells, ", "), + strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", "))) } if !oneOf(knownSlots, c.Slot) { problems = append(problems, fmt.Sprintf( @@ -265,9 +275,9 @@ func placeholderProblems(m Manifest, r map[string]any) []string { if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) { problems = append(problems, fmt.Sprintf( "%s's resource %v names %s; shell code is ${shell::}, the shell one of "+ - "%s or the session's file one of %s, and the slot one of %s", m.Module, r["id"], c[0], - strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "), - strings.Join(knownSlots, ", "))) + "%s, the session's file one of %s or the power moment one of %s, and the slot one of %s", + m.Module, r["id"], c[0], strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "), + strings.Join(powerMoments, ", "), strings.Join(knownSlots, ", "))) continue } seat := placerOf(target) @@ -275,6 +285,13 @@ func placeholderProblems(m Manifest, r map[string]any) []string { continue } refusedFor[seat] = true + if seat == PowerSeat { + problems = append(problems, fmt.Sprintf( + "%s's resource %v names %s and %s does not claim %s; every module's code for a power "+ + "moment is placed by the power seat's holder alone (novox/hq ADR 0211)", + m.Module, r["id"], c[0], m.Module, seat)) + continue + } if seat == DisplayServerSeat { problems = append(problems, fmt.Sprintf( "%s's resource %v names %s and %s does not claim %s; every module's %s is placed by "+ diff --git a/internal/catalogue/seats.go b/internal/catalogue/seats.go index 02d177c..ab7f517 100644 --- a/internal/catalogue/seats.go +++ b/internal/catalogue/seats.go @@ -172,6 +172,10 @@ var defaultSeats = append([]Seat{ // node may call; the holder places every module's shell code in its slots. {Name: LoginShellSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0204", Serves: loginShellVerbs()}, + // A machine's power (novox/hq ADR 0211): its holder owns logind's power handling, places the + // code modules contribute for the power moments, and publishes the machine's power states as + // its events. Every machine has one — every machine boots and shuts down. No verbs yet. + {Name: PowerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0211"}, // Deferred (novox/hq ADR 0121): renaming to mesh-private-network is a scope + server/client // model change, not a rename, so it stays until that is built. {Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"}, diff --git a/internal/catalogue/seats_test.go b/internal/catalogue/seats_test.go index cc06a02..1852a0d 100644 --- a/internal/catalogue/seats_test.go +++ b/internal/catalogue/seats_test.go @@ -46,12 +46,13 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { delivered[s.Delivers] = s.Name } } - // Thirty-two since the graphical session's eleven (novox/hq ADR 0208); twenty-one with + // Thirty-three with node-power (novox/hq ADR 0211); thirty-two since the graphical session's + // eleven (ADR 0208); twenty-one with // node-package-manager and node-container-runtime (ADR 0207); nineteen with node-environment and // node-login-shell (ADR 0203, ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer // once the retired mesh-build-machine row goes, when no registered manifest claims it any more. - if len(Seats()) != 32 { - t.Errorf("the mesh defines %d seats rather than 32; the set is closed, so a change here is "+ + if len(Seats()) != 33 { + t.Errorf("the mesh defines %d seats rather than 33; the set is closed, so a change here is "+ "a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames()) } }