Run FedWiki's retention purge and parking on every deployment

#283. The retention purge (archived sites removed after 30 days) and
parking (sites over a lowered plan limit made read-only) ran only inside
the farm sync, after its projection, and the sync is off by default, so
the default deployment did neither. FedWiki's existing schedule now
exists whenever the farm connection is configured, and its one workflow
runs, in order: the farm listing and projection, only when
fedwiki-sync-enabled is on; the retention purge; parking. One schedule
keeps today's order and skips an overlapping tick, so no purge or
parking runs beside a projection working from a stale listing. The
interval is the existing fedwiki-sync-interval for the whole run; boot
refreshes the existing schedule's action and keeps an operator's pause.
After a failed projection with the sync on, the purge is skipped
(deletion is irreversible and farm-side restores reach the database only
through the projection) and parking still runs, since it is reversible
and uses local state, as every sync-off deployment already does; the run
reports the sync failure as before. Counts go on the existing completion
line as structured fields.

Parking orders sites by the farm's recorded modification time, falling
back to the site's creation time when the sync never recorded one, so it
works with the sync off; the member's Keep active swap uses the same
order, which changes the site a swap parks when no time was recorded.
FedWiki now requires Temporal, because expired archives and lowered
limits are work nothing else processes; the shared refusal for the three
integrations is shortened.

Ride-alongs, listed in the proposal:
- A failed entitlement read during parking logs a warning with its pool
  ("quota reconcile: read entitlement") instead of counting as no
  entitlement.
- An unconfigured farm pauses a leftover schedule even with the sync
  flag on.
- The consequence spec names its manifest and boot registration path.
- ReconcileDomains' comment now says Startup waits for a published
  Temporal connection, not for the sync flag.
- fedwiki-swap-cooldown's description reads "How often an organization
  over its site limit can change which site is active; 0 means any
  time".

Strings (approved): "Sites over the limit become read-only."; "%s needs
Temporal: set temporal-host (or leave %s empty to disable it)"; the
interval and creation-trigger descriptions ("Interval between FedWiki
maintenance runs", "Run FedWiki maintenance when its schedule is
created"); the operator panel's "Site maintenance", "Last run", "No run
yet", and its tooltip "Syncs with the farm when the sync is on, deletes
archived sites after 30 days, and makes sites over a plan's limit
read-only." (the day count from ArchiveRetention); the swap-cooldown
description above. No schema or privilege change.

Upgrading a deployment (not in the product docs, which describe the new
behaviour only):
1. Before deploying, count read-only the archived sites already past
   their 30 days and the pools over a force-reduce limit, from the site
   rows and the governing policies, not from stored usage. The first run
   processes that whole backlog, and the purge's deletion is
   irreversible.
2. If an old sync run is executing, pause the schedule and let the run
   finish on the old worker, then deploy and resume. If that is skipped
   and the open old run fails replay, terminate it: the schedule skips
   overlapping ticks, so a stuck run blocks every later maintenance tick
   until it is terminated. The next tick runs the new code.

Full suite on reset databases, on this commit's tree: 5441 passed, 16
skipped, none failed. make screens against main's baseline: the FedWiki
sites page changed (the panel's title, label and tooltip); no other
capture this commit touches changed. The FedWiki settings page, which
make screens does not capture, was captured by hand at 1280 and 390.

Breaks, all CAUGHT (56):
- createSchedule identity
- createSchedule projection flag
- createSchedule interval
- createSchedule overlap
- createSchedule pause on failure
- createSchedule trigger
- updateSchedule identity
- updateSchedule projection flag
- updateSchedule interval
- updateSchedule overlap
- updateSchedule pause on failure
- updateSchedule operator state
- describe failure is not absence
- create race still updates
- startup sync guard
- startup flag
- startup interval
- startup trigger
- startup unpauses operator
- gate purge on sync
- gate quota on sync
- purge after failed projection
- skip quota after failed projection
- retention failure exits before quota
- friendly failure becomes workflow error
- friendly failure message cleared
- purge count log inflated
- completion logged after failed sync
- projection flag mistaken for completion
- bound retries
- purge starts before projection completes
- quota starts before retention completes
- ListActiveSitesByWorkspace nulls last
- swap nulls last
- ListActiveSitesByWorkspace creation only
- swap creation only
- ListActiveSitesByPool nulls last
- ListActiveSitesByPool creation only
- ListForceReducedSitesByPool nulls last
- ListForceReducedSitesByPool creation only
- entitlement error hidden
- entitlement error falls through
- FedWiki temporal need TestFedWikiTemporalRequirement
- FedWiki temporal need TestIntegrationsWithStoredWorkNeedTemporal
- consequence
- section title
- last run label
- no run value
- drop maintenance tooltip
- success sync counts
- completion sync counts
- failed projection listing count
- effective retry policy
- update triggers run
- resolve failure still runs quota
- literal maintenance retention days
This commit is contained in:
2026-10-30 15:03:15 -05:00
parent 5698d9707c
commit 9bcbaebe05
41 changed files with 1675 additions and 252 deletions
+5 -5
View File
@@ -338,7 +338,7 @@ shared skeleton; the *content* of each part is yours:
`ActiveCapability: "integrations"` so the sidebar highlights both the
parent section and your entry.
2. **Delivery health**: if your integration runs a reconciliation
schedule, lead with a "Sync health" card showing the latest run's
schedule, lead with a health card for the schedule showing the latest run's
status and time via `workflows.ScheduleRunStatus(ctx, temporal,
<yourworkflows>.SyncScheduleID)`. Resolve runs through the *schedule*,
never by describing your workflow ID directly — schedule-spawned
@@ -643,13 +643,13 @@ type ConfigKey struct {
operator who cannot read the token must not be able to choose its
recipient. `Secret` implies it; declaring both is a declaration error.
- **`NeedsTemporal`** — goes on a key of the `RequiredGroup` of an integration
that receives events processed only on Temporal. `config.ValidateStart`
with stored work processed only on Temporal, including required periodic work. `config.ValidateStart`
refuses the configured group without `temporal-host`, naming the group and
its keys (`member-console config validate` runs the same check);
`config.CheckSpecs` refuses the declaration on a key that has no
`RequiredGroup`. An integration whose actions run on Temporal but that
receives no events does not declare it. Stripe declares it on
`stripe-api-key`, Discourse on `discourse-base-url`.
`RequiredGroup`. An integration whose actions refuse immediately without leaving stored work does not need it for those actions alone. Stripe declares it on
`stripe-api-key`, Discourse on `discourse-base-url`, and FedWiki on
`fedwiki-farm-api-url` for retention and quota enforcement.
The composition root binds flags/env/defaults for every declared key
generically (`cmd/start.go`'s `registerIntegrationConfigFlags`, looping
+5
View File
@@ -428,6 +428,11 @@ one. The slot gate inserts its hold after the usage row's lock and count.
### In the downgrade sweep and at boot
One FedWiki maintenance schedule awaits optional farm projection, then retention,
then quota reconciliation. Scheduled runs do not overlap; member requests still
use the locks below. Quota and Keep active rank by recorded modification time,
falling back to creation time for an unobserved site.
- **Park** (`parkSite`): the farm call first, with no lock held, then one
transaction (`sweepLocally`, at READ COMMITTED) that takes the site row,
writes the status and the mark, and recounts the pool.
+5 -5
View File
@@ -86,7 +86,7 @@ See [identity-provider-setup.md](identity-provider-setup.md) for configuring the
| Key | Type | Env override | Purpose | Default | Required |
|---|---|---|---|---|---|
| `temporal-host` | string | `MC_TEMPORAL_HOST` | Temporal server `host:port`, e.g. `localhost:7233`. Leaving it unset disables Temporal, the worker, integration workflows, and every scheduled sweep below. A configured Stripe or Discourse needs it: both receive events that only Temporal processes, and `start` refuses the configuration without it. | none | Required when Stripe or Discourse is configured; otherwise optional (functionally required for provisioning and scheduled jobs) |
| `temporal-host` | string | `MC_TEMPORAL_HOST` | Temporal server `host:port`, e.g. `localhost:7233`. Leaving it unset disables Temporal, the worker, integration workflows, and every scheduled sweep below. A configured Stripe, Discourse or FedWiki needs it: Stripe and Discourse process stored events; FedWiki must purge expired archives and enforce limits even with sync off, and `start` refuses the configuration without it. | none | Required when Stripe, Discourse or FedWiki is configured; otherwise optional (functionally required for provisioning and scheduled jobs) |
| `temporal-namespace` | string | `MC_TEMPORAL_NAMESPACE` | Temporal namespace. | `default` | Required once `temporal-host` is set |
| `temporal-oauth-token-url` | url | `MC_TEMPORAL_OAUTH_TOKEN_URL` | OAuth2 token endpoint for Temporal client authentication. | none | Optional; if any `temporal-oauth-*` key below is set, all three must be |
| `temporal-oauth-client-id` | string | `MC_TEMPORAL_OAUTH_CLIENT_ID` | OAuth2 client ID for Temporal authentication. | none | Optional; see above |
@@ -143,10 +143,10 @@ See [stripe.md](stripe.md) for the webhook endpoint, subscribed events, and cata
| `fedwiki-admin-token-file` | string | `MC_FEDWIKI_ADMIN_TOKEN_FILE` | File path holding `fedwiki-admin-token`. | none | Optional |
| `fedwiki-allowed-domains` | list | `MC_FEDWIKI_ALLOWED_DOMAINS` | Domains where users can create FedWiki sites (comma-separated), e.g. `wiki.example.com,wiki2.example.com`. | none | Optional |
| `fedwiki-site-scheme` | http\|https | `MC_FEDWIKI_SITE_SCHEME` | URL scheme for generated FedWiki site links: `http` or `https`. | `https` | Optional |
| `fedwiki-sync-enabled` | bool | `MC_FEDWIKI_SYNC_ENABLED` | Enable periodic sync of sites from the FedWiki farm. | `false` | Optional |
| `fedwiki-sync-interval` | duration, greater than zero | `MC_FEDWIKI_SYNC_INTERVAL` | Interval between FedWiki site syncs. Inert while sync is disabled. | `1h` | Optional |
| `fedwiki-sync-trigger-immediately` | bool | `MC_FEDWIKI_SYNC_TRIGGER_IMMEDIATELY` | Run a sync immediately once the console connects to Temporal, in addition to the interval. | `true` | Optional |
| `fedwiki-swap-cooldown` | duration | `MC_FEDWIKI_SWAP_COOLDOWN` | Cooldown window between FedWiki active-site rotations (swap or archive-then-restore). `0` disables it. | `720h` (30 days) | Optional |
| `fedwiki-sync-enabled` | bool | `MC_FEDWIKI_SYNC_ENABLED` | Enable the farm listing and projection before retention and quota; maintenance runs even when false. | `false` | Optional |
| `fedwiki-sync-interval` | duration, greater than zero | `MC_FEDWIKI_SYNC_INTERVAL` | Interval for the whole maintenance run, whether sync is enabled or not; applied at restart. | `1h` | Optional |
| `fedwiki-sync-trigger-immediately` | bool | `MC_FEDWIKI_SYNC_TRIGGER_IMMEDIATELY` | Trigger the whole maintenance run when its schedule is first created, not on updates. | `true` | Optional |
| `fedwiki-swap-cooldown` | duration | `MC_FEDWIKI_SWAP_COOLDOWN` | How often an organization over its site limit can change which site is active; 0 means any time | `720h` (30 days) | Optional |
See [fedwiki-setup.md](fedwiki-setup.md) for the FarmManager security stack and local dev setup.
+3
View File
@@ -67,6 +67,9 @@ Tokens are generated by `wiki-plugin-useraccesstokens` and stored as bcrypt hash
## Member-Console Configuration
Configured FedWiki requires `temporal-host` for retention and quota enforcement,
even when `fedwiki-sync-enabled` is false.
Relevant settings in `mc-config.yaml`:
```yaml
+5 -4
View File
@@ -43,8 +43,8 @@ Decide now which services member-console needs for your offering:
- **An OIDC identity provider** — always required (any OIDC-compliant
provider; see step 4).
- **Temporal** — required for provisioning, integration sync, and billing
sweeps to actually run, and required whenever Stripe or Discourse is
configured; without it the console starts, logs `Temporal not
sweeps to actually run, and required whenever Stripe, Discourse or FedWiki is
configured; with all three dormant and Temporal unset the console starts, logs `Temporal not
configured`, and every scheduled job stays off.
- **A FedWiki farm** — required only if you offer FedWiki sites, the
product's first conforming integration; see step 6.
@@ -380,8 +380,9 @@ order, and exits 1 at the first one that fails:
`*-file` secret, and validates the aggregated configuration, failing fast
with every problem named at once if something required is missing (see
[environment-reference.md](environment-reference.md) for what is
required). A configured Stripe or Discourse without `temporal-host` fails
here, because both receive events that only Temporal processes. Stripe's
required). A configured Stripe, Discourse or FedWiki without `temporal-host` fails
here: Stripe and Discourse receive events that only Temporal processes;
FedWiki needs periodic retention and quota enforcement even with sync off. Stripe's
mode is derived from its API key here, and a key with an unknown prefix
fails.
2. Opens the runtime connection; when `db-migrate-dsn` is set, opens the
+5 -8
View File
@@ -94,14 +94,11 @@ type ConfigKey struct {
// both is a declaration error CheckSpecs refuses.
DeploymentOnly bool
// NeedsTemporal declares that the integration whose RequiredGroup this
// key belongs to receives events from outside that only Temporal
// processes, so ValidateStart refuses a configured group while
// temporal-host is empty (design D11, serve-before-dependencies). It
// belongs on a key of a RequiredGroup, because the group is what says the
// integration is configured; CheckSpecs refuses it elsewhere. An
// integration whose actions run on Temporal but that receives no events
// does not declare it: each action answers that it is not available, and
// nothing is stored for a worker that never comes.
// key belongs to has stored work that only Temporal processes, whether
// webhook events or required periodic operations. ValidateStart refuses a
// configured group while temporal-host is empty. It belongs on a key of a
// RequiredGroup; CheckSpecs refuses it elsewhere. Actions that immediately
// refuse without storing work do not alone require this declaration.
NeedsTemporal bool
}
+3 -3
View File
@@ -198,8 +198,8 @@ func validateRequiredGroups(specs []ConfigKey) []error {
// validateTemporalNeeds refuses a configured group that carries a
// NeedsTemporal key while temporal-host is empty (serve-before-dependencies design D11). A group with
// only some of its keys set is left to validateRequiredGroups, which refuses
// it on its own. Stripe and Discourse declare the need today: both receive
// events from outside that only Temporal processes.
// it on its own. Stripe and Discourse need event processing; FedWiki needs
// periodic retention and quota enforcement even without farm projection.
func validateTemporalNeeds(specs []ConfigKey) []error {
if strings.TrimSpace(viper.GetString("temporal-host")) != "" {
return nil
@@ -208,7 +208,7 @@ func validateTemporalNeeds(specs []ConfigKey) []error {
for _, g := range collectRequiredGroups(specs) {
if g.needsTemporal && g.set == len(g.keys) {
errs = append(errs, fmt.Errorf(
"%s needs Temporal, which processes its events: set temporal-host (or leave %s empty to disable it)",
"%s needs Temporal: set temporal-host (or leave %s empty to disable it)",
g.name, strings.Join(g.keys, " and ")))
}
}
+2 -2
View File
@@ -349,8 +349,8 @@ var temporalNeedSpec = []ConfigKey{
}
const (
stripeNeedsTemporal = "Stripe needs Temporal, which processes its events: set temporal-host (or leave stripe-api-key and stripe-webhook-secret empty to disable it)"
discourseNeedsTemporal = "Discourse needs Temporal, which processes its events: set temporal-host (or leave discourse-base-url and discourse-api-key empty to disable it)"
stripeNeedsTemporal = "Stripe needs Temporal: set temporal-host (or leave stripe-api-key and stripe-webhook-secret empty to disable it)"
discourseNeedsTemporal = "Discourse needs Temporal: set temporal-host (or leave discourse-base-url and discourse-api-key empty to disable it)"
stripePartial = "Stripe is partially configured: set stripe-api-key and stripe-webhook-secret together (or leave them all empty to disable it)"
dbDSNRequired = "db-dsn is required"
)
+1 -1
View File
@@ -53,7 +53,7 @@ func TestRegisterProviders_DB(t *testing.T) {
// The cross-schema stamp landed on the renamed key.
{"fedwiki_sites.provider", `SELECT coalesce(provider, 'NULL') FROM core.resource_keys WHERE resource_key=$1`, "fedwiki_sites", "fedwiki"},
{"fedwiki_sites over-limit", `SELECT over_limit_behavior || ' / ' || coalesce(over_limit_consequence, '') FROM core.resource_keys WHERE resource_key=$1`,
"fedwiki_sites", "park / Sites over the limit become read-only at the next sync."},
"fedwiki_sites", "park / Sites over the limit become read-only."},
// The bare 'sites' key is gone (renamed by the fwmod migration). A
// parameter, not a literal, keeps this absence check clear of the
// retired-resource-key lint.
+14 -22
View File
@@ -199,22 +199,23 @@ func configured() bool {
// via server.Deps). fedwiki-farm-api-url carries an explicit url Type: it
// has no Default for TypeOf to infer from (typed-config-keys design D1). It
// is DeploymentOnly because fedwiki-admin-token travels to whatever it
// names (security-audit-remediation-3 design D1).
// fedwiki-sync-interval declares Positive: SyncFedWikiSitesWorkflow (below)
// names (security-audit-remediation-3 design D1). NeedsTemporal requires a
// worker for expired archives and changed limits even when sync is disabled.
// fedwiki-sync-interval declares Positive: the schedule consumer
// replaces a saved zero with DefaultSyncInterval silently, which would make
// the settings page state a value the scheduler ignores (design D6);
// fedwiki-swap-cooldown does not: zero disables it by contract, and its
// consumer (fedwiki_partials.go) honours that.
func (Adapter) ConfigSpec() []config.ConfigKey {
return []config.ConfigKey{
{Name: "fedwiki-farm-api-url", Type: config.TypeURL, DeploymentOnly: true, RequiredGroup: "FedWiki", Usage: "URL of the FedWiki farm API endpoint (e.g., https://admin.wiki.example.com)"},
{Name: "fedwiki-farm-api-url", Type: config.TypeURL, DeploymentOnly: true, RequiredGroup: "FedWiki", NeedsTemporal: true, Usage: "URL of the FedWiki farm API endpoint (e.g., https://admin.wiki.example.com)"},
{Name: "fedwiki-allowed-domains", Default: []string(nil), Usage: "Domains where users can create FedWiki sites (e.g., wiki.example.com,wiki2.example.com)"},
{Name: "fedwiki-site-scheme", Default: "https", Enum: []string{"http", "https"}, Usage: "URL scheme for FedWiki sites (http or https)"},
{Name: "fedwiki-admin-token", Secret: true, RequiredGroup: "FedWiki", Usage: "FedWiki admin access token for farmmanager API"},
{Name: "fedwiki-sync-enabled", Default: false, Usage: "Enable periodic sync of sites from FedWiki farm"},
{Name: "fedwiki-sync-interval", Default: time.Hour, Positive: true, Usage: "Interval between FedWiki site syncs"},
{Name: "fedwiki-sync-trigger-immediately", Default: true, Usage: "Run sync immediately on startup"},
{Name: "fedwiki-swap-cooldown", Default: 30 * 24 * time.Hour, Usage: "Cooldown window between FedWiki active-site rotations (swap or archive-then-restore); 0 disables"},
{Name: "fedwiki-sync-interval", Default: time.Hour, Positive: true, Usage: "Interval between FedWiki maintenance runs"},
{Name: "fedwiki-sync-trigger-immediately", Default: true, Usage: "Run FedWiki maintenance when its schedule is created"},
{Name: "fedwiki-swap-cooldown", Default: 30 * 24 * time.Hour, Usage: "How often an organization over its site limit can change which site is active; 0 means any time"},
}
}
@@ -508,21 +509,12 @@ func checkNoRemovedRuns(ctx context.Context, c workflowLister) error {
return nil
}
// Startup sets up the FedWiki site sync schedule when enabled, replacing
// the hand-wired conditional block that used to live in cmd/start.go.
// Non-fatal: a failure here is logged and boot continues without the sync
// schedule, matching today's convention.
//
// The System tenant workspace is no longer resolved here. Each sync execution
// resolves it fresh by natural key via ResolveSystemWorkspaceActivity, so no
// workspace ID is baked into the schedule's args (where it would go stale on an
// app-database wipe). cmd/start.go's separate, unconditional, fatal
// systemtenant.Ensure call is untouched and remains the boot-time guarantee that
// the System tenant exists at all, independent of FedWiki.
// Startup settles lifecycle requests, then ensures maintenance for a configured
// farm or pauses its leftover schedule. Schedule failures are logged, not fatal.
// Enabled projection resolves the System workspace at execution, not at boot.
func (a Adapter) Startup(ctx context.Context, c client.Client, taskQueue string, database *sql.DB, logger *slog.Logger) error {
// Settle lifecycle requests whose workflow never recorded an outcome
// (design D27) before the sync check below returns early: the sync is
// off by default, and the requests are the member's, not the sync's.
// (design D27) before ensuring or pausing the maintenance schedule.
a.settleLifecycleRequests(ctx, c, taskQueue, database, logger)
// A request committed just before the process died is younger than
// SettleRequestAge at a quick restart, so the boot pass leaves it; a
@@ -539,9 +531,8 @@ func (a Adapter) Startup(ctx context.Context, c client.Client, taskQueue string,
}
}()
if !viper.GetBool("fedwiki-sync-enabled") {
// Schedules are durable: one ensured by an earlier sync-enabled boot
// would keep firing into this boot's unconfigured sync path forever.
if !configured() {
// A durable schedule must not run against an unconfigured farm.
if err := corework.PauseLeftoverSchedule(ctx, c, workflows.SyncScheduleID, logger); err != nil {
logger.Error("failed to pause leftover FedWiki sync schedule", slog.Any("error", err))
}
@@ -555,6 +546,7 @@ func (a Adapter) Startup(ctx context.Context, c client.Client, taskQueue string,
scheduleManager := workflows.NewScheduleManager(c, logger)
if err := scheduleManager.EnsureSyncSchedule(ctx, workflows.ScheduleConfig{
Interval: syncInterval,
SyncEnabled: viper.GetBool("fedwiki-sync-enabled"),
TriggerImmediately: viper.GetBool("fedwiki-sync-trigger-immediately"),
}); err != nil {
logger.Error("failed to set up FedWiki sync schedule", slog.Any("error", err))
@@ -0,0 +1,188 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package fedwiki
import (
"bytes"
"context"
"errors"
"log/slog"
"reflect"
"slices"
"strings"
"testing"
"time"
"github.com/spf13/viper"
"github.com/stretchr/testify/mock"
"go.temporal.io/api/serviceerror"
"go.temporal.io/sdk/client"
"go.temporal.io/sdk/mocks"
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/workflows"
corework "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows"
)
// maintenanceConfig restores each setting without changing other tests' defaults.
func maintenanceConfig(t *testing.T, values map[string]interface{}) {
t.Helper()
for key, value := range values {
previous := viper.Get(key)
viper.Set(key, value)
t.Cleanup(func() { viper.Set(key, previous) })
}
}
// TestFedWikiStartupMaintenanceSchedule checks startup's single schedule and pause ownership.
func TestFedWikiStartupMaintenanceSchedule(t *testing.T) {
rows := []startupMaintenanceCase{
{"fresh off", true, false, false, false, "", "", []string{"describe", "create", "describe"}},
{"fresh on", true, true, false, false, "", "", []string{"describe", "create", "describe"}},
{"existing off", true, false, true, false, "", "", []string{"describe", "update", "describe"}},
{"dormant", true, false, true, true, corework.DormantPauseNote, "", []string{"describe", "update", "describe", "unpause"}},
{"operator", true, true, true, true, "operator", "", []string{"describe", "update", "describe"}},
{"unconfigured off", false, false, true, false, "", "", []string{"describe", "pause"}},
{"unconfigured on", false, true, true, false, "", "", []string{"describe", "pause"}},
{"ensure failure", true, false, true, false, "", "update", []string{"describe", "update"}},
{"resume failure", true, false, true, true, corework.DormantPauseNote, "unpause", []string{"describe", "update", "describe", "unpause"}},
{"pause failure", false, false, true, false, "", "pause", []string{"describe", "pause"}},
}
for _, row := range rows {
t.Run(row.name, row.run)
}
}
// startupMaintenanceCase carries inputs and explicit expectations for one row.
type startupMaintenanceCase struct {
name string
configured, enabled, exists, paused bool
note, fail string
calls []string
}
// run exercises one startupMaintenanceCase without deriving expectations from its name.
func (row startupMaintenanceCase) run(t *testing.T) {
url := ""
if row.configured {
url = "https://farm.example.test"
}
maintenanceConfig(t, map[string]interface{}{"fedwiki-farm-api-url": url, "fedwiki-sync-enabled": row.enabled, "fedwiki-sync-interval": 17 * time.Minute, "fedwiki-sync-trigger-immediately": false})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
temporalClient := &mocks.Client{}
schedules := &mocks.ScheduleClient{}
handle := &mocks.ScheduleHandle{}
temporalClient.On("ScheduleClient").Return(schedules)
schedules.On("GetHandle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
if args.String(1) != workflows.SyncScheduleID {
t.Errorf("startup schedule config = %v, want retained ID", args.Get(1))
}
}).Return(handle)
description := &client.ScheduleDescription{Schedule: client.Schedule{State: &client.ScheduleState{Paused: row.paused, Note: row.note}}}
exists := row.exists
var calls []string
handle.On("Trigger", mock.Anything, mock.Anything).Run(func(mock.Arguments) { calls = append(calls, "trigger") }).Return(nil)
handle.On("Delete", mock.Anything).Run(func(mock.Arguments) { calls = append(calls, "delete") }).Return(nil)
handle.On("Describe", mock.Anything).Return(func(context.Context) (*client.ScheduleDescription, error) {
calls = append(calls, "describe")
if !exists {
return nil, serviceerror.NewNotFound("absent")
}
return description, nil
})
check := func(spec *client.ScheduleSpec, action client.ScheduleAction) {
t.Helper()
actual := action.(*client.ScheduleWorkflowAction)
if spec.Intervals[0].Every != 17*time.Minute || !reflect.DeepEqual(actual.Args, []interface{}{workflows.SyncFedWikiSitesWorkflowInput{SyncEnabled: row.enabled}}) {
t.Errorf("startup schedule config = %+v %+v, want 17m and sync %v", spec, actual, row.enabled)
}
}
schedules.On("Create", mock.Anything, mock.Anything).Return(func(_ context.Context, options client.ScheduleOptions) (client.ScheduleHandle, error) {
calls = append(calls, "create")
exists = true
check(&options.Spec, options.Action)
if options.TriggerImmediately {
t.Errorf("startup schedule config = trigger true, want false")
}
return handle, nil
})
handle.On("Update", mock.Anything, mock.Anything).Return(func(_ context.Context, options client.ScheduleUpdateOptions) error {
calls = append(calls, "update")
result, err := options.DoUpdate(client.ScheduleUpdateInput{Description: *description})
if err != nil {
return err
}
description.Schedule = *result.Schedule
check(result.Schedule.Spec, result.Schedule.Action)
if row.fail == "update" {
return errors.New("schedule unavailable")
}
return nil
})
handle.On("Pause", mock.Anything, mock.Anything).Return(func(context.Context, client.SchedulePauseOptions) error {
calls = append(calls, "pause")
if row.fail == "pause" {
return errors.New("schedule unavailable")
}
return nil
})
handle.On("Unpause", mock.Anything, mock.Anything).Return(func(context.Context, client.ScheduleUnpauseOptions) error {
calls = append(calls, "unpause")
if row.fail == "unpause" {
return errors.New("schedule unavailable")
}
return nil
})
var logs bytes.Buffer
err := New().Startup(ctx, temporalClient, "queue", nil, slog.New(slog.NewTextHandler(&logs, nil)))
if err != nil {
t.Errorf("startup result = %v, want nil", err)
}
if !slices.Equal(calls, row.calls) {
t.Errorf("startup schedule calls = %v, want %v", calls, row.calls)
}
if got := strings.Contains(logs.String(), "schedule unavailable"); got != (row.fail != "") {
t.Errorf("startup error logs = %s, want failure %v", logs.String(), row.fail != "")
}
}
// TestFedWikiTemporalRequirement uses the actual declaration and compares only its own errors.
func TestFedWikiTemporalRequirement(t *testing.T) {
const needs = "FedWiki needs Temporal: set temporal-host (or leave fedwiki-farm-api-url and fedwiki-admin-token empty to disable it)"
const partial = "FedWiki is partially configured: set fedwiki-farm-api-url and fedwiki-admin-token together (or leave them all empty to disable it)"
rows := []struct {
name, url, token, host string
enabled bool
want string
}{
{"off", "https://farm.example.test", "token", "", false, needs},
{"on", "https://farm.example.test", "token", "", true, needs},
{"dormant", "", "", "", false, ""},
{"url only", "https://farm.example.test", "", "", false, partial},
{"token only", "", "token", "", false, partial},
{"temporal", "https://farm.example.test", "token", "localhost:7233", false, ""},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
maintenanceConfig(t, map[string]interface{}{"fedwiki-farm-api-url": row.url, "fedwiki-admin-token": row.token, "temporal-host": row.host, "fedwiki-sync-enabled": row.enabled})
err := config.ValidateStart(New().ConfigSpec())
var got []string
if err != nil {
for _, line := range strings.Split(err.Error(), "\n") {
if strings.Contains(line, "needs Temporal") || strings.Contains(line, "FedWiki is partially configured") {
got = append(got, strings.TrimSpace(line))
}
}
}
var want []string
if row.want != "" {
want = []string{row.want}
}
if !reflect.DeepEqual(got, want) {
t.Errorf("FedWiki temporal errors = %v, want %v", got, want)
}
})
}
}
@@ -26,10 +26,9 @@ import (
// It satisfies the composition root's point-of-use DomainsReconciler
// interface (cmd/start.go), which runs it UNCONDITIONALLY after migrations
// and provider registration — before the server and the Temporal worker
// start. That placement is the whole point: FedWiki's Startup hook runs only
// when Temporal is configured AND `fedwiki-sync-enabled` is true (default
// false), so a default deployment would never seed its operator roots from
// there, and `/domains/ask` would refuse every hosted name it serves.
// start. FedWiki's Startup hook runs only once a Temporal connection is
// published, after the listener opens, so the roots would not be seeded
// before `/domains/ask` answers.
//
// Four passes, all idempotent:
//
@@ -26,12 +26,12 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
)
// TestStartupSettlesRequestsWithTheSyncDisabled: the boot pass runs at the
// top of Startup, before the sync-enabled check returns early (design D27),
// so a request whose workflow never started is settled on a deployment that
// never enables the sync (task 11.5). The member's pool has no usage row, so
// TestStartupSettlesRequestsWithTheFarmUnconfigured: the boot pass runs at the
// top of Startup, before the configured-farm check returns early (design D27),
// so a request whose workflow never started is settled before the leftover-pause
// path on an unconfigured farm (task 11.5). The member's pool has no usage row, so
// the finish has no count to write: it commits the outcome all the same.
func TestStartupSettlesRequestsWithTheSyncDisabled(t *testing.T) {
func TestStartupSettlesRequestsWithTheFarmUnconfigured(t *testing.T) {
database := dbtest.Open(t)
ctx := context.Background()
if err := integration.RegisterProviders(ctx, database, []integration.ProviderSource{fwmod.ProviderSource()}); err != nil {
@@ -67,6 +67,9 @@ func TestStartupSettlesRequestsWithTheSyncDisabled(t *testing.T) {
prev := viper.GetBool("fedwiki-sync-enabled")
viper.Set("fedwiki-sync-enabled", false)
previousFarm := viper.Get("fedwiki-farm-api-url")
viper.Set("fedwiki-farm-api-url", "")
t.Cleanup(func() { viper.Set("fedwiki-farm-api-url", previousFarm) })
t.Cleanup(func() { viper.Set("fedwiki-sync-enabled", prev) })
c := &mocks.Client{}
@@ -156,6 +159,7 @@ func TestStartupSettleRemovesTheHoldOfTheRequestItFinishes(t *testing.T) {
// A request younger than SettleRequestAge at boot, as one committed just
// before a quick restart is, is left by the boot pass and settled by the
// second pass once it has aged (design D27, Open Questions 54).
// The farm is unconfigured so Startup takes the leftover-pause path after settling.
func TestStartupSettlesAYoungRequestOnceItHasAged(t *testing.T) {
database := dbtest.Open(t)
ctx, cancel := context.WithCancel(context.Background())
@@ -195,6 +199,9 @@ func TestStartupSettlesAYoungRequestOnceItHasAged(t *testing.T) {
prev, prevDelay := viper.GetBool("fedwiki-sync-enabled"), settleAgainAfter
viper.Set("fedwiki-sync-enabled", false)
previousFarm := viper.Get("fedwiki-farm-api-url")
viper.Set("fedwiki-farm-api-url", "")
t.Cleanup(func() { viper.Set("fedwiki-farm-api-url", previousFarm) })
settleAgainAfter = 1500 * time.Millisecond
t.Cleanup(func() { viper.Set("fedwiki-sync-enabled", prev); settleAgainAfter = prevDelay })
@@ -48,7 +48,7 @@ func (providerSource) ProviderManifest() integration.Manifest {
OverLimit: map[string]integration.OverLimit{
"fedwiki_sites": {
Behavior: integration.OverLimitPark,
Consequence: "Sites over the limit become read-only at the next sync.",
Consequence: "Sites over the limit become read-only.",
},
},
}
@@ -66,10 +66,10 @@ type Querier interface {
// instance lock, after SlotHoldExists, so the primary key never conflicts.
InsertSlotHold(ctx context.Context, requestID string) error
// The active sites of every workspace whose active primary assignment is the
// pool, most-recently-modified first: the force_reduce keep/park order across
// pool, ranked by recorded modification or creation: the keep/park order across
// the workspaces that draw down the pool's one `fedwiki_sites` counter.
ListActiveSitesByPool(ctx context.Context, poolID string) ([]Site, error)
// Active sites, most-recently-modified first — the force_reduce keep/park order.
// Active sites ranked by recorded modification or creation time, newest first.
ListActiveSitesByWorkspace(ctx context.Context, workspaceID string) ([]Site, error)
ListAllSites(ctx context.Context) ([]ListAllSitesRow, error)
// The operator sites surface, governed by operator-list-scale: one page of
@@ -114,7 +114,7 @@ type Querier interface {
// instead of inserting it.
ListDomainsWithRequestsUpdatedSince(ctx context.Context, since time.Time) ([]string, error)
// Sites a downgrade parked that are still read-only, in every workspace
// whose active primary assignment is the pool, most-recently-modified first:
// whose active primary assignment is the pool, ranked by modification or creation:
// the re-upgrade reactivation order. A parked site the member archived since
// is in the trash, not in reserve.
ListForceReducedSitesByPool(ctx context.Context, poolID string) ([]Site, error)
@@ -199,25 +199,25 @@ SET force_reduced_at = NULL, updated_at = NOW()
WHERE domain = $1;
-- name: ListActiveSitesByWorkspace :many
-- Active sites, most-recently-modified first — the force_reduce keep/park order.
-- Active sites ranked by recorded modification or creation time, newest first.
SELECT * FROM fedwiki.sites
WHERE workspace_id = $1 AND status = 'active'
ORDER BY last_modified_at DESC NULLS LAST, created_at DESC, site_id DESC;
ORDER BY COALESCE(last_modified_at, created_at) DESC, created_at DESC, site_id DESC;
-- name: ListActiveSitesByPool :many
-- The active sites of every workspace whose active primary assignment is the
-- pool, most-recently-modified first: the force_reduce keep/park order across
-- pool, ranked by recorded modification or creation: the keep/park order across
-- the workspaces that draw down the pool's one `fedwiki_sites` counter.
SELECT * FROM fedwiki.sites
WHERE status = 'active' AND workspace_id IN (
SELECT pa.workspace_id FROM core.pool_assignments pa
WHERE pa.pool_id = sqlc.arg(pool_id)::uuid AND pa.is_primary = TRUE AND pa.status = 'active'
)
ORDER BY last_modified_at DESC NULLS LAST, created_at DESC, site_id DESC;
ORDER BY COALESCE(last_modified_at, created_at) DESC, created_at DESC, site_id DESC;
-- name: ListForceReducedSitesByPool :many
-- Sites a downgrade parked that are still read-only, in every workspace
-- whose active primary assignment is the pool, most-recently-modified first:
-- whose active primary assignment is the pool, ranked by modification or creation:
-- the re-upgrade reactivation order. A parked site the member archived since
-- is in the trash, not in reserve.
SELECT * FROM fedwiki.sites
@@ -225,7 +225,7 @@ WHERE force_reduced_at IS NOT NULL AND status = 'readonly' AND workspace_id IN (
SELECT pa.workspace_id FROM core.pool_assignments pa
WHERE pa.pool_id = sqlc.arg(pool_id)::uuid AND pa.is_primary = TRUE AND pa.status = 'active'
)
ORDER BY last_modified_at DESC NULLS LAST, created_at DESC, site_id DESC;
ORDER BY COALESCE(last_modified_at, created_at) DESC, created_at DESC, site_id DESC;
-- name: InsertSlotHold :exec
-- A request's slot gate stamps its hold. Written under the request's
@@ -343,11 +343,11 @@ WHERE status = 'active' AND workspace_id IN (
SELECT pa.workspace_id FROM core.pool_assignments pa
WHERE pa.pool_id = $1::uuid AND pa.is_primary = TRUE AND pa.status = 'active'
)
ORDER BY last_modified_at DESC NULLS LAST, created_at DESC, site_id DESC
ORDER BY COALESCE(last_modified_at, created_at) DESC, created_at DESC, site_id DESC
`
// The active sites of every workspace whose active primary assignment is the
// pool, most-recently-modified first: the force_reduce keep/park order across
// pool, ranked by recorded modification or creation: the keep/park order across
// the workspaces that draw down the pool's one `fedwiki_sites` counter.
func (q *Queries) ListActiveSitesByPool(ctx context.Context, poolID string) ([]Site, error) {
rows, err := q.db.QueryContext(ctx, listActiveSitesByPool, poolID)
@@ -386,10 +386,10 @@ func (q *Queries) ListActiveSitesByPool(ctx context.Context, poolID string) ([]S
const listActiveSitesByWorkspace = `-- name: ListActiveSitesByWorkspace :many
SELECT site_id, workspace_id, domain, created_at, updated_at, status, storage_bytes, last_modified_at, force_reduced_at, archived_at FROM fedwiki.sites
WHERE workspace_id = $1 AND status = 'active'
ORDER BY last_modified_at DESC NULLS LAST, created_at DESC, site_id DESC
ORDER BY COALESCE(last_modified_at, created_at) DESC, created_at DESC, site_id DESC
`
// Active sites, most-recently-modified first — the force_reduce keep/park order.
// Active sites ranked by recorded modification or creation time, newest first.
func (q *Queries) ListActiveSitesByWorkspace(ctx context.Context, workspaceID string) ([]Site, error) {
rows, err := q.db.QueryContext(ctx, listActiveSitesByWorkspace, workspaceID)
if err != nil {
@@ -727,11 +727,11 @@ WHERE force_reduced_at IS NOT NULL AND status = 'readonly' AND workspace_id IN (
SELECT pa.workspace_id FROM core.pool_assignments pa
WHERE pa.pool_id = $1::uuid AND pa.is_primary = TRUE AND pa.status = 'active'
)
ORDER BY last_modified_at DESC NULLS LAST, created_at DESC, site_id DESC
ORDER BY COALESCE(last_modified_at, created_at) DESC, created_at DESC, site_id DESC
`
// Sites a downgrade parked that are still read-only, in every workspace
// whose active primary assignment is the pool, most-recently-modified first:
// whose active primary assignment is the pool, ranked by modification or creation:
// the re-upgrade reactivation order. A parked site the member archived since
// is in the trash, not in reserve.
func (q *Queries) ListForceReducedSitesByPool(ctx context.Context, poolID string) ([]Site, error) {
@@ -4,8 +4,7 @@
{{/* fedwiki_operator_sites.html
Operator-tier list of every FedWiki site, below the Integrations
trail with the Settings action in the header slot. Built from the
page-anatomy parts: sections titled by sectionHeader (the sync
explainer rides the Sync health tooltip), type and status render
page-anatomy parts: sections titled by sectionHeader, type and status render
through statusBadge, rows lead with the site's domain (the site's
human identifier; the link is external because sites have no console
detail page). Unconfigured renders the same blocked-empty-state shape
@@ -25,7 +24,7 @@
<div class="card mb-3">
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-3">Last sync</dt>
<dt class="col-sm-3">Last run</dt>
<dd class="col-sm-9">
{{ .SyncStatus }}{{ if .SyncTime }} <span class="text-muted">({{ .SyncTime }})</span>{{ end }}
</dd>
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package web
import (
"html"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/stretchr/testify/mock"
enumspb "go.temporal.io/api/enums/v1"
workflowpb "go.temporal.io/api/workflow/v1"
"go.temporal.io/api/workflowservice/v1"
"go.temporal.io/sdk/client"
"go.temporal.io/sdk/mocks"
"google.golang.org/protobuf/types/known/timestamppb"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/workflows"
)
// TestOperatorMaintenanceStatus_DB renders the real no-run default and schedule result.
func TestOperatorMaintenanceStatus_DB(t *testing.T) {
for _, row := range []struct {
name string
execution bool
present, absent []string
}{
{"no execution", false, []string{"Site maintenance", "Last run", "No run yet", `data-bs-content="Syncs with the farm when the sync is on, deletes archived sites after 30 days, and makes sites over a plan's limit read-only."`}, []string{"Sync health", "Last sync"}},
{"completed", true, []string{"Site maintenance", "Last run", "Completed", `data-bs-content="Syncs with the farm when the sync is on, deletes archived sites after 30 days, and makes sites over a plan's limit read-only."`}, []string{"Sync health", "Last sync"}},
} {
t.Run(row.name, func(t *testing.T) {
env := newSiteEnv(t, 2)
temporalClient := &mocks.Client{}
schedules := &mocks.ScheduleClient{}
handle := &mocks.ScheduleHandle{}
temporalClient.On("ScheduleClient").Return(schedules)
schedules.On("GetHandle", mock.Anything, workflows.SyncScheduleID).Return(handle)
description := &client.ScheduleDescription{}
if row.execution {
description.Info.RecentActions = []client.ScheduleActionResult{{StartWorkflowResult: &client.ScheduleWorkflowExecution{WorkflowID: "scheduled-run"}}}
temporalClient.On("DescribeWorkflowExecution", mock.Anything, "scheduled-run", "").Return(&workflowservice.DescribeWorkflowExecutionResponse{WorkflowExecutionInfo: &workflowpb.WorkflowExecutionInfo{Status: enumspb.WORKFLOW_EXECUTION_STATUS_COMPLETED, StartTime: timestamppb.Now()}}, nil)
}
handle.On("Describe", mock.Anything).Return(description, nil)
handler, err := NewFedWikiOperatorHandler(FedWikiOperatorHandlerConfig{SiteQ: fwmod.New(env.db), Database: env.db, Logger: discardLogger(), TemplatesFS: os.DirFS("../templates"), Configured: true, Temporal: publishedHolder(temporalClient), SyncScheduleID: workflows.SyncScheduleID})
if err != nil {
t.Fatal(err)
}
authConfig, ctx := sessionFor(t, env.ws)
handler.AuthConfig = authConfig
response := httptest.NewRecorder()
handler.GetSitesPage(response, httptest.NewRequest("GET", "/operator/integrations/fedwiki?q="+env.uniq, nil).WithContext(ctx))
for _, want := range row.present {
if !strings.Contains(html.UnescapeString(response.Body.String()), want) {
t.Errorf("maintenance panel missing %q: %s", want, response.Body.String())
}
}
for _, banned := range row.absent {
if strings.Contains(html.UnescapeString(response.Body.String()), banned) {
t.Errorf("maintenance panel retained %q", banned)
}
}
})
}
}
+10 -5
View File
@@ -9,9 +9,11 @@ import (
"io/fs"
"log/slog"
"net/http"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
fwworkflows "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/workflows"
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows"
@@ -49,7 +51,7 @@ type FedWikiSitesData struct {
Description string
Sites []FedWikiSiteViewModel
Error string
// Sync health (latest schedule-spawned sync execution, from Temporal)
// Site maintenance (latest schedule-spawned maintenance execution, from Temporal)
// — same panel the Discourse operator page leads with; the shared
// anatomy is documented in docs/building-an-integration.md.
SyncStatus string
@@ -110,8 +112,8 @@ type FedWikiOperatorHandlerConfig struct {
// from web.CoreTemplates — no cycle risk, since internal/web is below
// both.
TemplatesFS fs.FS
// Temporal + SyncScheduleID feed the Sync health panel (nil/"" →
// "no sync yet"); see workflows.ScheduleRunStatus for why health is
// Temporal + SyncScheduleID feed the Site maintenance panel (nil/"" →
// "No run yet"); see workflows.ScheduleRunStatus for why health is
// resolved through the schedule rather than a workflow ID.
Temporal *clientholder.Holder
SyncScheduleID string
@@ -168,7 +170,7 @@ func (h *FedWikiOperatorHandler) GetSitesPage(w http.ResponseWriter, r *http.Req
bodyData.SyncStatus, bodyData.SyncTime = workflows.ScheduleRunStatus(r.Context(), h.Temporal.Client(), h.SyncScheduleID)
if bodyData.SyncStatus == "" {
bodyData.SyncStatus = "No sync has run yet"
bodyData.SyncStatus = "No run yet"
}
// The governed list's state comes off the request (operator-list-scale):
@@ -259,7 +261,10 @@ func (d FedWikiSitesData) Header() server.PageHeader {
}
func (FedWikiSitesData) SyncHeader() server.SectionHeader {
return server.SectionHeader{Title: "Sync health", Help: "The sync is the periodic background job that reconciles this list with the wiki farm."}
return server.SectionHeader{
Title: "Site maintenance",
Help: fmt.Sprintf("Syncs with the farm when the sync is on, deletes archived sites after %d days, and makes sites over a plan's limit read-only.", int(fwworkflows.ArchiveRetention/(24*time.Hour))),
}
}
// SitesHeader counts the whole filtered set, never the page's rows
@@ -4,6 +4,7 @@
package web
import (
"html"
"log/slog"
"net/http/httptest"
"os"
@@ -47,14 +48,13 @@ func TestOperatorSitesPageRenders(t *testing.T) {
if rec.Code != 200 {
t.Fatalf("render = %d (template-set regression?), body: %s", rec.Code, rec.Body.String())
}
out := rec.Body.String()
out := html.UnescapeString(rec.Body.String())
for _, want := range []string{
"wiki.example.test", // a site row
`<h1 class="h2 mb-0">FedWiki sites</h1>`, // titled by pageHeader
`<li class="breadcrumb-item"><a href="/operator/integrations">Integrations</a>`, // the trail
`>Settings</a>`, // header action slot
`<h2 class="h5 mb-0">Sync health`, // section header outside the card
`<h2 class="h5 mb-0">Sites`, // sites section with count slot
`>Settings</a>`, // header action slot
`<h2 class="h5 mb-0">Sites`, // sites section with count slot
`<span class="badge text-bg-light border">Subdomain</span>`, // type through statusBadge
`<span class="badge text-bg-success">Active</span>`, // status through statusBadge
`<th scope="row" class="text-nowrap">`, // domain is the row primary, never wraps
@@ -63,6 +63,16 @@ func TestOperatorSitesPageRenders(t *testing.T) {
t.Errorf("rendered page missing %q", want)
}
}
for _, want := range []string{`<h2 class="h5 mb-0">Site maintenance`, `<dt class="col-sm-3">Last run</dt>`, `data-bs-content="Syncs with the farm when the sync is on, deletes archived sites after 30 days, and makes sites over a plan's limit read-only."`} {
if !strings.Contains(out, want) {
t.Errorf("maintenance panel missing %q", want)
}
}
for _, banned := range []string{"Sync health", "Last sync"} {
if strings.Contains(out, banned) {
t.Errorf("maintenance panel retained %q", banned)
}
}
for _, banned := range []string{"Total: ", "card-header", ">ID</th>"} {
if strings.Contains(out, banned) {
t.Errorf("rendered page still contains %q", banned)
@@ -1694,3 +1694,37 @@ func TestSiteActionReadsTheConnectionPerRequest(t *testing.T) {
t.Errorf("starts after Publish = %v, want the set-status workflow of the request", got)
}
}
// TestKeepActiveUsesRecordedTimeOrCreation_DB tests the member's actual swap selection.
func TestKeepActiveUsesRecordedTimeOrCreation_DB(t *testing.T) {
for _, row := range []struct {
name string
created [2]int
modified [2]int
park int
}{
{"absent", [2]int{1, 2}, [2]int{}, 0},
{"mixed", [2]int{3, 2}, [2]int{1, 0}, 0},
} {
t.Run(row.name, func(t *testing.T) {
env := newSiteEnv(t, 2)
var ids [2]string
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
for index, label := range []string{"observed", "unobserved"} {
ids[index], _ = env.site(label+env.uniq, "active")
env.exec("UPDATE fedwiki.sites SET created_at=$2,last_modified_at=$3 WHERE site_id=$1", ids[index], base.Add(time.Duration(row.created[index])*time.Hour), sql.NullTime{Valid: row.modified[index] != 0, Time: base.Add(time.Duration(row.modified[index]) * time.Hour)})
}
chosenID, domain := env.site("chosen"+env.uniq, "readonly")
generation := env.nextGen(chosenID)
rec := env.rowAction(env.h.KeepSiteActive, "keep-active", domain, chosenID, generation)
if !accepted(rec) || len(env.temporal.starts()) != 1 {
t.Fatalf("keep-active swap target = response %d starts %v, want one accepted swap", rec.Code, env.temporal.starts())
}
input := env.temporal.inputs[0].(workflows.SwapSiteRequestInput)
request, ok := env.request(input.ParkInstanceID, input.ParkGeneration)
if input.ParkInstanceID != ids[row.park] || !ok || request.WorkflowID != workflows.SwapRequestWorkflowID(chosenID, generation) {
t.Errorf("keep-active swap target = %s request %+v, want %s", input.ParkInstanceID, request, ids[row.park])
}
})
}
}
@@ -609,7 +609,7 @@ func (h *FedWikiPartialsHandler) swapActiveSite(w http.ResponseWriter, r *http.R
refusal = reasonSentence(workflows.ReasonActiveLimitReached)
return errRollback
}
parked := locked[len(locked)-1] // least-recently-modified active
parked := locked[len(locked)-1] // lowest-ranked by recorded modification or creation
parkDom = parked.Domain
answer, err = integration.RecordLifecycleRequest(ctx, tx, newRequestSpec(conn, ws, act.session.PersonID,
@@ -1,12 +1,12 @@
# FedWiki Workflows
This package implements Temporal workflows for FedWiki site provisioning and synchronization operations.
This package implements Temporal workflows for FedWiki lifecycle requests and scheduled maintenance.
## Overview
The FedWiki workflows handle asynchronous site management operations:
- **CreateFedWikiSiteRequestWorkflow**, **SetFedWikiSiteStatusRequestWorkflow**, **SwapFedWikiActiveSiteRequestWorkflow**, **DeleteFedWikiSiteRequestWorkflow**: carry out a member's lifecycle request on the farm and record its outcome (see Lifecycle Requests)
- **SyncFedWikiSitesWorkflow**: Synchronizes sites from the FedWiki farm to the local database (scheduled)
- **SyncFedWikiSitesWorkflow**: Runs optional farm projection, retention and quota reconciliation in order (scheduled)
## Architecture
@@ -28,8 +28,8 @@ Available activities:
- `requests.go`, `request_activities.go` - Request-driven lifecycle workflows and their activities
- `workflow.go` - Shared activity options and the already-running check
- `sync.go` - Sync workflow and activities
- `schedule.go` - Temporal schedule management for periodic sync
- `sync.go` - Maintenance workflow and farm projection activities
- `schedule.go` - Temporal schedule management for maintenance
- `activities.go` - Activity implementations
- `farmmanager_client.go` - HTTP client for FarmManager API
@@ -74,80 +74,55 @@ owners will not be recognized even if the username matches.
### SyncFedWikiSitesWorkflow
This workflow synchronizes sites from the FedWiki farm to the local database. It runs on
a configurable schedule (default: hourly) to ensure the local database reflects the current
state of the farm.
The configured farm has one maintenance schedule, even with farm sync disabled.
It awaits optional System workspace resolution, farm listing and projection
(including the final usage recount), then retention, then quota reconciliation.
A failed enabled sync skips irreversible retention but still runs quota on local
state, returning the existing friendly failure result. Sweep failures remain
non-fatal logs. A farm outage prevents each sweep's remote call before local writes.
**Use Cases:**
- Importing existing sites that were created outside the member console
- Removing sites from the database that were deleted directly on the farm
- Keeping the local database in sync with external changes
**Input:**
```go
type SyncFedWikiSitesWorkflowInput struct {
DefaultUserID int64 // User ID to assign to orphaned sites (typically admin)
}
```
**Output:**
```go
type SyncFedWikiSitesWorkflowOutput struct {
Success bool // Whether sync succeeded
SitesFound int // Number of sites found on farm
SitesAdded int // Number of sites added to database
SitesRemoved int // Number of sites removed from database
ErrorMessage string // Error message (on failure)
SyncTimestamp time.Time // When the sync completed
}
```
**Steps:**
1. **ListFedWikiSitesActivity** - Fetch all sites from the FarmManager API
2. **SyncSitesToDBActivity** - Compare with local database and sync changes
**Sync Logic:**
- Sites on farm but not in local DB → Add to local DB with `DefaultUserID`
- Sites in local DB but not on farm (or inactive) → Remove from local DB
- Sites in both → Update `updated_at` timestamp
Input is `SyncFedWikiSitesWorkflowInput{SyncEnabled bool}`. Output retains
Success, SitesFound, SitesAdded, SitesRemoved, ErrorMessage and SyncTimestamp,
and adds SyncPerformed, Purged, Parked and Reactivated. SyncPerformed means
projection completed successfully. With sync off the three site-sync counts
are zero. The existing completion line carries counts as structured fields;
failed sync returns its counts without that success line.
## Scheduling
The sync workflow can be scheduled to run periodically using Temporal Schedules.
`fedwiki-site-sync` and action `fedwiki-site-sync-workflow` retain their identity.
Boot refreshes their spec and action, preserves operator pauses and resumes only
a dormancy pause. An unconfigured farm pauses its leftover schedule. Skip-overlap
drops ticks while a run is open; one pause stops all steps. A manual trigger
normally uses skip, but an explicit overlap override or direct workflow start
can bypass serialization.
### Configuration
Enable the sync schedule in your configuration:
Configured FedWiki requires Temporal. These settings apply at restart:
```yaml
fedwiki-sync-enabled: true
fedwiki-sync-interval: 1h # Sync interval (default: 1 hour)
fedwiki-sync-default-user-id: 1 # User ID for orphaned sites
fedwiki-sync-trigger-immediately: true # Run sync on startup
fedwiki-sync-enabled: false
fedwiki-sync-interval: 1h
fedwiki-sync-trigger-immediately: true
```
Or via command line flags:
```bash
member-console start \
--fedwiki-sync-enabled \
--fedwiki-sync-interval=30m \
--fedwiki-sync-default-user-id=1
```
### ScheduleManager
The `ScheduleManager` creates or updates the sync schedule:
The interval governs the whole run with sync on or off. The trigger setting
applies only when creating the schedule. The first run processes already-due
archives and force-reduce surpluses without a grace period.
```go
manager := workflows.NewScheduleManager(temporalClient, logger)
err := manager.EnsureSyncSchedule(ctx, workflows.ScheduleConfig{
Interval: time.Hour,
SyncEnabled: false,
Interval: time.Hour,
TriggerImmediately: true,
})
```
Maintenance uses `SyncActivityOptions`: five-minute attempts, five attempts,
one-second initial backoff, coefficient two and a one-minute maximum interval.
Quota and Keep active rank sites by observed modification or, where absent,
creation time, with creation time and site ID descending as tie breakers.
## Activity Options
### FedWiki API Activities
@@ -0,0 +1,184 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"slices"
"strings"
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
fwmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/fedwiki/store"
)
// quotaReadFailure records any read after a failed entitlement without panicking.
type quotaReadFailure struct {
entitlements.Querier
err error
work []string
}
// GetNumericEntitlementByPoolAndResource returns the row's configured read failure.
func (query *quotaReadFailure) GetNumericEntitlementByPoolAndResource(context.Context, entitlements.GetNumericEntitlementByPoolAndResourceParams) (entitlements.NumericEntitlement, error) {
return entitlements.NumericEntitlement{}, query.err
}
// GetGoverningReductionPolicy records a fall-through that could park every site.
func (query *quotaReadFailure) GetGoverningReductionPolicy(context.Context, entitlements.GetGoverningReductionPolicyParams) (string, error) {
query.work = append(query.work, "policy")
return "force_reduce", nil
}
// quotaReadSites supplies no sites if a broken caller falls through.
type quotaReadSites struct {
fwmod.Querier
calls *quotaReadFailure
}
// ListActiveSitesByPool makes an erroneous read observable without a nil panic.
func (query *quotaReadSites) ListActiveSitesByPool(context.Context, string) ([]fwmod.Site, error) {
query.calls.work = append(query.calls.work, "sites")
return nil, nil
}
// TestQuotaEntitlementReadError distinguishes absence from failure and refuses further work.
func TestQuotaEntitlementReadError(t *testing.T) {
for _, row := range []struct {
name string
err error
wantLog []string
}{
{"absent", sql.ErrNoRows, nil}, {"wrapped absent", fmt.Errorf("read: %w", sql.ErrNoRows), nil}, {"database failure", errors.New("database unavailable"), []string{"level=WARN", "quota reconcile: read entitlement", "pool=pool", "database unavailable"}},
} {
t.Run(row.name, func(t *testing.T) {
query := &quotaReadFailure{err: row.err}
var logs bytes.Buffer
acts := &Activities{EntitlementsQ: query, SiteQ: &quotaReadSites{calls: query}, Logger: slog.New(slog.NewTextHandler(&logs, nil))}
parked, reactivated := acts.reconcilePoolQuota(context.Background(), "pool")
if len(query.work) > 0 || parked != 0 || reactivated != 0 {
t.Errorf("entitlement failure performed work: %v, counts %d/%d", query.work, parked, reactivated)
}
got := logs.String()
if len(row.wantLog) > 0 {
for _, want := range row.wantLog {
if !strings.Contains(got, want) {
t.Errorf("entitlement read logs = %q, want %q", got, want)
}
}
} else if got != "" {
t.Errorf("entitlement read logs = %q, want empty", got)
}
})
}
}
// TestQuotaUsesRecordedTimeOrCreation_DB checks all lists and real parking/reactivation.
func TestQuotaUsesRecordedTimeOrCreation_DB(t *testing.T) {
rows := []quotaOrderCase{
{"all absent", [3]int{1, 2, 4}, [3]int{}, [3]int{2, 1, 0}},
{"mixed", [3]int{3, 2, 4}, [3]int{1, 0, 4}, [3]int{2, 1, 0}},
{"observed overrides creation", [3]int{3, 2, 4}, [3]int{1, 2, 4}, [3]int{2, 1, 0}},
}
for _, row := range rows {
t.Run(row.name, row.run)
}
}
// quotaOrderCase carries inputs and explicit expectations for one row.
type quotaOrderCase struct {
name string
created [3]int
modified [3]int
want [3]int
}
// run exercises one quotaOrderCase without deriving expectations from its name.
func (row quotaOrderCase) run(t *testing.T) {
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
acts, database := newReconcileTestEnv(t)
ws, domains := setupQuotaFixture(t, database, 1, 3)
governFedwikiLimit(t, database, ws, "force_reduce")
ctx := context.Background()
var ids [3]string
var pool string
for index, domain := range domains {
modified := sql.NullTime{Valid: row.modified[index] != 0, Time: base.Add(time.Duration(row.modified[index]) * time.Hour)}
if err := database.QueryRowContext(ctx, "UPDATE fedwiki.sites SET created_at=$2,last_modified_at=$3 WHERE domain=$1 RETURNING site_id::text", domain, base.Add(time.Duration(row.created[index])*time.Hour), modified).Scan(&ids[index]); err != nil {
t.Fatal(err)
}
}
if err := database.QueryRowContext(ctx, "SELECT pool_id::text FROM core.pool_assignments WHERE workspace_id=$1 AND is_primary AND status='active'", ws).Scan(&pool); err != nil {
t.Fatal(err)
}
want := []string{ids[row.want[0]], ids[row.want[1]], ids[row.want[2]]}
query := fwmod.New(database)
byWorkspace, err := query.ListActiveSitesByWorkspace(ctx, ws)
if err != nil {
t.Fatal(err)
}
byPool, err := query.ListActiveSitesByPool(ctx, pool)
if err != nil {
t.Fatal(err)
}
if _, err := database.ExecContext(ctx, "UPDATE fedwiki.sites SET status='readonly',force_reduced_at=NOW() WHERE workspace_id=$1", ws); err != nil {
t.Fatal(err)
}
reduced, err := query.ListForceReducedSitesByPool(ctx, pool)
if err != nil {
t.Fatal(err)
}
for _, list := range []struct {
name string
sites []fwmod.Site
}{{"ListActiveSitesByWorkspace", byWorkspace}, {"ListActiveSitesByPool", byPool}, {"ListForceReducedSitesByPool", reduced}} {
if got := siteIDs(list.sites); !slices.Equal(got, want) {
t.Errorf("%s order = %v, want %v", list.name, got, want)
}
}
if _, err := database.ExecContext(ctx, "UPDATE fedwiki.sites SET status='active',force_reduced_at=NULL WHERE workspace_id=$1", ws); err != nil {
t.Fatal(err)
}
out, err := acts.ReconcileFedWikiQuotaActivity(ctx, ReconcileFedWikiQuotaInput{WorkspaceID: ws})
if err != nil {
t.Fatal(err)
}
active, err := query.ListActiveSitesByPool(ctx, pool)
if err != nil {
t.Fatal(err)
}
if out.Parked != 2 || !slices.Equal(siteIDs(active), want[:1]) {
t.Errorf("quota kept sites = %v (%+v), want %v", siteIDs(active), out, want[:1])
}
if _, err := database.ExecContext(ctx, "UPDATE core.numeric_entitlements SET resource_limit=2 WHERE pool_id=$1 AND resource_key='fedwiki_sites'", pool); err != nil {
t.Fatal(err)
}
out, err = acts.ReconcileFedWikiQuotaActivity(ctx, ReconcileFedWikiQuotaInput{WorkspaceID: ws})
if err != nil {
t.Fatal(err)
}
active, err = query.ListActiveSitesByPool(ctx, pool)
if err != nil {
t.Fatal(err)
}
if out.Reactivated != 1 || !slices.Equal(siteIDs(active), want[:2]) {
t.Errorf("quota reactivated sites = %v (%+v), want %v", siteIDs(active), out, want[:2])
}
for rank, index := range row.want {
status, marked := siteState(t, database, domains[index])
wantStatus, wantMark := "active", false
if rank == 2 {
wantStatus, wantMark = "readonly", true
}
if status != wantStatus || marked != wantMark {
t.Errorf("quota status and marks = %s/%v, want %s/%v", status, marked, wantStatus, wantMark)
}
}
}
@@ -23,7 +23,7 @@ const ArchiveRetention = 30 * 24 * time.Hour
// ReconcileFedWikiQuotaInput is the input for the quota reconcile activity. An
// empty WorkspaceID sweeps the primary pool of every workspace that owns sites
// (the periodic-sync path); a set WorkspaceID reconciles just that workspace's
// (the periodic maintenance path); a set WorkspaceID reconciles just that workspace's
// pool (the immediate path).
type ReconcileFedWikiQuotaInput struct {
WorkspaceID string
@@ -40,7 +40,7 @@ type ReconcileFedWikiQuotaOutput struct {
// the active sites of every workspace that pool is primary for against the
// pool's fedwiki_sites limit (usage is pool-grain: an organization's
// workspaces draw down one counter against one limit) and:
// - parks the excess read-only (oldest by last-activity first), stamping
// - parks the excess read-only (oldest by recorded modification or creation first), stamping
// force_reduced_at, when active > limit and the rule governing the pool's
// fedwiki_sites limit carries the force_reduce reduction policy (Decision
// 147: under clamp the surplus stays the member's and nothing is parked;
@@ -49,7 +49,7 @@ type ReconcileFedWikiQuotaOutput struct {
// up to the limit, when headroom opened (a re-upgrade just landed).
//
// It is idempotent and cheap in steady state (no farm calls when active == limit),
// so running it on every sync tick safely covers all downgrade/upgrade sources
// so running it on every maintenance tick safely covers all downgrade/upgrade sources
// (Stripe period-end, operator revoke-and-transition, grant expiry, re-upgrade).
func (a *Activities) ReconcileFedWikiQuotaActivity(ctx context.Context, input ReconcileFedWikiQuotaInput) (*ReconcileFedWikiQuotaOutput, error) {
workspaceIDs := []string{input.WorkspaceID}
@@ -182,7 +182,7 @@ func (a *Activities) purgeArchivedSite(ctx context.Context, database db.Beginner
}
// reconcilePoolQuota reconciles one pool across every workspace it is
// primary for: the surplus parked is the pool's least recently modified
// primary for: the surplus parked is the pool's lowest-ranked
// active sites, whichever workspace holds them, and the headroom reactivation
// fills is the pool's. Best-effort: a failure on one site is logged and the
// sweep continues (the next tick retries).
@@ -192,7 +192,9 @@ func (a *Activities) reconcilePoolQuota(ctx context.Context, poolID string) (par
ResourceKey: "fedwiki_sites",
})
if err != nil {
// No fedwiki_sites entitlement → no limit to enforce; leave sites as-is.
if !errors.Is(err, sql.ErrNoRows) {
a.Logger.Warn("quota reconcile: read entitlement", slog.String("pool", poolID), slog.Any("error", err))
}
return 0, 0
}
limit := int(ent.ResourceLimit)
@@ -215,7 +217,7 @@ func (a *Activities) reconcilePoolQuota(ctx context.Context, poolID string) (par
switch {
case len(active) > limit && parksExcess:
// Park the excess (active is newest-first, so the tail is the
// least-recently-modified, the ones not kept).
// lowest-ranked, the ones not kept).
for _, site := range active[limit:] {
if a.parkSite(ctx, site) {
parked++
@@ -11,6 +11,7 @@ import (
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/queues"
enumspb "go.temporal.io/api/enums/v1"
"go.temporal.io/api/serviceerror"
"go.temporal.io/sdk/client"
"go.temporal.io/sdk/temporal"
@@ -21,15 +22,17 @@ const (
SyncScheduleID = "fedwiki-site-sync"
// SyncWorkflowID is the workflow ID used for sync workflow executions.
SyncWorkflowID = "fedwiki-site-sync-workflow"
// DefaultSyncInterval is the default interval between site syncs.
// DefaultSyncInterval is the default interval between maintenance runs.
DefaultSyncInterval = 1 * time.Hour
)
// ScheduleConfig holds configuration for the FedWiki sync schedule.
// ScheduleConfig holds configuration for the FedWiki maintenance schedule.
type ScheduleConfig struct {
// Interval is how often to run the sync workflow.
// SyncEnabled selects projection before retention and quota.
SyncEnabled bool
// Interval is how often to run maintenance.
Interval time.Duration
// TriggerImmediately runs the sync immediately when creating the schedule.
// TriggerImmediately starts one maintenance run when Create registers the schedule; Update never triggers.
TriggerImmediately bool
}
@@ -88,9 +91,8 @@ func isScheduleNotFound(err error) bool {
return errors.As(err, &nf)
}
// createSchedule registers the sync schedule. Args carry an empty
// SyncFedWikiSitesWorkflowInput — the System workspace is resolved per execution
// by the workflow, not baked into schedule args.
// createSchedule registers maintenance with the current projection flag.
// The System workspace is resolved only by enabled projection at execution.
func (m *ScheduleManager) createSchedule(ctx context.Context, cfg ScheduleConfig) error {
m.logger.Info("creating FedWiki sync schedule",
slog.Duration("interval", cfg.Interval),
@@ -107,9 +109,11 @@ func (m *ScheduleManager) createSchedule(ctx context.Context, cfg ScheduleConfig
ID: SyncWorkflowID,
Workflow: SyncFedWikiSitesWorkflow,
TaskQueue: queues.Main,
Args: []interface{}{SyncFedWikiSitesWorkflowInput{}},
Args: []interface{}{SyncFedWikiSitesWorkflowInput{SyncEnabled: cfg.SyncEnabled}},
},
TriggerImmediately: cfg.TriggerImmediately,
Overlap: enumspb.SCHEDULE_OVERLAP_POLICY_SKIP,
PauseOnFailure: false,
}); err != nil {
return err
}
@@ -119,9 +123,8 @@ func (m *ScheduleManager) createSchedule(ctx context.Context, cfg ScheduleConfig
return nil
}
// updateSchedule rewrites the existing sync schedule's spec and action. Args
// carry an empty SyncFedWikiSitesWorkflowInput, which strips any stale
// DefaultWorkspaceID baked into a schedule created under the previous code.
// updateSchedule refreshes the interval, projection flag and overlap policy
// without replacing the schedule's pause state.
func (m *ScheduleManager) updateSchedule(ctx context.Context, handle client.ScheduleHandle, cfg ScheduleConfig) error {
m.logger.Info("updating existing FedWiki sync schedule",
slog.Duration("interval", cfg.Interval))
@@ -137,8 +140,13 @@ func (m *ScheduleManager) updateSchedule(ctx context.Context, handle client.Sche
ID: SyncWorkflowID,
Workflow: SyncFedWikiSitesWorkflow,
TaskQueue: queues.Main,
Args: []interface{}{SyncFedWikiSitesWorkflowInput{}},
Args: []interface{}{SyncFedWikiSitesWorkflowInput{SyncEnabled: cfg.SyncEnabled}},
}
if schedule.Description.Schedule.Policy == nil {
schedule.Description.Schedule.Policy = &client.SchedulePolicies{}
}
schedule.Description.Schedule.Policy.Overlap = enumspb.SCHEDULE_OVERLAP_POLICY_SKIP
schedule.Description.Schedule.Policy.PauseOnFailure = false
return &client.ScheduleUpdate{
Schedule: &schedule.Description.Schedule,
}, nil
@@ -4,12 +4,23 @@
package workflows
import (
"context"
"errors"
"fmt"
"testing"
corework "git.coopcloud.tech/wiki-cafe/member-console/internal/workflows"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/queues"
"github.com/stretchr/testify/mock"
enumspb "go.temporal.io/api/enums/v1"
"go.temporal.io/api/serviceerror"
"go.temporal.io/sdk/client"
"go.temporal.io/sdk/mocks"
"go.temporal.io/sdk/temporal"
"io"
"log/slog"
"reflect"
"slices"
"testing"
"time"
)
// TestIsScheduleNotFound verifies that only Temporal's *serviceerror.NotFound is
@@ -55,3 +66,113 @@ func TestCreateRaceClassification(t *testing.T) {
t.Fatal("wrapped ErrScheduleAlreadyRunning should match under errors.Is")
}
}
// TestEnsureFedWikiMaintenanceSchedule exercises the API options and real Update callback.
func TestEnsureFedWikiMaintenanceSchedule(t *testing.T) {
unavailable := errors.New("unavailable")
rows := []maintenanceScheduleCase{
{name: "absent default", cfg: ScheduleConfig{SyncEnabled: true, TriggerImmediately: true}, interval: time.Hour, calls: []string{"describe", "create"}, wantScheduleID: "fedwiki-site-sync", wantActionID: "fedwiki-site-sync-workflow", wantArgs: SyncFedWikiSitesWorkflowInput{SyncEnabled: true}, wantTrigger: true},
{name: "absent custom off", cfg: ScheduleConfig{Interval: 17 * time.Minute}, interval: 17 * time.Minute, calls: []string{"describe", "create"}, wantScheduleID: "fedwiki-site-sync", wantActionID: "fedwiki-site-sync-workflow", wantArgs: SyncFedWikiSitesWorkflowInput{SyncEnabled: false}, wantTrigger: false},
{name: "absent trigger false", interval: time.Hour, calls: []string{"describe", "create"}, wantScheduleID: "fedwiki-site-sync", wantActionID: "fedwiki-site-sync-workflow", wantArgs: SyncFedWikiSitesWorkflowInput{SyncEnabled: false}, wantTrigger: false},
{name: "existing operator paused", exists: true, cfg: ScheduleConfig{SyncEnabled: true}, interval: time.Hour, state: &client.ScheduleState{Paused: true, Note: "operator"}, calls: []string{"describe", "update"}, wantScheduleID: "fedwiki-site-sync", wantActionID: "fedwiki-site-sync-workflow", wantArgs: SyncFedWikiSitesWorkflowInput{SyncEnabled: true}, wantTrigger: false},
{name: "existing dormant custom", exists: true, cfg: ScheduleConfig{Interval: 17 * time.Minute}, interval: 17 * time.Minute, state: &client.ScheduleState{Paused: true, Note: corework.DormantPauseNote}, calls: []string{"describe", "update"}, wantScheduleID: "fedwiki-site-sync", wantActionID: "fedwiki-site-sync-workflow", wantArgs: SyncFedWikiSitesWorkflowInput{SyncEnabled: false}, wantTrigger: false},
{name: "create race", createErr: temporal.ErrScheduleAlreadyRunning, interval: time.Hour, calls: []string{"describe", "create", "update"}, wantScheduleID: "fedwiki-site-sync", wantActionID: "fedwiki-site-sync-workflow", wantArgs: SyncFedWikiSitesWorkflowInput{SyncEnabled: false}, wantTrigger: false},
{name: "describe failure", describeErr: unavailable, wantErr: unavailable, calls: []string{"describe"}, wantScheduleID: "fedwiki-site-sync", wantActionID: "fedwiki-site-sync-workflow", wantArgs: SyncFedWikiSitesWorkflowInput{SyncEnabled: false}, wantTrigger: false},
{name: "create failure", createErr: unavailable, wantErr: unavailable, interval: time.Hour, calls: []string{"describe", "create"}, wantScheduleID: "fedwiki-site-sync", wantActionID: "fedwiki-site-sync-workflow", wantArgs: SyncFedWikiSitesWorkflowInput{SyncEnabled: false}, wantTrigger: false},
{name: "update failure", exists: true, updateErr: unavailable, wantErr: unavailable, interval: time.Hour, calls: []string{"describe", "update"}, wantScheduleID: "fedwiki-site-sync", wantActionID: "fedwiki-site-sync-workflow", wantArgs: SyncFedWikiSitesWorkflowInput{SyncEnabled: false}, wantTrigger: false},
}
for _, row := range rows {
t.Run(row.name, row.run)
}
}
// maintenanceScheduleCase carries inputs and explicit expectations for one row.
type maintenanceScheduleCase struct {
name string
exists bool
cfg ScheduleConfig
interval time.Duration
state *client.ScheduleState
describeErr, createErr, updateErr, wantErr error
calls []string
wantTrigger bool
wantArgs SyncFedWikiSitesWorkflowInput
wantScheduleID, wantActionID string
}
// run exercises one maintenanceScheduleCase without deriving expectations from its name.
func (row maintenanceScheduleCase) run(t *testing.T) {
temporalClient := &mocks.Client{}
schedules := &mocks.ScheduleClient{}
handle := &mocks.ScheduleHandle{}
temporalClient.On("ScheduleClient").Return(schedules)
var handleID string
schedules.On("GetHandle", mock.Anything, mock.Anything).Run(func(args mock.Arguments) { handleID = args.String(1) }).Return(handle)
description := &client.ScheduleDescription{Schedule: client.Schedule{State: row.state, Policy: &client.SchedulePolicies{Overlap: enumspb.SCHEDULE_OVERLAP_POLICY_ALLOW_ALL, PauseOnFailure: true}}}
describeErr := row.describeErr
if !row.exists && describeErr == nil {
describeErr = serviceerror.NewNotFound("absent")
}
var calls []string
handle.On("Trigger", mock.Anything, mock.Anything).Run(func(mock.Arguments) { calls = append(calls, "trigger") }).Return(nil)
handle.On("Delete", mock.Anything).Run(func(mock.Arguments) { calls = append(calls, "delete") }).Return(nil)
handle.On("Describe", mock.Anything).Run(func(mock.Arguments) { calls = append(calls, "describe") }).Return(description, describeErr)
var created *client.ScheduleOptions
var updated *client.Schedule
schedules.On("Create", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
calls = append(calls, "create")
options := args.Get(1).(client.ScheduleOptions)
created = &options
}).Return(handle, row.createErr)
handle.On("Update", mock.Anything, mock.Anything).Return(func(_ context.Context, opts client.ScheduleUpdateOptions) error {
calls = append(calls, "update")
result, err := opts.DoUpdate(client.ScheduleUpdateInput{Description: *description})
if err != nil {
return err
}
updated = result.Schedule
return row.updateErr
})
err := NewScheduleManager(temporalClient, slog.New(slog.NewTextHandler(io.Discard, nil))).EnsureSyncSchedule(context.Background(), row.cfg)
if !errors.Is(err, row.wantErr) {
t.Errorf("schedule error = %v, want %v", err, row.wantErr)
}
if !slices.Equal(calls, row.calls) {
t.Errorf("schedule calls = %v, want %v", calls, row.calls)
}
check := func(id string, spec *client.ScheduleSpec, action client.ScheduleAction, policy *client.SchedulePolicies) {
t.Helper()
workflowAction, ok := action.(*client.ScheduleWorkflowAction)
if !ok {
t.Errorf("schedule action = %T, want workflow", action)
return
}
if id != row.wantScheduleID || workflowAction.ID != row.wantActionID {
t.Errorf("schedule identity = %s/%s, want retained IDs", id, workflowAction.ID)
}
if reflect.ValueOf(workflowAction.Workflow).Pointer() != reflect.ValueOf(SyncFedWikiSitesWorkflow).Pointer() || workflowAction.TaskQueue != queues.Main || !reflect.DeepEqual(workflowAction.Args, []interface{}{row.wantArgs}) {
t.Errorf("schedule action = %+v, want configured workflow and flag %v", workflowAction, row.wantArgs.SyncEnabled)
}
if spec == nil || len(spec.Intervals) != 1 || spec.Intervals[0].Every != row.interval {
t.Errorf("schedule cadence = %+v, want %v", spec, row.interval)
}
if policy == nil || policy.Overlap != enumspb.SCHEDULE_OVERLAP_POLICY_SKIP {
t.Errorf("schedule overlap = %+v, want skip", policy)
}
if policy == nil || policy.PauseOnFailure {
t.Errorf("schedule pause-on-failure = %+v, want false", policy)
}
}
if created != nil {
check(created.ID, &created.Spec, created.Action, &client.SchedulePolicies{Overlap: created.Overlap, PauseOnFailure: created.PauseOnFailure})
if created.TriggerImmediately != row.wantTrigger {
t.Errorf("schedule trigger = %v, want %v", created.TriggerImmediately, row.wantTrigger)
}
}
if updated != nil {
check(handleID, updated.Spec, updated.Action, updated.Policy)
if !reflect.DeepEqual(updated.State, row.state) {
t.Errorf("schedule state = %+v, want %+v", updated.State, row.state)
}
}
}
+61 -59
View File
@@ -34,18 +34,19 @@ func normalizeFarmStatus(s string) string {
}
}
// SyncFedWikiSitesWorkflowInput is the input for the SyncFedWikiSitesWorkflow.
// It carries no workspace identity: the System tenant workspace that owns
// ownerless sites is resolved per execution by ResolveSystemWorkspaceActivity,
// never from configuration or persisted schedule args. The struct is retained as
// an empty type for backward compatibility — schedules created under the
// previous code still carry a {"DefaultWorkspaceID": "<uuid>"} Args JSON, which
// Go's default DataConverter silently ignores until the next Update rewrites it.
type SyncFedWikiSitesWorkflowInput struct{}
// SyncFedWikiSitesWorkflowInput selects farm projection within the maintenance run.
type SyncFedWikiSitesWorkflowInput struct {
SyncEnabled bool
}
// SyncFedWikiSitesWorkflowOutput is the output for the SyncFedWikiSitesWorkflow.
// SyncFedWikiSitesWorkflowOutput reports returned counts and whether projection
// succeeded. Sync failures retain ErrorMessage; sweep failures only log errors.
type SyncFedWikiSitesWorkflowOutput struct {
Success bool
SyncPerformed bool
Purged int
Parked int
Reactivated int
SitesFound int
SitesAdded int
SitesRemoved int
@@ -53,7 +54,7 @@ type SyncFedWikiSitesWorkflowOutput struct {
SyncTimestamp time.Time
}
// SyncActivityOptions returns activity options for sync operations.
// SyncActivityOptions returns bounded retry options for the maintenance run.
func SyncActivityOptions() workflow.ActivityOptions {
return workflow.ActivityOptions{
StartToCloseTimeout: 5 * time.Minute,
@@ -66,21 +67,53 @@ func SyncActivityOptions() workflow.ActivityOptions {
}
}
// SyncFedWikiSitesWorkflow synchronizes sites from the FedWiki farm to the local database.
// It fetches all sites from the FarmManager API and ensures the local database reflects
// the current state of the farm.
// SyncFedWikiSitesWorkflow orders optional farm projection, retention and quota.
// Failed projection skips irreversible retention, but quota still runs on local
// state. Sync failures retain friendly results; sweep failures remain non-fatal.
func SyncFedWikiSitesWorkflow(ctx workflow.Context, input SyncFedWikiSitesWorkflowInput) (*SyncFedWikiSitesWorkflowOutput, error) {
logger := workflow.GetLogger(ctx)
logger.Info("SyncFedWikiSitesWorkflow started")
var activities *Activities
activityCtx := workflow.WithActivityOptions(ctx, SyncActivityOptions())
out := &SyncFedWikiSitesWorkflowOutput{Success: true}
if input.SyncEnabled {
out = syncFarmSites(ctx, activityCtx)
}
if out.Success {
var purgeResult *PurgeExpiredArchivedOutput
if err := workflow.ExecuteActivity(activityCtx, activities.PurgeExpiredArchivedActivity, struct{}{}).Get(ctx, &purgeResult); err != nil {
logger.Error("FedWiki retention purge failed (non-fatal)", "error", err)
} else if purgeResult != nil {
out.Purged = purgeResult.Purged
if out.Purged > 0 {
logger.Info("FedWiki retention purge applied", "purged", out.Purged)
}
}
}
var reconcileResult *ReconcileFedWikiQuotaOutput
if err := workflow.ExecuteActivity(activityCtx, activities.ReconcileFedWikiQuotaActivity, ReconcileFedWikiQuotaInput{}).Get(ctx, &reconcileResult); err != nil {
logger.Error("FedWiki quota reconcile failed (non-fatal)", "error", err)
} else if reconcileResult != nil {
out.Parked = reconcileResult.Parked
out.Reactivated = reconcileResult.Reactivated
if out.Parked > 0 || out.Reactivated > 0 {
logger.Info("FedWiki quota reconcile applied", "parked", out.Parked, "reactivated", out.Reactivated)
}
}
out.SyncTimestamp = workflow.Now(ctx)
if out.Success {
logger.Info("SyncFedWikiSitesWorkflow completed successfully",
"sitesFound", out.SitesFound, "sitesAdded", out.SitesAdded, "sitesRemoved", out.SitesRemoved,
"purged", out.Purged, "parked", out.Parked, "reactivated", out.Reactivated, "syncPerformed", out.SyncPerformed)
}
return out, nil
}
// Step 0: Resolve the System tenant workspace by natural key at execution
// time, so the sync never trusts a workspace ID captured once at
// schedule-creation time. On failure, return the same friendly output shape
// Step 1 uses rather than a workflow-level error, keeping both pre-steps
// uniform.
// syncFarmSites resolves the workspace and projects one farm observation. Its
// friendly failure result lets the caller skip retention without skipping quota.
func syncFarmSites(ctx, activityCtx workflow.Context) *SyncFedWikiSitesWorkflowOutput {
logger := workflow.GetLogger(ctx)
var activities *Activities
var resolveResult *ResolveSystemWorkspaceOutput
if err := workflow.ExecuteActivity(activityCtx, activities.ResolveSystemWorkspaceActivity, ResolveSystemWorkspaceInput{}).Get(ctx, &resolveResult); err != nil {
logger.Error("Failed to resolve System tenant workspace", "error", err)
@@ -88,11 +121,11 @@ func SyncFedWikiSitesWorkflow(ctx workflow.Context, input SyncFedWikiSitesWorkfl
Success: false,
ErrorMessage: "Failed to resolve the System tenant workspace. Please try again later.",
SyncTimestamp: workflow.Now(ctx),
}, nil
}
}
logger.Info("Resolved System tenant workspace", "workspaceID", resolveResult.WorkspaceID)
// Step 1: Fetch all sites from the FedWiki farm
// Fetch all sites from the FedWiki farm
var listResult *ListFedWikiSitesOutput
err := workflow.ExecuteActivity(activityCtx, activities.ListFedWikiSitesActivity, ListFedWikiSitesInput{}).Get(ctx, &listResult)
if err != nil {
@@ -101,12 +134,12 @@ func SyncFedWikiSitesWorkflow(ctx workflow.Context, input SyncFedWikiSitesWorkfl
Success: false,
ErrorMessage: "Failed to fetch sites from FedWiki farm. Please try again later.",
SyncTimestamp: workflow.Now(ctx),
}, nil
}
}
logger.Info("Fetched sites from FedWiki farm", "count", len(listResult.Sites))
// Step 2: Sync the sites to the local database
// Sync the sites to the local database
var syncResult *SyncSitesToDBOutput
err = workflow.ExecuteActivity(activityCtx, activities.SyncSitesToDBActivity, SyncSitesToDBInput{
FarmSites: listResult.Sites,
@@ -120,44 +153,13 @@ func SyncFedWikiSitesWorkflow(ctx workflow.Context, input SyncFedWikiSitesWorkfl
SitesFound: len(listResult.Sites),
ErrorMessage: "Failed to sync sites to local database.",
SyncTimestamp: workflow.Now(ctx),
}, nil
}
}
// Step 3: Enforce "quota counts active sites" (force_reduce park / re-upgrade
// reactivate) across every workspace that owns sites. Idempotent and cheap
// when already compliant, so running it each tick covers all downgrade and
// upgrade sources without per-trigger wiring. Non-fatal: the sync itself
// already succeeded; the next tick retries.
var reconcileResult *ReconcileFedWikiQuotaOutput
if err := workflow.ExecuteActivity(activityCtx, activities.ReconcileFedWikiQuotaActivity, ReconcileFedWikiQuotaInput{}).Get(ctx, &reconcileResult); err != nil {
logger.Error("FedWiki quota reconcile failed (non-fatal)", "error", err)
} else if reconcileResult != nil && (reconcileResult.Parked > 0 || reconcileResult.Reactivated > 0) {
logger.Info("FedWiki quota reconcile applied",
"parked", reconcileResult.Parked,
"reactivated", reconcileResult.Reactivated)
}
// Step 4: Retention purge — permanently delete archived sites past the
// retention window. Non-fatal; the next tick retries.
var purgeResult *PurgeExpiredArchivedOutput
if err := workflow.ExecuteActivity(activityCtx, activities.PurgeExpiredArchivedActivity, struct{}{}).Get(ctx, &purgeResult); err != nil {
logger.Error("FedWiki retention purge failed (non-fatal)", "error", err)
} else if purgeResult != nil && purgeResult.Purged > 0 {
logger.Info("FedWiki retention purge applied", "purged", purgeResult.Purged)
}
logger.Info("SyncFedWikiSitesWorkflow completed successfully",
"sitesFound", len(listResult.Sites),
"sitesAdded", syncResult.SitesAdded,
"sitesRemoved", syncResult.SitesRemoved)
return &SyncFedWikiSitesWorkflowOutput{
Success: true,
SitesFound: len(listResult.Sites),
SitesAdded: syncResult.SitesAdded,
SitesRemoved: syncResult.SitesRemoved,
SyncTimestamp: workflow.Now(ctx),
}, nil
Success: true, SyncPerformed: true,
SitesFound: len(listResult.Sites), SitesAdded: syncResult.SitesAdded, SitesRemoved: syncResult.SitesRemoved,
}
}
// ResolveSystemWorkspaceInput is the input for ResolveSystemWorkspaceActivity.
@@ -170,7 +172,7 @@ type ResolveSystemWorkspaceOutput struct {
// ResolveSystemWorkspaceActivity resolves the System tenant workspace by natural
// key (org_type='system') via systemtenant.Ensure, which is idempotent and cheap
// in steady state (one indexed SELECT). Run as Step 0 of the sync workflow so the
// in steady state (one indexed SELECT). Run before enabled farm projection so the
// workspace ID is derived fresh per execution rather than carried in workflow
// input or schedule args, where it would go stale on an app-database wipe.
func (a *Activities) ResolveSystemWorkspaceActivity(ctx context.Context, input ResolveSystemWorkspaceInput) (*ResolveSystemWorkspaceOutput, error) {
@@ -6,17 +6,20 @@ package workflows
import (
"context"
"errors"
"git.coopcloud.tech/wiki-cafe/member-console/internal/systemtenant"
"github.com/stretchr/testify/mock"
"go.temporal.io/sdk/activity"
"go.temporal.io/sdk/converter"
"go.temporal.io/sdk/testsuite"
"reflect"
"slices"
"sync"
"testing"
"time"
"github.com/stretchr/testify/mock"
"go.temporal.io/sdk/testsuite"
"git.coopcloud.tech/wiki-cafe/member-console/internal/systemtenant"
)
// TestSyncWorkflowResolvesWorkspaceFirst asserts Step 0
// (ResolveSystemWorkspaceActivity) runs before Step 1 (ListFedWikiSitesActivity)
// TestSyncWorkflowResolvesWorkspaceFirst asserts the resolve activity runs
// before the list activity
// and that the workspace ID it returns is threaded into SyncSitesToDBActivity's
// input — i.e. the sync projects into the run-time resolved System workspace,
// not a value carried in workflow input — together with the moment the list
@@ -62,7 +65,7 @@ func TestSyncWorkflowResolvesWorkspaceFirst(t *testing.T) {
env.OnActivity(acts.PurgeExpiredArchivedActivity, mock.Anything, mock.Anything).
Return(&PurgeExpiredArchivedOutput{}, nil)
env.ExecuteWorkflow(SyncFedWikiSitesWorkflow, SyncFedWikiSitesWorkflowInput{})
env.ExecuteWorkflow(SyncFedWikiSitesWorkflow, SyncFedWikiSitesWorkflowInput{SyncEnabled: true})
if !env.IsWorkflowCompleted() {
t.Fatal("workflow did not complete")
@@ -91,9 +94,8 @@ func TestSyncWorkflowResolvesWorkspaceFirst(t *testing.T) {
}
}
// TestSyncWorkflowResolveFailureReturnsFriendlyOutput asserts that a Step 0
// failure yields (*SyncFedWikiSitesWorkflowOutput{Success:false}, nil) — the same
// log-and-return shape Step 1 uses — rather than a workflow-level error.
// TestSyncWorkflowResolveFailureReturnsFriendlyOutput asserts that a resolve
// failure returns the friendly output with a nil workflow error after quota runs.
func TestSyncWorkflowResolveFailureReturnsFriendlyOutput(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
@@ -101,8 +103,11 @@ func TestSyncWorkflowResolveFailureReturnsFriendlyOutput(t *testing.T) {
var acts *Activities
env.OnActivity(acts.ResolveSystemWorkspaceActivity, mock.Anything, mock.Anything).
Return(nil, errors.New("system tenant unavailable"))
quotaRan := false
env.OnActivity(acts.ReconcileFedWikiQuotaActivity, mock.Anything, mock.Anything).
Run(func(mock.Arguments) { quotaRan = true }).Return(&ReconcileFedWikiQuotaOutput{}, nil)
env.ExecuteWorkflow(SyncFedWikiSitesWorkflow, SyncFedWikiSitesWorkflowInput{})
env.ExecuteWorkflow(SyncFedWikiSitesWorkflow, SyncFedWikiSitesWorkflowInput{SyncEnabled: true})
if !env.IsWorkflowCompleted() {
t.Fatal("workflow did not complete")
@@ -117,6 +122,9 @@ func TestSyncWorkflowResolveFailureReturnsFriendlyOutput(t *testing.T) {
if out.Success {
t.Error("Success = true, want false on resolve failure")
}
if !quotaRan {
t.Error("quota after failed resolve = false, want true")
}
if out.ErrorMessage == "" {
t.Error("ErrorMessage is empty, want a friendly message")
}
@@ -146,3 +154,252 @@ func TestResolveSystemWorkspaceActivity_DB(t *testing.T) {
t.Errorf("activity WorkspaceID = %q, systemtenant.Ensure = %q", out.WorkspaceID, want)
}
}
// maintenanceLog captures only workflow messages; its lock also covers activity logging.
type maintenanceLog struct {
mu sync.Mutex
entries []maintenanceEntry
}
// maintenanceEntry holds one message and its structured attributes.
type maintenanceEntry struct {
message string
fields []interface{}
}
// append records a message without retaining the caller's field slice.
func (log *maintenanceLog) append(message string, fields ...interface{}) {
log.mu.Lock()
defer log.mu.Unlock()
log.entries = append(log.entries, maintenanceEntry{message, slices.Clone(fields)})
}
// Debug records SDK diagnostics alongside workflow messages.
func (log *maintenanceLog) Debug(message string, fields ...interface{}) {
log.append(message, fields...)
}
// Info records completion messages and their counts.
func (log *maintenanceLog) Info(message string, fields ...interface{}) {
log.append(message, fields...)
}
// Warn records warning messages.
func (log *maintenanceLog) Warn(message string, fields ...interface{}) {
log.append(message, fields...)
}
// Error records activity and workflow failures.
func (log *maintenanceLog) Error(message string, fields ...interface{}) {
log.append(message, fields...)
}
// matching snapshots messages of one kind after the workflow has finished.
func (log *maintenanceLog) matching(message string) [][]interface{} {
log.mu.Lock()
defer log.mu.Unlock()
var found [][]interface{}
for _, entry := range log.entries {
if entry.message == message {
found = append(found, entry.fields)
}
}
return found
}
// TestFedWikiMaintenanceWorkflow holds failure reporting and both enablement paths.
func TestFedWikiMaintenanceWorkflow(t *testing.T) {
rows := []maintenanceWorkflowCase{
{"sync on", true, nil, []string{"resolve", "list", "project", "purge", "quota"}, [3]int{2, 3, 1}, [3]int{4, 2, 1}, true, true, "", true},
{"sync off", false, nil, []string{"purge", "quota"}, [3]int{2, 3, 1}, [3]int{}, true, false, "", true},
{"sync off zero", false, nil, []string{"purge", "quota"}, [3]int{}, [3]int{}, true, false, "", true},
{"resolve error", true, []string{"resolve"}, []string{"resolve", "quota"}, [3]int{0, 3, 1}, [3]int{}, false, false, "Failed to resolve the System tenant workspace. Please try again later.", false},
{"list error", true, []string{"list"}, []string{"resolve", "list", "quota"}, [3]int{0, 3, 1}, [3]int{}, false, false, "Failed to fetch sites from FedWiki farm. Please try again later.", false},
{"project error", true, []string{"project"}, []string{"resolve", "list", "project", "quota"}, [3]int{0, 3, 1}, [3]int{4, 0, 0}, false, false, "Failed to sync sites to local database.", false},
{"project and quota error", true, []string{"project", "quota"}, []string{"resolve", "list", "project", "quota"}, [3]int{}, [3]int{4, 0, 0}, false, false, "Failed to sync sites to local database.", false},
{"purge error", false, []string{"purge"}, []string{"purge", "quota"}, [3]int{0, 3, 1}, [3]int{}, true, false, "", true},
{"quota error", false, []string{"quota"}, []string{"purge", "quota"}, [3]int{2, 0, 0}, [3]int{}, true, false, "", true},
{"both sweep errors", false, []string{"purge", "quota"}, []string{"purge", "quota"}, [3]int{}, [3]int{}, true, false, "", true},
}
for _, row := range rows {
t.Run(row.name, row.run)
}
}
// maintenanceWorkflowCase carries inputs and explicit expectations for one row.
type maintenanceWorkflowCase struct {
name string
enabled bool
failures []string
calls []string
counts [3]int
sync [3]int
success, performed bool
friendly string
completion bool
}
// run exercises one maintenanceWorkflowCase without deriving expectations from its name.
func (row maintenanceWorkflowCase) run(t *testing.T) {
logger := &maintenanceLog{}
var suite testsuite.WorkflowTestSuite
suite.SetLogger(logger)
env := suite.NewTestWorkflowEnvironment()
var acts *Activities
var calls []string
var mutex sync.Mutex
record := func(name string) func(mock.Arguments) {
return func(_ mock.Arguments) {
mutex.Lock()
defer mutex.Unlock()
if len(calls) == 0 || calls[len(calls)-1] != name {
calls = append(calls, name)
}
}
}
activityRows := []struct {
name string
method interface{}
result interface{}
}{
{"resolve", acts.ResolveSystemWorkspaceActivity, &ResolveSystemWorkspaceOutput{WorkspaceID: "workspace"}},
{"list", acts.ListFedWikiSitesActivity, &ListFedWikiSitesOutput{Sites: make([]SiteInfo, 4)}},
{"project", acts.SyncSitesToDBActivity, &SyncSitesToDBOutput{SitesAdded: 2, SitesRemoved: 1}},
{"purge", acts.PurgeExpiredArchivedActivity, &PurgeExpiredArchivedOutput{Purged: row.counts[0]}},
{"quota", acts.ReconcileFedWikiQuotaActivity, &ReconcileFedWikiQuotaOutput{Parked: row.counts[1], Reactivated: row.counts[2]}},
}
for _, item := range activityRows {
var err error
if slices.Contains(row.failures, item.name) {
err = errors.New(item.name + " unavailable")
item.result = nil
}
env.OnActivity(item.method, mock.Anything, mock.Anything).Run(record(item.name)).Return(item.result, err)
}
env.ExecuteWorkflow(SyncFedWikiSitesWorkflow, SyncFedWikiSitesWorkflowInput{SyncEnabled: row.enabled})
if err := env.GetWorkflowError(); err != nil {
t.Fatalf("maintenance error = %v, want nil", err)
}
var out SyncFedWikiSitesWorkflowOutput
if err := env.GetWorkflowResult(&out); err != nil {
t.Fatal(err)
}
if !slices.Equal(calls, row.calls) {
t.Errorf("maintenance calls = %v, want %v", calls, row.calls)
}
if out.Success != row.success || out.SyncPerformed != row.performed || out.ErrorMessage != row.friendly || [3]int{out.Purged, out.Parked, out.Reactivated} != row.counts || out.SyncTimestamp.IsZero() || [3]int{out.SitesFound, out.SitesAdded, out.SitesRemoved} != row.sync {
t.Errorf("maintenance result = %+v, want success %v performed %v friendly %q counts %v and sync counts %v", out, row.success, row.performed, row.friendly, row.counts, row.sync)
}
completions := logger.matching("SyncFedWikiSitesWorkflow completed successfully")
var want [][]interface{}
if row.completion {
want = [][]interface{}{{"sitesFound", row.sync[0], "sitesAdded", row.sync[1], "sitesRemoved", row.sync[2], "purged", row.counts[0], "parked", row.counts[1], "reactivated", row.counts[2], "syncPerformed", row.performed}}
}
if !reflect.DeepEqual(completions, want) {
t.Errorf("maintenance completion counts = %v, want %v", completions, want)
}
for _, sweep := range []struct {
name, message string
count int
}{{"purge", "FedWiki retention purge", row.counts[0]}, {"quota", "FedWiki quota reconcile", row.counts[1] + row.counts[2]}} {
wantErrors := 0
if slices.Contains(row.failures, sweep.name) {
wantErrors = 1
}
if got := len(logger.matching(sweep.message + " failed (non-fatal)")); got != wantErrors {
t.Errorf("maintenance error logs = %d, want %d", got, wantErrors)
}
wantApplied := 0
if sweep.count > 0 {
wantApplied = 1
}
if got := len(logger.matching(sweep.message + " applied")); got != wantApplied {
t.Errorf("maintenance completion counts = %d applied, want %d", got, wantApplied)
}
}
}
// TestFedWikiMaintenanceWaitsForEachStep delays both handoffs and retries projection.
func TestFedWikiMaintenanceWaitsForEachStep(t *testing.T) {
for _, row := range []maintenanceStepCase{
{"retry succeeds", false, 2, []time.Duration{3 * time.Second}, [][2]string{{"SyncSitesToDBActivity", "PurgeExpiredArchivedActivity"}, {"PurgeExpiredArchivedActivity", "ReconcileFedWikiQuotaActivity"}}},
{"retry exhausted", true, 5, []time.Duration{3 * time.Second, 4 * time.Second, 6 * time.Second, 10 * time.Second}, [][2]string{{"SyncSitesToDBActivity", "ReconcileFedWikiQuotaActivity"}}},
} {
t.Run(row.name, row.run)
}
}
// maintenanceStepCase holds the retry and handoff expectations for one run.
type maintenanceStepCase struct {
name string
alwaysFails bool
starts int
gaps []time.Duration
pairs [][2]string
}
// run observes actual activity attempts and handoffs on the mock clock.
func (row maintenanceStepCase) run(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
env.SetWorkflowRunTimeout(10 * time.Minute)
var acts *Activities
var mutex sync.Mutex
var events []string
started := map[string]int{}
var projectionStarts []time.Time
env.SetOnActivityStartedListener(func(info *activity.Info, _ context.Context, _ converter.EncodedValues) {
mutex.Lock()
defer mutex.Unlock()
events = append(events, "start "+info.ActivityType.Name)
started[info.ActivityType.Name]++
if info.ActivityType.Name == "SyncSitesToDBActivity" {
projectionStarts = append(projectionStarts, env.Now())
}
policy := SyncActivityOptions().RetryPolicy
if info.StartToCloseTimeout != 5*time.Minute || policy == nil || policy.MaximumAttempts != 5 || policy.InitialInterval != time.Second || policy.MaximumInterval != time.Minute || policy.BackoffCoefficient != 2 {
t.Errorf("maintenance activity options = %+v, want bounded five attempts", info)
}
})
env.SetOnActivityCompletedListener(func(info *activity.Info, _ converter.EncodedValue, _ error) {
mutex.Lock()
defer mutex.Unlock()
events = append(events, "end "+info.ActivityType.Name)
})
env.OnActivity(acts.ResolveSystemWorkspaceActivity, mock.Anything, mock.Anything).Return(&ResolveSystemWorkspaceOutput{WorkspaceID: "workspace"}, nil)
env.OnActivity(acts.ListFedWikiSitesActivity, mock.Anything, mock.Anything).Return(&ListFedWikiSitesOutput{}, nil)
if row.alwaysFails {
env.OnActivity(acts.SyncSitesToDBActivity, mock.Anything, mock.Anything).After(2*time.Second).Return(nil, errors.New("retry projection"))
} else {
env.OnActivity(acts.SyncSitesToDBActivity, mock.Anything, mock.Anything).After(2*time.Second).Return(nil, errors.New("retry projection")).Once()
env.OnActivity(acts.SyncSitesToDBActivity, mock.Anything, mock.Anything).After(2*time.Second).Return(&SyncSitesToDBOutput{}, nil).Once()
}
env.OnActivity(acts.PurgeExpiredArchivedActivity, mock.Anything, mock.Anything).After(4*time.Second).Return(&PurgeExpiredArchivedOutput{}, nil)
env.OnActivity(acts.ReconcileFedWikiQuotaActivity, mock.Anything, mock.Anything).Return(&ReconcileFedWikiQuotaOutput{}, nil)
env.ExecuteWorkflow(SyncFedWikiSitesWorkflow, SyncFedWikiSitesWorkflowInput{SyncEnabled: true})
if err := env.GetWorkflowError(); err != nil {
t.Errorf("maintenance activity options = workflow error %v, want completion within ten minutes", err)
}
for _, pair := range row.pairs {
lastEnd := -1
firstStart := -1
for index, event := range events {
if event == "end "+pair[0] {
lastEnd = index
}
if event == "start "+pair[1] && firstStart < 0 {
firstStart = index
}
}
if lastEnd < 0 || firstStart <= lastEnd {
t.Errorf("maintenance started a later step before completion: %v, want %v before %v", events, pair[0], pair[1])
}
}
var gaps []time.Duration
for index := 1; index < len(projectionStarts); index++ {
gaps = append(gaps, projectionStarts[index].Sub(projectionStarts[index-1]))
}
if started["SyncSitesToDBActivity"] != row.starts || !slices.Equal(gaps, row.gaps) {
t.Errorf("maintenance activity options = %v starts %v gaps, want %v starts %v gaps", started, gaps, row.starts, row.gaps)
}
}
+6 -7
View File
@@ -10,18 +10,17 @@ import (
)
// needsTemporalByIntegration is which registered integrations declare
// NeedsTemporal on a key of a RequiredGroup (design D11): the two that
// receive events from outside and process them only on Temporal.
// NeedsTemporal on a grouped key for stored work no other processor handles.
var needsTemporalByIntegration = map[string]bool{
"stripe": true,
"discourse": true,
"fedwiki": false,
"fedwiki": true,
}
// TestIntegrationsThatReceiveEventsNeedTemporal holds each registered
// integration's ConfigSpec to the table above: Stripe and Discourse declare
// NeedsTemporal on a grouped key, FedWiki on none.
func TestIntegrationsThatReceiveEventsNeedTemporal(t *testing.T) {
// TestIntegrationsWithStoredWorkNeedTemporal holds each registered
// integration's ConfigSpec to its stored-work obligation, including FedWiki's
// periodic retention and quota enforcement.
func TestIntegrationsWithStoredWorkNeedTemporal(t *testing.T) {
seen := 0
for _, integ := range All() {
want, ok := needsTemporalByIntegration[integ.Key()]
@@ -58,7 +58,7 @@ func newRuleChangeFixture(t *testing.T, database *sql.DB) ruleChangeFixture {
`UPDATE core.resource_keys
SET provider = 'fedwiki',
over_limit_behavior = 'park',
over_limit_consequence = 'Sites over the limit become read-only at the next sync.'
over_limit_consequence = 'Sites over the limit become read-only.'
WHERE resource_key = 'fedwiki_sites'`); err != nil {
t.Fatalf("stamp fedwiki_sites: %v", err)
}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-30
@@ -0,0 +1,78 @@
## Current inputs and order
HEAD is `c7741fa5`. Sync resolves the System workspace, lists the farm, projects rows, parks/reactivates, then purges (`internal/integrations/fedwiki/workflows/sync.go:85`, `:97`, `:111`, `:132`, `:143`). Its schedule skips overlapping runs by default. Startup pauses that schedule when sync is disabled (`internal/integrations/fedwiki/fedwiki.go:542`).
Neither sweep takes the listing or the System workspace. Retention reads local archive times and accepted requests (`workflows/reconcile.go:101`, `:148`). Quota reconciliation reads primary pools, limits, governing policies, active sites and holds (`:54`, `:189`, `:227`), not the stored usage figure. However, the preceding projection supplies current statuses and modification times. The sync's `usage.RecountAll` (`workflows/sync.go:246`) repairs counters and stays inside projection; neither sweep replaces it.
## One scheduled run
Maintenance means the run that optionally reads and projects the farm, then purges expired archives, then parks or reactivates sites. Reuse `fedwiki-site-sync`, its action ID `fedwiki-site-sync-workflow`, and the registered `SyncFedWikiSitesWorkflow`. Change that workflow's input to exactly `SyncFedWikiSitesWorkflowInput{SyncEnabled bool}`. The schedule supplies one such struct in `Args`; sweep activities still take `struct{}{}` and `ReconcileFedWikiQuotaInput{}`. Keeping these existing identifiers adds no alias, history test or compatibility branch.
At boot, `EnsureSyncSchedule` replaces the stored spec and action through its normal Update path, including the current sync flag. Only NotFound creates; a concurrent Create returns to Update. Do not delete/recreate the schedule: that would lose its operator pause and its knowledge of an open action. This same identity prevents an old schedule firing alongside a new one. Neither of the previous draft's two schedule IDs is introduced or handled specially.
`fedwiki-sync-interval` already exists, is positive, and defaults to one hour (`fedwiki.go:215`). Use it for the whole run with sync on or off; do not select an interval by the flag or introduce a second knob. Keep the key name and change its description. `fedwiki-sync-trigger-immediately` controls the whole run on creation only, as its consumer does today (`workflows/schedule.go:112`); default true. Updating an existing schedule does not trigger an extra run. Explicitly set skip-overlap and `PauseOnFailure: false` on both Create and Update, preserving schedule state. An operator's single pause stops all steps. Today retention and parking cannot be paused apart from sync either, so no existing control is lost.
Startup keeps lifecycle settlement first. A configured farm always ensures this schedule and resumes only a dormancy-note pause. An unconfigured farm pauses its leftover schedule and creates none, even if sync is enabled. Ensure, resume and pause errors remain logged and non-fatal under `internal/workflows/worker.go:86`; a missing schedule after a failed ensure needs a successful later boot. Temporal connection retries stay unchanged.
The workflow awaits the optional resolve/list/project sequence, then retention, then quota. The requested retention-before-quota order differs from HEAD's last two steps; neither consumes the other's output, and archived rows hold no slot. Separate schedules, simultaneous activities in one workflow, or running sweeps inside sync when enabled and elsewhere when disabled would create two execution paths or let writes overtake the listing. One ordered run retains the useful ordering without depending on the enable flag.
Retain `SyncActivityOptions`, its five-minute attempt and five-attempt policy. When an enabled resolve, list or projection activity exhausts its retries, skip retention, run quota, then return the existing friendly failure result with a nil workflow error. The purge is irreversible, and farm-side restores reach local rows only through projection. Parking is reversible and uses local state, as every sync-off deployment already does. A listing failure must not indefinitely prevent a lower limit from taking effect. If the farm is unavailable, quota's single-site call fails before the local status write.
With sync disabled, no resolver/list/projection activity runs, and both sweeps use recorded local state. Retention and quota errors keep today's non-fatal logs and do not become workflow errors; a retention error still permits quota. Keep all existing log messages, including the nonzero-only "applied" lines. Add `purged`, `parked`, `reactivated` and `syncPerformed` attributes to the existing completion line on its normal success path, not a new line or a false success claim after a failed sync. With sync off, the existing `sitesFound`, `sitesAdded` and `sitesRemoved` attributes are zero; the skipped activities supply no result to dereference. Counts describe successful activity results, not every candidate; failed or skipped activities contribute no returned count. Their existing error logs distinguish failure from no work. Explicit `PauseOnFailure: false` preserves later ticks.
Keep all current output fields, including `Success` and `ErrorMessage`, and add `SyncPerformed`, `Purged`, `Parked` and `Reactivated`. `SyncPerformed` is true only after projection completes successfully. After an enabled-sync failure, quota's returned counts accompany `Success: false` and the unchanged friendly message; the workflow error remains nil. With sync disabled, or after successful sync, `Success` remains true even if a sweep logs an error, as today's non-fatal handling does. `SyncTimestamp` remains the run timestamp. Temporal and the operator panel still show Completed for friendly failure results; changing that visibility is outside #283.
## Ordering, races and locks
| Reviewed case | Result with one scheduled run |
|---|---|
| Purge between farm listing and local-row listing recreates the site | Impossible between this schedule's steps: every projection attempt finishes before purge starts; ticks while this run is open are dropped, not buffered. |
| Parking in that window is overwritten as active | Impossible for the same reason; quota runs last. |
| Reactivation in that window loses its downgrade mark | Impossible for the same reason; no later projection in this run writes the old readonly observation. |
| Site restored on the farm before this run's listing is hard-deleted from a stale local archive | Successful projection records the restore before retention tests eligibility. A failed enabled-sync activity skips retention but still permits quota. |
This closes the races the two-schedule draft would introduce; there is no separate stale-listing issue from this change. It does not claim to prevent an out-of-band farm restore after the listing, an individual projection error that today's best-effort projection logs without failing its activity, or a farm-only restore while sync is disabled. In those cases the local row does not record the restore, so retention can still see an eligible archive, as it can today. A plain manual schedule trigger uses skip-overlap. An operator can bypass it by starting the workflow type by hand, or by triggering or backfilling the schedule with an explicit overlap-policy override. A remote call whose cancellation was not honored can also outlive its attempt; this is not a new handoff risk.
The order in `docs/database-locks.md:429`, `:460`, `:476`, `:549` stays: purge takes lifecycle instance → eligible site by ID → placement rows through the trigger, holding the first two locks through farm DELETE/local commit; release takes the registry lock afterward. Quota calls the farm first, then locks site → placement → usage and recounts, with a reactivation limit check. Sync writes site → placement → usage, or creates a placement separately under registry → one site FOR SHARE. Member requests may overlap the run and use the existing locks. No global lock, reverse edge, schema reference or privilege is added; provider-integration invariant 12 (`docs/models/provider-integration.md:100`) remains intact.
## Parking order and a failed read
Only sync writes `last_modified_at`; console creation leaves it NULL. Today the keep/park queries put NULL observations after every observed site (`internal/integrations/fedwiki/store/queries/sites.sql:204`, `:216`, `:228`). Change all three selection queries, including the workspace picker, to `ORDER BY COALESCE(last_modified_at, created_at) DESC, created_at DESC, site_id DESC`. Each site's known farm time wins over its creation time; an unobserved site competes by its creation time, rather than always losing to an older observed site. Reactivation uses the same order. The member's Keep-active swap also uses this order to park the last active site from its workspace list (`internal/integrations/fedwiki/web/site_actions.go:601`, `:612`). For an older observed site and a newer unobserved one, the swap now parks the observed site instead of always parking the unobserved one. A separate swap rule would make the sweep and the member action disagree. Reading the farm solely to rank surplus sites would make sync-off enforcement depend on extra farm reads; using the recorded value with an explicit fallback keeps one path. Tests must mix NULL and observed times, not only test an all-NULL list.
`reconcilePoolQuota` currently treats any entitlement-read error as no entitlement (`workflows/reconcile.go:190`). Keep silent absence only for `errors.Is(err, sql.ErrNoRows)`; log every other error at Warn, like the neighboring reads, with `pool` and `error`, then leave that pool unchanged and continue. This is the small fix approved to ride along, not a new failure-propagation framework.
## Temporal, first run and visibility
This revisits D11 of `openspec/changes/archive/2026-10-24-serve-before-dependencies/design.md:108`, which said FedWiki did not need Temporal because it left no work for an absent worker. An expired archive or lowered limit is such work, even without webhook events. Set `NeedsTemporal` on the farm URL's RequiredGroup declaration. Dormant FedWiki remains valid; configured FedWiki with no host is refused. The shared refusal format forces the same removal of its events clause for Stripe and Discourse; their validation behavior stays.
The first run attempts the entire existing eligible backlog: archives older than 30 days can be permanently deleted and existing force-reduce surpluses parked. On a newly created schedule the default triggers that run immediately; on an updated schedule or with the trigger setting false, it runs at the next scheduled time. There is no age reset or extra grace period. Deployment-specific counts and handling of already-open old executions belong only in the lander's commit note, not in product documentation or compatibility code.
| Changed failure path | Today | After |
|---|---|---|
| Sync off | Leftover paused; no sweeps | Same schedule runs retention then quota without farm projection |
| Farm unconfigured, sync flag on | Schedule can be created | Leftover paused; none created |
| No Temporal host | Configured FedWiki boots without automatic work | Configuration refused |
| Enabled resolve/list/project activity fails | Friendly failure result; no sweeps | Skip retention, run quota, then return the same friendly failure result |
| Retention activity fails | Logged after quota | Same non-fatal log; quota is still attempted afterward |
| Entitlement read fails | Silently treated as absent | Warn with pool; pool untouched, later run retries |
Inspect the same schedule/execution in Temporal; workflow logs distinguish the optional sync and both sweeps. The existing operator panel reads this schedule (`internal/integrations/fedwiki/web/operator.go:169`), so label it Site maintenance / Last run, with No run yet as its empty value, and replace its sync tooltip with the combined run's actions. Build the tooltip's integer retention days from `workflows.ArchiveRetention`, not a literal. The panel ignores the sync flag and reports the execution's Temporal status, not proof of a successful projection. The settings page describes the interval and first trigger as applying to the whole run. Both changed pages need hand captures at landing. The unrendered consequence metadata is updated by boot registration; the member's archive copy (`templates/fedwiki_sites.html:170`, `:187`) stays true without requiring sync, with service failures and accepted requests still able to delay removal.
## Copy
All rows are approved. Old row numbers are retained for review. Existing workflow, schedule, startup and sweep messages and friendly results stay unchanged. Counts are structured attributes, not new prose.
| # | Location | Old text | New text | Status |
|---|---|---|---|---|
| 1 | Provider consequence | Sites over the limit become read-only at the next sync. | Sites over the limit become read-only. | approved |
| 2 | Shared config refusal format | `%s needs Temporal, which processes its events: set temporal-host (or leave %s empty to disable it)` | `%s needs Temporal: set temporal-host (or leave %s empty to disable it)` | approved |
| 4 | Interval Usage | Interval between FedWiki site syncs | Interval between FedWiki maintenance runs | approved |
| 5 | Creation-trigger Usage | Run sync immediately on startup | Run FedWiki maintenance when its schedule is created | approved |
| 6 | Operator section | Sync health | Site maintenance | approved |
| 7 | Operator label | Last sync | Last run | approved |
| 8 | Operator empty value | No sync has run yet | No run yet | approved |
| 9 | Operator tooltip | The sync is the periodic background job that reconciles this list with the wiki farm. | Syncs with the farm when the sync is on, deletes archived sites after 30 days, and makes sites over a plan's limit read-only. | approved |
| 10 | Swap-cooldown Usage | Cooldown window between FedWiki active-site rotations (swap or archive-then-restore); 0 disables | How often an organization over its site limit can change which site is active; 0 means any time | approved |
| 16 | Entitlement read error | None | quota reconcile: read entitlement | approved |
Row 2 is one approval covering FedWiki, Stripe and Discourse renderings. Its group keys are respectively `fedwiki-farm-api-url and fedwiki-admin-token`, `stripe-api-key and stripe-webhook-secret`, and `discourse-base-url and discourse-api-key`. Only the shared format forces the Stripe/Discourse wording change; no provider-specific format is added. Row 16 logs at Warn.
@@ -0,0 +1,39 @@
## Why
Archived wiki sites are promised removal after 30 days, and a lower plan limit must park surplus sites where the governing rule requires it. Both operations currently run only after the optional farm sync, so the default deployment does neither (#283).
## What Changes
- Make the existing FedWiki schedule run whenever the farm connection is configured. Its one workflow runs the farm listing and projection only when sync is enabled, then retention, then quota reconciliation, waiting for each step. Skip overlapping scheduled runs. Separate schedules are rejected because their writes could race a stale farm listing.
- Use the existing `fedwiki-sync-interval` (default one hour) for the whole run, including with sync off. Keep its existing creation-trigger setting. Refresh the existing schedule's action at boot rather than adding another schedule.
- After a failed enabled sync, skip retention but still attempt parking, then return today's friendly failure result. Retain non-fatal sweep errors and existing log messages; add counts as structured fields on the existing completion line. Keep `PauseOnFailure` false.
- Order sites by their recorded farm modification time, falling back to creation time for each site with no observation. Use that same order for keeping and reactivating sites.
- Require Temporal for configured FedWiki: expired archives and lowered limits are work that otherwise has no processor. Shorten the shared Temporal refusal, which also changes its Stripe and Discourse renderings.
- Say "Sites over the limit become read-only." Relabel the existing operator run panel to describe the combined run. The member's archive copy remains.
The first run attempts every eligible archive already past its window and every force-reduce surplus; there is no grace period for the backlog.
Small changes riding along are explicit: an unconfigured farm now pauses its leftover schedule even when the sync flag is on; the consequence spec names its actual manifest/boot registration path instead of a migration; and a failed entitlement read logs a warning with its pool rather than silently counting as no entitlement. The shared ordering also changes which active site a member's Keep-active swap parks: a site whose recorded modification precedes an unobserved site's creation can now be parked instead, even when the observed site's own creation is later. The sweep and member swap keep one rule.
The domains reconciliation comment also gets a small correction: Startup waits for a published Temporal connection, not for the sync flag. The landing commit note lists this comment fix.
The swap-cooldown setting's Usage also gets a ride-along copy fix: "How often an organization over its site limit can change which site is active; 0 means any time". The maintenance section tooltip names the optional sync, retention and parking, with its day count taken from the retention constant.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `fedwiki-sites`: one ordered schedule independent of sync enablement, run visibility and consequence text.
- `plan-downgrade`: recorded-time ordering with a creation-time fallback.
- `integration-config-declaration`: required periodic work can require Temporal; FedWiki declares it.
- `deployment-docs`: FedWiki's Temporal prerequisite in deployment instructions.
## Impact
FedWiki's workflow, scheduler, startup, queries, provider metadata and operator panel change; queries are regenerated. No schema or privilege change. Sync keeps its final usage recount and all existing lifecycle locks remain. Boot registration updates consequence metadata without a data migration. Operator FedWiki and its settings page need captures; the member archive page does not change.
The record steps that find a site by name and leave a deleted site's request pending remain #284. No new workflow-history compatibility path is introduced.
@@ -0,0 +1,21 @@
## MODIFIED Requirements
### Requirement: The first-boot section states the order start runs
The production deployment guide's first-boot section SHALL list the steps of `member-console start` in the order the code runs them, configuration validation first, and SHALL state which routes are served before Temporal and the identity provider answer (the certificate ask, the static files, the provider webhooks and signed-in pages), that sign-in answers 503 until a usable discovery document is published, that start keeps retrying Temporal instead of exiting and logs at Error after `temporal-connect-timeout`, and that a configured Stripe, Discourse or FedWiki needs `temporal-host`. The guide SHALL explain that Stripe and Discourse process stored webhook events, while FedWiki must purge expired archives and enforce changed limits even when farm sync is off.
#### Scenario: The guide's order matches the code
- **WHEN** a reader follows the first-boot section
- **THEN** configuration validation SHALL be listed before the database steps, and the listener after the session store probe
#### Scenario: The guide says what serves early
- **WHEN** a reader looks for what a restart serves while the identity provider is unreachable
- **THEN** the first-boot section SHALL name the certificate ask as served and sign-in as answering 503 until a usable discovery document is published
#### Scenario: The guide includes periodic work in the Temporal prerequisite
- **WHEN** a reader configures FedWiki without enabling farm sync
- **THEN** the guide SHALL still require `temporal-host` for retention and quota enforcement
@@ -0,0 +1,205 @@
## MODIFIED Requirements
### Requirement: Operator panel site listing
The operator panel's FedWiki page SHALL display all sites across all workspaces with correct Owner (organization name), Type (subdomain/custom badge), and Created (formatted date) columns. The view model SHALL include fields that match the template references. When FedWiki's required configuration is unresolved, the page SHALL render the not-configured state the Discourse operator page renders (what is missing and a link to the settings page) instead of an empty listing that reads as "no sites".
The existing run panel SHALL report the combined scheduled run under "Site maintenance" with "Last run" and "No run yet" when no execution exists. The panel SHALL ignore the sync flag and show the execution's Temporal status, not proof of successful farm projection.
#### Scenario: All sites render in operator panel
- **WHEN** an operator views the FedWiki page
- **THEN** all sites from all workspaces are listed
- **AND** the Owner column shows the organization name (resolved via workspace → organization)
- **AND** the Type column shows "Subdomain" or "Custom" badge derived from the site's placement's claim kind (`external` ⇒ Custom)
- **AND** the Created column shows the formatted creation date
- **AND** the Total count reflects the actual number of sites
#### Scenario: No sites exist
- **WHEN** an operator views the FedWiki page on a configured deployment and no sites exist in the database
- **THEN** a "No sites found" message is displayed
#### Scenario: FedWiki is not configured
- **WHEN** an operator views the FedWiki page while its required keys are unresolved
- **THEN** the page states that FedWiki is not configured, names what is missing, and links to its settings page, and renders no sites listing
#### Scenario: The run panel describes the scheduled execution
- **WHEN** an operator views a configured FedWiki page
- **THEN** the run panel SHALL show the same schedule's status under "Site maintenance" and "Last run", or "No run yet" before any execution
- **AND** the section header tooltip SHALL read "Syncs with the farm when the sync is on, deletes archived sites after 30 days, and makes sites over a plan's limit read-only."
- **AND** the integer day count SHALL come from `workflows.ArchiveRetention`, not a literal
### Requirement: Sync schedule ensure semantics
On boot, ensuring the FedWiki maintenance schedule SHALL treat only a Temporal `NotFound` response from `Describe` as "schedule absent," routing to `Create`. Any other `Describe` error SHALL be returned to the caller as a failure and SHALL NOT be misread as absence (and therefore SHALL NOT trigger a `Create` attempt). If a `Create` call races with a concurrent creation of the same schedule and fails because the schedule already exists ("already registered"), the ensure operation SHALL fall back to the `Update` path instead of failing, so the schedule's args are still refreshed.
Create and Update SHALL both set skip-overlap and `PauseOnFailure` false.
#### Scenario: Transient Temporal error during describe is surfaced, not masked
- **WHEN** `EnsureSyncSchedule` calls `Describe` and Temporal returns an error other than `NotFound` (e.g. a transient connection or server error)
- **THEN** the ensure operation SHALL return that error to the caller
- **AND** the ensure operation SHALL NOT attempt `Create`
- **AND** the ensure operation SHALL NOT attempt `Update`
#### Scenario: Schedule absent is created
- **WHEN** `EnsureSyncSchedule` calls `Describe` and Temporal returns `NotFound`
- **THEN** the ensure operation SHALL call `Create` to register the schedule
#### Scenario: Create races with already-registered falls back to update
- **WHEN** `EnsureSyncSchedule` calls `Create` after a `NotFound` describe, and `Create` fails because the schedule was concurrently registered by another boot
- **THEN** the ensure operation SHALL fall back to `Update` on the existing schedule instead of returning the "already registered" error
- **AND** the schedule's args SHALL reflect the current configuration after the fallback completes
#### Scenario: Existing schedule is replaced without losing its pause
- **WHEN** boot finds `fedwiki-site-sync` already registered
- **THEN** it SHALL update that schedule's interval, action and sync-enabled argument rather than create a second schedule or delete the first
- **AND** it SHALL preserve an operator pause and resume only a pause bearing the existing dormancy note
### Requirement: The fedwiki_sites key declares what its provider does with a surplus
The `fedwiki_sites` resource key SHALL declare two provider-owned values on `resource_keys`: an `over_limit_behavior` of `park`, which states that the provider's answer to a surplus is to park the excess read-only rather than to deny new instances or to reclaim them, and an `over_limit_consequence`, the one sentence stating what that behavior does to a surplus. Both SHALL be declared by the integration's provider manifest and registered at boot, and neither SHALL be operator-editable on any surface: they describe what the provider does, not what an operator wants.
The consequence sentence SHALL read "Sites over the limit become read-only." It SHALL be rendered verbatim wherever it appears, and the console SHALL neither rewrite it nor add a sentence of its own beside it.
Declaring these values SHALL change no behavior by itself. What a surplus actually meets is decided by the governing rule's `tier_reduction_policy`, per `plan-downgrade`.
#### Scenario: The key declares park with its sentence
- **WHEN** the `fedwiki_sites` key is read from `resource_keys`
- **THEN** its `over_limit_behavior` is `park` and its `over_limit_consequence` is the sentence above
#### Scenario: No operator surface edits the declaration
- **WHEN** an operator opens any surface that shows a resource key
- **THEN** neither `over_limit_behavior` nor `over_limit_consequence` is editable
### Requirement: The database maintains each site's archive time
The database SHALL maintain `fedwiki.sites.archived_at` for every INSERT and UPDATE, including farm projection, console status writes, creation, imports and direct SQL. Inserting an archived row or changing a non-archived row to `archived` SHALL stamp the database's current clock time at the write. Every INSERT or UPDATE SHALL leave a row outside `archived` with a NULL archive time. A row that stays archived SHALL keep its original archive time across repeated status writes, metadata writes and attempts to change only the timestamp. Caller-supplied timestamps SHALL NOT override these rules. An archived row missing its time SHALL receive a current time on its next write. Status and archive time SHALL commit or roll back together. Rows written before this rule keep their values until their next write; the migration does not correct them.
#### Scenario: A farm archive starts its window when observed
- **WHEN** sync first inserts an archived farm site or projects an archived status onto an active or readonly site
- **THEN** the database SHALL stamp the archive time at that write
- **AND** retention after that projection in the same maintenance run SHALL leave the newly archived site intact
#### Scenario: Restore and rearchive receive separate windows
- **WHEN** sync observes a previously archived site as active or readonly
- **THEN** its archive time SHALL be cleared
- **WHEN** a later sync observes that site archived again
- **THEN** its archive time SHALL be newly stamped, independent of the first archive's time
#### Scenario: Console status and swap writes use the same rule
- **WHEN** a console status outcome records an archive, a restore, or a swap activation
- **THEN** the archive SHALL receive a timestamp and the activation SHALL clear any archive timestamp in the transaction that records the status
#### Scenario: Repeated observations preserve the window
- **WHEN** an archived site receives another archived status through an upsert, or receives only storage or last-modified updates
- **THEN** its existing archive time SHALL remain unchanged
- **AND** an insert that does nothing on conflict SHALL leave the existing row's archive time unchanged
#### Scenario: A SQL writer cannot choose the archive window
- **WHEN** a writer inserts or newly archives a site with an old supplied timestamp, clears or replaces the timestamp of a site that remains archived, or supplies a timestamp for an active or readonly row
- **THEN** the database SHALL stamp a new archive at the write, preserve an ongoing archive's original time, or clear the non-archived row's time, respectively
#### Scenario: A failed transaction does not change the window
- **WHEN** a transaction changes a site's status and then rolls back
- **THEN** both status and archive time SHALL retain their values from before the transaction
### Requirement: A FedWiki lifecycle request with no outcome is settled at boot
When Temporal is configured, FedWiki's startup SHALL, before it ensures or pauses the maintenance schedule, describe the workflow of every `accepted` request older than two minutes and act on the answer, at boot and once more after two minutes have passed since boot: when the workflow does not exist, the request SHALL end `failed` with `not_started`; when it is running, nothing SHALL change; when it closed and the request is a create, the console SHALL start a reconcile workflow, identified by the instance, that reads the farm's entry for the domain as a failed create does and then records the site and succeeds or compensates and fails; when it closed and the request is a status change to `active` that still holds a slot, the console SHALL start a status reconcile workflow, identified by the instance and generation, that reads the site's entry from the farm and records the status and succeeds when the farm holds the site in the requested status, ends the request `failed` with `retries_exhausted` when the farm holds it otherwise, not at all, or the site row is gone, and keeps the request `accepted` and its slot held while the farm cannot answer; when it closed and the request is any other operation, the request SHALL end `failed` with `retries_exhausted` and the site SHALL keep its observed status; on any other error, nothing SHALL change. Every outcome settle records SHALL be written under the request's instance lock, with the request's hold removed and the pool recounted. A request that never started SHALL NOT be restarted. The reconcile workflows' input SHALL carry identifiers only.
#### Scenario: A process that died before the start
- **WHEN** the console boots with an `accepted` request older than two minutes whose workflow Temporal does not know
- **THEN** the request SHALL end `failed` with the reason `not_started`, and no workflow SHALL start for it
#### Scenario: The pass runs with the sync disabled
- **WHEN** the console boots with the FedWiki sync disabled and an `accepted` request whose workflow closed without recording an outcome
- **THEN** the pass SHALL still settle the request
#### Scenario: A closed create the farm holds
- **WHEN** the pass finds a closed create workflow whose request is `accepted` and the farm's entry names the requester as owner
- **THEN** the reconcile workflow SHALL insert the site row under the instance identifier and the request SHALL end `succeeded`
#### Scenario: A closed activation the farm made active
- **WHEN** the pass finds a closed status change to `active` whose request is `accepted` and holds a slot, and the farm's entry shows the site active
- **THEN** the status reconcile SHALL write the site `active`, remove the hold, and end the request `succeeded`, and the site SHALL count once
#### Scenario: A closed activation the farm did not make
- **WHEN** the status reconcile reads the farm's entry and the site is `readonly`
- **THEN** the request SHALL end `failed` with `retries_exhausted`, its hold SHALL be removed, and the site SHALL keep its `readonly` status
#### Scenario: A request committed just before a quick restart
- **WHEN** the console restarts within two minutes of committing an `accepted` request whose workflow never started
- **THEN** the boot pass SHALL leave it, and a second pass, run once two minutes have passed since boot, SHALL end it `failed` with `not_started`
#### Scenario: Temporal unreachable at boot
- **WHEN** describing a workflow fails with an error other than not-found
- **THEN** the request SHALL stay `accepted`
## ADDED Requirements
### Requirement: One schedule orders optional farm sync, retention and quota
FedWiki maintenance SHALL mean one workflow that optionally reads and projects the farm, then purges expired archives, then parks or reactivates sites under their governing quota policies, waiting for each step to finish. Whenever FedWiki is configured, boot SHALL ensure this schedule regardless of `fedwiki-sync-enabled`. That flag SHALL govern only the resolve/list/project steps. Projection SHALL retain its final usage recount. An unconfigured farm SHALL pause its leftover schedule and create none even if the flag is on.
The schedule SHALL use the existing `fedwiki-site-sync` identity and `fedwiki-site-sync-workflow` action with one `SyncFedWikiSitesWorkflowInput{SyncEnabled bool}` argument. `fedwiki-sync-interval` SHALL govern the entire run with sync on or off, defaulting to one hour. `fedwiki-sync-trigger-immediately` SHALL trigger a newly created schedule only, defaulting to true; an update SHALL NOT trigger an extra run. A run still open SHALL cause later ticks to be skipped. An operator pause SHALL stop the whole schedule. Activity failures SHALL NOT automatically pause it.
A resolve, list or projection activity failure after bounded retries SHALL skip retention, because local archive eligibility may not reflect a farm-side restore. Quota SHALL still run, using local state as it does with sync disabled, before the workflow returns today's friendly failure result and nil workflow error. Retention and quota activity errors SHALL keep their existing non-fatal logging; a retention failure SHALL NOT prevent quota. Existing per-site best-effort behavior SHALL remain. The workflow result SHALL expose counts returned by successful activities and whether projection completed successfully; the existing completion line SHALL carry them as structured fields whenever it is logged. These counts SHALL NOT claim every candidate succeeded. The existing completion message SHALL carry the counts as structured fields; existing nonzero-only sweep messages SHALL remain.
#### Scenario: Sync disabled does not disable maintenance
- **WHEN** a configured farm boots with sync disabled
- **THEN** it SHALL ensure the one schedule with the configured interval and a false sync argument
- **AND** a run SHALL perform retention followed by quota without resolving the System workspace, listing the farm or projecting it
- **AND** the first run SHALL attempt all already-eligible archives and force-reduce surpluses
#### Scenario: Projection cannot overlap either sweep
- **WHEN** a scheduled run has sync enabled
- **THEN** every resolve/list/project attempt SHALL finish before retention starts, and retention SHALL finish before quota starts
- **AND** no later scheduled projection SHALL start while this run remains open
- **AND** neither purge nor quota SHALL write between this run's farm listing and its projection, including retries of that projection
#### Scenario: Failed enabled sync blocks deletion from stale local state
- **WHEN** an enabled resolve, list or projection activity exhausts its retries
- **THEN** the workflow SHALL skip retention, run quota and return the existing friendly sync-failure result with a nil workflow error
- **AND** the schedule SHALL remain enabled for a later tick unless an operator paused it
#### Scenario: Failed retention does not prevent quota
- **WHEN** retention exhausts its retries
- **THEN** the workflow SHALL log the retention error with its existing non-fatal message and quota SHALL still run
- **AND** a quota error SHALL also be logged as non-fatal, without changing the workflow result to a failure
- **AND** the schedule SHALL remain enabled for a later tick unless an operator paused it
#### Scenario: Completion counts describe returned work
- **WHEN** retention returns 2 purged and quota returns 3 parked and 1 reactivated
- **THEN** the workflow result and the existing completion line's structured fields SHALL report exactly those counts and whether projection completed successfully
#### Scenario: No configured farm means no scheduled work
- **WHEN** the farm is unconfigured, whether or not sync is enabled
- **THEN** boot SHALL pause any leftover schedule using the dormancy helper and SHALL NOT create one
@@ -0,0 +1,42 @@
## MODIFIED Requirements
### Requirement: A declared integration may need Temporal
A `ConfigKey` MAY declare `NeedsTemporal`, which says that the integration its RequiredGroup enables needs Temporal to process stored work, whether received events or required periodic operations. Only a key that belongs to a RequiredGroup SHALL carry it; `CheckSpecs` SHALL refuse it on any other key as a declaration error. When every key of a group that carries it is set and `temporal-host` is empty, `ValidateStart` SHALL refuse the configuration, reported with the other configuration problems, naming the group, `temporal-host` and the group's keys. A dormant group (none of its keys set) SHALL pass. Stripe and Discourse SHALL declare it because they receive webhook events that only Temporal processes. FedWiki SHALL declare it because expired archives and changed limits require periodic work regardless of sync enablement. An integration whose only Temporal actions refuse immediately when Temporal is absent, leaving no stored work requiring processing, SHALL NOT declare it.
#### Scenario: Stripe without Temporal is refused
- **WHEN** start runs with `stripe-api-key` and `stripe-webhook-secret` set and `temporal-host` empty
- **THEN** startup validation SHALL fail with `Stripe needs Temporal: set temporal-host (or leave stripe-api-key and stripe-webhook-secret empty to disable it)`
#### Scenario: Discourse without Temporal is refused
- **WHEN** start runs with `discourse-base-url` and `discourse-api-key` set and `temporal-host` empty
- **THEN** startup validation SHALL fail naming the Discourse group and `temporal-host`
#### Scenario: A dormant group needs nothing
- **WHEN** start runs with no Stripe key set and `temporal-host` empty
- **THEN** startup validation SHALL NOT refuse on Stripe's account
#### Scenario: The need belongs to a group
- **WHEN** an integration declares `NeedsTemporal` on a key with no RequiredGroup
- **THEN** `CheckSpecs` SHALL report a declaration error naming the key
#### Scenario: Configured FedWiki requires Temporal even with sync off
- **WHEN** start runs with `fedwiki-farm-api-url` and `fedwiki-admin-token` set and `temporal-host` empty
- **THEN** startup validation SHALL fail with `FedWiki needs Temporal: set temporal-host (or leave fedwiki-farm-api-url and fedwiki-admin-token empty to disable it)`
- **AND** the result SHALL be the same with sync enabled or disabled
#### Scenario: Dormant FedWiki needs no Temporal
- **WHEN** start runs with neither FedWiki required key set and `temporal-host` empty
- **THEN** startup validation SHALL NOT refuse on FedWiki's account
#### Scenario: FedWiki with Temporal passes its prerequisite
- **WHEN** start runs with both FedWiki required keys and `temporal-host` set
- **THEN** startup validation SHALL NOT report that FedWiki needs Temporal
@@ -0,0 +1,92 @@
## MODIFIED Requirements
### Requirement: FedWiki site downgrade parks excess sites read-only
When a downgrade lowers the `fedwiki_sites` limit below the count of active sites, the system SHALL park the excess sites read-only via `set_status(readonly)` so that active usage is restored to the new limit, but only where the reduction policy governing that pool's `fedwiki_sites` limit is `force_reduce`. Where that policy is `clamp`, the surplus is the member's: no site SHALL be parked, no `force_reduced_at` SHALL be stamped, and the over-limit state SHALL be resolved by refusing new creation alone. Where the policy is `block` or `defer`, it resolves to `clamp` and the system SHALL behave as under `clamp`. Under `force_reduce` the `limit`-many highest-ranked sites SHALL be kept active by default. The rank SHALL use each site's observed farm `last_modified_at`, or its `created_at` when no modification time has been recorded, descending; ties SHALL use `created_at` descending and then `site_id` descending. Only sync records farm modification times. Sites never observed by sync SHALL rank by creation time; disabling sync SHALL retain any times already recorded. Parked sites SHALL be stamped with `force_reduced_at`. Read-only parking SHALL be non-destructive; content is retained and remains readable.
The member MAY change which sites are active, subject to a per-workspace cooldown that gates **rotations**, which means bringing a *different* site into the active set in place of one already there. A transition **into** `active` SHALL be a rotation when the activated site is **force-reduced** (`force_reduced_at IS NOT NULL`, a site this downgrade parked), OR when it is performed while active usage is at the limit (a swap that parks an incumbent to promote another). Restoring a site that is **not** force-reduced, the member's own incumbent, whether currently active or one they archived themselves, SHALL always be allowed and SHALL NOT consult the cooldown (it returns the status quo, not a rotation). The first rotation within a cooldown window SHALL be free and subsequent rotations SHALL be refused until the window elapses for that workspace; restoring an incumbent SHALL neither be refused nor start or extend the window. When a force-reduced site is rotated back into the active set, its `force_reduced_at` SHALL be cleared. The cooldown window SHALL be configurable, default 30 days, and any user-facing refusal message SHALL reflect the configured window.
When the limit later rises (re-upgrade), the system SHALL auto-reactivate `force_reduced_at` sites, in the same rank order, up to the new limit, clearing `force_reduced_at`; each reactivation SHALL be quota-gated.
The member's Keep-active swap SHALL use the same ranking to choose the lowest-ranked active site in that member's workspace to park.
Only an absent entitlement (`sql.ErrNoRows`) SHALL count as no limit. Any other entitlement-read error SHALL be logged at Warn with the pool and error; that pool SHALL be left unchanged for a later run.
#### Scenario: Downgrade parks excess sites and keeps the default active
- **WHEN** a member with 3 active sites is downgraded so the `fedwiki_sites` limit becomes 1, under a governing rule whose policy is `force_reduce`
- **THEN** the highest-ranked site SHALL remain active
- **AND** the other 2 sites SHALL be set read-only and stamped `force_reduced_at`
- **AND** active `current_usage` SHALL equal 1 (the new limit)
#### Scenario: A clamp policy parks nothing
- **WHEN** a member with 3 active sites has the `fedwiki_sites` limit lowered to 1 under a governing rule whose policy is `clamp`
- **THEN** no site SHALL be parked and none SHALL be stamped `force_reduced_at`
- **AND** all 3 sites SHALL remain active and usable
- **AND** new site creation SHALL be refused while active usage is above the limit
#### Scenario: Parked site content remains readable
- **WHEN** a site has been parked read-only by `force_reduce`
- **THEN** its pages SHALL remain served and readable
- **AND** edits, forks, and new pages SHALL be blocked
#### Scenario: Member swaps the active site, second swap refused by cooldown
- **WHEN** a member on a 1-site limit swaps which site is active for the first time
- **THEN** the swap SHALL succeed (the chosen site becomes active, the prior one read-only)
- **WHEN** the same member attempts a second swap before the cooldown elapses
- **THEN** the swap SHALL be refused
#### Scenario: Restoring your incumbent active site is always free
- **WHEN** a member archives the site that was active (not force-reduced) and then restores that same site
- **THEN** the restore SHALL be allowed without consulting the cooldown
- **AND** it SHALL NOT stamp or extend the cooldown window
#### Scenario: Rotating a downgrade-parked reserve in is gated
- **WHEN** a member whose quota was reduced restores a `force_reduced` (parked) site for the first time within the window
- **THEN** the restore SHALL succeed, clear that site's `force_reduced_at`, and stamp the cooldown
- **WHEN** the member tries to rotate in another `force_reduced` site before the window elapses
- **THEN** that rotation SHALL be refused
- **AND** restoring their own (non-force-reduced) incumbent SHALL still be allowed during the window
#### Scenario: Cooldown window honors configuration
- **WHEN** the configured cooldown window is changed from its default
- **THEN** rotations SHALL be gated by the configured window
- **AND** the user-facing refusal message SHALL reflect the configured window
#### Scenario: Re-upgrade reactivates parked sites up to the new limit
- **WHEN** a member with 2 `force_reduced` read-only sites is upgraded so the limit becomes 3
- **THEN** the system SHALL reactivate the `force_reduced` sites in the same rank order, up to the limit
- **AND** the reactivated sites SHALL have `force_reduced_at` cleared
#### Scenario: No farm modification time has been recorded
- **WHEN** a force-reduce pool with three active sites and no recorded modification times has its limit lowered to one
- **THEN** the most recently created site SHALL remain active and the other two SHALL be parked
- **AND** a later re-upgrade SHALL choose among parked sites using the same fallback
#### Scenario: An unobserved site competes with an observed site
- **WHEN** an unobserved site's creation is newer than an observed site's recorded modification, but older than that observed site's creation
- **THEN** the unobserved site SHALL rank ahead for both keeping active and reactivating
- **AND** the observed site's recorded modification SHALL take precedence over its own creation time
#### Scenario: A failed entitlement read is not absence
- **WHEN** a pool's entitlement read fails with an error other than `sql.ErrNoRows`
- **THEN** quota reconciliation SHALL log that error with the pool at Warn and leave the pool unchanged
- **WHEN** the read returns `sql.ErrNoRows`
- **THEN** reconciliation SHALL leave the pool unchanged without reporting an error
#### Scenario: Keep-active uses the same fallback to choose the parked site
- **WHEN** a workspace at its two-site limit has an unobserved active site whose creation is newer than an observed active site's recorded modification but older than that observed site's creation, and the member chooses Keep active on a third read-only site
- **THEN** the swap SHALL choose the observed site to park, leaving the unobserved site active
- **AND** a recorded modification time SHALL take precedence over that site's creation time, as in the sweep's ranking
@@ -0,0 +1,88 @@
Follow `docs/go-conventions.md`, `docs/testing.md` and `docs/database-locks.md`. Every table row carries its expected calls, values and errors as fields; no expectation is derived from a row name. New test helpers have doc comments at the package's density. Sections 1–4 are implementation; the caller runs section 5. Do not commit, start the app or run the full suite during implementation.
Write the breaks in section 3 to **a file outside the repository**; the phase 3 caller supplies its path. Use the `scripts/mutation-check` header's record format, `side: new`, exact test patterns and compilable replacements against the finished code. Each `want_new` below is printed only by its failing assertion. Expand alternatives into separate records. Verify each Go break with `go vet` on its package while applied, then restore it. Mutate generated query strings for query breaks so the test executes the changed SQL; restore afterward. Do not commit the list.
## 1. One schedule, one ordered workflow
- [x] 1.1 Rework `workflows/sync.go` around `SyncFedWikiSitesWorkflowInput{SyncEnabled bool}`: with true await resolve/list/project; with false skip all three; then await retention and quota in that order. If enabled resolve/list/project fails after retries, skip retention, run quota and return the existing friendly failure result with nil workflow error. Preserve `ErrorMessage`, non-fatal sweep logs and `SyncActivityOptions`. A retention error still permits quota. Keep projection's `usage.RecountAll`, activity inputs, locks and per-site behavior. With sync off, log zero for `sitesFound`, `sitesAdded` and `sitesRemoved`. Add the design's output fields and structured counts/`syncPerformed` on the existing completion line on its normal success path; do not rename messages or log "applied" for zero counts.
- [x] 1.2 Add `SyncEnabled` to `ScheduleConfig`; Create and Update supply exactly one workflow input struct. Keep both current IDs, workflow registration and task queue. Both paths explicitly set skip-overlap and `PauseOnFailure: false`; Update replaces the action/spec/policy without replacing state. Preserve NotFound-only Create and create-race Update.
- [x] 1.3 `fedwiki.go` Startup keeps lifecycle settlement first. Ensure the same schedule whenever configured, passing the current flag, existing interval and creation-trigger setting; resume only a dormancy pause. Pause a leftover schedule only when the farm is unconfigured. Preserve non-fatal startup error logging. Do not introduce another schedule, a version marker, a history fixture or a legacy-ID cleanup branch.
- [x] 1.4 Set `NeedsTemporal` on the FedWiki farm URL's RequiredGroup declaration. Change the shared refusal format and its Stripe/Discourse expected strings, not their declarations or conditions. Apply the three config Usage replacements from the Copy table, including the approved swap-cooldown quick fix, and update its quoted environment-reference description.
- [x] 1.5 In all three keep/reactivate queries in `store/queries/sites.sql`, order by `COALESCE(last_modified_at, created_at) DESC, created_at DESC, site_id DESC`. Regenerate via `make sqlc-generate`, retaining only expected generated changes. In `reconcilePoolQuota`, distinguish `errors.Is(err, sql.ErrNoRows)` from other errors; log the latter at Warn with `pool` and `error`, then skip that pool.
- [x] 1.6 Replace the provider consequence in `store/provider.go`; boot registration, not a migration, updates metadata. Update the matching registration expectation and server test fixture. Relabel the existing operator section/last-run label/empty value and replace its tooltip with approved Copy row 9 through SectionHeader.Help, deriving integer days from workflows.ArchiveRetention in `web/operator.go` and `templates/fedwiki_operator_sites.html`. Keep the same status lookup and schedule ID; add no status service or separate panels.
## 2. Tests
Use existing Temporal SDK schedule mocks and workflow testsuite, not a live Temporal server. Do not use sleeps for ordering assertions. Database rows come from the existing fixtures and tests filter to their own rows.
- [x] 2.1 Add `TestEnsureFedWikiMaintenanceSchedule` in `workflows/schedule_test.go`. Rows: absent/default interval; absent/custom 17m with sync off; absent/creation trigger false; existing/operator-paused with sync on; existing/dormancy-paused with sync off and custom 17m; create race; Describe failure; Create failure; Update failure. Invoke the real Update callback with a populated description. Row fields hold expected API calls/error, interval, trigger, input, state and IDs. Check exactly `fedwiki-site-sync`, `fedwiki-site-sync-workflow`, `SyncFedWikiSitesWorkflow`, `queues.Main`, and one typed input; no extra Create or trigger on Update. Assert skip-overlap and false PauseOnFailure separately for both paths. Failure texts: `schedule calls = %v, want %v`; `schedule identity = %v, want %v`; `schedule action = %v, want %v`; `schedule cadence = %v, want %v`; `schedule trigger = %v, want %v`; `schedule state = %v, want %v`; `schedule overlap = %v, want skip`; `schedule pause-on-failure = %v, want false`; `schedule error = %v, want %v`.
- [x] 2.2 Add `TestFedWikiStartupMaintenanceSchedule` in package `fedwiki`, with an unbound adapter for schedule-only checks; the existing settlement DB tests separately assert settlement before the schedule. Rows: configured/sync off/fresh; configured/sync on/fresh; configured/sync off/existing unpaused; configured/dormancy-paused; configured/operator-paused; unconfigured with flag off and on; ensure/resume/pause failure. Set the interval to 17m and creation trigger false in a sync-off row; assert both reach the action/spec. Assert the enabled flag reaches Args in the sync-on row. Assert existing identity, refreshed flag, no unpause of operator state, no creation when unconfigured, and logged-but-nonfatal failures. Failure texts: `startup schedule calls = %v, want %v`; `startup schedule config = %v, want %v`; `startup result = %v, want %v`; `startup error logs = %v, want %v`. Adjust `settle_db_test.go` mocks for the unconfigured leftover pause; do not weaken its settlement assertions.
- [x] 2.3 Add `TestFedWikiMaintenanceWorkflow` in `workflows/sync_test.go`. Rows: sync on with nonzero counts; sync off with nonzero counts; sync off with zeros; resolve/list/project exhausted error (three rows); projection error plus quota error; retention error; quota error; both sweep errors. Expected activity sequence, result fields, Success, ErrorMessage, counts and logs are explicit row fields; every row expects a nil workflow error. Enabled-sync failures skip retention but call quota after the last failed attempt, then return Success false and the unchanged friendly message with any quota counts. All other rows return Success true; sweep failures keep their non-fatal logs. Assert SyncPerformed is true only after successful projection. Assert list/project counts 4/2/1 in the sync-on row, 4/0/0 in projection-failure rows, and zero sitesFound/sitesAdded/sitesRemoved in both sync-off rows. Assert timestamp and returned counts, the existing completion line's attributes on its normal path (including a zero-count control), no completion-success line after sync failure, and unchanged nonzero-only "applied" logs. Failure texts: `maintenance calls = %v, want %v`; `maintenance result = %v, want %v`; `maintenance error = %v, want nil`; `maintenance completion counts = %v, want %v`; `maintenance error logs = %v, want %v`.
- [x] 2.4 Add `TestFedWikiMaintenanceWaitsForEachStep`: delay projection's successful completion in the testsuite and assert no purge/quota has started; include a projection retry before success. Delay retention and assert no quota has started. Record activity start/completion events and compare with the row's sequence; failure `maintenance started a later step before completion: %v, want %v`. Assert effective timeout/retry policy for all executed activities, using ActivityInfo for the five-minute timeout, actual retry execution (including always-failing projection: five starts with mock-clock gaps of 3s, 4s, 6s and 10s for a two-second activity delay plus backoff), and SyncActivityOptions for the initial second, coefficient 2, maximum minute and five attempts (the SDK testsuite leaves ActivityInfo.RetryPolicy nil); failure `maintenance activity options = %v, want %v`. This ordering plus 2.1's skip-overlap assertion guards the four interleavings in the design; do not add competing workflows just to reproduce the discarded design.
- [x] 2.5 Update `TestSyncWorkflowResolvesWorkspaceFirst` to enable sync and retain workspace/ListedAt threading assertions. Retain `TestSyncWorkflowResolveFailureReturnsFriendlyOutput`, enable sync and expect quota after the failed resolve. Keep `TestResolveSystemWorkspaceActivity_DB` and the projection's recount tests unchanged.
- [x] 2.6 Add `TestQuotaUsesRecordedTimeOrCreation_DB` beside `site_order_db_test.go`, using `newReconcileTestEnv`, `fedwikitest` and `governFedwikiLimit`. Rows: all three times NULL; mixed observed/unobserved; recorded time overrides creation. Give explicit created/modified times and expected ordered site IDs. In the mixed row an unobserved site's creation lies between an observed site's earlier modification and later creation, so either NULLS LAST or creation-only is wrong. Assert all three query results, then actual quota parking at limit one and reactivation after raising the limit; check IDs/status/force-reduced marks/counts. For reactivation arrange that the competing mixed pair are both parked so their relative order is exercised. Failures: `%s order = %v, want %v` (query name); `quota kept sites = %v, want %v`; `quota reactivated sites = %v, want %v`; `quota status and marks = %v, want %v`. Retain the site-ID tie tests.
- [x] 2.7 Add `TestQuotaEntitlementReadError` in package `workflows`. Use an embedded entitlement querier overriding the numeric read to return `sql.ErrNoRows`, a wrapped ErrNoRows, or a database error (row fields include expected log level/message/pool/error and zero counts). Invoke the pool helper with a captured slog handler. Override policy/site methods to record unexpected work and return harmless values (force_reduce policy, empty site list), so falling through fails an assertion rather than panicking. Missing rows are silent; other errors produce exactly the approved Warn entry. Assert no policy/site/farm calls on any failed entitlement read. Failure `entitlement read logs = %v, want %v`; `entitlement failure performed work: %v`. Existing `TestReconcileForceReduceAndReupgrade`, clamp and ungoverned controls cover successful reads.
- [x] 2.8 Add `TestFedWikiTemporalRequirement` in package `fedwiki` using its real ConfigSpec and `config.ValidateStart`. Reset/restore Viper as neighboring tests do. Rows: both keys/no host with sync off and on; neither key/no host; each partial group/no host; both keys/host. Compare only errors containing `needs Temporal` or `FedWiki is partially configured` against literal expected slices; unrelated core errors are not this test's subject. Do not call package config's unexported `validConfig`. Failure `FedWiki temporal errors = %v, want %v`. Update the shared validation tests' two literal refusal constants. In `internal/integrations/registry_test.go`, change `"fedwiki": false` to true; rename `TestIntegrationsThatReceiveEventsNeedTemporal` to `TestIntegrationsWithStoredWorkNeedTemporal` and rewrite its and the table's comments for work no other processor handles. Retain its failure text `NeedsTemporal declared = %v, want %v`.
- [x] 2.9 Update `TestRegisterProviders_DB`'s literal consequence expectation; failure text remains its existing `fedwiki_sites over-limit` assertion. Move the maintenance label checks in `TestOperatorSitesPageRenders` onto `maintenance panel missing %q` and `maintenance panel retained %q` assertions. Require the Site maintenance section header outside the card and Last run dt and reject Sync health/Last sync. Assert the exact approved tooltip sentence in the rendered section header's data-bs-content, including 30 days, after HTML entity decoding. Add `TestOperatorMaintenanceStatus_DB` using `GetSitesPage`, existing DB fixtures and a Temporal holder/mock: configured/no execution expects No run yet; configured/completed execution expects its status. The handler ignores the sync flag, so there is no separate sync-off case. Expected present/absent text belongs to row fields; both status rows require the approved tooltip sentence. Failure `maintenance panel missing %q` and `maintenance panel retained %q`. Update the FedWiki label assertions in `test/e2e/operator-walkthroughs/integration_settings_test.go`; preserve its route/interaction checks.
- [x] 2.10 Add `TestKeepActiveUsesRecordedTimeOrCreation_DB` in `web/requests_db_test.go`, using `newSiteEnv(t, 2)`, two active sites and a read-only chosen reserve. Rows: both modification times absent (park the older creation); mixed observed/unobserved (observed modified T1, unobserved created T2, observed created T3, T1 < T2 < T3: park the observed site). Expected park ID is a row field. Invoke the real KeepSiteActive handler; assert acceptance, one swap start, `SwapSiteRequestInput.ParkInstanceID` and its matching lifecycle request, not just a query result. Failure `keep-active swap target = %v, want %v`. The mixed row catches both NULLS LAST and creation-only regressions.
## 3. Breaks
Every row names the production edit, exact test and `want_new`. For alternatives make one record each. Use narrow subtest patterns where a row is named. No break may rely on a panic, compile failure or success log as its evidence.
| Break | Test | want_new |
|---|---|---|
| Change Create's ScheduleOptions.ID, then separately Update's action ID | TestEnsureFedWikiMaintenanceSchedule | schedule identity = |
| Hard-code SyncEnabled true in each of Create/Update Args | TestEnsureFedWikiMaintenanceSchedule | schedule action = |
| Force default interval rather than the supplied 17m in Create/Update | TestEnsureFedWikiMaintenanceSchedule | schedule cadence = |
| Ignore false creation trigger | TestEnsureFedWikiMaintenanceSchedule | schedule trigger = |
| Replace preserved Update state with unpaused state | TestEnsureFedWikiMaintenanceSchedule | schedule state = |
| Set AllowAll overlap separately in Create and Update | TestEnsureFedWikiMaintenanceSchedule | schedule overlap = |
| Set PauseOnFailure true separately in Create and Update | TestEnsureFedWikiMaintenanceSchedule | schedule pause-on-failure = |
| Treat a non-NotFound Describe error as absence; separately return the create-race error without Update | TestEnsureFedWikiMaintenanceSchedule | schedule calls = |
| Restore Startup's sync-enabled guard instead of configured guard | TestFedWikiStartupMaintenanceSchedule | startup schedule calls = |
| Pass SyncEnabled false always from Startup; separately pass DefaultSyncInterval instead of the setting; separately pass TriggerImmediately true | TestFedWikiStartupMaintenanceSchedule | startup schedule config = |
| Unconditionally unpause an operator-paused schedule | TestFedWikiStartupMaintenanceSchedule | startup schedule calls = |
| Gate retention or quota on SyncEnabled (separate records) | TestFedWikiMaintenanceWorkflow | maintenance calls = |
| Start retention after an enabled-sync failure; separately skip quota after that failure | TestFedWikiMaintenanceWorkflow | maintenance calls = |
| Return immediately on retention failure | TestFedWikiMaintenanceWorkflow | maintenance calls = |
| Return a workflow error instead of the friendly sync result; separately clear the friendly ErrorMessage | TestFedWikiMaintenanceWorkflow | maintenance error = ; maintenance result = |
| Start retention before projection finishes; separately start quota before retention finishes, retaining Get/error handling | TestFedWikiMaintenanceWaitsForEachStep | maintenance started a later step before completion: |
| Stop using SyncActivityOptions in the workflow, retaining only the five-minute timeout | TestFedWikiMaintenanceWaitsForEachStep | maintenance activity options = |
| Trigger after Update | TestEnsureFedWikiMaintenanceSchedule | schedule calls = |
| Skip quota after failed resolve | TestSyncWorkflowResolveFailureReturnsFriendlyOutput | quota after failed resolve = false |
| Zero SitesAdded in the success return; separately drop SitesFound from the projection-failure return | TestFedWikiMaintenanceWorkflow | maintenance result = |
| Log zero sitesFound in the completion line | TestFedWikiMaintenanceWorkflow | maintenance completion counts = |
| Set maximum attempts to 1 | TestFedWikiMaintenanceWaitsForEachStep | maintenance activity options = |
| Report returned purge count plus one in the existing completion line's attributes | TestFedWikiMaintenanceWorkflow | maintenance completion counts = |
| Restore NULLS LAST ordering in each of the three generated queries (three records) | TestQuotaUsesRecordedTimeOrCreation_DB | ListActiveSitesByWorkspace order = ; ListActiveSitesByPool order = ; ListForceReducedSitesByPool order = |
| Use creation time alone in each query (three records) | TestQuotaUsesRecordedTimeOrCreation_DB | Corresponding query's order failure above |
| Restore NULLS LAST, then creation-only ordering in the generated ListActiveSitesByWorkspace query (two records) | TestKeepActiveUsesRecordedTimeOrCreation_DB | keep-active swap target = |
| Make the non-ErrNoRows logging condition false, keeping imports used | TestQuotaEntitlementReadError | entitlement read logs = |
| Remove the return after logging a non-ErrNoRows entitlement error so execution continues with the zero-value entitlement | TestQuotaEntitlementReadError | entitlement failure performed work: |
| Remove FedWiki's NeedsTemporal declaration (two records, one per test) | TestFedWikiTemporalRequirement; TestIntegrationsWithStoredWorkNeedTemporal | FedWiki temporal errors = ; NeedsTemporal declared = |
| Restore the old consequence in the provider manifest | TestRegisterProviders_DB | fedwiki_sites over-limit |
| Restore the old section title or label (two records) | TestOperatorSitesPageRenders | maintenance panel missing |
| Drop the tooltip Help; separately replace the constant-derived days with a different literal count | TestOperatorSitesPageRenders | maintenance panel missing |
| Log the completion line before returning a friendly sync failure | TestFedWikiMaintenanceWorkflow | maintenance completion counts = |
| Set SyncPerformed from the input flag | TestFedWikiMaintenanceWorkflow | maintenance result = |
| Restore the old empty value | TestOperatorMaintenanceStatus_DB | maintenance panel missing |
The identity assertion compares the pair of ScheduleOptions.ID (or the handle ID on Update) and ScheduleWorkflowAction.ID. The action assertion compares workflow, task queue and typed args. Schedule mocks record Create, Trigger and Delete calls even on failure paths, so extra calls fail assertions rather than panicking.
Tests of returned counts and of both PauseOnFailure writes give the two previously untestable spec clauses observable failures. SDK API-error controls and unchanged locks need no extra mutation beyond these guards; record that boundary, not an invented coverage claim.
## 4. Finish comments and documents
- [x] 4.1 Rewrite the touched workflow/input/output comments to describe optional projection, unchanged friendly failures and quota after a failed sync; delete the empty-input compatibility claim. Keep `SyncActivityOptions` and its bounded policy. Rewrite schedule manager/config/Create/Update comments for the one input and normal refresh, and Startup's sync-only/early-return comments. Explain FedWiki's NeedsTemporal declaration in the ConfigSpec doc comment. Update quota/query order comments and the swap picker's `web/site_actions.go:612` comment for the fallback. Replace stale tooltip/Sync health descriptions from `templates/fedwiki_operator_sites.html:7`, `web/operator.go:52` and `:113`.
- [x] 4.2 Update `internal/config/spec.go:96` and `validate.go:199` to explain required stored periodic work as well as events. Update `docs/building-an-integration.md`'s NeedsTemporal guidance and generalize its `:340` Sync health pattern to a health card for the schedule. Finish all consequence fixtures; do not change archived specs or member archive copy.
- [x] 4.3 Update `docs/environment-reference.md:89` (temporal-host) and its FedWiki rows: requirement, same interval with sync off, flag controlling only projection, creation-only trigger. Keep documented types/defaults/env derivation and add no key. Update `docs/production-deployment.md:44` and `:379` with FedWiki's prerequisite and its different reason; do not say all three receive events. Add the Temporal prerequisite to `docs/fedwiki-setup.md:68`'s Member-Console Configuration section, including with sync disabled.
- [x] 4.4 Update `workflows/README.md`'s overview, sync input/output examples, activity order and scheduling examples, including removing obsolete default-user/workspace args. Explain retained schedule identity, pause behavior, step failures and returned counts without an upgrade recipe. Update `docs/database-locks.md`'s FedWiki sweep prose to put optional projection then retention then quota in one run and preserve the existing lock edges.
- [x] 4.5 Run `./scripts/spdx-headers.sh <dir>` for every directory with new Go/SQL/template files. Run focused affected package tests, database tests twice without resetting, `openspec validate serialize-fedwiki-maintenance-in-one-schedule --strict` and `make lint`. No migration is required. No golden or recorded workflow-history update is expected; investigate an unexpected one rather than accepting it.
## 5. Landing (caller)
- [x] 5.1 Approve every Copy row; run `make test` with the existing stack and strict OpenSpec validation. Run the external break list after green controls; every record must report CAUGHT. Record results and any limits in the commit note.
- [x] 5.2 Hand-capture the changed configured `/operator/integrations/fedwiki` and `/operator/integrations/fedwiki/settings` pages at desktop and mobile sizes. Check the existing screenshot manifest/walkthrough entry `operator-integration-fedwiki`. No member-page capture is needed because its copy is unchanged; no capture is needed for unrendered consequence metadata alone.
- [x] 5.3 List the quick correction to ReconcileDomains' Startup timing comment in the commit note. Put deployment-specific upgrade steps only in the landing commit note, not product docs: before deploying, count expired eligible archives and pools over a force-reduce limit read-only, using site rows/current governing policies rather than stored usage. State that the first run processes that backlog and deletion is irreversible. For an executing old sync, pause and drain it on the old worker before the upgrade, then deploy and resume. If this is skipped and an open old run fails replay, terminate that stuck run; skip-overlap otherwise blocks all later maintenance ticks until termination. The next tick runs the new code. No workflow-version branch or one-deployment data repair belongs in this change.
@@ -255,14 +255,14 @@ func settingsRoutes(t *testing.T, page *rod.Page, base string) {
if !strings.Contains(fedwikiAdmin, "/operator/integrations/fedwiki/settings") {
t.Errorf("phase 6: FedWiki admin page missing its settings link")
}
// A configured farm shows its sync health; an unconfigured one (the
// A configured farm shows its Site maintenance panel; an unconfigured one (the
// stack's default, where the fedwiki profile is off) renders the
// not-configured state instead of an empty listing (fedwiki-sites
// "Operator panel site listing"; acceptance-fixes, 2026-09-02). Either
// shape is the honest one for its state; a page with neither is not.
syncHealth := strings.Contains(fedwikiAdmin, "Sync health") && strings.Contains(fedwikiAdmin, "Last sync")
maintenancePanel := strings.Contains(fedwikiAdmin, "Site maintenance") && strings.Contains(fedwikiAdmin, "Last run")
notConfigured := strings.Contains(strings.ToLower(fedwikiAdmin), "not configured")
if !syncHealth && !notConfigured {
t.Errorf("phase 6: FedWiki admin page shows neither its sync health nor the not-configured state")
if !maintenancePanel && !notConfigured {
t.Errorf("phase 6: FedWiki admin page shows neither its Site maintenance panel nor the not-configured state")
}
}