Documentation
¶
Overview ¶
Package multiui manages a registry of cloop projects for the multi-project web UI dashboard. The registry is persisted at <root>/projects.json, where root is $CLOOP_HOME when set and $HOME/.cloop otherwise — see Root.
Index ¶
- Constants
- func AddPaths(paths []string) error
- func AddPathsOwned(paths []string, owner string) error
- func AdmitOwned(path, owner string, admit func(existing []ProjectEntry) error) error
- func AllCloopRunPIDs() []int
- func CloopRunPIDsInDir(dir string) []int
- func IsCloopRunningInDir(dir string) bool
- func IsCloopRunningUnder(dir string) bool
- func RemovePath(path string) error
- func Root() (string, error)
- func Save(projects []ProjectEntry) error
- func Scan(dir string) ([]string, error)
- func SetHidden(path, viewer string, hidden bool) error
- type AggregateStats
- type FeatureStatus
- type Health
- type HubProcess
- type ProjectEntry
- type ProjectStatus
- type RunningDirs
Constants ¶
const EnvRoot = "CLOOP_HOME"
EnvRoot names the environment variable that relocates the directory this registry lives in, which would otherwise be $HOME/.cloop. It follows the CARGO_HOME convention: the variable holds the directory itself, not the home that contains it.
Scope note: only this package consults it. The other things under ~/.cloop — profiles, plugins, the executor agent credential — still resolve from $HOME directly, so setting this alone does not relocate them. Redirect $HOME for that. docs/reference/configuration.md states the same thing for operators.
It exists because this registry is process-global mutable state living at a fixed path outside the working tree. Without a seam, any test that reached AddPaths wrote into the developer's real ~/.cloop/projects.json and stayed there. That is not hypothetical: a single test in pkg/ui accumulated 99 entries into the real registry on a development host, at which point project index 99 resolved to a deleted /tmp directory and three unrelated authorization tests began failing on every run. Ephemeral CI runners always start from an empty HOME, so the pipeline stayed green while every developer went red — and the failures surfaced as authorization assertions, which invites "fixing" the assertion instead of the leak.
Variables ¶
This section is empty.
Functions ¶
func AddPaths ¶
AddPaths appends new paths to the registry, avoiding duplicates, and saves. The load-mutate-save sequence runs under registryMu so two concurrent in-process callers cannot each load the same baseline and then race to overwrite each other's additions.
func AddPathsOwned ¶
AddPathsOwned is AddPaths with an ownership stamp: entries created by this call record owner (an oidcauth OwnerKey; "" = unowned/shared). Existing entries keep their current owner — registering an already-known path never re-assigns it.
func AdmitOwned ¶ added in v0.0.5
func AdmitOwned(path, owner string, admit func(existing []ProjectEntry) error) error
AdmitOwned registers one project owned by owner, after admit has accepted its path against the registry as it stands under the registry's own lock (Task 20418). admit sees every entry already registered; an error from it refuses the registration and is returned unchanged. A path already registered is not added again, and keeps its owner.
This is the hub's door into the registry: a project created through the dashboard or `cloop remote init` is admitted here, so the confinement check and the write cannot be separated by another writer. AddPaths and AddPathsOwned are the operator's, for paths named on `cloop ui`'s command line, which are registered as given and flagged — on the Projects tab and by `cloop hub doctor` — rather than refused.
func AllCloopRunPIDs ¶
func AllCloopRunPIDs() []int
AllCloopRunPIDs returns the PIDs of every "cloop run" process visible to the current user via /proc. Linux-only; returns nil otherwise. Used by the Web UI's global Stop control which intentionally signals every cloop run on the host (single-project setups have only one).
func CloopRunPIDsInDir ¶
CloopRunPIDsInDir returns the PIDs of all "cloop run" processes whose working directory is dir or a subdirectory of dir. It reads /proc/*/cwd symlinks (Linux only) and returns nil on non-Linux hosts or when /proc is unreadable.
Used by the Web UI's per-project Stop button so that pressing stop on project A signals only the cloop run for A, not every cloop run on the host. The pre-fix handler shelled out to `pkill -f "cloop run"`, which fired SIGINT at *every* cloop run process — a real correctness bug in multi-project mode where a single user click would terminate unrelated projects' runs.
func IsCloopRunningInDir ¶
IsCloopRunningInDir returns true if a "cloop run" process is executing in the project at dir. It reads /proc/*/cwd symlinks (Linux only).
A feature worktree below dir (.cloop/features/<slug>) is a project of its own and does not count — see dirScopeMatch. IsCloopRunningUnder is the variant for the one question where it must: "may this directory tree be deleted?".
func IsCloopRunningUnder ¶ added in v0.0.3
IsCloopRunningUnder reports whether any "cloop run" process is executing in dir or anywhere below it, feature worktrees included. It answers whether removing the directory tree would pull a working directory out from under a live run — which is a question about the tree, not about the project.
func RemovePath ¶
RemovePath drops the entry with the given absolute path from the registry and saves. Removal is by path (not by index) so a concurrent re-order or addition does not cause the wrong project to be deleted. Unknown paths are silently ignored — the registry is the source of truth and a successful no-op delete is the desired outcome when the entry is already gone.
func Root ¶
Root returns cloop's per-user state directory: $CLOOP_HOME when set and non-empty, otherwise $HOME/.cloop.
A relative CLOOP_HOME is resolved against the current working directory, so that a caller cannot silently end up addressing two different directories from two different working directories.
func Save ¶
func Save(projects []ProjectEntry) error
Save writes projects to the registry file atomically: it serialises the JSON, writes it to a sibling tmp file, fsyncs, and renames into place. A crash or disk-full mid-write therefore leaves the previous registry intact instead of a half-written / zero-byte projects.json that future loads would refuse.
func Scan ¶
Scan walks a parent directory one level deep and returns all subdirectories (including the directory itself) that contain a .cloop/ folder.
func SetHidden ¶
SetHidden records (hidden=true) or clears (hidden=false) viewer's wish to keep the project at path off their dashboard, and saves the registry.
viewer is an opaque per-user key chosen by the caller — pkg/ui passes the OIDC OwnerKey, or a fixed single-user sentinel when OIDC is disabled. This package deliberately does not know how that key is derived.
Addressing is by path, not by index, for the same reason RemovePath is: an index is only meaningful relative to a list that a concurrent add or re-order can shift underneath the request.
A path that is not yet registered is added rather than rejected. Projects reach the dashboard from three places — the registry, --projects, and the directory the server was launched in — and only the first is persistent. Refusing here would make "hide" fail for the other two with an error about registration that says nothing about what the user asked for.
Types ¶
type AggregateStats ¶
type AggregateStats struct {
TotalProjects int `json:"total_projects"`
ActiveRuns int `json:"active_runs"`
TotalTasks int `json:"total_tasks"`
DoneTasks int `json:"done_tasks"`
FailedTasks int `json:"failed_tasks"`
TotalSteps int `json:"total_steps"`
}
AggregateStats holds aggregated metrics across all projects.
func Aggregate ¶
func Aggregate(statuses []ProjectStatus) AggregateStats
Aggregate computes aggregate stats from a slice of project statuses.
Statuses the recipient has hidden are skipped, so the headline counters agree with the cards actually on screen. A "12 projects" total above a grid showing nine would read as a rendering bug.
type FeatureStatus ¶ added in v0.0.3
type FeatureStatus struct {
Slug string `json:"slug"`
Title string `json:"title"`
Branch string `json:"branch"`
Base string `json:"base"`
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by,omitempty"`
AutoPR bool `json:"auto_pr,omitempty"`
PR *feature.PR `json:"pr,omitempty"`
// AutoEvolve and Innovate are the feature's own run settings, shown on
// its row in the parent's Features panel so the parallel lines of work
// can be told apart at a glance.
AutoEvolve bool `json:"auto_evolve,omitempty"`
Innovate bool `json:"innovate,omitempty"`
// Return is what became of the last isolated run's work — a conflict
// is what the dashboard flags (Task 20367).
Return *feature.Return `json:"return,omitempty"`
}
FeatureStatus is what the dashboard shows about a feature beyond its project status: which branch it is, what it will be proposed into, and the pull request once there is one.
type HubProcess ¶ added in v0.0.5
type HubProcess struct {
PID int
// Dir is the process's working directory: the directory whose control
// plane it serves.
Dir string
}
HubProcess is a `cloop ui` process on this host.
func HubProcesses ¶ added in v0.0.5
func HubProcesses() []HubProcess
HubProcesses lists the `cloop ui` processes running on this host, read from /proc the way runs are (Linux only; nil elsewhere or when /proc cannot be read).
A hub keeps the runs it dispatched in its control plane, which lives in its own working directory rather than in the project's (Task 20374). A process that is not a hub can find those control planes only through the hubs themselves, and this is how.
type ProjectEntry ¶
type ProjectEntry struct {
Name string `json:"name"`
Path string `json:"path"`
// Owner records which dashboard user created/owns this project when the
// Web UI runs with OIDC authentication enabled. The value is the
// oidcauth.Identity.OwnerKey() — lowercased email, or "sub:<subject>"
// when the IdP supplies no email. Empty means unowned/shared: the
// project is visible to every authenticated user. The field is ignored
// entirely when OIDC is disabled.
Owner string `json:"owner,omitempty"`
// HiddenFor lists the viewers who have hidden this project from their
// dashboard. Values are viewer keys in the same namespace as Owner (see
// SetHidden).
//
// This is a per-viewer set rather than a single bool because the
// registry is one file shared by every user of a hub. A global flag
// would mean one user decluttering their own dashboard blanks the
// project out of everyone else's — the same tenancy mistake a global
// task list would be.
//
// Hiding is presentation only. It is deliberately *not* access control:
// a hidden project stays in the caller's project list (flagged via
// ProjectStatus.Hidden) so index-addressed routes keep resolving to the
// same project, and so the settings panel can offer it back. Owner
// governs who may see a project at all; this governs who wants to.
HiddenFor []string `json:"hidden_for,omitempty"`
// Parent is set on an entry that is a feature of another project (Task
// 20341): the parent project's path. Feature entries are never stored in
// the registry — the dashboard discovers them under each project's
// .cloop/features — so this field is only ever set in memory.
Parent string `json:"parent,omitempty"`
// Feature is the feature's slug when Parent is set.
Feature string `json:"feature,omitempty"`
}
ProjectEntry is a registered project in the multi-project registry.
func Load ¶
func Load() ([]ProjectEntry, error)
Load reads the registry from disk; returns an empty registry if file is absent.
On parse failure (zero-byte file from a torn pre-atomicfile write, schema drift, manual edit gone wrong) the corrupt projects.json is quarantined aside as projects.json.corrupt-<unix> and (nil, nil) is returned. The multi-project Web UI dashboard is the worst pre-fix case here: a bad save in this global registry bricked every "cloop ui" multi-project listing across all projects on the host. Returning empty is a recoverable state — the user re-adds projects via `cloop ui add` — whereas a hard error disabled the entire dashboard.
func (ProjectEntry) HiddenForViewer ¶
func (e ProjectEntry) HiddenForViewer(viewer string) bool
HiddenForViewer reports whether viewer has hidden this project. Comparison is case-insensitive to match the Owner rule, since both hold email-derived keys that an IdP may not case-normalize.
func (ProjectEntry) IsFeature ¶ added in v0.0.3
func (e ProjectEntry) IsFeature() bool
IsFeature reports whether the entry is a feature of another project.
type ProjectStatus ¶
type ProjectStatus struct {
Name string `json:"name"`
Path string `json:"path"`
Status string `json:"status"` // state.Status field value
// PauseReason explains a "paused" Status — which condition stopped the
// run and, for a usage cap, when it lifts. Nil for every other status.
// Without it a project card can only say "paused", which is the same word
// for a run waiting on a human and one that resumes by itself at 14:50.
PauseReason *pausereason.Reason `json:"pause_reason,omitempty"`
Health Health `json:"health"` // computed indicator
Goal string `json:"goal"`
TotalTasks int `json:"total_tasks"`
DoneTasks int `json:"done_tasks"`
FailedTasks int `json:"failed_tasks"`
ActiveTasks int `json:"active_tasks"`
TotalSteps int `json:"total_steps"`
LastActivity time.Time `json:"last_activity"`
// LastStepTime is the timestamp of the project's most recent step. It is
// kept off the wire and exists so RefreshHealth can re-derive staleness
// from a cached status without reloading the project.
LastStepTime time.Time `json:"-"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
PMMode bool `json:"pm_mode"`
HasProject bool `json:"has_project"` // false if no state file found
Running bool `json:"running"` // true if cloop run is actually executing
// Error explains why a registered project could not be loaded.
//
// HasProject=false answers two very different questions with the same
// value: "this directory was never initialised" and "this project exists
// but its database would not open". On 2026-09-14 the second happened to
// all 18 projects at once — a schema skew refused every state.db — and the
// dashboard rendered them as 18 initialised-looking cards with no goal and
// no tasks, because a discarded error looks exactly like an empty project
// (Task 20254).
//
// Only real faults are reported. A directory that is simply not a cloop
// project is the normal state of a freshly registered path and leaves this
// empty, so the field's presence always means something is wrong.
Error string `json:"error,omitempty"`
// Hidden reports that the recipient of this payload has hidden the
// project from their dashboard. It is per-recipient, so GetStatus
// deliberately leaves it false: the status cache is shared by every
// connected client, and the flag is stamped on a per-recipient copy
// when the list is filtered for delivery.
//
// Hidden projects are still delivered. Dropping them would renumber
// the list, and the whole dashboard addresses projects by their index
// in it — a hidden project at index 2 would silently shift every
// project after it onto a neighbour's tasks, runs and deletes.
Hidden bool `json:"hidden,omitempty"`
// Parent is the parent project's path when this project is a feature of
// it, and Feature describes the feature (Task 20341). The dashboard nests
// features under their parent with these; everything else about a feature
// — its tasks, its run, its options — is an ordinary project status.
Parent string `json:"parent,omitempty"`
Feature *FeatureStatus `json:"feature,omitempty"`
// Confinement says why the hub refuses to run this project, when its
// directory overlaps the hub's control plane, another hub or another
// project (Task 20418). Empty for every project the hub will run. The hub
// fills it in; GetStatus, which knows nothing of the hub, leaves it empty.
Confinement string `json:"confinement,omitempty"`
// HasGit reports that a (non-feature) project is the top of a git
// repository — the precondition for creating features of it, which the
// dashboard checks before offering the button.
HasGit bool `json:"has_git,omitempty"`
}
ProjectStatus is the live status of a project, returned by the /api/projects endpoint.
func GetStatus ¶
func GetStatus(entry ProjectEntry) ProjectStatus
GetStatus loads the state for the project at path and returns a ProjectStatus.
It determines liveness itself by walking /proc. A caller refreshing many projects at once should scan once and use GetStatusUsing instead, or it pays for that walk per project.
func GetStatusUsing ¶ added in v0.0.2
func GetStatusUsing(entry ProjectEntry, running bool) ProjectStatus
GetStatusUsing is GetStatus for a caller that has already determined whether a run is executing in the project — typically from a single ScanRunningDirs shared across the whole registry.
func (*ProjectStatus) RefreshHealth ¶ added in v0.0.2
func (ps *ProjectStatus) RefreshHealth()
RefreshHealth recomputes the time-derived part of Health from the fields the status already carries, so a caller holding a cached ProjectStatus can keep its staleness indicator current without reloading the project.
Health is the one field that moves on its own: a run that stops writing is "running" for fifteen minutes and "stalled" after, with no state change in between to notice. Everything else in a ProjectStatus only changes when the project's state does.
type RunningDirs ¶ added in v0.0.2
type RunningDirs struct {
// contains filtered or unexported fields
}
RunningDirs is a snapshot of where every "cloop run" process on this host is executing, prepared for repeated containment queries.
It exists because the hub's project sweep asked "is a run executing in this project?" once per registered project, and every one of those questions walked the whole of /proc. That made the sweep cost (projects × processes) on a timer that fires every two seconds, so a hub paid for tenants that were doing nothing at all — see BenchmarkWatchProjectsTick in pkg/ui. One walk per tick answers the same question for every project.
The zero value is usable and reports every project as not running, which is the same answer IsCloopRunningInDir gives on a host without a readable /proc.
func ScanRunningDirs ¶ added in v0.0.2
func ScanRunningDirs() RunningDirs
ScanRunningDirs walks /proc once and records where every cloop run process is executing. It returns an empty snapshot when /proc cannot be read (non-Linux hosts, restricted containers), matching cloopRunPIDs.
func (*RunningDirs) Add ¶ added in v0.0.5
func (r *RunningDirs) Add(dir string)
Add records a run known by other means than this host's process table — a run another hub process is streaming (Task 20354), or one this process dispatched to an executor whose workload is not a local process.
func (RunningDirs) Any ¶ added in v0.0.2
func (r RunningDirs) Any() bool
Any reports whether any cloop run process was found at all. A hub whose tenants are all idle gets a false here and can skip per-project work.
func (RunningDirs) Contains ¶ added in v0.0.2
func (r RunningDirs) Contains(dir string) bool
Contains reports whether a cloop run is executing in dir or anywhere below it. It is the batched equivalent of IsCloopRunningInDir(dir).