feat: system management - #4
Open
brunozoric wants to merge 114 commits into
Open
brunozoric wants to merge 114 commits into
brunozoric wants to merge 114 commits into
Conversation
The `chore: update dependencies` commit left 24 type errors on main.
@clack/prompts 1.8.0 narrowed `isCancel` from `value is symbol` to
`value is typeof CANCEL_SYMBOL`, where CANCEL_SYMBOL is a unique symbol.
Excluding a unique symbol from `string | symbol` leaves the union
unchanged, so every `if (isCancel(x)) { return; }` guard in the CLI
commands stopped narrowing and 23 downstream errors appeared.
Add `isCancelled()`, a narrowing wrapper that restores `value is symbol`,
and route all 25 call sites through it. The Prompts abstraction keeps
returning `T | symbol` rather than leaking a @Clack type.
The remaining error was in `defineOneRoute`: `z.object({ [key]: item })`
widens a computed key to an index signature, which is not assignable to
`Record<TKey, TItem>` under exactOptionalPropertyTypes. `defineListRoute`
already worked around this with an inline double cast. Extract both into
one documented `envelopeSchema()` helper, so the assertion exists once
with an explanation instead of twice without one.
Typecheck clean, 356 tests passing, lint and format clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 0 of the system-management work: a project becomes a Webiny system
on disk that may be deployed to several environments, so everything that
was project-scoped becomes environment-scoped.
Schema
- New tables: project_environments, project_stacks, scan_roots.
- environment_id (NOT NULL) on project_tenants, project_groups,
project_models, project_files, seed_jobs, seed_entries, sync_logs;
nullable on jobs, which now has three scopes (global, project,
environment). seed_templates stays project-scoped, since a saved seed
config is meant to be reused across environments.
- projects loses api_url/api_token/tenant and gains root_path,
version_major, version_source, pulumi_backend, aws_profile, aws_region
and sync timestamps.
- Migrations regenerated as a single 0000: drizzle-kit needs a TTY to
resolve the moved columns as renames, and the DB is a clean break.
webiny_version is split in two. It is detected and display-only, and is
legitimately null for a framework workspace root where @webiny/cli
resolves to "0.0.0". operations_version is NOT NULL and drives the
GraphQL operation registry, which calls version.split(".") — null throws
and "0.0.0" silently selects the lowest registered operation set.
Credentials move to the environment
- Token encryption moves out of the four project repositories into the
new environments feature, where the token now lives.
- EnvironmentContextService resolves {project, environment, apiUrl,
apiToken, tenant, operationsVersion} once, so the seven services that
each re-derived it now share one path.
- It also owns the partial-deploy rule: an environment with core
deployed but no api app has a null api_url, and fails with
EnvironmentNotConnectedError (409) rather than a null dereference.
testing/testing-v6 is in exactly this state today.
Jobs
- One descriptor table replaces six hand-maintained lists (JobType, the
enqueue enum, two label maps, the dataset map, the filter options).
They had already drifted: pull-picsum was missing from all four UI
maps, upload-files from the enqueue enum and the notification labels.
- enqueueable is a per-type flag, not derived. The enqueue route takes a
bare config record, so deriving it would make every type enqueueable
and let a destructive type start without its confirmation flow.
Stack reads are three-state. A missing, unreadable or unparseable stack
file is "unknown", distinct from a destroyed stack, and UpsertStackRepository
refuses to write nulls over a previously-good stack_output on that path —
otherwise a transient read failure would erase the record the destroy
dialog reads to say what is about to be deleted.
Routes: 25 env-scoped routes re-pathed under
/api/projects/:projectId/environments/:environmentId/*. Jobs, templates
and the four global file routes stay where they were.
WIP: does not typecheck yet. 182 errors remain in the API routes, UI
gateways, CLI commands and test fixtures — all call sites the compiler
is now correctly demanding an environmentId from.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cope
Eight new handlers under /api/projects/:projectId/environments — list,
get, create, update, remove, stacks, sync and health — registered
through registerEnvironmentRoutes.
Health moves from the project to the environment. It asks "does this API
answer", which is a property of one deployed stack: a project has no
api_url of its own, and an environment with core deployed but no api app
has none either. The handler reports that case as
{reachable: false, error: "...the api app is not deployed"} rather than
surfacing the 409 from EnvironmentContextService — "can I reach it" has a
truthful negative answer, it is not a request error. Cache is keyed by
environment id.
Manual environment creation exists for remote Pulumi backends, where
stack state lives in a bucket and the local checkpoint glob finds
nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jobs now have three scopes: global (no ids, e.g. pull-picsum), project (projectId only, e.g. sync-system) and environment (both, e.g. seed, pull-tenants, pull-models, cleanup, import). environmentId is threaded through ICreateJobInput, IJob, the execution context and the list filter, and the five environment-scoped executors now guard on environmentId rather than projectId. The enqueue route validates config against the descriptor's per-type configSchema. Its body types config as a bare record, so without this an environment-scoped job could be enqueued with no environmentId and would fail deep inside its executor, long after the 201 was returned. The validated environmentId is lifted onto the job row so the job can be listed and filtered by environment. Sync logs are environment-scoped for reads and carry both ids on write: the project owns the log, the environment scopes it. CreateProjectUseCase now creates a project and its first environment together, and returns both. A project alone has nothing seedable hanging off it. A folder-backed project gets its environments from the next sync, which reads the Pulumi checkpoints; a remote-only project has no checkpoints, so the connection details given at creation become its one environment. Verification and tenant discovery are skipped when there is no API to talk to yet. Remaining: 89 errors in production code (UI gateways, CLI commands, file/seeding repositories), 196 in test fixtures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Read-side repositories (list seed jobs, list/get/delete seed entries, list project files, get project model) now narrow on environmentId. Write-side ones (create seed job, create seed entry, upload file, sync project files, create sync log) carry both ids: the project owns the row, the environment scopes it. Private helpers in SeedService, CleanupService and ImportEntriesService took a bare projectId and read credentials off the Project they were handed. Their signatures now say which id they actually need — environmentId for reads, both for writes — rather than deriving it. In ImportEntriesService the helper takes an explicit IImportConnection, since credentials no longer live on the project at all. Two repairs to earlier mechanical edits in this branch: SyncFilesService had `const apiUrl = apiUrl.replace(...)` shadowing itself (the file manager URL is derived from the API URL, so it is now fileManagerUrl), and ImportEntriesService referenced apiUrl/apiToken/operationsVersion inside a helper where they were never in scope. Production errors: 81 remaining, all in UI gateways, CLI commands and presenters. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
shared/, shared/node/ and api/ all typecheck. Remaining 51 errors are in src/ui and src/cli only. - KeyRotationService rotates environment tokens, not project ones, and skips environments that have no token yet (discovered but not connected). - seedProjects creates a project plus one environment from .projects.json and writes the root tenant against the environment. Its schema gains `env` and renames webinyVersion to operationsVersion, matching what the operation registry actually consumes. Documented that apiUrl is a BASE url — operations append their own "/cms/manage" path, so the example file's value was wrong. - FileUploadService, the file pool services and UploadFilesJobExecutor are environment-scoped; logUpload writes sync_logs with both ids. - Create repositories return environmentId in the row they echo back. - 16 API route handlers pass environmentId through. The six that enqueue jobs pass BOTH ids: a job row records the project that owns it and the environment it acts on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All seven UI gateways now take an EnvironmentRef instead of a bare projectId. The ref is an object rather than two positional strings on purpose: both ids are opaque generated values of the same shape, so a transposed pair would typecheck and then query the wrong environment. Health check moves with its route, from project to environment. Response schemas gain environmentId to match the domain types they mirror, and the project create/update inputs describe a Webiny system — rootPath, operationsVersion, aws profile/region, plus the connection details that seed its first environment — rather than a bare connection. UI gateways typecheck. The 43 remaining UI errors are all in presenters and use cases, which need an environment selection rather than a mechanical id swap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Environments appear in the URL as /projects/:projectId/env/:envName/*, where envName is the Pulumi stack name — `dev`, or `dev___blue` for a variant. Name rather than id, because the database is a clean break and will be recreated again: generated ids would break every bookmark on the next `rm .webiny/data-mock.db`. A stack name is stable, readable, and already unique per project through the (project_id, env, variant) index, so resolving one is a single indexed lookup rather than a PK hit. Adds shared getStackName/splitStackName, mirroring Webiny's own helpers — the ___ separator is identical in v5 and v6 — plus a UI environments feature (gateway, MobX repository keyed by project with stack-name resolution) that the project detail presenter will consume. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ProjectDetailPresenter.load() takes (projectId, envName). It lists the project's environments first, resolves the stack name from the URL — or takes the first when the URL carries none, the common case since most projects have only dev — and gates every dataset load on having both ids. The five UI repositories that were keyed or filtered by project id are now keyed by environment id. They had to be: a project's tenants, models, files, entries and sync logs all differ per environment, so a project-keyed store would show the previous environment's rows after a switch, with nothing to indicate it. The VM gains environments, currentEnvironment, showEnvironmentSelector (false for a single-environment project, so no selector is rendered) and environmentError, which distinguishes "no environments yet, sync to discover them" from "that stack name is not in this project". Each environment VM carries `connectable`, false when apiUrl is null — a core-only deployment is deployed but cannot be seeded, and the UI needs to say so rather than failing at request time. Project-scoped operations (jobs, templates, project edit) keep taking a project id; only environment-scoped ones take the ref. IProjectVM loses apiUrl/apiToken/tenant, which are environment properties now, and gains rootPath and the detected/operations version split. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven use cases (SyncAll, LoadTenants, SyncModels, SyncTenants, LoadSeedConfig, TriggerSeed, LoadSeedHistory) take an EnvironmentRef. Project-scoped calls inside them — projectsGateway.getById, template operations — keep taking a project id. The project list now describes systems rather than connections. A row shows the checkout path, detected version, environment count, how many are deployed, and when it was last synced. apiUrl/tenant are gone from the row: they are environment properties, and a project can have several. Its per-row actions change with it. Pull tenants, pull models and the health check were environment-scoped operations offered from a page where no environment is selected — they now live on the project detail page, which has one. In their place is Sync, which is genuinely project-scoped: it reads the Pulumi checkpoints on disk and rediscovers the project's environments. webinyVersion stays nullable through the VM so a framework workspace root renders as "workspace root" rather than a blank or a fabricated "0.0.0". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds /projects/:projectId/env/:envName/* alongside the bare project route. It is registered FIRST, because RouteRegistry is first-match-wins and /projects/:projectId/* would otherwise swallow "env/dev/models" as a subPath. The bare route still works and resolves to the first environment, so existing links keep functioning. The detail header gains an environment Select, rendered only when a project has more than one environment — most have just dev, and a one-option dropdown is noise. Not-deployed environments are labelled as such in the list rather than hidden, since a destroyed stack still has a row. A partially deployed environment (core up, api not) says so and explains that it cannot be seeded. EditProjectForm edits the system: name, checkout path, operations version. API URL, token and tenant moved out — they belong to an environment, and a project can have several. Clearing the path makes the project remote-only, so it writes null rather than "". SeedConfig and SeedHistory take an EnvironmentRef from the shell, which has already resolved one. Their standalone routes are deleted: they were never registered in App.tsx and the shell renders both as tabs, so they were dead code that would each have needed their own environment resolution. src/ui typechecks. 18 CLI errors remain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
selectEnvironment() picks the environment a command acts on. With exactly one it selects and reports it rather than prompting — most projects have only dev, and a one-option prompt is a keypress that can only be answered one way. With several the user chooses, and an environment whose api app is not deployed is shown but marked, since picking one would otherwise fail later with a worse message. seed, upload-files and pull-models gain the step between project selection and anything environment-scoped. list-projects and remove-project show the checkout path and version instead of an API URL, which a project no longer has. add-project creates a project plus its first environment and says so. All production code now typechecks: shared, shared/node, api, ui and cli. 229 errors remain in test fixtures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds createTestProject(), which creates a project and its first environment and returns both plus the two ids. Every suite had its own copy of that setup (createProject, setupProject) returning a bare project; they now share one, so the next shape change happens in a single place. Call sites follow the rule the repositories enforce: reads take only an environmentId, writes carry both, because the project owns the row and the environment scopes it. Flattening that distinction is what a blanket replace got wrong here, and SyncLogs.test.ts was rebuilt by hand after it. ProjectUseCases assertions follow fields to where they now live — the API token is asserted against project_environments rather than projects, and the list test no longer claims to check decrypted tokens, which listing projects no longer returns. 229 test errors down to 44. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ature Typecheck is clean across production and tests. createTestContainer never registered EnvironmentsFeature, so every suite that created a project failed to resolve CreateEnvironmentRepository — one missing registration accounted for 93 of the 134 failures. Remaining fixture work followed the same rule as the production code: services and read repositories take environmentId, write repositories take both. Getting that backwards in either direction is what several intermediate passes did, so the distinction is now applied deliberately rather than by pattern. 356 tests: 315 passing, 41 failing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The env-scoped routes moved under /environments/:environmentId, so the API tests were hitting 404 where they asserted 400 — the validation they were checking never ran. The create route returns the project alone, so tests resolve the first environment through GET /api/projects/:projectId/environments rather than reading an id the response does not carry. That exercises the new endpoint as a side effect. 336 of 356 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Services resolve the environment before the project, so an unknown id now yields Environment/NotFound rather than Project/NotFound. The assertions follow. GetProjectUseCase and RemoveProjectUseCase are genuine project lookups and keep Project/NotFound — I changed those by mistake first and reverted them. ImportEntriesService's deleted setupProject helper had been seeding the model those tests import into, not just creating a project. Restored as setupImportProject, now syncing the model against the environment. Job enqueues through the API carry an environmentId in config, which is the per-type configSchema doing its job: without it the route rejects an environment-scoped job instead of accepting one that would fail later in its executor. The project create response no longer carries apiUrl or tenant — both are environment properties — so that assertion checks what the endpoint actually returns. 351 of 356 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 0 gate is green: 0 type errors, 0 lint warnings, 356/356 tests, format clean. Two real bugs, neither caught by types alone: SeedService passed project.id to resolveModels(), which takes an environmentId. Model lookup silently matched nothing, so seeding created zero entries and reported success. The rewritten Seeding tests caught it. ProjectDetailPresenter wrote tenants into the store keyed by project id while the view model read them back keyed by environment id — data written under one key, read under another, yielding an empty list with no error. UI presenters have no test coverage, so this only surfaced when removing an unused binding made the compiler look at the call site. Also restored SyncFilesService's fileManagerUrl at its call site: the GraphQL endpoint hangs off the base URL, not off a /cms/manage path, and an earlier rename had left the derived value unused. FileUploadService already did this correctly. Two test suites lost their model seeding when their local setup helpers were removed — the helpers did more than create a project. Restored as setupSeedProject and setupImportProject. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verified the migration end to end against a live server: a fresh database gets all 13 tables, seedProjects creates a project plus its environment, the tenant row carries both ids, an environment-scoped route returns a job carrying its environmentId, health reports per environment, and enqueuing a seed job without an environmentId is rejected with 400 by the descriptor's configSchema. That run surfaced a silent downgrade. .projects.json still used the key webinyVersion, which the reshaped schema no longer reads, so Zod's default applied and the project dropped from 6.5.0 to 6.0.0 — quietly selecting a different GraphQL operation set. webinyVersion is now accepted as an alias for operationsVersion, so existing config files keep working. The example file uses the current key and a base apiUrl, since operations append their own /cms/manage path. The pre-migration database is archived at .webiny/backups/data-mock-preEnvMigration-<timestamp>.db — it held 28 models, 1674 seed entries and 20 jobs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 1 groundwork: the two pieces the sync job is built on. Verified
against all six Webiny checkouts on this machine.
WebinyProjectDetector identifies a project by its marker file
(webiny.config.tsx for v6; webiny.project.ts/.js or the legacy
webiny.root.js for v5), then resolves the version through a four-rung
chain — WEBINY_VERSION, the package.json dependency, the installed
@webiny/cli, and for v5 the template field. "0.0.0" is treated as absent
throughout, because that is Webiny's own sentinel and it resolves that
way inside the framework monorepo; four of the six checkouts here are
workspace roots and now report no version rather than a fabricated one.
v6's deployable apps are the fixed APP_NAME set, not a directory listing:
.pulumi/apps is created on first login, so reading it would leave a fresh
checkout with nothing to deploy. v5 takes the intersection of apps/ and
the project's appAliases, since the alias map can name an app that was
never scaffolded.
PulumiCheckpointReader reads the local file backend directly. The
checkpoint's Stack outputs are byte-identical to `pulumi stack output
--json`, and going through the CLI would mean a cache that cannot be
bypassed on v6, a stream that mixes JSON with other output on v5, and a
spawn per app per environment. Reads are safe against concurrent writes:
pulumi's fileblob driver writes a temp file and renames.
It returns three states. A destroyed stack keeps its file but drops the
resources key — that is "not-deployed", distinct from a file that could
not be read, which is "unknown" and must never overwrite good data. Both
CLI versions report {} for the destroyed case, which is why deployment is
decided on the resources array rather than on output emptiness. Secret
outputs are masked rather than emitted as ciphertext.
Against the real checkouts: webiny-js and webiny-js-next fully deployed
(the former's apiUrl matches its `webiny info` output exactly),
testing-v6 core-only at 16 resources, and three destroyed.
13 tests over fixture trees; 369 passing overall.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sync-system job reads a checkout and writes what it finds into project_environments and project_stacks. Verified against the real deployed webiny-js checkout: it produces the same api_url, admin_url, region and environment name as `yarn webiny info`, plus resource counts and full stack output the CLI never shows — in about a second, with no AWS calls. Discovery never deletes. Environments found in the checkpoints are added to whatever is already recorded, because a manually added environment (remote backend, or a stack not yet deployed) has no checkpoint to find, and discovery must not remove what it cannot see. A remote backend keeps its state in a bucket, so there is nothing on disk to read. That reports "partial" with an explanation rather than marking every environment destroyed. apiUrl and adminUrl are written per app: an environment with core deployed but no api app is deployed yet has no API to talk to. An unknown read leaves the previously stored values in place, matching UpsertStackRepository's rule that a failed read must not erase good data. StackOutputKeyMap resolves the fields whose names differ between majors — v5's logDynamodbTableName and elasticsearch* against v6's auditLogsDynamodbTableName and opensearch* — and treats absence as absence, since a DynamoDB-only project has no search keys and VPC keys appear only when VPC is enabled. CMS endpoints are derived from the base apiUrl rather than stored, because operations append their own paths. 369 tests passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Schema section rewritten for 13 tables, with the project/environment split explained and the rule that reads narrow on environment_id while writes carry both ids — an un-narrowed projectId filter compiles cleanly and silently merges rows across environments. Records a known risk: every child table cascades from both projects and project_environments, so deleting either destroys its seed entries, sync logs, job history, models, tenants and files. Needs a soft-delete or detach strategy before deletion is exposed in the UI. Also: 43 routes with the new environments group, the env/:envName UI route and why it registers before the bare project route, the environment-scoped seeding rule, two new ADRs, and 369 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every child table cascades from both `projects` and `project_environments`, so the existing DELETE destroyed that row's seed entries, sync logs, job history, models, tenants, stacks and files with no warning and no way back. The UI already offered it behind a one-line "this cannot be undone". Deleting now means archiving. `archived_at` on both tables soft-deletes the row; `POST .../restore` undoes it; the cascade only runs from the new `DELETE .../purge`. `GET .../deletion-impact` counts the rows a purge would destroy, and both the UI confirmation and `yarn cli remove-project` show those counts before offering the permanent delete. A failed count is reported as unknown rather than as "nothing stored" — silence there would read as a licence to delete. Lists hide archived rows unless `?includeArchived=true`; `GET /api/projects/:id` still returns an archived project so it can be restored. Archiving twice keeps the original `archived_at`. An archived environment keeps its slot in the (project, env, variant) unique index, so `sync-system` now lists environments with `includeArchived: true` — without it, rediscovery would try to insert a duplicate beside an archived stack name and fail. Archived environments are skipped rather than revived, and each skip is reported in the sync messages: archiving is a deliberate "stop tracking this stack", and a stranded environment keeps the history that belonged to it. Environment archive/restore is wired through the API and the repositories; it has no UI yet because the environments tab does not exist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…edule Registering a project still meant a manual `PUT` with a `rootPath`. Three additions close that: a directory browser, a scanner over saved roots, and a scheduler that keeps what is registered up to date. `GET /api/fs/browse` lists the subdirectories of one directory and flags the ones that are Webiny checkouts. It returns directory names only — never files, never file contents — and resolves the path through `realpathSync` first, so what comes back is the real location rather than the link that pointed at it. The server binds to 127.0.0.1, which is what keeps this from being a filesystem read primitive on the network. `POST /api/fs/scan` walks the saved scan roots (or paths given inline) and reports every checkout under them. Descent stops at the first marker: a checkout's own `apps/` holds nothing that is a separate project, and the framework monorepo carries dozens of `webiny.config.tsx` files below its root that are fixtures, not systems. `node_modules` is never entered — every checkout has one holding packages that carry a marker of their own. Already-registered checkouts come back flagged rather than dropped, so the result is a picture of the disk instead of a list that shrinks as you use it. Unreadable roots are reported alongside the candidates; a scan that silently skipped half the tree would read as "nothing is there". `scan_roots` gets CRUD. Paths are resolved before storage, so `~/work` and `~/work/` cannot both be added and scan the same tree twice, and re-adding an existing root returns it rather than failing. The scheduler runs at boot and daily, and only ever enqueues. Running the sync directly would let a tick read a checkpoint a deploy is halfway through rewriting — a valid file with partial `resources` and stale outputs, which makes `resource_count` flap. A project that already has a `sync-system` job pending or running is skipped, so a slow sync cannot accumulate identical jobs behind it. Archived projects are excluded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The backend could browse, scan and sync; none of it was reachable from the UI, and a project's environments and infrastructure had nowhere to be displayed. Add Project becomes four tabs. Scan lists the checkouts under the saved scan roots, with the already-registered ones shown disabled rather than hidden — the list is a picture of the disk, and a checkout vanishing from it after being added reads as the scan having lost it. Browse is a directory picker. Path takes a typed absolute path. Remote is the pre-existing flow for a project with no checkout here. The project name defaults to the folder name and stops following the path once the user types their own. Adding a project with a checkout enqueues a sync immediately, because until it runs the project has no version, environments or stack output. Project detail gains an Environments tab and a System Info panel. Environments shows three states, not two: "partially deployed" is a real state on this machine — core deployed, api not — and it is the one that cannot be seeded, so it is called out rather than folded into "deployed". Per-app stack rows show the read state, and an unreadable stack shows a dash where the resource count would be; 0 would claim it is empty. Raw stack output opens in the code viewer. System Info resolves the infrastructure facts through the per-major key map, with copy buttons, and says why it is empty when it is — an empty panel with no explanation reads as a broken page. A sync started from either place reloads the environment list, not just the stacks hanging off the selected one: a sync can add, remove or redeploy environments. `stackOutputKeyMap` moves out of `src/shared/node/` — it is pure, the UI needs it, and `src/shared/node/` is the tree the UI must never import from. Verified against the real checkouts: a scan of ~/work/webiny found all seven with no `node_modules` noise, and a register-then-sync of `webiny-js` reproduced its stack state exactly — core 20, api 82, admin 11 resources. A server restart logged the boot sync enqueueing through the job queue. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Environment archive and restore existed on the API but had no UI, because there was no environments tab to put them in. There is one now. The Environments tab gets the same two-step confirmation the project list uses: the default action is Archive, the impact counts are on screen, and the permanent delete is a second, explicit choice. Archived environments stay listed below the active ones with a Restore next to them, so a stranded stack holding history stays visible instead of disappearing from the tab that just archived it. An archived environment is never auto-selected on load — archiving is "stop looking at this stack", and selecting one would undo that on every page load. Purging the environment the URL addresses falls back to the project's first remaining one rather than leaving the page pointed at a row that is gone. `toDeletionImpactLines` moves to `src/shared/deletion/`, shared by the project list, the environments tab and the CLI, so the three cannot drift on what a delete would destroy. The project list gets "Sync all", which enqueues one job per project rather than a single batch — each is scoped to its own project, so one failing checkout cannot take the rest of the run down with it. The per-row Sync button is now hidden for remote-only projects, which have nothing on disk to sync. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`processPendingJobs` flipped every pending row to `running` and launched them all, unawaited, in one tick. With deploy and destroy arriving — each holding a child process for tens of minutes — that puts the whole queue on the machine at once, and lets a scheduled sync read a checkpoint that a deploy is halfway through rewriting. Four changes. A global cap of 4 against `inFlight`. One running job per project, with `projectId === null` never blocked — global jobs belong to no project and would otherwise queue behind whichever project happened to be busy. `ORDER BY created_at` on the pending select, because once rows are skipped the natural order becomes rowid luck and a job can sit behind later arrivals indefinitely. And the claim update is guarded on the row still being `pending`, with a changed-rows check: `recoverStaleJobs` and `cancelJob` write the same rows, so an unguarded update would resurrect a job cancelled between the select and the claim. A skipped job stays `pending` — no third status — and carries `"waiting: project busy"` so it does not look stuck. The label is cleared on claim rather than at completion, because `finishJobWithLogs` only nulls it when `setProgress` was used, so a job that never reports progress would carry the waiting label through its whole run. This path had no test coverage at all: `processNextJob` is called only from `src/api/entry.ts` and nothing invoked it. Eight tests now cover the cap, the per-project skip, the null exemption, creation order, the claim guard and the label lifecycle. Also repoints the project detail presenter at `getJobTypeDatasets`. The descriptor table is meant to be the single source for that map, but the presenter still carried a hand-maintained copy — the exact drift the table was introduced to end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ment Deploy and destroy need the CLI. This adds the three pieces they run on: a process runner, an argv builder per major version, and a parser for the output those commands produce. The child's environment is an allow-list, not this process's environment with a few names removed. Both majors load the project `.env` WITHOUT override, so anything already here beats the project's own — one server-level `PULUMI_CONFIG_PASSPHRASE` would silently win over every project. `AWS_*` is kept as a prefix rather than a list of names: beyond key/secret/session-token there are `AWS_DEFAULT_REGION`, the OIDC/IRSA trio, the container-credentials vars, `AWS_SDK_LOAD_CONFIG` and more, and dropping any one of them fails deploys with "unable to locate credentials" for anyone not on a named profile. `AWS_PROFILE` and `AWS_REGION` are the exception — the tool sets those per project. `NODE_*` is not a glob: `NODE_EXTRA_CA_CERTS` is kept by name, `NODE_ENV` and `NODE_OPTIONS` are not, because they would leak into a child running webpack, esbuild, vite and pulumi's node runtime. `CI=1` is load-bearing on v6: it skips the telemetry gate that otherwise hard-fails deploys. `HOME` is mandatory — pulumi shells out to `yarn info`, and every one of these monorepos is on yarn 4, so corepack needs it. The runner spawns the checkout's own `node_modules/.bin/webiny`, splits both streams into ANSI-stripped lines (holding the tail across chunk boundaries, so the last line — usually the error — is never lost), and on abort sends SIGTERM before SIGKILL so pulumi can unwind rather than leaving its stack lock behind. The command builder's flags were verified against both checkouts rather than assumed: v5's `destroy` declares only folder/region/env/variant/confirm-destroy-*, and v6's declares only env/variant/region, so neither takes `--build` or a deployment-logs flag — those are deploy-only, and the log flag's own name differs by major. `output` always gets `--json`; without it the null path prints prose and there is nothing for the parser to find. The app positional is never optional: v6's `destroy` with no app destroys admin, api and core in sequence with no confirmation of any kind. `parseJsonOutput` takes the LAST balanced value, because v5 runs pulumi with `stdio: "inherit"` and banners can contain braces. A literal `null` is a real answer from both majors — what they print for a stack that does not exist — so it parses to null, and `undefined` is reserved for "nothing found". 37 tests, including a fake `webiny` binary installed into a temp checkout so the real spawn path runs without a Webiny project or AWS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two executors, a service behind them, and their own API routes. The service runs one app per child process, always with the app positional. On v5, omitting it trips the gate that then demands `--confirm-destroy-env`; on v6 it is worse — `destroy` with no app tears down admin, api and core in sequence with no confirmation of any kind. Passing exactly the apps that were confirmed is what keeps a destroy scoped to what was asked for. An empty list means every deployable app for the project's version, expanded here and ordered core → api → admin for deploy, reversed for destroy. An app the version does not know is rejected up front rather than failing deep inside the CLI. Neither type is enqueueable through `POST /api/projects/:projectId/jobs`: that route takes a bare `config` record, so allowing them would let a plain POST deploy or destroy an environment with no confirmation at all. Destroy's own route requires the project name typed back and checks it server-side — a confirmation that exists only in the browser is not a confirmation. The signal is forwarded into the runner, so cancelling the job kills the child instead of orphaning a 20-minute pulumi run that keeps writing to the stack. Stack state is re-read after every run, including a failed one: a failed deploy is rarely a no-op, and leaving the stored state untouched would report infrastructure that exists as absent. That refresh moves out of `SyncSystemService` into `RefreshEnvironmentStacksService`, shared by both, and its derivation is fixed along the way. It now reads the environment row back from EVERY stored stack rather than only the apps just read. Deriving from a partial read would write a null `adminUrl` over a good one after deploying `api` alone, and would mark the whole environment not-deployed after destroying `admin` alone while core and api are still up. A destroyed stack's stored output is also no longer used for URLs — it is kept for reference, and reading a URL out of it would report a torn-down API as live. Adds `GET /api/projects/:projectId/deployable-apps`, read from disk each call. The deployable set is a per-version fact, and it is NOT the list of apps with Pulumi state — that is empty on a fresh checkout, which is exactly when someone wants to deploy. 20 tests, none of which spawn a real deploy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deploy gets one confirmation. Destroy gets two: a review naming every app that will be torn down, its stored resource count and the named resources going with it — tables, buckets, user pools, resolved through the same key map the System Info panel uses — then the project's name typed back. The second step exists because a destroy is the one action here that cannot be undone by re-running it. An unreadable stack shows a dash in that table rather than 0; claiming a stack we could not read holds nothing is exactly what would make a destroy look safe. Region is a closed dropdown copied from `@webiny/project`'s own list, not free text: `withRegion` throws for anything outside it, so free text produces a deploy that fails after the user has already confirmed it. Job logs now stream. Nothing had ever listened to `job:log` — it was broadcast and declared, with zero subscribers — so a running job showed only the `logs` column, flushed every couple of seconds. The presenter keeps a bounded tail per job (a deploy streams raw Pulumi output, and `CI=1` forces those logs on), the detail modal prefers live lines over the stored column while they exist, and the viewer follows the tail only while the job runs so a finished log can be scrolled back through. Also repoints `JobsTab` and `JobNotificationListener` at the descriptor table. Both still carried hand-maintained label maps that were already missing `pull-picsum`, `upload-files` and `sync-system`, and would have rendered the new deploy and destroy jobs as raw slugs — the exact drift the table exists to end. Verified against the real `webiny-js` checkout: the runner resolved and spawned its own v6 binary, got past the telemetry gate, and `webiny info --env dev` returned the same API URL, admin URL and region the Pulumi checkpoint reader produces — cross-validating both paths. The three destroy gates were exercised over HTTP and all reject: wrong name, missing name, and the generic jobs route. No real deploy or destroy was run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`yarn cli sync-tenants` has never existed — the eight commands are init, add-project, list-projects, remove-project, sync-models, seed, rotate-key and upload-files. A user who ran what the message told them to run got "Unknown command: sync-tenants". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`RefreshEnvironmentStacksService` derived the environment row from `listStacksRepository.execute(...)` with `stored.isOk() ? stored.value : []`. A failed select is not an environment with no stacks: deriving from an empty list writes `deployed: false` with no `api_url` and no `admin_url`, so a transient database error immediately after a successful deploy records a live environment as never deployed. The UI then shows it as unreachable and offers no way to seed it. A failed read-back now leaves the row exactly as it was and counts its stacks as unknown, which is what turns the sync's own status to partial — the same rule the per-stack path above it already followed. A failed environment write is treated the same way, rather than being dropped. The test writes the deployed state directly before resolving the service: the refresh service is a singleton, so a stub registered after the first deploy would never reach it, and the test would pass against the bug. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four defects in the same file, all of them the run misdescribing itself. **A cancelled run was recorded as completed.** The batch loop breaks on `signal.aborted`, but the status was then computed from the error count alone. `JobWorker` marks the job `cancelled`, so Seed History and Jobs disagreed about the same run — and nothing could tell there was anything left to resume. `SeedJobStatus` gains `cancelled`, the output carries the flag, and the executor's log line says "Cancelled" rather than "Completed". **A failed status write stranded the row at `running` forever.** The `updateSeedJobRepository` result was dropped on both the success path and inside the catch. If that write failed, nothing ever corrected the row. Now written through one `recordOutcome` helper that checks the Result and says so when it could not store the outcome. The run itself still succeeds: the entries were sent, and failing over a status write would misreport worse. **The first failed entry quietly abandoned the rest of the model.** One failure sets `modelFailed` and ends the model, which is right — a broken field would otherwise be sent `amount` times against an API that refuses every one — but the report said only "1 errors", which reads as 99 fine and 1 bad rather than stopped after 1 of 100. It now says which. **The completion log claimed the requested amount as attempted**, even when the run stopped after one entry. It reports what was processed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ProjectListPresenter.load` set `_loaded = true` in its `finally`, whether or not the read succeeded, and then refused every later call. A one-second outage left the page on an error banner with nothing able to ask again for the life of the presenter. `ProjectDetailPresenter.load` had the same bug in a different shape: it decided "already here" by comparing `_projectId`/`_envName` to the arguments, and those are set before the read, so a failed load looked exactly like a successful one. Re-entering the page short-circuited. Both now mark themselves loaded only on success. The detail presenter keeps a separate `_loadedKey` so the requested ids can still be set for the error state to render. This is the rule AGENTS.md already states, and the one `ProjectDatasets` in the same directory follows: a failed read is never marked loaded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three audit writes whose Result was never checked. `SeedService.logEntry` and `seedDryRun` — the entry is already sent by the time the audit row is written, so a lost write is invisible: the stored count understates what was created, and a later cleanup cannot delete an entry it has no record of. Both now say so. `CleanupService` — the entry was deleted upstream but not marked deleted locally, so the next cleanup run tries to delete something that is already gone and reports that as a failure. `ImportEntriesService` — the local row is the entire product of an import, since the entry already exists in the CMS. It counted rows that were never stored, reporting an import that did not happen. It now skips them. Also in this pass: - `JobsFeature` was the only gateway feature not declaring `HTTPClientFeature`. It resolved only because `main.tsx` happens to register the HTTP client first, which is a bootstrap order rather than a guarantee. - `entries/list` cast an unvalidated `?status=` straight into the query, unlike every sibling filter. An invalid value narrowed the query to nothing and read as "no entries". - `richText.ts` defined the long-text field and `longText.ts` the rich-text one. Swapped back, so opening a file by name gets the field it names. - `seedProjects` closed with the module constant instead of the path it was actually given. - `SyncAllUseCase` deleted: registered nowhere, injected nowhere, a leftover from before pull-tenants and pull-models became separate confirmed actions. - AGENTS.md named `yarn cli pull-models` in the Quick Start and the command table. The command is `sync-models`; following the Quick Start hit "Unknown command" on step three. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…able `parseListQuery` already capped the limit at 1000 and floored it at 1, but read it with `parseInt(...) || DEFAULT_LIMIT`, which cannot tell `?limit=0` from `?limit=`. An explicit zero came back as the default page size rather than being clamped like any other number. Same for `?page=0`. Now parsed through a helper that distinguishes "not a number" from zero, with the whole function under test for the first time. `MAX_CONCURRENT_JOBS` moves to the environment, defaulting to 4 and falling back to 4 for anything that is not a positive number — a typo should not uncap the launcher. `init` writes it into the generated `.env` with a note that one running job per project is enforced regardless, so it only binds with several projects active at once. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the run `sendMutation` retried a 429 and nothing else, and did not catch a throw at all. A dropped connection escaped the method, rejected the batch's `Promise.all`, hit the outer catch and marked the whole run FATAL — one bad socket ending a seed of ten thousand entries. A thrown request is now retried like any other transient failure and, if it keeps failing, fails that entry alone. 500, 502, 503 and 504 join 429 as retryable: they are what a CMS emits while restarting or under load. A 4xx other than 429 is the request's own fault and will fail identically every time, so it is not retried. `httpStatus` becomes nullable, for a request that never got an answer. The two tests that used a 500 to check reporting now use a 400 — a 5xx is retried, which is not what they are about — and two new tests cover the retry itself and the dropped connection, on fake timers so the backoff costs the suite nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sing `seed` was the only place the CLI discovered that an environment has no tenants, and all it could do was say so — for a while, by naming a `sync-tenants` command that has never existed. It now offers to run the sync there and carries straight on into the tenant choice. Offered rather than done: a sync talks to a live CMS and rewrites what is stored for the environment, which is not something to do because someone ran `seed`. Declining, cancelling, a failed sync and a sync that finds nothing all end the run and say which of those happened. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`listJobs` selected every column, `logs` included. A deploy streams thousands of Pulumi lines into that column and the list pulls a page of fifty rows of it to render a table that displays none of them. The Activity page did the same. Nobody has felt it because no resource-creating deploy has ever run; the first real one would. Lists now select an explicit column set and return a `JobSummary` — the job without its log — all the way through the route schema, the gateway, the repository and both view models. The log is read one job at a time through the existing detail route. That moves the log panel's source: `JobsTab` held the selected job in local component state and read `job.logs` off the list row. Selection now belongs to the presenters, which fetch the job when the panel opens, so the component stays dumb. The Activity page picks the global or the project route depending on whether the job has a project — it lists both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A cancelled or failed seed left its entries behind and offered no way forward: starting the same run again duplicated everything it had already created, so the only clean option was to delete the lot. `ResumeSeedService` reads what the run was asked for off its own `seed_jobs` row, counts what actually landed in `seed_entries`, and asks for the difference. Nothing is replayed — the entries already created are real, and the remainder is seeded as an ordinary run through the same service, queue and confirmation as any other. Only `created` entries count: a row left behind by a failed send is not an entry the CMS has. A model the run finished is left out entirely. The publish options, tenant and batch size come from the original run, so the resumed part is seeded the same way as the first part. `seed_jobs.config` now stores the tenant and batch size, which it never did. For rows written before that, the tenant is recovered from the entries the run created; a run that created none is refused rather than guessed at, because guessing would seed the wrong tenant. Resume lives on the Seed History tab, not the Jobs tab: a `seed_jobs` id is not a `jobs` id, and seed history is where a run's own record is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… client Three things, one rule: a barrel exports abstractions, a feature is registered once and imported from its own feature.ts, and an implementation is never imported outside its domain directory. **Barrels.** All eleven UI barrels exported their own feature alongside the abstractions, and the httpClient one also exported `HTTPError` and a pile of router helpers. Trimmed to abstractions. `features/tenants/index.ts` held nothing else and is gone. Nothing imported any of them — everything imports the concrete file — so this changes no call site. **The GraphQL client.** `GraphQLFeature` was registered nowhere. `GraphQLClient` and `GraphQLConfig` were registered only by `createTestContainer`, and the only thing that ever resolved the client was its own test. Production reaches the CMS through the endpoint clients, which take `HttpClient` directly. So the whole subsystem existed to satisfy a test of itself, and it is deleted: the client, its config, its feature and that test. `abstractions/GraphQLClient.ts` stays, minus the client interface and token. Its response types — `ApiGraphQLResultJson` and the rest — are what the operations layer and the seeding services actually use. That was also the last user of `lodash` and `p-retry`, both now dropped from package.json. **The duplicate registration** went with it: `GraphQLFeature` reached outside its own domain to register `FetchHttpClient`, which `AppFeature` already owns. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two skills said barrels export "abstractions and features", one said abstractions only. The barrels were written to the wrong two, which is why every UI feature exported itself from its own index.ts. Both now say the same thing, and AGENTS.md carries it as a numbered rule alongside the rest: a barrel exports abstractions, a feature is registered once and imported from its own feature.ts, and an implementation is never imported outside its domain directory — its only importer being that domain's feature.ts. An implementation reached directly is one the container cannot substitute. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`KeyRotationService` re-encrypts every stored API token, and was 15% covered with no branch tested at all. Testing it properly turned up the failure mode it half-acknowledged in its own error message: "Some tokens may be in an inconsistent state." It rotated row by row and returned on the first failure, leaving the database holding two keys at once. `rotate-key` does not rewrite `.env` when the rotation fails, so `ENCRYPTION_KEY` still named the old key — and every token already written under the new one was unreadable for good. No recovery exists: the plaintext only lives inside that loop. The loop now runs inside a transaction and the failure throws rather than returns, so a rotation that cannot finish changes nothing. The message says so instead of warning about a state it has left behind. Eighteen tests where there were none: the round trip through the same `EncryptionService` that stores tokens for real, the old key ceasing to work, environments with no token, both key-length refusals, a token encrypted under a foreign key, a value that is not ciphertext at all, and the half-rotation itself — which fails without the transaction. 100% statements, 83% branches. Also: it counted environments and called them projects, in the log and on the CLI spinner. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…place **One crypto implementation.** The `iv:authTag:ciphertext` format was written twice — `EncryptionService` with the container's key, `KeyRotationService` with an arbitrary one — same four constants, same layout, copied. Two copies of a format that must agree is how a rotation comes to read every stored token as corrupt, or writes something the reader cannot decrypt. Both now call `aesGcm.ts`. **The four sync repositories delete before they insert.** Run loose, a failure part-way through left the environment holding fewer rows than it started with: the delete had happened and the inserts replacing them had not. A sync that failed destroyed the inventory it was refreshing. Models, groups, tenants and files now do both inside a transaction. **`SyncProjectModelsRepository` gets the tests it never had** — 36% branches, and it is what model sync actually stores. Covers the replace semantics, environment scoping, the field round trip, the pattern-validator sanitising at all three depths (plain, inside an object field, inside a dynamic-zone template), the plugin flag, an empty sync, and the rollback — which fails without the transaction above. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`entries/list`, `environments/health`, `environments/update`, `files/list`, `projects/update` and the scan-root trio were all at 0% branches. Thin handlers, but that is where the unvalidated `?status=` lived. Eighteen tests through `app.inject`: the entries list with each of its filters, the invalid-status case that used to narrow the query to nothing, paging, both updates and their 404s, an environment with no files, the scan-root round trip, and health reporting an unreachable environment as a 200 that says why rather than as a failed request. Three of them assert the environment-ownership check, which `routeFactory` applies to every route carrying both ids — the class of bug that let a destroy tear down another project's stack. Two things the tests had to be corrected to match, rather than the other way round: a scan root must exist on disk, and adding the same path twice returns the existing row on purpose. Both are documented behaviour I had assumed wrongly. Suite-wide 82.8% -> 85.5% statements, 71% -> 74.1% branches. Thresholds raised to match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oes wrong The health route kept its answers in a module-level `Map`. One cache for the whole process, outside the DI graph, which nothing could substitute and every test shared — the route tests only passed because they sent `force=true`, which is the cache deciding how tests get written. Nothing cleared an entry either. Repoint an environment's `apiUrl`, or archive it, and health kept answering for the old one for ten minutes. A purged environment left a verdict behind that outlived it. `EnvironmentHealthCache` is now registered by `ApiFeature`, so each container gets its own, and update, archive and purge drop the entry. The url, the token and the tenant are exactly what health asked with, so a change to any of them makes the stored answer wrong. The cache itself stays: health costs a GraphQL round trip and the project list asks once per environment, so a list of seven projects would fire seven requests on every page load without it. Five tests, none of which needs `force=true` to be truthful: the second call is served from the cache, `force` bypasses it, repointing and archiving both invalidate, and one environment's answer never serves another's. The two invalidation tests fail without the route changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`minLength` and `maxLength` come from the CMS and count characters. Both
text generators handed them to `faker.lorem.words({ min, max })`, which
counts words.
Two consequences. The generated value satisfied the field's own validators
only by accident — ten words comfortably exceed ten characters, so it
happened to pass. And `min > max` made faker throw outright: a `long-text`
field with `minLength: 200` and no maximum met the default of 25 and
raised "Max 25 should be greater than min 200", which failed the entry and,
with the seed stopping a model at its first failure, abandoned the rest of
it. Any long-text field with a minimum above 25 characters was unseedable.
`generateTextOfLength` builds words until the target length is reached and
cuts there, so the result lands inside the range the CMS asked for. A
maximum below the minimum keeps the minimum — a value under it is the one
the CMS rejects. The long-text default maximum moves from 25 to 250, which
is a length rather than a word count.
Also adds the registry tests it had none of: type and list-form selection,
the `type:variant` split, the null generator and its log for a type it does
not know, and that a generator is registered for every field type the CMS
can send. Boolean has no list form — Webiny cannot express `boolean[]` —
so the list-form test names the types that have one.
Generators 41% -> 67% branches on the registry; suite 874 -> 894 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tors `createTime` had two faults, both of which produce a value the CMS then rejects, which fails the entry and — through the seed's stop-at-first- failure rule — abandons the rest of the model. It appended `:00` to every bound, so a `HH:mm:ss` bound became `09:00:00:00` and faker threw "Invalid from date". A Webiny time field stores seconds, so that is the ordinary case, not the odd one. Both shapes are accepted now. With only one bound it used `faker.date.future`/`past`, which move by days — the time of day came out unconstrained, so a field with only `dateGte: "20:00"` generated times before it. A one-sided bound is completed with the start or the end of the same day instead. Tests for all four date types: the shape each produces, both bounds, each bound alone, and the two time-bound formats. Plus `createEntryVariables` — field mapping, the reference map and file pool reaching the generators, and the rethrow path — and the list-value halves of both length validators, including a non-numeric setting, which reaches faker as NaN and throws. createEntryVariables 50% -> 100% branches, the length validators 50% -> 100%, createTime and createDateTimeWithoutTimezone to 100%. Suite 894 -> 927 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every generator file is now above 90% branches; the set sits at 98.3% branches and 99.7% statements. Two more defects turned up on the way, both the same shape as the text-length and time-bound ones: a legal CMS field configuration that makes seeding throw, which fails the entry and, through the seed's stop-at-first-failure rule, abandons the rest of the model. A dynamic zone whose `settings.current` points past its last template reached faker as `min > max`. The index is clamped to the templates that exist. The registry's "generator not found" error named the wrong thing: `type.constructor?.name` is "Function" for every class, so the message always read `Generator for type "Function" not found!`. It names the class now, and the three dead fallbacks behind it are gone. New coverage: every pattern preset and every custom-regex shape the text generator recognises, plus the two fall-throughs; predefined values on number and number-list fields; dynamic zone template selection including `current`; the list and default paths of all five validators, and a malformed rule that must read as absent rather than throw; `iterate` given a range rather than a count, and its dropping of null results; and fields whose `settings` the CMS left null, which the model type allows. 964 tests. Suite 86.2% statements, 75.6% branches; thresholds raised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A project with no environments, synced while its page is open, listed the environment it found and went on saying "This project has no environments yet. Sync it to discover them." Nothing was selected, so the environment selector and every environment-scoped action stayed dead until a reload. A finished job reloads the datasets its type declares, and `sync-system` declares `environments`. But the environment list is not just another dataset: what it holds decides which environment the page is on, and that is resolved in `resolveEnvironment` — which reloading the dataset alone never reached. The rows landed and everything downstream kept its old answer. `reloadEnvironments` now delegates to `resolveEnvironment`, so the selection, the notice and the stack name are decided in one place, and a job that touches `environments` goes through it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An archived project's Delete opened the confirmation on the Archive step, so it asked the user to archive something already archived and hid the only action left behind a second click. It now opens on the permanent delete, with the same impact counts on screen — the reversible step has already been taken, so it is not offered again. Adds the test the purge never had: every table that hangs off a project — environments, stacks, tenants, groups, models, files, seed jobs, seed entries, sync logs, jobs and templates — is seeded, and all of it is gone after the request. The cascade was already right; nothing proved it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`.projects.json` is read on every boot, so purging a project it names destroyed the project's jobs, logs, entries, models and history and then handed back an empty project of the same name on the next start. The delete looked like it worked and did not. `RemoveProjectUseCase` now refuses those and says which file to edit. `Project.seeded` carries it to the UI and the CLI, so neither offers a delete that can only be refused: the archived card shows a "seeded" marker instead of Delete, the confirmation explains why and drops the permanent step, and `remove-project` leaves that option out of its list. Derived from the file on every read rather than stored, so removing an entry makes the project deletable immediately rather than after a restart. An unreadable or missing seed file names nothing, so it never blocks a delete. Archiving is untouched — it is reversible and destroys nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five incremental migrations become one, covering all fourteen tables as they stand. The history they recorded — adding the result column, the child_processes table, dropping parent_job_id — is in the commits that made those changes; nothing has shipped, so there is no deployed database whose upgrade path this erases. The local development database was deleted along with them: it carried the five old migrations in `__drizzle_migrations`, so the new baseline would have been treated as unapplied and run CREATE TABLE against tables that already existed. A fresh one is built from this baseline on the next start. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The header read `apiUrl ?? rootPath ?? "no local checkout"`, so the directory a project lives in disappeared the moment one of its environments was deployed — which is exactly when it matters, with several checkouts of the same product side by side. Both are shown now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The detail page runs two effects that do not wait for each other: one loads the project and resolves its environment, the other activates the view. The view is activated first, so a tab was opened while there was still no environment — and everything a tab reads is scoped to one, so `ProjectDatasets` skipped it, correctly, and nothing asked again. Neither effect's inputs change when the environment finally arrives, so the tab sat empty until the page was left and re-entered, which is when it worked. The presenter remembers which view is open and loads its data after the environment resolves. Also adds a reactivity test file, because the existing presenter tests could not have caught this. `vm` is a MobX computed, and with no observer a computed recomputes on every read — reading `presenter.vm` in a test passes whether or not a real observer would ever be told. These wrap the reads in `autorun`, which is what the page does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…esenter memory The previous fix had the presenter remember which tab was open so it could re-fetch after resolving the environment. That put a React concern in the presenter: the component already knows when it is on screen, and the reason nothing re-fetched was simply that the effect did not depend on the environment. The effect now depends on the resolved environment as well as the view, so the first visit runs once with none, fetches nothing, and runs again the moment there is one. What makes that work is already in `ProjectDatasets`: a dataset skipped for want of an environment is not recorded as loaded. That invariant now has its own tests, along with the once-only rule, project-scoped datasets loading without an environment, a failed read never counting as loaded, and reload ignoring both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records the 29 commits of 2026-09-17 and the rules they established: barrels export abstractions only, React owns when data is fetched, a presenter test must observe, and a seeded project can be archived but never deleted. Also notes in the refactoring plan that the DI cache layer it describes was removed — neither abstraction was ever resolved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ProjectDetailPresenter was 1593 lines driving fifteen tabs. VIEW_DATASETS and activateView existed only because tabs did not own their loading: the shell knew which datasets each view needed and fetched them on the component's behalf. Twelve tabs are now presentation units of their own under ProjectDetail/tabs/<Name>/, each with its own abstraction, presenter, feature, component and tests. A tab is handed a ProjectDetailTabContext as a prop — project id, resolved environment or null, stack name, tenant — reads once per context, never records a failed or environment-less read as loaded, and subscribes to job:status for the one dataset it owns. Environments, System Info and the deployment dialog stay with the page frame: they are the environment resolution itself, and two owners of the same state is what produced the last round of bugs. The frame reads their one dataset through loadStacks(), which the page asks for when either view is on screen. Deleted: ProjectDatasets, projectDatasetDefinitions, the twelve old tab components, and the old DeleteTemplate use case. JobsTab became the props-driven ui/components/JobsTable, which the global Activity page had been borrowing from ProjectDetail. ProjectDetailPresenter 1593 -> 737 lines, its abstraction 340 -> 182, ProjectDetailPage 577 -> 435. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Turns the tool from a single-connection seeder into a multi-project system manager.
Model. An environment — one Pulumi stack name — is what everything hangs off: tokens, tenants, models, files, seed entries, sync logs and jobs. The project owns the checkout and the version.
Does now. Registers checkouts by scan, browse or path, or remote-only. Discovers environments from Pulumi state. Deploys and destroys through the project's own
webinyCLI with live logs and cancellation. Previews a sync before applying. Seeds with revisions, reference ordering and publish strategies, and resumes a run that stopped early. Archives, restores and purges.Safety.
routeFactory— a destroy could previously tear down a different project's stack.webinychildren spawn detached and are reaped on boot..projects.jsoncannot be deleted — the seed file recreates it, so the delete would destroy its history and hand back an empty project of the same name.Tests. 504 → 976 across 74 files, ~86% statements / ~76% branches, thresholds enforced. Generators are held near 100%: five bugs found there were all legal CMS field configurations that made seeding throw, and a failed entry ends the whole model. Nothing spawns a real deploy or reaches a live CMS.
Removed. The
GraphQLClientsubsystem,FileCacheand the DI cache layer,parentJobId,SyncAllUseCase, seven unused CLI abstractions,lodash,p-retry— each found by following what nothing called. Migrations squashed to one baseline.Caveat. No resource-creating deploy or destroy has ever run against real infrastructure.
🤖 Generated with Claude Code