PR #3427
Sections
Review

feat: add Task Manager host monitor support

main ← feature/expand-task-manager-347 20 files +412 −68 PR #3427 ↗

Host adds numeric bounds for plugin config, exposes personal plugin settings to all users, and lets rich top-bar monitors keep their size on mobile.

Why this change

The Task Manager plugin needs an ambient host monitor with per-user layout and install-wide sampling policy. The host has no numeric bounds, no personal settings surface for non-admins, and no way for a rich monitor to keep its size on mobile.

What it does

Architecture, end to end

Operator config flows through manifest schema to backend validation and frontend form. Personal preferences flow through host.storage to the top-bar monitor. The host normalizes only ordinary buttons on mobile.

flowchart LR
  Manifest[manifest.yaml config_schema] --> Backend[plugins/config.go validate]
  Backend --> Form[plugin-config-form min/max]
  Manifest --> Parser[config-schema.ts parse]
  Parser --> Form
  Form --> Storage[host.storage topbar-settings-v1]
  Storage --> TopBar[main-top-bar plugin slot]
  TopBar --> Mobile[Mobile wrapper :not rich]
  Settings[plugin-detail PluginSlot] --> Storage

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

Backend numeric bounds validationapps/backend/internal/plugins/config.go ↗
func checkNumericBounds(name string, value any, prop map[string]any, typeName string) error
Click for details →

Enforces minimum and maximum for number and integer fields and rejects NaN and Inf.

Bounds check
func checkNumericBounds(name string, value any, prop map[string]any, typeName string) error {
  if typeName != "number" && typeName != "integer" {
    return nil
  }
  valueNumber, ok := numericValue(value)
  if !ok || math.IsNaN(valueNumber) || math.IsInf(valueNumber, 0) {
    return fmt.Errorf("%w: field %q must be finite", ErrConfigInvalid, name)
  }
  if minimum, ok := numericSchemaBound(prop["minimum"]); ok && valueNumber < minimum {
    return fmt.Errorf("%w: field %q must be at least %v", ErrConfigInvalid, name, minimum)
  }
  if maximum, ok := numericSchemaBound(prop["maximum"]); ok && valueNumber > maximum {
    return fmt.Errorf("%w: field %q must be at most %v", ErrConfigInvalid, name, maximum)
  }
  return nil
}

func numericSchemaBound(value any) (float64, bool) {
  bound, ok := numericValue(value)
  return bound, ok && !math.IsNaN(bound) && !math.IsInf(bound, 0)
}
Wider numeric types
func numericValue(v any) (float64, bool) {
  switch n := v.(type) {
  case float64:
    return n, true
  case float32:
    return float64(n), true
  case int:
    return float64(n), true
  case int8:
    return float64(n), true
  case int16:
    return float64(n), true
  case int32:
    return float64(n), true
  case int64:
    return float64(n), true
  case uint:
    return float64(n), true
  case uint8:
    return float64(n), true
  case uint16:
    return float64(n), true
  case uint32:
    return float64(n), true
  case uint64:
    return float64(n), true
  default:
    return 0, false
  }
}
Property validation
func checkPropertyValue(name string, value any, prop map[string]any) error {
  typeName, hasType := prop["type"].(string)
  if hasType {
    if !valueMatchesType(value, typeName) {
      return fmt.Errorf("%w: field %q must be a %s", ErrConfigInvalid, name, typeName)
    }
    if err := checkNumericBounds(name, value, prop, typeName); err != nil {
      return err
    }
  }
  if enum, ok := prop["enum"].([]any); ok && len(enum) > 0 {
    for _, allowed := range enum {
      if enumValueMatches(value, allowed) {
        return nil
      }
    }
    return fmt.Errorf("%w: field %q must be one of the declared enum values", ErrConfigInvalid, name)
  }
  return nil
}
Frontend schema parsing for boundsapps/web/lib/plugins/config-schema.ts ↗
export function parseConfigSchema(schema: Record<string, unknown> | undefined): PluginConfigField[]
Click for details →

Extracts finite minimum and maximum for numeric fields and stores them on the field.

