multiui

package
v0.0.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 17, 2026 License: MIT Imports: 14 Imported by: 0

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

View Source
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

func AddPaths(paths []string) error

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

func AddPathsOwned(paths []string, owner string) error

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 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

func CloopRunPIDsInDir(dir string) []int

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

func IsCloopRunningInDir(dir string) bool

IsCloopRunningInDir returns true if a "cloop run" process has its working directory set to dir. It reads /proc/*/cwd symlinks (Linux only).

func RemovePath

func RemovePath(path string) error

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

func Root() (string, error)

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

func Scan(dir string) ([]string, error)

Scan walks a parent directory one level deep and returns all subdirectories (including the directory itself) that contain a .cloop/ folder.

func SetHidden

func SetHidden(path, viewer string, hidden bool) error

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 Health

type Health string

Health is a high-level project health indicator.

const (
	HealthRunning  Health = "running"
	HealthStalled  Health = "stalled"
	HealthFailed   Health = "failed"
	HealthComplete Health = "complete"
	HealthIdle     Health = "idle"
	HealthUnknown  Health = "unknown"
)

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"`
}

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.

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"`
}

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) 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).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL