feat(serenity): sub-workspace dual-mode provisioning (brand-create, activate/deactivate, subworkspace reads/writes)#2610
Conversation
Codebase-grounded Phase-1 plan implementing serenity-docs PR 12 on the current main baseline, with full local-dev setup (real Semrush dev gateway). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ation rest-transport.js gains 9 thin workspace/project wrappers (createChildWorkspace, getWorkspaceStatus, listWorkspaceFamily, transferWorkspaceResources, removeWorkspaceMember, deleteWorkspace [test-cleanup only], listProjects, getProject, getInitStatus) for the child path. errors.js gains the normative classification predicates (isAllocationFailure = 405+HTML permanent quota failure; isWorkspaceNotReady = 500 transient post-create; isWorkspaceDrift = 403 out-of-band deletion) and new ERROR_CODES tokens. No behaviour change to existing handlers. 51 transport+errors unit tests pass; lint clean. Implements serenity-docs brand-semrush-provisioning-v2-phase1-implementation.md Step 2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Brand-level cache (mirrors the org cache) holding the brand's own child
workspace id; resolveBrandWorkspace returns {mode:'child',workspaceId:childWs}
when brands.semrush_workspace_id is set, else {mode:'legacy',workspaceId:<org
parent>} (reusing resolveWorkspaceId so the parent never goes stale behind a
brand entry). No 'null'-string coercion (per requester). 21 unit tests; lint
clean. Not yet wired into the controller (next commit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ensureChildWorkspace: re-grant onto a kept workspace when the column is set; else create child → poll until 'created' → persist the column (flips brand to child mode) → clear resolver cache. Ambiguous-create (504 timeout) recovers by adopting a unique parent-family match by exact title; multiple matches fail with an ambiguousWorkspace alert. decommissionBrandWorkspace: delete every listed project (404-as-success) then release the allocation; never deletes the workspace. Poll timing is injectable for tests. Transfer payloads marked as Gate-A live-smoke pins. 16 unit tests; lint clean. Implements serenity-docs v2-phase1 Steps 3/6 (lifecycle). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
child-projects.js centralizes the child-mode listing/mapping rules so every child handler resolves a slice the same way: mapPublishStatus (5 states 1:1), projectToSlice (elmo DTO + additive status/semrushProjectId from settings.ai location/language + project timestamps), listChildMarkets (one v1 GET), and resolveChildProject (slice match with deterministic oldest-created_at-wins + alert on duplicate slice, design §7). 9 unit tests; lint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… verification Gate-A net-zero live smoke against the dev parent (bb0f4e1c) surfaced four issues the design doc/unit tests alone missed; all fixed and re-validated live: - Workspace lifecycle (create child/status/family/resources/members/delete) is served by the user-manager API under /enterprise/users/api, NOT the project-engine prefix /enterprise/projects/api (which 404s those routes). - listProjects requires ?type=ai (omitting it 500s; the v2 list 400s 'type query parameter is required'). - Project list items echo NESTED settings.ai.location.id (geo) and settings.ai.language.name (lang), not scalars; they carry updated_at (and published_at when live) but no created_at — so the duplicate-slice oldest rule orders by created_at ?? updated_at. - resources/transfer is async: it briefly flips the workspace to 'locked' and a subsequent op 422s 'workspace not ready'. ensureChildWorkspace now settles (polls to 'created') before AND after a re-grant transfer. Full child-mode flow (create→list→draft→publish→decommission→delete) verified end-to-end live with the actual transport + child-projects mapping; tenant left at baseline. 99 serenity support unit tests pass; lint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
markets-child.js implements the child path: list (live listing -> slice DTOs), get (resolve + init_status enrichment, 404 marketNotFound), create (ensure workspace -> adopt-leftover-draft-or-create -> publish-once, no DB write, no floor, 409 only on a LIVE slice), delete (resolve + 404-as-success, no floor). Exports resolveLanguageId/defaultMarketName from the frozen markets.js (no behaviour change). 16 unit tests; lint clean. Controller dispatch wired next. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
authorize() now returns {brandUuid, mode, workspaceId, parentWorkspaceId} via
resolveBrandWorkspace. Markets list/get/create/delete dispatch legacy-vs-child;
legacy handlers unchanged. Prompts/tags/models 501 for child-mode brands
(child resolution is the documented follow-up). New POST /serenity/activate
(ensure workspace + per-market create/publish, sets brand active) and POST
/serenity/deactivate (decommission, sets brand pending) endpoints + routes.
301 serenity+routes tests pass (38 legacy controller tests unchanged + 11 new
dual-mode/activate/deactivate); lint clean.
Implements serenity-docs v2-phase1 Steps 3-6 (controller + endpoints).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…i contract test The dual-mode authorize() now resolves the brand workspace, so the contract test's mock context needs dataAccess.Brand + the resolveBrandWorkspace stub (legacy mode). 11 contract tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the child-mode 501 stubs for prompts/tags/models with real handlers. The child path differs from legacy only in slice->project resolution: legacy reads BrandSemrushProject, child resolves the project from the brand's own child workspace via the live listProjects listing. Everything downstream (the Semrush prompt/tag/model calls, publish-once, per-project tag-cache invalidation, bulk caps) is shared, project-keyed logic — reused via additive exports and behaviour-preserving extraction: - prompts.js: additively export the pure leaf helpers (buildPromptDto, normalizePromptInput, mapLimit, publishAffected, limit constants). - markets.js: extract project-keyed cores (listTagsForProject, listGlobalModelCatalog, listSliceModels, syncModelsForProject); the legacy handlers now delegate to them with identical behaviour (legacy tests unchanged and green). - child-projects.js: add buildChildSliceProjectMap + sliceKey for the bulk child write handlers (one listing, oldest-wins dedup). - new prompts-child.js + tags/models child handlers in markets-child.js. - controller dispatches child handlers on mode === 'child'. 342 serenity unit/contract/route tests pass; lint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… publish on model change - OpenAPI: add POST /serenity/activate + POST /serenity/deactivate paths and the previously-undocumented PUT /serenity/models, with request/response schemas (SerenityActivateRequest/Response, SerenityDeactivateResponse, SerenityUpdateModelsRequest). Additive child-mode fields on the market schemas: SerenityMarketStatus + status/semrushProjectId on SerenityMarket, initialized on SerenityMarketDetail (all optional → legacy responses still validate). Contract test extended with the 3 new operations + parity. - handleUpdateModels: publish the project after a real add/remove so the new model set goes live — the one deliberate exception to the frozen-legacy rule, in the shared core so legacy + child both get it. Latent bug: PUT /models never published before. docs:lint valid, docs:build OK, 351 serenity tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…vate/PUT models Extend the serenity IT suite to the new dual-mode routes, matching the existing suite's scope (the JWT harness can't mint IMS tokens, so handler behaviour stays covered by unit + contract + the live through-api e2e). Locks that POST activate, POST deactivate, and PUT models are registered and enforce the same pre-handler contract: 401 for non-IMS auth, 400 route-gate on a non-UUID brandId. Adds a `put` method to the shared IT http-client. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The non-child resolution path is the flat (shared parent-workspace) mode. Calling it "legacy" wrongly implied the brand was deprecated or never a Serenity brand. Rename the resolveBrandWorkspace mode value 'legacy' -> 'flat' and reword every comment, JSDoc, OpenAPI description, and test to say "flat (resolution) mode". Dispatch only branches on mode === 'child', so the value rename has no behavioural effect. Also clarify the brand-cache comment: a null cached value means no child workspace is bound, not that the brand is legacy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
This PR will trigger a minor release when merged. |
… cleanup
The transport DELETE /v1/workspaces/{ws} call was guarded only by a doc
comment ("TEST CLEANUP ONLY"). Nothing actually prevented it from running
in a deployed environment. Add a fail-closed runtime guard: deleteWorkspace
throws unless SERENITY_ALLOW_WORKSPACE_DELETE === "true", which no deployed
environment (dev/stage/prod) sets — only a local box running the net-zero
live smoke opts in.
Production decommission (decommissionBrandWorkspace) already empties and
releases a workspace but never deletes it (design SS6); this closes the
remaining gap so deletion is impossible in prod by construction, not just
by convention.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The non-flat mode operates on the brand's own Semrush *sub-workspace*; "child" was ambiguous (projects also live "inside" a workspace). Rename the mode value (child -> subworkspace), the handler files (markets-child/prompts-child/child-projects -> *-subworkspace), and all symbols/comments accordingly. Internal helpers in subworkspace-projects.js drop the now-redundant qualifier (resolveProject/listMarkets/ buildSliceProjectMap). Dispatch still branches on mode, so no behaviour change. Unrelated uses of "child" (brand child tables, child_process, ephemeral parent/child runs) are left untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deactivation now deletes the sub-workspace's projects, releases the allocation, then CLEARS brands.semrush_workspace_id (the disconnect) and invalidates the resolver brand cache so the brand returns to flat mode immediately. The sub-workspace itself is still never deleted (deletion is forbidden) — it is left empty and unowned; a future activate allocates a fresh one. Updates the decommission/ensure docs (the re-grant branch no longer handles a decommissioned brand, which now has a NULL pointer) and the deactivate OpenAPI description. Also finishes the child -> subworkspace rename in the OpenAPI specs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What to delete, what shared cores to keep, and the naming cleanup so the codebase shows no trace of two modes once flat mode is retired. Includes precondition signals (forward-only retire) and per-phase validation gates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pulls in the Brand entity (adobe/spacecat-shared#1679) that the Serenity dual-mode resolver consumes at runtime via dataAccess.Brand. Replaces the local npm-link used for through-api e2e validation with the released package. Serenity unit suite (352) green against 3.75.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…auth + cache correctness - required-capabilities: map activate/deactivate to organization:write (fixes the route-coverage drift test that was failing CI — every route must be in routeRequiredCapabilities or INTERNAL_ROUTES). - activate: ensure the sub-workspace ONCE for the whole batch, sized to the real market count, then create each market against the resolved workspace — instead of re-granting + double-polling per market (N markets risked the Lambda timeout and under-sized the allocation). handleCreateMarketSubworkspace takes an optional pre-resolved workspace id; the single-market POST path still ensures on the spot. - authorize: a brand already in subworkspace mode resolves against its OWN workspace, so a missing/cleared org parent pointer no longer 404s it — only flat mode without a parent is a genuine 404. - deactivate: invalidate the resolver brand cache BEFORE the save (the upstream is already emptied by decommission), so a failed save can't keep routing to the emptied sub-workspace for the positive-TTL window. - workspace-lifecycle: clarify the ambiguous-create guard as !(e instanceof ErrorWithStatusCode) instead of `=== false`. - rest-transport: correct the createTaggedPrompts body-shape comment to match the actual prompt-text-keyed shape both modes send. - drop a stray log arg passed to the 4-param flat handleListMarkets. Tests updated/added for batch-once activate, subworkspace-survives-missing-parent, cache-cleared-on-failed-save, and the pre-resolved-workspace create path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Pushed a round of fixes from a local multi-perspective review (correctness, simplicity/DRY, conventions/security), plus the dependency bump and a description refresh. Code fixes (commit c2f3cff):
Tests: added/updated for batch-once activate, subworkspace-survives-missing-parent, cache-cleared-on-failed-save, and the pre-resolved-workspace create path. Serenity unit + dispatch + OpenAPI-contract + route-coverage suites green; lint and docs:lint clean. Dependency: bumped Deliberately deferred: the remaining flat/subworkspace prompts-orchestration duplication (list/create/bulk-delete) is left as-is and tracked in |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
… full branch/line coverage Safety guard (new): a brand's sub-workspace must never be the org's shared parent workspace. If they coincide, every sub-workspace operation - most dangerously deactivate's decommission (deletes all projects + releases the allocation) - would run against the shared parent pool and wipe it for the whole org. Enforced at the authorize() chokepoint (409 workspaceMisconfigured, refuses ALL operations) plus defense-in-depth assertNotParent() guards in ensureSubworkspace (re-grant and create paths). Local-review follow-ups: - Delete the unwired error-classification surface (isAllocationFailure, isWorkspaceNotReady, isWorkspaceDrift + their ERROR_CODES) and the unused transport methods getProject/removeWorkspaceMember - tested-but-never-consumed code whose docstrings claimed wiring that did not exist (3-reviewer consensus). - activate: a single market throwing no longer aborts the batch (would strand already-published markets live while the brand stays pending); record the failure per market and continue. - Fold parentWorkspaceId into resolveBrandWorkspace's return so authorize stops resolving the org workspace twice. - subworkspace-projects: deterministic id tie-break in orderKey when timestamps are equal/absent (was listing-order-dependent). Coverage: add tests so the changed serenity surface is fully covered (every handler's mapError + auth.error short-circuit, authorize error branches, the new guard, prompts-subworkspace create/bulk-delete error paths, markets- subworkspace validation branches, the default poll timer). 2983 serenity tests pass; lint + docs clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…409, cover fallbacks Second local review round (no Critical/Important findings). Addressed: - decommissionBrandWorkspace now self-defends: takes parentWorkspaceId and refuses to empty/release the org parent workspace, so the destructive primitive is protected even if a future caller bypasses the authorize() guard. deactivate passes auth.parentWorkspaceId. - OpenAPI: document the new 409 workspaceMisconfigured response on activate/deactivate. - Plan doc: note the implementation deviations (dropped unwired error predicates + transport methods, added the sub-workspace!=parent invariant). - Tests: cover the statusless-throw fallbacks (activate 502, prompts-subworkspace create/bulk-delete 500), flat-mode createMarket/bulkDeletePrompts dispatch, the decommission self-guard, and assert brand.save on the partial-failure path. All serenity files have zero uncovered lines; 2989+ serenity tests pass; lint + docs:lint clean. (codecov/patch + ci build already green on the prior commit.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Second local review round (multi-perspective: correctness/architecture, security/auth, QA). No Critical or Important findings - both the security and architecture passes approved the new sub-workspace-vs-parent safety guard. Addressed the minor follow-ups:
Coverage: every changed serenity file now has zero uncovered lines; codecov/patch and ci build are green. Lint and docs:lint clean. Context on the headline guard (answering an earlier question): if a brand's semrush_workspace_id ever equals the org's parent workspace id, the brand would resolve to sub-workspace mode pointing at the shared parent - and deactivate's decommission (delete all projects + release allocation) would wipe the parent pool for every brand in the org. That is now forbidden at three layers: the authorize() chokepoint (409, refuses all operations), ensureSubworkspace (re-grant and create paths), and decommissionBrandWorkspace itself. |
…space-dual-mode # Conflicts: # src/controllers/brands.js # src/support/brands-storage.js # test/support/brands-storage.test.js
Add tests for the untested error/skip branches surfaced after merging
main: org-catalogue handler failures (no data-access, org-not-found,
upstream error), brand-URL benchmark create/skip logging, the
no-resolvable-benchmark market skip, competitor-sync summary logging,
markets-subworkspace best-effort publish success + {items} topics
envelope + zero-prompt skip, and the createBenchmarks/deleteBenchmarks
transport wrappers. All previously-uncovered lines in the serenity
dual-mode files are now exercised.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…overage Apply the local review fixes and bring the PR's changed code to 100% line coverage so the codecov/patch gate passes. Review fixes: - workspace-resolver: brand sub-workspace positive cache uses a dedicated BRAND_CACHE_TTL_MS (10s) instead of the 5min org TTL; resolve brand and workspace ids concurrently. - serenity deactivate: wrap brand.save() so a decommissioned-upstream but failed-persist divergence is logged (SERENITY_DEACTIVATE_SAVE_DIVERGENCE) and surfaced instead of silently swallowed. - markets-subworkspace: correct the JSDoc to match the best-effort (non-fatal) brand-URL push behaviour. - workspace-lifecycle: sub-workspace title hard-fails when no brand id is resolved rather than building a non-unique name-only title. - brand-provisioning: use the strict IMS token helper on release; drop the unused non-strict import; clarify naming. - prompts handlers: rename shadowed loop vars; note records are pre-redacted. Coverage: - Add targeted tests for the previously-uncovered defensive paths across the serenity helpers, handlers and the brands controller (every changed file is now at 100% line coverage). Includes direct unit tests for locations and prompt-tags. Full suite green (12328 passing); lint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
.scout/scout.config.yaml is per-developer local tooling config and was accidentally committed on this branch. Untrack it and gitignore .scout/. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Backwards compatibility check: flat modeI reviewed whether existing brands (no Routing is additive. The new routes ( Mode resolution falls through to flat safely. Dispatch preserves the original handlers. Every operation uses Markets handlers keep identical flat behavior. Brand create/edit gates the new behavior. Semrush provisioning is gated on One intentional behavior change worth flagging: the flat |
Multi-persona review (Architect · DBA · Security · Tester)Strong, unusually well-defended PR — the destructive-safety design (3-layer parent-equality guard, never-delete decommission, brand-uuid adoption titles, fail-closed delete flag) and security posture (in-org authorize on every route, server-derived workspace ids, uniform 401/403 redaction, IMS-only bearer forwarding) all hold up. Security found nothing exploitable. One Major worth fixing before merge. 🟠 Major —
|
…ollow-ups
Addresses calvarezg's multi-persona review on PR 2610.
Major — activate status-save was unguarded (asymmetric with deactivate):
after markets go live, `brand.setStatus('active'); save()` had no try/catch.
A save failure threw to mapError and collapsed to a 5xx, discarding the
per-market results that tell the caller which markets went live. Mirror the
deactivate seam: catch the save, emit a distinct greppable token
(SERENITY_ACTIVATE_SAVE_DIVERGENCE) with {brandId, semrushWorkspaceId,
marketsLive}, force the partial-failure path so the caller gets a 207 (markets
ARE live) instead of a 5xx, and fall through to return the multi-status body.
A re-activate converges (every live market returns 409), so the divergence
self-heals.
Minor follow-ups:
- Per-market failure observability: the brand-edit re-sync fan-outs
(brand-urls, competitor-benchmarks) now log {workspaceId, projectId, market}
(status only — upstream text carries the gateway URL) before rethrowing, so a
mid-fan-out throw names which market split, not just an aggregate count.
- brand_name_display now honors an explicit brandDisplayName (falling back to
brandNames[0]), keeping the project display name consistent with the own-brand
benchmark built from brandDisplayName and with the re-sync read path.
- prompts.js / prompts-subworkspace.js: added explicit TWIN-FILE notes flagging
the duplication as deferred pending flat-mode removal.
Tests: activate save-divergence (207 + token); per-market throw logging for both
re-sync fan-outs; brandDisplayName honored vs fallback; and a both-read-null
two-writer characterization test pinning the documented residual race.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Thanks for the thorough multi-persona pass — addressed in Major —
|
Review follow-up — finding status after
|
| # | Finding | Severity | Status |
|---|---|---|---|
| 1 | activate status-save unguarded (asymmetric with deactivate) |
🟠 Major | ✅ Fixed — SERENITY_ACTIVATE_SAVE_DIVERGENCE token + forces 207 (markets are live) instead of 5xx, with test (serenity.js:850) |
| 2 | Brand-edit re-sync had no per-market failure observability | 🟡 Minor | ✅ Fixed — fan-outs now log {workspaceId, projectId, market} before rethrow, + tests |
| 3 | brand_name_display ignored explicit brandDisplayName |
❓ Question | ✅ Fixed — now honors brandDisplayName, falls back to brandNames[0], + test |
| 4 | Concurrent-activate "both-read-null" race not pinned by a test | 🟡 Minor | ✅ Fixed — two-writer characterization test added |
| 5 | Deferred flat/subworkspace prompts duplication | 🟡 Minor | ✅ Fixed — explicit TWIN-FILE cross-reference notes in both files |
| 6 | Flat PUT /serenity/models now publishes |
❓ Question | ⚪ No change needed — disclosed in the PR description as a latent-bug fix; the open part is a product confirmation (did any flat caller rely on staging model changes before a separate publish?), not a code defect |
| 7 | deactivate save-failure stale-pointer window |
🟡 Minor | ⚪ Accepted for Phase 1 — already carries the SERENITY_DEACTIVATE_SAVE_DIVERGENCE alert token; documented and self-healing on re-activate |
| 8 | Unicode brand-name → workspace-title round-trip untested | 🟡 Minor | ⚪ Open (nit) — lowest priority; plain JS strings in JSON bodies, low risk |
Net: 0 Critical, the 1 Major closed, all actionable minors addressed with tests. Items 6–8 are a disclosed product question and two explicitly-accepted Phase-1 residuals — none is a merge blocker. Code is in good shape to accept once ci / build goes green and an approving review lands.
🤖 Multi-persona review follow-up
Audited the transport against the new project-engine + user-manager swaggers
for the "prefer v2 when available" rule. addAiModel was the one clean gap: it
POSTed to /v1/.../ai_models, but v2 exposes the same route
(POST /v2/workspaces/{ws}/projects/{pid}/ai_models) with identical request
(CreateProjectAIModelRequest) and response (ProjectAIModelResponse) schemas —
a drop-in, matching the existing createBenchmarks v2 move.
The sibling list/delete ai_models routes have no v2 variant (v2 ai_models is
POST-only), so listAiModels/deleteAiModelsByIds stay on v1 — same pattern as
listBenchmarks/deleteBenchmarks (v2 benchmarks is also POST-only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the v2 audit. The v1 resources/transfer route's documented body
(handlers.WorkspaceResources) is a FLAT object with no `ai` key — it never
matched what the transport sends. Our `{ ai: { projects, prompts } }` payload is
exactly v2's aiProductResources shape, already proven live as the v2 child-create
`resources` body (createSubworkspace). v2 wraps it under a `resources` key
(WorkspaceResourcesTransferV2Form), so the transport now POSTs
`{ resources: payload }` to POST /v2/workspaces/{ws}/resources/transfer.
Callers (resourceAllocation / RELEASE_ALLOCATION / CREATE_ALLOCATION) are
unchanged — they still build the bare resources object; the v2 envelope lives in
the transport. Only the allocation values (release = zeroed ai) remain a Gate-A
live-smoke pin; the wire shape is now contract-correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GET /v1/workspaces/{id}/family returns a BARE ARRAY of workspaces (live-verified
against the gateway; the swagger types it as a top-level array too). The code read
`family?.items`, assuming an `{ items: [...] }` envelope — on the real response
`.items` is undefined, so EVERY entry was discarded:
- adoptFromFamily always saw zero matches → ambiguous-create recovery (its whole
purpose) was dead on arrival, throwing 502 "no family match to adopt" even when
the parent returned real children.
- decommissionBrandWorkspace's enforceLinkedGuard branch (default-off, latent) saw
zero children → would have let a decommission proceed against a parent that
still has live linked sub-workspaces.
Added a familyItems() normalizer used at both sites: prefer the bare array,
tolerate a legacy `.items` envelope. New tests pin the real bare-array shape for
both the adopt path and the linked-child guard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…llback
The gateway only ever returns a bare array for GET /v1/workspaces/{id}/family, so
familyItems() no longer tolerates a hypothetical { items: [...] } envelope — it
reads the array directly, guarding only against a non-array (null/malformed) body.
Updated every family test mock to the real bare-array shape (the {items} mocks
were encoding the original bug's assumption) and folded the two added bare-array
characterizations into the existing tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# [1.579.0](v1.578.2...v1.579.0) (2026-06-18) ### Features * **serenity:** sub-workspace dual-mode provisioning (brand-create, activate/deactivate, subworkspace reads/writes) ([#2610](#2610)) ([47b8352](47b8352)), closes [throu#api](https://github.com/throu/issues/api) [hi#market-count](https://github.com/hi/issues/market-count)
|
🎉 This PR is included in version 1.579.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
What
Implements Phase 1 (synchronous dual-mode) of Serenity Semrush sub-workspace provisioning per adobe/serenity-docs PR 12. Every
/serenity/*operation resolves, in one place, whether a brand runs in flat mode (shared org parent workspace) or subworkspace mode (its own Semrush sub-workspace), driven entirely bybrands.semrush_workspace_id. The flat path is unchanged; the subworkspace path is added alongside. This PR also adds serenity-first brand creation (provision the sub-workspace before the brand row) and propagates a brand's full identity (URLs, competitors, aliases) onto its Semrush projects.NULL
brands.semrush_workspace_idmeans the brand is not connected to a Semrush sub-workspace - nothing more. It does not imply anything about flat mode or whether the brand uses Semrush at all.How it works
resolveBrandWorkspace(ctx, spaceCatId, brandId)returns{ mode, workspaceId }:semrush_workspace_idset ->{ mode: 'subworkspace', workspaceId: <brand sub-workspace> }{ mode: 'flat', workspaceId: <org parent ws> }The controller dispatches on
mode. The only thing that differs between modes is how a(geoTargetId, languageCode)slice resolves to an upstream project - flat reads theBrandSemrushProjectmapping; subworkspace resolves it live from the brand's own workspace vialistProjects. Everything downstream (the Semrush prompt/tag/model calls, publish-once, per-project tag-cache invalidation, bulk caps) is shared, project-keyed logic. Auth is identical in both modes: an IMS bearer is required and forwarded to the Semrush gateway; only the workspace id in the upstream URL differs.Surface
resources/transfer, project list?type=ai, init-status, benchmark create/delete) + upstream error classification (allocation-failure, workspace-not-ready, workspace-drift). Sub-workspace deletion is fail-closed behindSERENITY_ALLOW_WORKSPACE_DELETE(never set in any deployed env), so it is test-cleanup only.ensureSubworkspace(lazy-create -> pollcreated-> persist column; re-grant path; ambiguous-create adoption by family title) anddecommissionBrandWorkspace(delete every project + release the allocation back to the parent pool - never deletes the workspace).POST /v2/orgs/:id/brandscarries asemrushMarket, provision the sub-workspace + one published AIO project before writing the brand row, attach the chosen models, generate+attach topics/prompts, and publish. On a post-provision DB failure the orphaned allocation is released back to the parent pool (best-effort) and logged, not leaked.settings.ci, so the legacy CI-competitor list was a no-op); brand aliases drive branded/non-branded prompt classification. Re-synced across markets on brand edit.POST /serenity/activate(ensure the sub-workspace once -> publish caller-supplied markets -> set brand active) andPOST /serenity/deactivate(decommission -> clear thesemrush_workspace_idpointer to disconnect the brand back to flat mode -> set brand pending). A future activate allocates a fresh sub-workspace; the emptied one is never deleted.GET /v2/orgs/:id/serenity/languagesreturns the Semrush-supported language catalogue so the UI only offers languages the AIO gateway accepts (sibling to the existingserenity/models). Both org-level catalogue routes are mappedorganization:read.PUT /serenity/modelspublishes after a model-set change so it goes live (latent bug; fixed in the shared core for both modes).PUT /serenity/models, and additive subworkspace-mode market fields (status,semrushProjectId,initialized) - all optional, so flat responses still validate.Hardening (multi-agent review pass)
routeRequiredCapabilities(route-coverage gate).getType() === 'ims') before forwarding the bearer upstream, matchingrequireImsBeareron the rest of/serenity/*.Validation
docs:lintvalid;docs:buildOK; lint clean.Cross-repo ordering
The runtime
dataAccess.Brandentity and thebrands.semrush_workspace_idcolumn are merged and released:@adobe/spacecat-shared-data-access@3.75.0.UI companion: adobe/project-elmo-ui 2078.
Known follow-ups (not blocking)
activatecarves a flatCREATE_ALLOCATION(1 project); sizing it frommarketCountis pending live Gate-A verification of the limits contract.🤖 Generated with Claude Code