Field type with bounds
export interface PluginConfigField {
  name: string;
  type: PluginConfigFieldType;
  label: string;
  description?: string;
  required: boolean;
  secret: boolean;
  enumValues?: string[];
  enumRawValues?: unknown[];
  minimum?: number;
  maximum?: number;
  defaultValue?: unknown;
}
Finite helper and parse
function finiteNumber(value: unknown): number | undefined {
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}

export function parseConfigSchema(schema: Record<string, unknown> | undefined): PluginConfigField[] {
  const schemaObj = asObject(schema);
  if (!schemaObj) return [];
  const properties = asObject(schemaObj.properties);
  if (!properties) return [];
  const required = requiredNames(schemaObj);
  const fields: PluginConfigField[] = [];
  for (const [name, raw] of Object.entries(properties)) {
    const prop = asObject(raw);
    if (!prop) continue;
    const type = fieldType(prop);
    const numeric = type === "number" || type === "integer";
    fields.push({
      name,
      type,
      label: typeof prop.title === "string" && prop.title !== "" ? prop.title : name,
      description: typeof prop.description === "string" ? prop.description : undefined,
      required: required.has(name),
      secret: isSecretProp(prop),
      enumValues: Array.isArray(prop.enum) ? prop.enum.map((v) => String(v)) : undefined,
      enumRawValues: Array.isArray(prop.enum) ? prop.enum : undefined,
      minimum: numeric ? finiteNumber(prop.minimum) : undefined,
      maximum: numeric ? finiteNumber(prop.maximum) : undefined,
      defaultValue: prop.default,
    });
  }
  return fields;
}
function ConfigFieldControl(props: ConfigFieldControlProps)
Click for details →

Passes minimum and maximum to the number input so the browser enforces the range.

Input with min/max
return (
  <Input
    id={inputId}
    type={inputType(field)}
    step={!field.secret && field.type === "integer" ? "1" : undefined}
    min={field.minimum}
    max={field.maximum}
    value={typeof value === "string" ? value : ""}
    disabled={disabled}
    data-settings-dirty={isDirty}
    autoComplete={field.secret ? "off" : undefined}
    className="max-w-md"
    onChange={(event) => onChange(field.name, event.target.value)}
  />
);
export function PluginDetail({ pluginId }: { pluginId: string })
Click for details →

Renders the owner-scoped plugin-settings slot for every signed-in user and keeps the operator form admin-only.

Slot outside admin gate
return (
  <div className="space-y-6" data-testid={`plugin-detail-${plugin.id}`}>
    <PluginDetailHeader plugin={plugin} />
    <Separator />

    {/* Owner-scoped personal settings are available to every signed-in user;
        the schema-driven operator form below remains administrator-only. */}
    <PluginSlot
      name="plugin-settings"
      ownerPluginId={plugin.id}
      slotProps={{ pluginId: plugin.id, status: plugin.status }}
    />
    {canManage && (
      <>
        <PluginSettingsCard
          plugin={plugin}
          form={form}
          busy={actions.busyId === plugin.id || actions.uninstallBusy}
        />
      </>
    )}
    <PluginShortcutsCard plugin={plugin} plugins={items} />
    <PluginManifestCard plugin={plugin} />
  </div>
);
Rich top-bar control keeps its size on mobileapps/web/components/kanban/main-top-bar-plugin-actions.tsx ↗
export function MainTopBarPluginActions(props: { workspaceId?: string; currentPage: TaskListingPage })
Click for details →

Keeps rich status controls at 44px touch height on mobile and normalizes only ordinary buttons.

Mobile wrapper
return (
  <div
    className="flex shrink-0 items-center gap-2 [&_[data-slot=button]:not([data-main-top-bar-rich])]:!size-8 [&_[data-slot=button]:not([data-main-top-bar-rich])]:!p-0 [&_[data-slot=button]:not([data-main-top-bar-rich])_svg]:!size-4"
    data-testid="mobile-main-top-bar-plugin-actions"
  >
    {content}
  </div>
);
SDK contract
// On mobile, the host normalizes ordinary `host.ui.Button` contributions to
// a 32px icon action. A rich status control with text may opt out by setting
// `data-main-top-bar-rich` on its root and must own its compact layout and
// minimum 44px touch height. Rich controls must not add a nested scroller.
export interface MainTopBarSlotProps {
  workspaceId: string | null;
  workspaceLabel?: string;
  currentPage: "kanban" | "tasks";
  presentation: "desktop" | "mobile";
}
Read the changes as a list

