A unit may be user-scoped: applied through the account's own manager (hq ADR 0177)

A workstation's per-user daemons — a window manager's reload watcher, an
audio mask, a memory guard — are units in the operator account's own service
manager, and until now had no form the mesh could send (to-be 29). The
`service` shape gains `scope` ("system", the default, or "user") and `user`
(the account, named ${machine:account} by a module); a user-scoped unit
without an account, or a system unit naming one, is refused at parse.

The host reaches the account's manager as `systemctl --user --machine=<account>@`
from its own process: no environment to forge, no user to switch to. Done on
the runner rather than per system, since every system's reading of a unit
already goes through systemctl. Apply, reflect-only and removal all go through
the same manager, and the applied record carries scope and user so removal
gives the unit back to the manager it came from. It answers only while that
manager runs — a login, or lingering enabled for the account; declaring
lingering is a follow-up.

Tests: a user-scoped unit is started and enabled in the account's manager and
recorded with its scope; a system unit never sees --user; the validation of
scope and user.
This commit is contained in:
jochen
2026-10-02 16:48:14 +02:00
parent 5b73e04192
commit a95c2b6ea9
5 changed files with 194 additions and 0 deletions
+32
View File
@@ -684,6 +684,16 @@ type Service struct {
// declaration that reports success and stops being true at the next power cut.
Boot string `json:"boot,omitempty"`
// Scope is whose service manager the unit belongs to: "system" (absent means system), or
// "user" — the operator account's own manager (novox/hq ADR 0177). A workstation's per-user
// daemons — a window manager's reload watcher, an audio mask, a memory guard — are units in
// that manager, and until this they had no form the mesh could send. A user-scoped unit names
// its User; the host talks to that account's manager and never starts a system unit by the
// same name.
Scope string `json:"scope,omitempty"`
// User is the account whose manager a user-scoped unit lives in. Required with scope "user",
// refused otherwise; a module names it ${machine:account} and never the person.
User string `json:"user,omitempty"`
// RestartOn names resources whose change means this service must be restarted.
//
// Because a running service does not re-read its configuration. Replace the file, find the
@@ -729,11 +739,33 @@ func (s *Service) Identity() string { return s.ID }
func (s *Service) Kind() Type { return TypeService }
func (s *Service) Target() string { return s.Unit }
// ScopeSystem and ScopeUser are the two managers a unit may belong to (novox/hq ADR 0177).
const (
ScopeSystem = "system"
ScopeUser = "user"
)
// UserScoped is whether this unit lives in an account's own service manager.
func (s *Service) UserScoped() bool { return s.Scope == ScopeUser }
func (s *Service) validate(where string, _ bool) []string {
var problems []string
if s.Unit == "" {
problems = append(problems, where+": a service needs a unit")
}
switch s.Scope {
case "", ScopeSystem:
if s.User != "" {
problems = append(problems, where+": a system unit names no user; only a user-scoped unit does")
}
case ScopeUser:
if s.User == "" {
problems = append(problems, where+": a user-scoped unit names the account whose manager it lives in")
}
default:
problems = append(problems, fmt.Sprintf(
"%s: scope %q; a unit is in the \"system\" manager or the operator account's \"user\" one", where, s.Scope))
}
switch {
case s.State == "running" || s.State == "stopped":
case s.State != "":