Backend numeric bounds validation

apps/backend/internal/plugins/config.go

Enforces minimum and maximum for number and integer fields and rejects NaN and Inf.

Bounds check
func checkNumericBounds(name string, value any, prop map[string]any, typeName string) error {
  if typeName != "number" && typeName != "integer" {
    return nil
  }
  valueNumber, ok := numericValue(value)
  if !ok || math.IsNaN(valueNumber) || math.IsInf(valueNumber, 0) {
    return fmt.Errorf("%w: field %q must be finite", ErrConfigInvalid, name)
  }
  if minimum, ok := numericSchemaBound(prop["minimum"]); ok && valueNumber < minimum {
    return fmt.Errorf("%w: field %q must be at least %v", ErrConfigInvalid, name, minimum)
  }
  if maximum, ok := numericSchemaBound(prop["maximum"]); ok && valueNumber > maximum {
    return fmt.Errorf("%w: field %q must be at most %v", ErrConfigInvalid, name, maximum)
  }
  return nil
}

func numericSchemaBound(value any) (float64, bool) {
  bound, ok := numericValue(value)
  return bound, ok && !math.IsNaN(bound) && !math.IsInf(bound, 0)
}
Wider numeric types
func numericValue(v any) (float64, bool) {
  switch n := v.(type) {
  case float64:
    return n, true
  case float32:
    return float64(n), true
  case int:
    return float64(n), true
  case int8:
    return float64(n), true
  case int16:
    return float64(n), true
  case int32:
    return float64(n), true
  case int64:
    return float64(n), true
  case uint:
    return float64(n), true
  case uint8:
    return float64(n), true
  case uint16:
    return float64(n), true
  case uint32:
    return float64(n), true
  case uint64:
    return float64(n), true
  default:
    return 0, false
  }
}
Property validation
func checkPropertyValue(name string, value any, prop map[string]any) error {
  typeName, hasType := prop["type"].(string)
  if hasType {
    if !valueMatchesType(value, typeName) {
      return fmt.Errorf("%w: field %q must be a %s", ErrConfigInvalid, name, typeName)
    }
    if err := checkNumericBounds(name, value, prop, typeName); err != nil {
      return err
    }
  }
  if enum, ok := prop["enum"].([]any); ok && len(enum) > 0 {
    for _, allowed := range enum {
      if enumValueMatches(value, allowed) {
        return nil
      }
    }
    return fmt.Errorf("%w: field %q must be one of the declared enum values", ErrConfigInvalid, name)
  }
  return nil
}

Frontend schema parsing for bounds

apps/web/lib/plugins/config-schema.ts

Extracts finite minimum and maximum for numeric fields and stores them on the field.

Field type with bounds
export interface PluginConfigField {
  name: string;
  type: PluginConfigFieldType;
  label: string;
  description?: string;
  required: boolean;
  secret: boolean;
  enumValues?: string[];
  enumRawValues?: unknown[];
  minimum?: number;
  maximum?: number;
  defaultValue?: unknown;
}
Finite helper and parse
function finiteNumber(value: unknown): number | undefined {
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}

export function parseConfigSchema(schema: Record<string, unknown> | undefined): PluginConfigField[] {
  const schemaObj = asObject(schema);
  if (!schemaObj) return [];
  const properties = asObject(schemaObj.properties);
  if (!properties) return [];
  const required = requiredNames(schemaObj);
  const fields: PluginConfigField[] = [];
  for (const [name, raw] of Object.entries(properties)) {
    const prop = asObject(raw);
    if (!prop) continue;
    const type = fieldType(prop);
    const numeric = type === "number" || type === "integer";
    fields.push({
      name,
      type,
      label: typeof prop.title === "string" && prop.title !== "" ? prop.title : name,
      description: typeof prop.description === "string" ? prop.description : undefined,
      required: required.has(name),
      secret: isSecretProp(prop),
      enumValues: Array.isArray(prop.enum) ? prop.enum.map((v) => String(v)) : undefined,
      enumRawValues: Array.isArray(prop.enum) ? prop.enum : undefined,
      minimum: numeric ? finiteNumber(prop.minimum) : undefined,
      maximum: numeric ? finiteNumber(prop.maximum) : undefined,
      defaultValue: prop.default,
    });
  }
  return fields;
}

Form input enforces range

apps/web/components/settings/plugins/plugin-config-form.tsx

Passes minimum and maximum to the number input so the browser enforces the range.

Input with min/max
return (
  <Input
    id={inputId}
    type={inputType(field)}
    step={!field.secret && field.type === "integer" ? "1" : undefined}
    min={field.minimum}
    max={field.maximum}
    value={typeof value === "string" ? value : ""}
    disabled={disabled}
    data-settings-dirty={isDirty}
    autoComplete={field.secret ? "off" : undefined}
    className="max-w-md"
    onChange={(event) => onChange(field.name, event.target.value)}
  />
);

Personal settings visible to all users

apps/web/components/settings/plugins/plugin-detail.tsx

Renders the owner-scoped plugin-settings slot for every signed-in user and keeps the operator form admin-only.

Slot outside admin gate
return (
  <div className="space-y-6" data-testid={`plugin-detail-${plugin.id}`}>
    <PluginDetailHeader plugin={plugin} />
    <Separator />

    {/* Owner-scoped personal settings are available to every signed-in user;
        the schema-driven operator form below remains administrator-only. */}
    <PluginSlot
      name="plugin-settings"
      ownerPluginId={plugin.id}
      slotProps={{ pluginId: plugin.id, status: plugin.status }}
    />
    {canManage && (
      <>
        <PluginSettingsCard
          plugin={plugin}
          form={form}
          busy={actions.busyId === plugin.id || actions.uninstallBusy}
        />
      </>
    )}
    <PluginShortcutsCard plugin={plugin} plugins={items} />
    <PluginManifestCard plugin={plugin} />
  </div>
);

Rich top-bar control keeps its size on mobile

apps/web/components/kanban/main-top-bar-plugin-actions.tsx

Keeps rich status controls at 44px touch height on mobile and normalizes only ordinary buttons.

Mobile wrapper
return (
  <div
    className="flex shrink-0 items-center gap-2 [&_[data-slot=button]:not([data-main-top-bar-rich])]:!size-8 [&_[data-slot=button]:not([data-main-top-bar-rich])]:!p-0 [&_[data-slot=button]:not([data-main-top-bar-rich])_svg]:!size-4"
    data-testid="mobile-main-top-bar-plugin-actions"
  >
    {content}
  </div>
);
SDK contract
// On mobile, the host normalizes ordinary `host.ui.Button` contributions to
// a 32px icon action. A rich status control with text may opt out by setting
// `data-main-top-bar-rich` on its root and must own its compact layout and
// minimum 44px touch height. Rich controls must not add a nested scroller.
export interface MainTopBarSlotProps {
  workspaceId: string | null;
  workspaceLabel?: string;
  currentPage: "kanban" | "tasks";
  presentation: "desktop" | "mobile";
}

Data and storage

Only numeric fields carry bounds. Non-numeric fields and malformed bounds stay undefined.

FieldTypeNotes
minimumnumber | undefinedInclusive lower bound for number/integer; undefined for other types or non-finite values
maximumnumber | undefinedInclusive upper bound for number/integer; undefined for other types or non-finite values
PluginConfigField.typestring | boolean | number | integer | enum | utility_agentDetermines whether minimum/maximum are parsed
refresh_interval_secondsinteger 1..300Install-wide ambient interval; validated by checkNumericBounds
disk_pathstringInstall-wide filesystem path for disk metric; not part of summary request

Risk

3 / 10 Low
1 low5 medium10 high

Why this score

  • Small host-only change with no database migration or new API; plugin remains in separate repo.
  • Backend validation is permissive for malformed bounds and covered by new unit tests.
  • Settings visibility change is isolated to plugin-detail and verified by a new test for non-admin users.

Trade-offs and review notes

Where to look first

  1. Check checkNumericBounds and numericValue in config.go for finite and range checks.
  2. Confirm parseConfigSchema only sets minimum/maximum for number/integer and drops non-finite values.
  3. Verify plugin-detail renders PluginSlot outside canManage and the new test for non-admin visibility.
  4. Review the mobile selector :not([data-main-top-bar-rich]) and the SDK comment for the rich contract.
  5. Confirm plugin-config-form passes min and max to Input and that types.ts documents the opt-out.