From 685da84a7aa9d39929f18ccd12725591561802b2 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Wed, 21 Oct 2026 18:13:42 -0400 Subject: [PATCH 01/38] feat(#2864): honor temporarily-unavailable path items in App Space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-path-item temporarily-unavailable flag (admin-toggled, already honored on the mission page) was dropped by the App Space data path, so a disabled item still rendered as an active, clickable kiosk card. - path-item-task-resolver: shapeItem/buildPathItems now carry temporarilyUnavailable + unavailableReason on direct items (group-expanded children stamped false, matching the mission page). - app-space-gating: isTutorialOpenable returns false for unavailable items — the single chokepoint that both blocks the click and strips .clickable. - AppSpace.vue: 'Temporarily Unavailable' badge + reason, greyed/delinked card. Tests: buildPathItems passthrough, gating block, and a mounted-component test proving badge render + no window.open on click. --- hugo-apps/src/app-space/AppSpace.vue | 46 ++++++++++- .../__tests__/AppSpace.unavailable.test.ts | 63 +++++++++++++++ hugo-apps/src/app-space/app-space-gating.ts | 4 + srv/lib/path-item-task-resolver.js | 13 ++- .../unit/app-space-unavailable-gating.test.js | 27 +++++++ test/unit/app-space-unavailable.test.js | 80 +++++++++++++++++++ 6 files changed, 229 insertions(+), 4 deletions(-) create mode 100644 hugo-apps/src/app-space/__tests__/AppSpace.unavailable.test.ts create mode 100644 test/unit/app-space-unavailable-gating.test.js create mode 100644 test/unit/app-space-unavailable.test.js diff --git a/hugo-apps/src/app-space/AppSpace.vue b/hugo-apps/src/app-space/AppSpace.vue index 061e0b78a..88d5daf80 100644 --- a/hugo-apps/src/app-space/AppSpace.vue +++ b/hugo-apps/src/app-space/AppSpace.vue @@ -55,6 +55,9 @@ interface AppSpaceItem { url: string description: string recordId?: number + // #2864: per-path-item temporarily-unavailable flag (admin-toggled). + temporarilyUnavailable?: boolean + unavailableReason?: string } interface AppSpaceTrack { @@ -488,7 +491,8 @@ const emptyStateMessage = computed(() => { class="timeline-item" :class="[ itemStatusClass(selectedTrack, index), - `timeline-item--${item.type.toLowerCase()}` + `timeline-item--${item.type.toLowerCase()}`, + { 'timeline-item--unavailable': item.temporarilyUnavailable } ]" > @@ -540,6 +544,13 @@ const emptyStateMessage = computed(() => { :class="{ clickable: item.type === 'TUTORIAL' && isTutorialOpenable(selectedTrack, index) }" @click="handleItemClick(selectedTrack, index, item)" >{{ item.title }} + +
+ Temporarily Unavailable + {{ item.unavailableReason }} +

{{ item.description }}

@@ -1109,6 +1120,39 @@ const emptyStateMessage = computed(() => { color: var(--sapCriticalColor, #e76500); } +/* #2864: path item temporarily disabled by an admin. Greyed + delinked (the + title/bubble lose their .clickable via isTutorialOpenable), with a badge + mirroring the mission-page "Temporarily Unavailable" treatment. */ +.timeline-item--unavailable .timeline-content { + opacity: 0.6; +} +.timeline-item--unavailable .timeline-content__title { + cursor: default; + text-decoration: none; +} +.item-unavailable { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin: 0.375rem 0 0; +} +.item-unavailable__badge { + align-self: flex-start; + padding: 0.125rem 0.5rem; + border-radius: 0.75rem; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.4; + color: var(--sapNeutralTextColor, #5b738b); + background: var(--sapNeutralBackground, #eaecee); + border: 1px solid var(--sapNeutralBorderColor, #a9b4be); +} +.item-unavailable__reason { + font-size: 0.8125rem; + line-height: 1.35; + color: var(--sapContent_LabelColor, #556b82); +} + .step--locked .timeline-content { opacity: 0.65; } diff --git a/hugo-apps/src/app-space/__tests__/AppSpace.unavailable.test.ts b/hugo-apps/src/app-space/__tests__/AppSpace.unavailable.test.ts new file mode 100644 index 000000000..3d9e19787 --- /dev/null +++ b/hugo-apps/src/app-space/__tests__/AppSpace.unavailable.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment happy-dom +// +// #2864 — a temporarily-unavailable path item must render a "Temporarily +// Unavailable" badge on the App Space kiosk timeline, be greyed, and NOT be +// clickable (no window.open on click). The whole point of the feature: an +// admin flips the flag to pull a broken/maintenance item from the live kiosk. +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' + +vi.mock('../useRealtimeProgress', async () => { + const { ref } = await import('vue') + return { useRealtimeProgress: () => ({ lastCompletion: ref(null), connected: ref(false) }) } +}) + +import AppSpace from '../AppSpace.vue' + +function stubFetch(body: unknown) { + vi.stubGlobal('fetch', vi.fn(async (input: unknown) => { + if (String(input).includes('getAppSpaceProgress')) return { ok: true, status: 200, json: async () => body } + return { ok: false, status: 404, json: async () => ({}) } + }) as unknown as typeof fetch) +} + +const feed = { + eventId: 10000015, eventName: 'Joule Studio Lab', eventType: 'OTHER', type: 'COMPLEX', + paths: [{ + id: 1, title: 'Track A', description: '', items: [ + { + imsId: 1, title: 'Joule Studio (BDC)', type: 'TUTORIAL', status: 'AVAILABLE', + progress: 0, experience: '', timeToComplete: 0, url: '/tutorials/joule-studio.html', + description: '', temporarilyUnavailable: true, unavailableReason: 'Down for maintenance', + }, + ], + }], +} + +describe('AppSpace — #2864 temporarily-unavailable item', () => { + beforeEach(() => { + vi.restoreAllMocks() + window.history.replaceState({}, '', '/app-space/?eventId=10000015') + }) + + it('renders the badge, reason, greyed item, and does not open on click', async () => { + stubFetch(feed) + const openSpy = vi.fn() + vi.stubGlobal('open', openSpy) + + const wrapper = mount(AppSpace) + await flushPromises() + await wrapper.find('.track-card').trigger('click') + await flushPromises() + + expect(wrapper.find('.item-unavailable__badge').text()).toBe('Temporarily Unavailable') + expect(wrapper.find('.item-unavailable__reason').text()).toBe('Down for maintenance') + expect(wrapper.find('.timeline-item--unavailable').exists()).toBe(true) + + // Title must NOT be clickable and clicking it must not open the tutorial. + const title = wrapper.find('.timeline-content__title') + expect(title.classes()).not.toContain('clickable') + await title.trigger('click') + expect(openSpy).not.toHaveBeenCalled() + }) +}) diff --git a/hugo-apps/src/app-space/app-space-gating.ts b/hugo-apps/src/app-space/app-space-gating.ts index 648b858b2..e24f25cd9 100644 --- a/hugo-apps/src/app-space/app-space-gating.ts +++ b/hugo-apps/src/app-space/app-space-gating.ts @@ -48,6 +48,10 @@ export function isItemUnlocked(items: GatingItem[], index: number): boolean { export function isTutorialOpenable(items: GatingItem[], index: number): boolean { const item = items[index] if (!item || item.type !== 'TUTORIAL') return false + // #2864: a temporarily-unavailable item is never openable — this single + // chokepoint both blocks the click handler and strips the `clickable` class + // in AppSpace.vue, so the card renders greyed and delinked. + if (item.temporarilyUnavailable === true) return false if (item.status === 'COMPLETED_OUT_OF_WINDOW') return true return isItemUnlocked(items, index) } diff --git a/srv/lib/path-item-task-resolver.js b/srv/lib/path-item-task-resolver.js index 924cd6a4b..da7ecc76f 100644 --- a/srv/lib/path-item-task-resolver.js +++ b/srv/lib/path-item-task-resolver.js @@ -202,7 +202,7 @@ function effectiveStatus(record, type, event) { return status; } -function shapeItem(task, effLegacyId, record, type, checkpointTitle, event) { +function shapeItem(task, effLegacyId, record, type, checkpointTitle, event, unavail) { return { imsId: effLegacyId, title: task?.title || record?.titleSnapshot || checkpointTitle || '', @@ -214,6 +214,12 @@ function shapeItem(task, effLegacyId, record, type, checkpointTitle, event) { url: task?.slug ? `/tutorials/${task.slug}.html` : '', description: task?.description || '', recordId: record?.legacyId || 0, + // #2864: per-path-item temporarily-unavailable flag. Carried only for + // direct items; GROUP-expanded children are stamped false by the caller + // (not inherited from the parent GROUP item), matching the mission page + // (packages/content/catalog-data.js). unavailableReason omitted when empty. + temporarilyUnavailable: unavail?.temporarilyUnavailable === true, + ...(unavail?.unavailableReason ? { unavailableReason: unavail.unavailableReason } : {}), }; } @@ -244,14 +250,15 @@ export function buildPathItems({ pathItems, taskMap, effLegacyByItem, groupTutor const children = (i.group_ID && groupTutorials.get(i.group_ID)) || []; for (const t of children) { const record = t.legacyId != null ? recordMap.get(`TUTORIAL:${t.legacyId}`) : undefined; - out.push(shapeItem(t, t.legacyId ?? null, record, 'TUTORIAL', null, event)); + // #2864: group children are NOT inherited-unavailable — stamp false. + out.push(shapeItem(t, t.legacyId ?? null, record, 'TUTORIAL', null, event, null)); } continue; } const task = resolveTask(taskMap, i); const effLegacyId = effLegacyByItem.get(i); const record = recordMap.get(`${i.taskType}:${effLegacyId}`); - out.push(shapeItem(task, effLegacyId, record, i.taskType, i.checkpointTitle, event)); + out.push(shapeItem(task, effLegacyId, record, i.taskType, i.checkpointTitle, event, i)); } return out; } diff --git a/test/unit/app-space-unavailable-gating.test.js b/test/unit/app-space-unavailable-gating.test.js new file mode 100644 index 000000000..17752c76c --- /dev/null +++ b/test/unit/app-space-unavailable-gating.test.js @@ -0,0 +1,27 @@ +// test/unit/app-space-unavailable-gating.test.js +// +// #2864 — a temporarily-unavailable path item must not be openable on the +// App Space kiosk, even if it would otherwise be unlocked/openable by the +// #2866 gating rules. isTutorialOpenable is the single chokepoint the template +// uses both to gate the click handler and to apply the `clickable` class, so +// returning false here greys AND delinks the card. + +import { describe, it, expect } from 'vitest'; +import { isTutorialOpenable } from '../../hugo-apps/src/app-space/app-space-gating.ts'; + +const tut = (status, extra = {}) => ({ type: 'TUTORIAL', status, ...extra }); + +describe('isTutorialOpenable — #2864 temporarilyUnavailable blocks opening', () => { + it('an available, unlocked first tutorial is normally openable', () => { + expect(isTutorialOpenable([tut('AVAILABLE')], 0)).toBe(true); + }); + + it('is NOT openable when the item is temporarilyUnavailable', () => { + expect(isTutorialOpenable([tut('AVAILABLE', { temporarilyUnavailable: true })], 0)).toBe(false); + }); + + it('temporarilyUnavailable overrides the COMPLETED_OUT_OF_WINDOW redo exception', () => { + const items = [tut('COMPLETED_OUT_OF_WINDOW', { temporarilyUnavailable: true })]; + expect(isTutorialOpenable(items, 0)).toBe(false); + }); +}); diff --git a/test/unit/app-space-unavailable.test.js b/test/unit/app-space-unavailable.test.js new file mode 100644 index 000000000..4ecec47da --- /dev/null +++ b/test/unit/app-space-unavailable.test.js @@ -0,0 +1,80 @@ +// test/unit/app-space-unavailable.test.js +// +// #2864 — the per-path-item "temporarily unavailable" flag must flow through +// the App Space data path (buildPathItems → shapeItem), the SAME flag the +// admin toggles and the mission page already honors. Before this fix the +// App Space feed dropped the flag entirely, so a disabled Joule Studio (BDC) +// path item still rendered as an active, clickable card on the kiosk. +// +// Treatment mirrors the mission page (packages/content/catalog-data.js): +// - direct path items carry their own temporarilyUnavailable/unavailableReason +// - GROUP-expanded child tutorials are NOT inherited-unavailable (stamp false) + +import { describe, it, expect } from 'vitest'; +import { buildPathItems } from '../../srv/lib/path-item-task-resolver.js'; + +// Minimal task map keyed as buildTaskMap would key it. resolveTask in the +// resolver reads TUTORIAL: for legacyId-only items. +function tutTask(legacyId, title, slug) { + return { legacyId, title, slug, description: '', experienceTag: '', averageTimeToComplete: 0 }; +} + +describe('buildPathItems — #2864 per-item temporarilyUnavailable passthrough', () => { + it('carries temporarilyUnavailable + unavailableReason onto a direct item', () => { + const item = { + path_ID: 'P1', taskType: 'TUTORIAL', taskLegacyId: 10, + temporarilyUnavailable: true, unavailableReason: 'Joule Studio down for maintenance', + }; + const taskMap = new Map([['TUTORIAL:10', tutTask(10, 'Joule Studio', 'joule-studio')]]); + const effLegacyByItem = new Map([[item, 10]]); + + const out = buildPathItems({ + pathItems: [item], + taskMap, + effLegacyByItem, + groupTutorials: new Map(), + recordMap: new Map(), + }); + + expect(out).toHaveLength(1); + expect(out[0].temporarilyUnavailable).toBe(true); + expect(out[0].unavailableReason).toBe('Joule Studio down for maintenance'); + }); + + it('defaults temporarilyUnavailable to false and omits reason on an available item', () => { + const item = { path_ID: 'P1', taskType: 'TUTORIAL', taskLegacyId: 11 }; + const taskMap = new Map([['TUTORIAL:11', tutTask(11, 'Other', 'other')]]); + const effLegacyByItem = new Map([[item, 11]]); + + const out = buildPathItems({ + pathItems: [item], + taskMap, + effLegacyByItem, + groupTutorials: new Map(), + recordMap: new Map(), + }); + + expect(out[0].temporarilyUnavailable).toBe(false); + expect(out[0].unavailableReason).toBeUndefined(); + }); + + it('GROUP-expanded child tutorials are stamped temporarilyUnavailable:false (not inherited)', () => { + const groupItem = { + path_ID: 'P1', taskType: 'GROUP', group_ID: 'G1', + temporarilyUnavailable: true, unavailableReason: 'should not leak to children', + }; + const groupTutorials = new Map([['G1', [tutTask(20, 'Child A', 'child-a')]]]); + + const out = buildPathItems({ + pathItems: [groupItem], + taskMap: new Map(), + effLegacyByItem: new Map(), + groupTutorials, + recordMap: new Map(), + }); + + expect(out).toHaveLength(1); + expect(out[0].temporarilyUnavailable).toBe(false); + expect(out[0].unavailableReason).toBeUndefined(); + }); +}); From 51e148dd516459a845b42b068f8e1f917f7da643 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 22 Oct 2026 12:16:47 -0500 Subject: [PATCH 02/38] docs: design for App Space parallel paths (fork lanes + free-order clusters) Models parallelism within a track entirely on the existing schema: shared itemOrder = parallel; 2+ GROUP rows at one Order = side-by-side branch lanes; N TUTORIAL rows at one Order = free-order cluster. Per-lane reward via GROUP+prize on the same CompletionPathItems row (resolver appends a synthetic CHECKPOINT at read time); join reward via a downstream CHECKPOINT. No schema change. --- ...6-10-22-app-space-parallel-paths-design.md | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-22-app-space-parallel-paths-design.md diff --git a/docs/superpowers/specs/2026-10-22-app-space-parallel-paths-design.md b/docs/superpowers/specs/2026-10-22-app-space-parallel-paths-design.md new file mode 100644 index 000000000..4ce0c083c --- /dev/null +++ b/docs/superpowers/specs/2026-10-22-app-space-parallel-paths-design.md @@ -0,0 +1,144 @@ +# App Space — Parallel paths within a track (fork lanes & free-order clusters) + +**Status:** Design — approved in brainstorming, pending plan +**Date:** 2026-10-22 +**Author:** Tom (DevRel) + Claude +**Target branch:** `DEV` (new feature — per project convention, not `main`) +**Related:** #172 (branching paths, partial — alt-group fields), #2866 (completion window), the App Space subsystem (`hugo-apps/src/app-space/`) + +--- + +## 1. Intent & the why + +The App Space tool (`/app-space/?eventid=…`, `hugo-apps/src/app-space/AppSpace.vue`) is how event attendees (SAP TechEd, Sapphire, Dev Garage) pick a **track**, work through its tutorials, and earn a prize. Today every track is a **single linear chain**: tutorial → tutorial → checkpoint → prize, each step unlocking strictly when the previous one is `COMPLETED`. + +Two real tracks for **2026 SAP TechEd Berlin** don't fit a straight line: + +1. **A forking track** ("Explore Agentic AI with CAP"): one shared setup tutorial, then the path **splits into two independent branches** — an *MCP* branch (a chain of tutorials) and an *Agents* branch (another chain). The attendee can pursue both. This is the diagram Tom brought to the design. +2. **A free-order track**: a setup tutorial followed by **6–7 tutorials offered in parallel**, completable in **any order**, no sequence between them. + +Neither is expressible today, so these tracks either get mis-modeled as arbitrary linear sequences (forcing a false order) or can't be offered. The why: give event attendees genuine choice of route through a track, and let authors model "do both branches" or "pick your own order" without inventing a parallel concept per event. + +**The guiding principle Tom set:** reuse concepts that already exist in the data model — the **`itemOrder` field** and the **GROUP path-item + Group entity** — rather than introduce new schema. Parallelism should *fall out of* how an admin already authors path items. + +## 2. The authoring model (decided) + +Everything keys off two things an author already sets on a `CompletionPathItems` row: its **Order** (`itemOrder`) and its **type** (`taskType`). + +**Rule: path items that share the same `itemOrder` within a path are parallel.** What that parallelism looks like depends on their type: + +| Shape | How the author models it | Renders as | +|---|---|---| +| **Linear step** | One path-item at a unique Order | A single timeline node (today's behavior) | +| **Parallel-any cluster** | N **TUTORIAL** path-items sharing one Order | An unordered cluster — all unlock at once, any order | +| **Fork / branch** | 2+ **GROUP** path-items sharing one Order | Side-by-side lanes; each GROUP expands **in place** as its own ordered chain | + +Segments run in **Order sequence**: all items at Order *N* must satisfy their completion rule before Order *N+1* unlocks. A single GROUP alone at an Order keeps today's behavior (expands inline as a linear run of its tutorials). The *new* behavior is triggered only when **2+ items share an Order**. + +### Worked examples + +**Forking track** (Order values illustrative): +- Order 10 — TUTORIAL "Set up CAP Node.js dev env" (shared root) +- Order 20 — GROUP "MCP" **and** GROUP "Agents" (two GROUP rows, both Order 20) → two parallel lanes. Each GROUP row *may* also carry a Prize (see §3) to reward its lane on its own. +- Order 30 — CHECKPOINT "Agentic AI T-shirt" (after the fork; carries the `prize` association) → needs both lanes + +**Free-order track:** +- Order 10 — TUTORIAL "Set up CAP dev space" (must be first) +- Order 20 — 6× TUTORIAL rows, all Order 20 → cluster, any order +- Order 30 — CHECKPOINT "Sampler badge" (carries the `prize` association) + +> **Terminology note (grounded in the schema).** There is **no `PRIZE` `TaskType`** — the enum is `{ TUTORIAL; GROUP; CHECKPOINT; PUZZLE; PETOBERFEST; KTT_LESSON }` (`db/schema.cds:20`). A track's reward is authored as a **`CHECKPOINT` path-item that carries a `prize` association** (`CompletionPathItems.prize`, schema:476); the App Space barcode/claim UI keys off `type === 'CHECKPOINT'`. The Vue code's `'PRIZE'` type is a **frontend-only display state** the resolver never emits today. Throughout this doc, "prize/checkpoint" means that CHECKPOINT-with-prize path-item. + +## 3. Completion rules = prize/checkpoint placement (decided) + +**No new completion-config field, and no new schema.** The rule for "what finishes a parallel segment" is expressed by **where the author places the reward** — using fields that already exist on the rows. + +**The keystone — GROUP + Prize on the same path-item row (per-lane reward).** `CompletionPathItems` already has **both** `group : Association to Groups` (schema:474) **and** `prize : Association to Prizes` (schema:476) on the same row. When an author sets a GROUP path-item **and** fills in its Prize field, the resolver, after expanding that group's tutorials into a lane, **appends a synthetic CHECKPOINT item carrying that prize at the end of the lane**. Effect: completing **that one branch** earns its reward — the "any one path" rule — *without* needing a checkpoint/prize slot inside `GroupPathItems` (which is tutorial-only: `tutorial @mandatory`, schema:488–490, and is **not** extended by this design). + +The synthetic lane-end checkpoint is **derived by the resolver from the path-item's `prize` association**; it is not a separate row the author manages in the Path Items grid. Author's mental model: "GROUP row + Prize on that row = that branch rewards on its own." (Confirmed with Tom: the reward item being implicit is intended.) + +**Requirement — the synthetic prize must behave like any normal one (confirmed with Tom).** The resolver must emit the lane-end reward as a **real `CHECKPOINT` output item** (the same shape `shapeItem` already produces: `type: 'CHECKPOINT'`, proper `status`, `imsId`, and a server-computed `unlocked` flag), **not** as a frontend-only `'PRIZE'` display state. This matters because the prize **barcode is already fully decoupled** from authoring: `checkpointBarcodeUrl(eventId)` carries only the event id, the `/api/my-prize-barcode` endpoint reads identity from the **session cookie**, and `shouldShowCheckpointBarcode` fires on `type === 'CHECKPOINT' && unlocked && authenticated` (`checkpoint-barcode.ts:36`) with no dependence on where the checkpoint came from. So a read-time-synthesized CHECKPOINT yields a scannable, claimable barcode identical to a stored one — **provided** it's a true CHECKPOINT in the payload with a correct `unlocked` flag. A plan must not produce a cosmetic-only prize that renders a trophy but no working barcode. + +**The join — reward placed *after* the parallel segment (all-paths rule).** A CHECKPOINT path-item at a **higher Order** than the shared fork/cluster Order unlocks only when **all** parallel members at the fork Order are complete. + +Both are authorable together: per-lane GROUP+Prize rows at Order 20 **and** a final CHECKPOINT at Order 30 → each branch rewards on its own *and* a final reward needs both. No conflict. + +So "complete either branch" vs "complete both" is authored purely by **where the Prize lives** (on the GROUP row vs. on a downstream CHECKPOINT row) — consistent with how linear tracks already work, and nothing new for the admin to learn. + +**Parallel-any cluster:** a CHECKPOINT after the cluster requires **all** cluster members complete. (An "N-of-M threshold" — e.g. any 4 of 7 — is explicitly a **non-goal** for this iteration; see §6. All-of is the shipped rule.) + +## 4. The rendering (decided — side-by-side lanes) + +Chosen over a stacked single-column layout because the fork reads most clearly as literal parallel columns (matches Tom's source diagram). In the **track detail view** of `AppSpace.vue`: + +- A **linear** segment renders as today: one bubble + card, vertical connector line down to the next. +- A **fork** segment renders as a **fork connector** (the backbone splits) into **2+ side-by-side lanes**. Each lane is a labeled column; **the label is the Group's own `title`** (e.g. "MCP", "Agents") read from the `Groups` entity — *not* the #172 `altGroupLabel` (see §5/§7: this design ignores the alt-group fields entirely). Each lane contains that group's tutorials as a mini vertical chain, ending in the synthetic prize item if the GROUP row carried one. If a downstream (post-fork) item exists, the lanes **rejoin** with a join connector into that item. +- A **parallel-any** segment renders as an **unordered cluster** (grid of cards), all bubbles in the "available" state at once (subject to the setup gate above them), with a heading like "Pick any — complete in any order." +- **Mobile / 3+ lanes:** lanes are a responsive grid that collapses to stacked labeled blocks on narrow viewports. (Tom approved the 2-lane desktop layout; graceful collapse is a builder responsibility, not a separate decision.) + +The mockup Tom approved lives at `.superpowers/brainstorm/*/content/fork-layout.html` for reference (gitignored). + +## 5. What this touches (the hard part — grounded in the current code) + +**Hard constraint (Tom): NO schema change.** Every field this feature needs already exists in `db/schema.cds` — the change is entirely in *behavior* (resolver, gating, Vue) that re-interprets data the schema already stores. Audit: + +| Feature need | Existing field — unchanged | +|---|---| +| Parallel grouping key | `CompletionPathItems.itemOrder` (schema:477) — shared Order = parallel | +| Branch lane | `CompletionPathItems.taskType = GROUP` + `group` assoc (472, 474) | +| Lane contents (ordered chain) | `GroupPathItems` (group→tutorial, ordered) (488–491) — **not extended** | +| Per-lane reward | `CompletionPathItems.prize` assoc **on the same GROUP row** (476) | +| Join reward | `CompletionPathItems.taskType = CHECKPOINT` + `prize`, higher Order | +| Free-order cluster | N `TUTORIAL` rows sharing one `itemOrder` | + +No new entities, columns, or `TaskType` enum values; `GroupPathItems` is untouched; the #172 `altGroupKey`/`altGroupLabel` fields are **ignored**, not extended. The synthetic lane-end checkpoint and the segment structure are **derived at read time** in the resolver — stored rows stay pure existing-schema. This is the mechanism that keeps the feature schema-free. + +This is **not** a CSS-only change, though: the current pipeline actively destroys the structure the UI needs: + +- **`srv/lib/path-item-task-resolver.js` → `buildPathItems()`** flattens every path into a **flat, order-less array**. GROUP items are expanded inline into their child tutorials and spliced into that flat list; `itemOrder` and group identity are **discarded** in the output. The payload (`AppSpaceTrack.items: AppSpaceItem[]`) has no notion of "these share an Order" or "these belong to the MCP lane." It also **never reads the Group's `title`** (only `GroupPathItems` → tutorials via `expandGroupTutorials`), so sourcing the lane label from the Group title is a **new read/join** to plan. +- **`hugo-apps/src/app-space/app-space-gating.ts` → `isItemUnlocked()`** gates strictly on the **immediately preceding array index** (`items[index-1].status === 'COMPLETED'`). No concept of Order groups or branch completion. +- **`AppSpace.vue`** renders one flat vertical `v-for` over `selectedTrack.items`, and computes `isTrackComplete`/`progressPercent`/`completedCount`/barcode-eligibility over that **flat** list (lines 234–243, 299–309). + +So the design requires, in order: + +1. **Payload shape change** — the track payload carries **segments** (ordered groups keyed by `itemOrder`), each tagged `kind: 'linear' | 'parallel-any' | 'fork'` and carrying its `order`; fork segments carry **named lanes** (label = Group `title`), each with its own ordered items (including a synthetic trailing CHECKPOINT when the GROUP row had a `prize`). A single-item Order stays a `linear` segment so linear tracks are byte-for-byte unchanged. Keep a flattened `items[]` alongside segments if cheaper for back-compat, or migrate the Vue fully to segments — builder's call (§7.2). +2. **Server builder change** — group path items by `itemOrder` **before** deciding flatten-vs-nest (the detection "2+ members at this Order" must precede the per-item loop that today flattens unconditionally). For a GROUP member, nest the expanded `GroupPathItems` chain under a lane; if that path-item has a `prize`, append a synthetic CHECKPOINT (carrying the prize) to the lane. A **lone** GROUP at an Order keeps today's inline-flatten behavior. Reuse `expandGroupTutorials`; add a batched read of `Groups.title` for lane labels. +3. **Gating engine change — server-authoritative.** Move unlock computation **server-side**: emit a per-item/`unlocked` (or per-lane/segment completion) flag in the payload, so the client stops index-walking. Rationale (builder call, §7.4): a cluster/fork has no "previous array index"; the server already owns `status` including the #2866 `COMPLETED_OUT_OF_WINDOW` downgrade (`effectiveStatus`) **and** must compose with the #2864 `temporarilyUnavailable` flag (schema:483) — keeping the gate next to that state avoids re-deriving two downgrade rules on the client. `app-space-gating.ts` becomes a thin reader of the server flag (keep its pure signature for tests). Rule: an Order-group unlocks when the previous Order-group meets its completion rule; a post-fork item unlocks only when **all** lanes complete; a GROUP-row's synthetic prize unlocks on **that lane** alone. +4. **Vue rendering change** — render segments: linear nodes, fork lanes (side-by-side, fork/join connectors), parallel-any clusters. **Recompute track-level aggregates against the authored completion rule, not the flat list:** when a fork uses per-lane rewards ("either branch"), `isTrackComplete`/progress/barcode must treat the track as complete when the authored rule is met (e.g. one lane + its prize), *not* when every tutorial is done — otherwise the progress ring contradicts a prize the user just earned. Preserve status styles, Joule/Sapphire theming, confetti, checkpoint barcode, redo (#2866) and temporarily-unavailable (#2864) states. +5. **Admin UI** — confirm the Path Items editor allows **duplicate Order values** and setting `group` + `prize` on one row (the screenshot shows Order, a GROUP type, and a Prize field already exist). If the editor blocks duplicate Orders or makes GROUP+Prize authoring awkward, that's in scope. *(Builder: verify against `app/admin/*` completion-path components — §7.3.)* + +## 6. Goals / non-goals / anti-goals + +**Goals** +- Model the **entire** feature within the existing schema — zero new entities/columns/enum values (hard constraint from Tom). +- Author parallel segments using only `itemOrder` + GROUP path-items + the existing `prize` association. +- Render forks as side-by-side lanes and free-order sets as any-order clusters inside one track. +- Completion rule ("either branch" vs "both branches") expressed by **where the Prize lives** (GROUP row vs. downstream CHECKPOINT row). +- Linear tracks render and gate **exactly** as they do today (zero regression). + +**Non-goals (this iteration)** +- **N-of-M thresholds** for clusters ("any 4 of 7"). Ship all-of; shape the segment model so a threshold could be added later without reshaping the payload. +- Nested forks (a lane that itself forks). Groups expand one level deep, as today. +- Cross-track dependencies / conditions (the #172 `altCondition` DSL). Out of scope. +- Changing the track-selection grid ("Choose Your Track") — only the **detail** view changes. +- Any change to `db/schema.cds`, `GroupPathItems`, or the `TaskType` enum. + +**Anti-goals (would make this a failure even if it "works")** +- Any regression to existing linear TechEd/Sapphire tracks, to #2866 window gating, or to #2864 temporarily-unavailable handling. +- Reviving or extending #172's half-built `altGroupKey`/`altGroupLabel` mechanism — this design **ignores** those fields and groups purely on `itemOrder`. Do not make authoring depend on altGroupKey, and do not alter the shared `groupByAlt`/alt-group validator for this feature. +- Making authors learn a new completion-config UI when Prize placement already expresses intent. + +## 7. Open calls left to the builder + +The builder-check resolved the data-model questions (folded into §3/§5 as decisions: CHECKPOINT-with-prize is the reward; grouping is pure-`itemOrder`; alt-group fields ignored; lane labels from Group `title`; gating moves server-side; track aggregates follow the authored rule). What remains genuinely open: + +1. **Exact payload schema** for segments/lanes (TypeScript interfaces in `AppSpace.vue` + the server shape). Sensible default: `AppSpaceTrack.segments: Array`, each tagged by `kind` and carrying `order`; decide whether to keep a parallel flat `items[]` for back-compat or migrate the Vue fully to segments. +2. **Admin editor ergonomics** — verify the Path Items editor allows duplicate Order values and GROUP+Prize on one row; extend only if it blocks them. +3. **Test strategy** — unit tests for the server segment builder and the (now server-side) gating rule (in-memory SQLite, `npm test`), including a lone-GROUP-stays-linear regression and a `temporarilyUnavailable`-member-in-a-fork case; plus a committed e2e spec for the detail view (advisory per CLAUDE.md for `app/**`/`hugo/**`). + +## 8. Constraints + +- CAP Node.js backend; HANA in prod, in-memory SQLite for unit tests. Never SELECT a HANA BLOB alongside metadata (not relevant here — these are metadata reads). +- Frontend is a Vue 3 island built into `hugo/static/js/` — not a route. Island manifest/build caveats from CLAUDE.md apply at deploy time. +- Feature should be shippable behind the existing event/mission plumbing; no new env var (DB-driven config is the house style, but here no config is even needed — placement drives behavior). +- PR to `DEV`; no direct merge to `main`. From 4db8148c2e5dabea1cac4bc7c90904cbc8e90381 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 22 Oct 2026 12:19:57 -0500 Subject: [PATCH 03/38] docs(#2887): implementation plan for App Space parallel paths --- .../2026-10-22-app-space-parallel-paths.md | 254 ++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-22-app-space-parallel-paths.md diff --git a/docs/superpowers/plans/2026-10-22-app-space-parallel-paths.md b/docs/superpowers/plans/2026-10-22-app-space-parallel-paths.md new file mode 100644 index 000000000..268091d65 --- /dev/null +++ b/docs/superpowers/plans/2026-10-22-app-space-parallel-paths.md @@ -0,0 +1,254 @@ +# App Space Parallel Paths — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a single App Space track render parallel segments — side-by-side branch lanes and any-order clusters — modeled entirely on the existing schema. + +**Architecture:** The server progress handler stops emitting a flat, order-less `items[]` and instead emits ordered **segments** (`linear` | `parallel-any` | `fork`) grouped by `itemOrder`, with per-item server-computed `unlocked` flags and (for a GROUP row carrying a `prize`) a synthetic trailing CHECKPOINT. `AppSpace.vue` renders segments; `app-space-gating.ts` becomes a thin reader of the server flag. No `db/schema.cds` change. + +**Tech Stack:** SAP CAP Node.js (`@sap/cds`), Vitest (server: `cds.test` in-memory; Vue: happy-dom + `@vue/test-utils`), Vue 3 island (`hugo-apps/`). + +**Spec:** `docs/superpowers/specs/2026-10-22-app-space-parallel-paths-design.md` + +## Global Constraints + +- **No schema change.** No new entities/columns/enum values; `db/schema.cds`, `GroupPathItems`, and the `TaskType` enum are untouched. All structure is derived at read time from existing fields (`itemOrder`, `taskType`, `group`, `prize`). +- **Grouping key is pure `itemOrder`.** The #172 `altGroupKey`/`altGroupLabel` fields are ignored; do not alter `srv/handlers/completion-path-items-altgroup.js`, `group-by-alt.js`, or their validator. +- **Lane label = the `Groups` entity's own `title`** (requires a new batched read of `Groups`), not `altGroupLabel`. +- **Zero regression for linear tracks:** a path where every item has a unique `itemOrder` must produce output identical in content to today, and a *lone* GROUP at an Order keeps today's inline-flatten behavior. +- **#2866 preserved:** `COMPLETED_OUT_OF_WINDOW` never counts toward unlock/progress/prize. **#2864 preserved:** a `temporarilyUnavailable` item must not be treated as completable. +- **The synthetic lane prize is a real `CHECKPOINT`** output item (not a frontend-only `PRIZE` state) so its barcode works via the existing cookie-based `/api/my-prize-barcode` path. +- Target branch `DEV`; PR, no direct merge to `main`. + +## Review Focus + +- **Lone GROUP at an Order** — must still expand inline as a linear run (today's behavior), NOT become a one-lane fork. Pinned in Task 2. +- **`temporarilyUnavailable` member inside a fork/cluster** (#2864) — an unavailable item must not count as complete, so a join/prize above it must stay locked. Pinned in Task 3. +- **Out-of-window completion inside a lane** (#2866) — a `COMPLETED_OUT_OF_WINDOW` tutorial in a branch must not advance that lane or unlock its prize. Pinned in Task 3. +- **Fork with no downstream item** — lanes that never rejoin (each lane terminal, e.g. per-lane prizes only) must render and gate without assuming a join node exists. Pinned in Task 2. +- **Mixed segment: a GROUP and a TUTORIAL sharing one Order** — ambiguous shape; define it (treat as a fork whose non-group member is a single-item lane) and pin so it can't silently drop the tutorial. Pinned in Task 2. + +--- + +### Task 1: Segment grouping + shape classification (pure helper) + +A pure function that groups a path's items by `itemOrder` and classifies each group's shape, with no DB access — the testable core of the restructure. + +**Files:** +- Create: `srv/lib/app-space-segments.js` +- Test: `test/unit/app-space-segments.test.js` + +**Interfaces:** +- Produces: `classifyPathSegments(pathItems) -> Array<{ order: number, kind: 'linear'|'parallel-any'|'fork', members: object[] }>` where `members` are the raw `CompletionPathItems` rows at that order. `kind` rules: one member → `linear`; 2+ members **all `TUTORIAL`** → `parallel-any`; 2+ members with **any `GROUP`** → `fork`. Input need not be pre-sorted; output is ascending by `order`. + +- [ ] **Step 1: Write the failing test** + +```js +import { describe, it, expect } from 'vitest'; +import { classifyPathSegments } from '../../srv/lib/app-space-segments.js'; + +const row = (order, taskType, extra = {}) => ({ itemOrder: order, taskType, ...extra }); + +describe('classifyPathSegments', () => { + it('one item at an order → linear', () => { + const segs = classifyPathSegments([row(10, 'TUTORIAL')]); + expect(segs).toEqual([{ order: 10, kind: 'linear', members: [row(10, 'TUTORIAL')] }]); + }); + it('a lone GROUP at an order is linear, not a one-lane fork', () => { + const segs = classifyPathSegments([row(10, 'GROUP', { group_ID: 'g1' })]); + expect(segs[0].kind).toBe('linear'); + }); + it('2+ TUTORIALs sharing an order → parallel-any', () => { + const segs = classifyPathSegments([row(20, 'TUTORIAL'), row(20, 'TUTORIAL')]); + expect(segs[0].kind).toBe('parallel-any'); + }); + it('2+ GROUPs sharing an order → fork', () => { + const segs = classifyPathSegments([row(20, 'GROUP', { group_ID: 'a' }), row(20, 'GROUP', { group_ID: 'b' })]); + expect(segs[0].kind).toBe('fork'); + }); + it('a GROUP and a TUTORIAL sharing an order → fork (tutorial becomes a single-item lane)', () => { + const segs = classifyPathSegments([row(20, 'GROUP', { group_ID: 'a' }), row(20, 'TUTORIAL')]); + expect(segs[0].kind).toBe('fork'); + expect(segs[0].members).toHaveLength(2); + }); + it('sorts segments ascending by order regardless of input order', () => { + const segs = classifyPathSegments([row(30, 'TUTORIAL'), row(10, 'TUTORIAL')]); + expect(segs.map(s => s.order)).toEqual([10, 30]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run --project unit test/unit/app-space-segments.test.js` +Expected: FAIL — `classifyPathSegments` not exported. + +- [ ] **Step 3: Implement `classifyPathSegments(pathItems)` in `srv/lib/app-space-segments.js`** + +Group by `itemOrder` into a Map, sort keys ascending, classify each group by the rules in the Interfaces block. Pure JS, no CDS import. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run --project unit test/unit/app-space-segments.test.js` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add srv/lib/app-space-segments.js test/unit/app-space-segments.test.js +git commit -m "feat(#2887): classifyPathSegments — group path items by itemOrder into typed segments" +``` + +--- + +### Task 2: Segment-building in the resolver (lanes + synthetic prize + Group titles) + +Replace the flat output of `buildPathItems` with a nested segment structure. GROUP members nest their expanded tutorials under a lane labeled by the Group's `title`; a GROUP row with a `prize` gets a synthetic trailing CHECKPOINT; lone GROUP and linear items keep today's shape. + +**Files:** +- Modify: `srv/lib/path-item-task-resolver.js` (add `buildPathSegments`; keep `buildPathItems` intact for `getEventProgress`, which stays flat) +- Test: `test/app-space-segments-resolver.test.js` (uses `cds.test` in-memory, per `app-space-fk-resolution.test.js`) + +**Interfaces:** +- Consumes: `classifyPathSegments` (Task 1); existing `expandGroupTutorials`, `shapeItem`, `resolveTask`, `effectiveLegacyId` (same module); `Groups` CDS entity (new batched read of `ID`→`title`). +- Produces: `buildPathSegments({ pathItems, taskMap, effLegacyByItem, groupTutorials, groupTitles, recordMap, event }) -> Array` where + - `LinearSegment = { order, kind:'linear', items: OutputItem[] }` (items via existing `shapeItem`; a lone GROUP expands inline to its tutorials exactly as `buildPathItems` does today) + - `ClusterSegment = { order, kind:'parallel-any', items: OutputItem[] }` + - `ForkSegment = { order, kind:'fork', lanes: Array<{ label: string, items: OutputItem[] }> }` — each GROUP member → one lane (label = `groupTitles.get(group_ID)`, items = expanded tutorials + optional synthetic CHECKPOINT); a non-GROUP member → a single-item lane (label = item title). + - Synthetic CHECKPOINT shape: `shapeItem`-compatible with `type:'CHECKPOINT'`, `title` = prize/group title, carries the prize linkage used by the barcode. Appended as the last item of its lane only when the GROUP path-item's `prize_ID` is set. +- Also produces: `fetchGroupTitles({ items, entities }) -> Map` (batched `SELECT ID,title from Groups where ID in …`). + +- [ ] **Step 1: Write the failing test** + +```js +import { describe, it, expect, beforeAll } from 'vitest'; +import cds from '@sap/cds'; +cds.test('serve', '--project', '.', '--in-memory'); +// Seed: a path with Order 10 TUTORIAL (setup); Order 20 two GROUPs (MCP w/ prize, Agents); +// Order 30 CHECKPOINT. Assert the getAppSpaceProgress payload for this path: +// - segments length 3, kinds ['linear','fork','linear'] +// - fork segment has 2 lanes labeled 'MCP' and 'Agents' (from Groups.title) +// - MCP lane's last item is type 'CHECKPOINT' (synthetic from the prize); Agents lane has none +// - a lone GROUP at its own order (separate path) yields a 'linear' segment whose items are the group's tutorials (no lane wrapper) +``` +(Full seeding mirrors `app-space-fk-resolution.test.js`: insert Events/Missions/CompletionPaths/CompletionPathItems/Groups/GroupPathItems/Tutorials/Prizes, call the service action, assert on `paths[0].segments`.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/app-space-segments-resolver.test.js` +Expected: FAIL — handler still returns flat `items`, no `segments`. + +- [ ] **Step 3: Implement `buildPathSegments` + `fetchGroupTitles` in `srv/lib/path-item-task-resolver.js`** + +Classify with `classifyPathSegments`, then per segment: linear → reuse today's per-item/GROUP-inline logic; parallel-any → `shapeItem` each; fork → one lane per member, GROUP members via `expandGroupTutorials` + synthetic CHECKPOINT when `prize_ID` set. Do **not** modify `buildPathItems`. + +- [ ] **Step 4: Wire the handler** — in `srv/developer-service.js` `getAppSpaceProgress`, batch-read group titles + prizes alongside the existing reads, and set `segments: buildPathSegments(...)` on each path (keep `items` too for now — Task 4 removes the Vue dependence). + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx vitest run test/app-space-segments-resolver.test.js` +Expected: PASS. Also run `npx vitest run test/app-space-fk-resolution.test.js` — existing flat behavior unbroken. + +- [ ] **Step 6: Commit** + +```bash +git add srv/lib/path-item-task-resolver.js srv/developer-service.js test/app-space-segments-resolver.test.js +git commit -m "feat(#2887): buildPathSegments — emit fork lanes + synthetic lane prize in app-space payload" +``` + +--- + +### Task 3: Server-side unlock flags (segment-aware gating) + +Compute `unlocked` per output item on the server so a fork/cluster no longer relies on array adjacency. Composes with #2866 and #2864. + +**Files:** +- Modify: `srv/lib/app-space-segments.js` (add `annotateUnlocked`) +- Test: `test/unit/app-space-unlock.test.js` + +**Interfaces:** +- Consumes: segment array from Task 2 (items carry `status`; a `temporarilyUnavailable` boolean is threaded through from the row). +- Produces: `annotateUnlocked(segments) -> segments` (mutates/returns items with `unlocked: boolean`). Rules: segment N unlocks when segment N-1 is **satisfied**; first segment always unlocked. A `linear` segment is satisfied when its single item is `COMPLETED`; a `parallel-any` cluster when **all** members `COMPLETED`; a `fork` when **all** lanes complete (a lane is complete when all its items are `COMPLETED`, synthetic checkpoint included). A lane's own synthetic prize is unlocked when **that lane** (its tutorials) completes — independent of other lanes. `COMPLETED_OUT_OF_WINDOW` and `temporarilyUnavailable` items are **never** "complete" for satisfaction. + +- [ ] **Step 1: Write the failing test** — cases: linear gate carries over; cluster locks downstream until all done; fork join locks until both lanes done; per-lane synthetic prize unlocks on its own lane; a `COMPLETED_OUT_OF_WINDOW` member leaves its lane incomplete; a `temporarilyUnavailable` member leaves its segment unsatisfied. + +- [ ] **Step 2: Run** `npx vitest run --project unit test/unit/app-space-unlock.test.js` → FAIL. + +- [ ] **Step 3: Implement `annotateUnlocked`** per the rules above; call it in `getAppSpaceProgress` after `buildPathSegments`. + +- [ ] **Step 4: Run** → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add srv/lib/app-space-segments.js srv/developer-service.js test/unit/app-space-unlock.test.js +git commit -m "feat(#2887): server-side segment-aware unlock flags (composes with #2866/#2864)" +``` + +--- + +### Task 4: Gating helper reads the server flag + +Make `app-space-gating.ts` prefer the server `unlocked` flag, falling back to the legacy index-walk only when the flag is absent (older payloads). Keeps the pure signature for tests. + +**Files:** +- Modify: `hugo-apps/src/app-space/app-space-gating.ts` +- Test: `test/unit/app-space-gating.test.js` (extend) + +**Interfaces:** +- Produces: `isItemUnlocked(items, index)` unchanged signature; now returns `items[index].unlocked` when that property is a boolean, else the existing index-adjacency logic. `isTutorialOpenable` unchanged (still allows `COMPLETED_OUT_OF_WINDOW` links). + +- [ ] **Step 1: Add failing tests** — `unlocked:true` on an item whose predecessor is incomplete returns true (flag wins); absent flag falls back to today's behavior (existing tests still pass). +- [ ] **Step 2: Run** the gating test → new case FAILs. +- [ ] **Step 3: Implement** the flag-preferring branch. +- [ ] **Step 4: Run** → PASS (old + new). +- [ ] **Step 5: Commit** `git commit -m "feat(#2887): app-space-gating reads server unlocked flag, legacy fallback"` + +--- + +### Task 5: Render segments in AppSpace.vue (fork lanes + clusters) + +Replace the flat `v-for` timeline with a segment renderer: linear nodes, side-by-side fork lanes (fork/join connectors), any-order clusters. Recompute track aggregates against segments. + +**Files:** +- Modify: `hugo-apps/src/app-space/AppSpace.vue` +- Test: `hugo-apps/src/app-space/__tests__/AppSpace.segments.test.ts` (happy-dom + `stubFetch`, per `AppSpace.empty-state.test.ts`) + +**Interfaces:** +- Consumes: `AppSpaceTrack.segments` from the payload (Task 2) with per-item `unlocked` (Task 3). Add TS interfaces `LinearSegment | ClusterSegment | ForkSegment` to the component; `selectedTrack.segments` drives rendering. +- Produces: rendering only. `isTrackComplete`/`progressPercent`/`completedCount`/barcode-eligibility computed over segments, honoring the authored rule (per-lane prize ⇒ a lane + its prize can complete the track's reward; a downstream checkpoint ⇒ all lanes). + +- [ ] **Step 1: Write the failing test** — stub `getAppSpaceProgress` to return a fork track (setup linear → 2 lanes, one with a synthetic CHECKPOINT → downstream CHECKPOINT). Mount, open the track, assert: two lane columns render with labels "MCP"/"Agents"; locked downstream checkpoint while lanes incomplete; the cluster track renders N cards all available after setup. Assert the barcode (`shouldShowCheckpointBarcode`) shows for an unlocked+authenticated synthetic lane checkpoint identically to a normal one. +- [ ] **Step 2: Run** `npx vitest run hugo-apps/src/app-space/__tests__/AppSpace.segments.test.ts` → FAIL. +- [ ] **Step 3: Implement** the segment template + scoped CSS (fork/join connectors, `.lanes` grid collapsing on narrow viewports, cluster grid), reusing existing `.timeline-*`/`.step--*` classes and theme rules. Keep confetti, redo (#2866), temporarily-unavailable (#2864) states. +- [ ] **Step 4: Run** → PASS; also run the existing app-space Vue tests (`npx vitest run hugo-apps/src/app-space/__tests__/`) to confirm linear tracks unregressed. +- [ ] **Step 5: Commit** `git commit -m "feat(#2887): render fork lanes + any-order clusters in App Space track detail"` + +--- + +### Task 6: Admin editor verification + e2e spec + +Confirm the Path Items editor allows duplicate `itemOrder` and GROUP+Prize on one row; add a committed e2e spec for the detail view (advisory per CLAUDE.md). + +**Files:** +- Verify (read): `app/admin/*` completion-path components; extend only if duplicate Order or GROUP+Prize is blocked. +- Create: `test/e2e/app-space-parallel.spec.ts` (self-skips without `SMOKE_BASE_URL`, per `test/e2e/README.md`) + +- [ ] **Step 1:** Read the admin completion-path-items component; confirm Order accepts duplicates and `group`+`prize` are both settable on a row. Record findings in the PR description. If blocked, write the failing test + fix in this task. +- [ ] **Step 2:** Write the e2e spec: load a seeded fork track in App Space, assert two lane columns + a locked downstream checkpoint. Self-skip when `SMOKE_BASE_URL` absent. +- [ ] **Step 3:** Run `npx vitest run test/e2e/app-space-parallel.spec.ts` locally (self-skips) → PASS/skip. +- [ ] **Step 4: Commit** `git commit -m "test(#2887): app-space parallel e2e spec + admin editor verification"` + +--- + +## Self-Review + +**1. Spec coverage:** §2 authoring → Tasks 1–2; §3 completion/prize placement → Tasks 2 (synthetic prize) + 3 (unlock rules); §4 rendering → Task 5; §5.1 payload → Task 2; §5.2 builder → Task 2; §5.3 server gating → Tasks 3–4; §5.4 Vue + aggregates → Task 5; §5.5 admin → Task 6; §7 open calls (payload schema, admin ergonomics, tests) → Tasks 5/6/each task's tests. No gaps. + +**2. Step scan:** Each code step gives a signature + file; bodies are left to the implementer except where the Interfaces block fixes the shape. Test steps carry names/assertions with spec values (segment kinds, lane labels, unlock rules). + +**3. Type consistency:** `classifyPathSegments` (T1) → consumed by `buildPathSegments` (T2) → `annotateUnlocked` (T3) → `unlocked` read by `isItemUnlocked` (T4) → `segments` rendered by Vue (T5). Segment kind strings `'linear'|'parallel-any'|'fork'` identical across tasks. `buildPathItems` left untouched (only `buildPathSegments` added) so `getEventProgress` is unaffected. + +**4. Review Focus:** all five pinned (lone GROUP → T1 & T2; temporarilyUnavailable → T3; out-of-window → T3; no-downstream fork → T2; mixed GROUP+TUTORIAL order → T1 & T2). + +**5. Proportion:** Plan carries interfaces + test assertions, not full bodies; longer than the spec only where signatures/tests must be pinned. Acceptable. From 40af964e06c6abe0fa9dc6d7298574060b5eadf5 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 22 Oct 2026 12:22:46 -0500 Subject: [PATCH 04/38] =?UTF-8?q?feat(#2887):=20classifyPathSegments=20?= =?UTF-8?q?=E2=80=94=20group=20path=20items=20by=20itemOrder=20into=20type?= =?UTF-8?q?d=20segments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- srv/lib/app-space-segments.js | 46 ++++++++++++++++++++++++++++ test/unit/app-space-segments.test.js | 32 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 srv/lib/app-space-segments.js create mode 100644 test/unit/app-space-segments.test.js diff --git a/srv/lib/app-space-segments.js b/srv/lib/app-space-segments.js new file mode 100644 index 000000000..958310969 --- /dev/null +++ b/srv/lib/app-space-segments.js @@ -0,0 +1,46 @@ +/** + * Groups a CompletionPath's items by itemOrder and classifies each group's shape. + * Pure JS function with no DB access. + * + * @param {Array} pathItems - Raw CompletionPathItems rows. Fields used: itemOrder, taskType. + * Input need not be pre-sorted. + * @returns {Array<{order: number, kind: string, members: Array}>} + * Segments sorted ascending by order. + * kind: 'linear' (1 member), 'parallel-any' (2+ all TUTORIAL), 'fork' (2+ with any GROUP). + */ +export function classifyPathSegments(pathItems) { + // Group by itemOrder + const grouped = new Map(); + for (const item of pathItems) { + const order = item.itemOrder; + if (!grouped.has(order)) { + grouped.set(order, []); + } + grouped.get(order).push(item); + } + + // Sort orders ascending and classify each group + const orders = Array.from(grouped.keys()).sort((a, b) => a - b); + const segments = []; + + for (const order of orders) { + const members = grouped.get(order); + let kind; + + if (members.length === 1) { + kind = 'linear'; + } else { + // 2+ members: check if any are GROUP + const hasGroup = members.some(m => m.taskType === 'GROUP'); + kind = hasGroup ? 'fork' : 'parallel-any'; + } + + segments.push({ + order, + kind, + members + }); + } + + return segments; +} diff --git a/test/unit/app-space-segments.test.js b/test/unit/app-space-segments.test.js new file mode 100644 index 000000000..a3e5b4fb3 --- /dev/null +++ b/test/unit/app-space-segments.test.js @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { classifyPathSegments } from '../../srv/lib/app-space-segments.js'; + +const row = (order, taskType, extra = {}) => ({ itemOrder: order, taskType, ...extra }); + +describe('classifyPathSegments', () => { + it('one item at an order → linear', () => { + const segs = classifyPathSegments([row(10, 'TUTORIAL')]); + expect(segs).toEqual([{ order: 10, kind: 'linear', members: [row(10, 'TUTORIAL')] }]); + }); + it('a lone GROUP at an order is linear, not a one-lane fork', () => { + const segs = classifyPathSegments([row(10, 'GROUP', { group_ID: 'g1' })]); + expect(segs[0].kind).toBe('linear'); + }); + it('2+ TUTORIALs sharing an order → parallel-any', () => { + const segs = classifyPathSegments([row(20, 'TUTORIAL'), row(20, 'TUTORIAL')]); + expect(segs[0].kind).toBe('parallel-any'); + }); + it('2+ GROUPs sharing an order → fork', () => { + const segs = classifyPathSegments([row(20, 'GROUP', { group_ID: 'a' }), row(20, 'GROUP', { group_ID: 'b' })]); + expect(segs[0].kind).toBe('fork'); + }); + it('a GROUP and a TUTORIAL sharing an order → fork (tutorial becomes a single-item lane)', () => { + const segs = classifyPathSegments([row(20, 'GROUP', { group_ID: 'a' }), row(20, 'TUTORIAL')]); + expect(segs[0].kind).toBe('fork'); + expect(segs[0].members).toHaveLength(2); + }); + it('sorts segments ascending by order regardless of input order', () => { + const segs = classifyPathSegments([row(30, 'TUTORIAL'), row(10, 'TUTORIAL')]); + expect(segs.map(s => s.order)).toEqual([10, 30]); + }); +}); From d4215a07a997ff23b516733a337ae8721203d403 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 22 Oct 2026 12:29:27 -0500 Subject: [PATCH 05/38] =?UTF-8?q?feat(#2887):=20buildPathSegments=20?= =?UTF-8?q?=E2=80=94=20emit=20fork=20lanes=20+=20synthetic=20lane=20prize?= =?UTF-8?q?=20in=20app-space=20payload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- srv/developer-service.js | 50 +++-- srv/lib/path-item-task-resolver.js | 162 +++++++++++++++ test/app-space-segments-resolver.test.js | 253 +++++++++++++++++++++++ 3 files changed, 448 insertions(+), 17 deletions(-) create mode 100644 test/app-space-segments-resolver.test.js diff --git a/srv/developer-service.js b/srv/developer-service.js index 739ce5ab0..a886f0f8b 100644 --- a/srv/developer-service.js +++ b/srv/developer-service.js @@ -14,7 +14,7 @@ import { maybeAutoSendCompletion } from './lib/ngds-autosend.js'; import { stampSubmissionId } from './lib/task-record-submission-id.js'; import { rollUpParentsForCompletion } from './lib/completion-rollup.js'; import { renderMarkdown } from './lib/markdown.js'; -import { buildTaskMap, effectiveLegacyId, expandGroupTutorials, buildPathItems, groupChildLegacyIds } from './lib/path-item-task-resolver.js'; +import { buildTaskMap, effectiveLegacyId, expandGroupTutorials, buildPathItems, buildPathSegments, fetchGroupTitles, groupChildLegacyIds } from './lib/path-item-task-resolver.js'; import { handleRequestAccountMerge, handleConfirmAccountMerge } from './lib/account-merge-request.js'; // Per-user rate limit for resetTutorialProgress — same window as the @@ -84,7 +84,7 @@ export default class DeveloperService extends cds.ApplicationService { Users: dbUsers, Events: dbEvents, Missions: dbMissions, CompletionPaths: dbPaths, CompletionPathItems: dbPathItems, Checkpoints: dbCheckpoints, Petoberfests: dbPetoberfests, - GroupPathItems: dbGroupPathItems, + GroupPathItems: dbGroupPathItems, Groups: dbGroups, UserLearningPreferences } = cds.entities('com.sap.developers.ims'); // Auto-assign legacyId on TaskRecord creation @@ -689,6 +689,12 @@ export default class DeveloperService extends cds.ApplicationService { entities: { GroupPathItems: dbGroupPathItems, Tutorials: dbTutorials }, }); + // #2887: Batch-fetch Group titles for fork lane labels. + const groupTitles = await fetchGroupTitles({ + items: allItems, + entities: { Groups: dbGroups }, + }); + const effLegacyByItem = new Map(allItems.map(i => [i, effectiveLegacyId(taskMap, i)])); const recordLegacyIds = [...new Set([ ...[...effLegacyByItem.values()].filter(v => v != null), @@ -715,21 +721,31 @@ export default class DeveloperService extends cds.ApplicationService { hasLogo: Boolean(event.hasLogo), authenticated: Boolean(dbUser), // #2314/#2730: false only for anonymous callers → client skips the logged-in-only realtime/confetti path. Truthy when user resolves via any tier (sapId, identity link, or pin). type: 'COMPLEX', - paths: paths.map(p => ({ - id: p.legacyId, - title: p.name, - description: p.description || mission.description || '', - items: buildPathItems({ - pathItems: allItems.filter(i => i.path_ID === p.ID), - taskMap, effLegacyByItem, groupTutorials, recordMap, - // #2866: App Space prize credit counts only completions within the - // event's [startDate,endDate]; passing `event` makes shapeItem - // downgrade out-of-window COMPLETED tutorials to - // COMPLETED_OUT_OF_WINDOW. getEventProgress omits `event`, so it - // stays time-agnostic. Fail-open when the window is not set. - event, - }), - })), + paths: paths.map(p => { + const pathItems = allItems.filter(i => i.path_ID === p.ID); + return { + id: p.legacyId, + title: p.name, + description: p.description || mission.description || '', + items: buildPathItems({ + pathItems, + taskMap, effLegacyByItem, groupTutorials, recordMap, + // #2866: App Space prize credit counts only completions within the + // event's [startDate,endDate]; passing `event` makes shapeItem + // downgrade out-of-window COMPLETED tutorials to + // COMPLETED_OUT_OF_WINDOW. getEventProgress omits `event`, so it + // stays time-agnostic. Fail-open when the window is not set. + event, + }), + // #2887: nested segment structure for the new parallel-paths UI. + // Kept alongside flat `items` — Task 4 will migrate the Vue off items. + segments: buildPathSegments({ + pathItems, + taskMap, effLegacyByItem, groupTutorials, groupTitles, recordMap, + event, + }), + }; + }), }; }); diff --git a/srv/lib/path-item-task-resolver.js b/srv/lib/path-item-task-resolver.js index 924cd6a4b..dfd101f32 100644 --- a/srv/lib/path-item-task-resolver.js +++ b/srv/lib/path-item-task-resolver.js @@ -1,4 +1,5 @@ import cds from '@sap/cds'; +import { classifyPathSegments } from './app-space-segments.js'; // Shared task resolution for CompletionPathItems in the event / app-space // progress handlers (srv/developer-service.js: getEventProgress, @@ -269,3 +270,164 @@ export function groupChildLegacyIds(groupTutorials) { } return [...ids]; } + +/** + * Batch-fetch the titles for Groups referenced by path items (for fork lane + * labels). Returns an empty Map when there are no GROUP items. + * + * @param {object} opts + * @param {Array} opts.items CompletionPathItems rows (taskType, group_ID). + * @param {object} opts.entities { Groups } CDS entity. + * @returns {Promise>} group_ID → title + */ +export async function fetchGroupTitles({ items, entities }) { + const { Groups } = entities; + const groupIds = [...new Set( + items + .filter(i => i.taskType === 'GROUP' && i.group_ID) + .map(i => i.group_ID) + )]; + const titleMap = new Map(); + if (groupIds.length === 0) return titleMap; + const rows = await SELECT.from(Groups).columns('ID', 'title').where({ ID: { in: groupIds } }); + for (const r of rows) titleMap.set(r.ID, r.title || ''); + return titleMap; +} + +/** + * Build a nested segment structure for a single App Space path. + * + * Uses `classifyPathSegments` (Task 1) to group path items by `itemOrder` and + * classify each group as 'linear' / 'parallel-any' / 'fork', then shapes each + * segment accordingly: + * + * - LinearSegment `{ order, kind:'linear', items }` + * A lone GROUP at an order expands inline to its child tutorials exactly as + * `buildPathItems` does today — no lane wrapper. + * + * - ClusterSegment `{ order, kind:'parallel-any', items }` + * All members are TUTORIAL items; each becomes one output item. + * + * - ForkSegment `{ order, kind:'fork', lanes:[{ label, items }] }` + * Each GROUP member → one lane (label = Groups.title from groupTitles); + * items = expanded child tutorials + optional synthetic CHECKPOINT when the + * path item's prize_ID is set. Non-GROUP members → a single-item lane + * (label = item title from the resolved task). + * + * Does NOT modify `buildPathItems` — `getEventProgress` continues to use that. + * + * #2866: pass `event` through so out-of-window COMPLETED tutorials are + * downgraded to COMPLETED_OUT_OF_WINDOW exactly as today. + * #2864: `temporarilyUnavailable` is threaded into each output item. + * + * @param {object} opts + * @param {Array} opts.pathItems CompletionPathItems for this path (itemOrder-sorted). + * @param {Map} opts.taskMap from buildTaskMap. + * @param {Map} opts.effLegacyByItem item → effective legacyId. + * @param {Map} opts.groupTutorials group_ID → child tutorial rows (expandGroupTutorials). + * @param {Map} opts.groupTitles group_ID → title (fetchGroupTitles). + * @param {Map} opts.recordMap `${taskType}:${legacyId}` → user TaskRecord. + * @param {object} [opts.event] #2866: event row for window-gating. + * @returns {Array} + */ +export function buildPathSegments({ + pathItems, taskMap, effLegacyByItem, groupTutorials, groupTitles, recordMap, event, +}) { + const classified = classifyPathSegments(pathItems); + const segments = []; + + for (const { order, kind, members } of classified) { + if (kind === 'linear') { + const [item] = members; + if (item.taskType === 'GROUP') { + // Lone GROUP: expand inline to child tutorials (same as buildPathItems). + const children = (item.group_ID && groupTutorials.get(item.group_ID)) || []; + const items = children.map(t => { + const record = t.legacyId != null ? recordMap.get(`TUTORIAL:${t.legacyId}`) : undefined; + return { + ...shapeItem(t, t.legacyId ?? null, record, 'TUTORIAL', null, event), + temporarilyUnavailable: false, + }; + }); + segments.push({ order, kind: 'linear', items }); + } else { + // Regular linear: single direct item. + const task = resolveTask(taskMap, item); + const effLegacyId = effLegacyByItem.get(item); + const record = recordMap.get(`${item.taskType}:${effLegacyId}`); + const shaped = shapeItem(task, effLegacyId, record, item.taskType, item.checkpointTitle, event); + segments.push({ + order, + kind: 'linear', + items: [{ + ...shaped, + temporarilyUnavailable: Boolean(item.temporarilyUnavailable), + }], + }); + } + } else if (kind === 'parallel-any') { + // All TUTORIAL members, no GROUP. Each becomes one output item. + const items = members.map(item => { + const task = resolveTask(taskMap, item); + const effLegacyId = effLegacyByItem.get(item); + const record = recordMap.get(`${item.taskType}:${effLegacyId}`); + return { + ...shapeItem(task, effLegacyId, record, item.taskType, item.checkpointTitle, event), + temporarilyUnavailable: Boolean(item.temporarilyUnavailable), + }; + }); + segments.push({ order, kind: 'parallel-any', items }); + } else { + // kind === 'fork': one or more GROUP members (possibly mixed with TUTORIAL members). + const lanes = members.map(item => { + if (item.taskType === 'GROUP') { + const label = (item.group_ID && groupTitles.get(item.group_ID)) || ''; + const children = (item.group_ID && groupTutorials.get(item.group_ID)) || []; + const laneItems = children.map(t => { + const record = t.legacyId != null ? recordMap.get(`TUTORIAL:${t.legacyId}`) : undefined; + return { + ...shapeItem(t, t.legacyId ?? null, record, 'TUTORIAL', null, event), + temporarilyUnavailable: false, + }; + }); + // Synthetic prize CHECKPOINT: appended only when prize_ID is set. + if (item.prize_ID) { + const prizeTitle = label || 'Prize'; + laneItems.push({ + imsId: null, + title: prizeTitle, + type: 'CHECKPOINT', + status: '', + progress: 0, + experience: '', + timeToComplete: 0, + url: '', + description: '', + recordId: 0, + temporarilyUnavailable: false, + prizeId: item.prize_ID, + }); + } + return { label, items: laneItems }; + } else { + // Non-GROUP member in a fork → single-item lane. + const task = resolveTask(taskMap, item); + const effLegacyId = effLegacyByItem.get(item); + const record = recordMap.get(`${item.taskType}:${effLegacyId}`); + const shaped = shapeItem(task, effLegacyId, record, item.taskType, item.checkpointTitle, event); + const label = shaped.title || ''; + return { + label, + items: [{ + ...shaped, + temporarilyUnavailable: Boolean(item.temporarilyUnavailable), + }], + }; + } + }); + segments.push({ order, kind: 'fork', lanes }); + } + } + + return segments; +} diff --git a/test/app-space-segments-resolver.test.js b/test/app-space-segments-resolver.test.js new file mode 100644 index 000000000..f8326767f --- /dev/null +++ b/test/app-space-segments-resolver.test.js @@ -0,0 +1,253 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import cds from '@sap/cds'; + +// Issue #2887: App Space parallel-paths — buildPathSegments must emit a nested +// segment structure (linear / parallel-any / fork lanes) instead of a flat +// items array. This test validates the new `segments` field on each path object +// returned by getAppSpaceProgress. +// +// Scenario A ("fork event"): a path with three distinct itemOrders: +// Order 10 — one TUTORIAL (Setup) → linear segment +// Order 20 — two GROUPs (MCP w/ prize; Agents) → fork segment, 2 lanes +// Order 30 — one CHECKPOINT → linear segment +// +// Scenario B ("lone-group event"): a path whose only item is a single GROUP +// → linear segment whose items are the group's expanded tutorials (no lane wrapper) + +const project = cds.test('serve', '--project', '.', '--in-memory'); + +// ─── Scenario A: fork event ────────────────────────────────────────────────── +const FORK_EVENT_ID = 10008001; +const FORK_MISSION_ID = '11111111-2887-aaaa-0000-000000000001'; +const FORK_EVENT_UUID = 'eeeeeeee-2887-aaaa-0000-000000000001'; +const FORK_PATH_ID = '22222222-2887-aaaa-0000-000000000001'; + +// Groups for the fork +const GRP_MCP_ID = '33333333-2887-aaaa-0000-000000000001'; +const GRP_AGENTS_ID = '33333333-2887-aaaa-0000-000000000002'; + +// Prize attached to the MCP group path item +const PRIZE_ID = '44444444-2887-aaaa-0000-000000000001'; + +// Tutorials for MCP group +const TUT_MCP_A = { ID: 'cccccccc-2887-aaaa-0000-000000000001', legacyId: 10008010, slug: 'mcp-tut-a', title: 'MCP Tutorial A' }; +const TUT_MCP_B = { ID: 'cccccccc-2887-aaaa-0000-000000000002', legacyId: 10008011, slug: 'mcp-tut-b', title: 'MCP Tutorial B' }; +// Tutorials for Agents group +const TUT_AGT_A = { ID: 'cccccccc-2887-aaaa-0000-000000000003', legacyId: 10008012, slug: 'agents-tut-a', title: 'Agents Tutorial A' }; +// Setup tutorial (Order 10) +const TUT_SETUP = { ID: 'cccccccc-2887-aaaa-0000-000000000004', legacyId: 10008013, slug: 'setup-tut', title: 'Setup Tutorial' }; + +// ─── Scenario B: lone-group event ──────────────────────────────────────────── +const LONE_EVENT_ID = 10008002; +const LONE_MISSION_ID = '11111111-2887-aaaa-0000-000000000002'; +const LONE_EVENT_UUID = 'eeeeeeee-2887-aaaa-0000-000000000002'; +const LONE_PATH_ID = '22222222-2887-aaaa-0000-000000000002'; +const LONE_GRP_ID = '33333333-2887-aaaa-0000-000000000003'; +const TUT_LONE_A = { ID: 'cccccccc-2887-aaaa-0000-000000000005', legacyId: 10008020, slug: 'lone-grp-a', title: 'Lone Group Tut A' }; +const TUT_LONE_B = { ID: 'cccccccc-2887-aaaa-0000-000000000006', legacyId: 10008021, slug: 'lone-grp-b', title: 'Lone Group Tut B' }; + +describe('App-Space segments: fork lanes + synthetic prize CHECKPOINT + lone-GROUP linear (#2887)', () => { + beforeAll(async () => { + const { + Events, Missions, CompletionPaths, CompletionPathItems, + Groups, GroupPathItems, Tutorials, Prizes + } = cds.entities('com.sap.developers.ims'); + + // ── Tutorials ────────────────────────────────────────────────────────── + await INSERT.into(Tutorials).entries([ + TUT_SETUP, TUT_MCP_A, TUT_MCP_B, TUT_AGT_A, + TUT_LONE_A, TUT_LONE_B, + ]); + + // ── Groups ───────────────────────────────────────────────────────────── + await INSERT.into(Groups).entries([ + { ID: GRP_MCP_ID, legacyId: 10008030, title: 'MCP', slug: 'grp-mcp', published: true }, + { ID: GRP_AGENTS_ID, legacyId: 10008031, title: 'Agents', slug: 'grp-agents', published: true }, + { ID: LONE_GRP_ID, legacyId: 10008032, title: 'Lone Group', slug: 'grp-lone', published: true }, + ]); + + // ── GroupPathItems ───────────────────────────────────────────────────── + await INSERT.into(GroupPathItems).entries([ + // MCP group children + { ID: 'ffffffff-2887-aaaa-0000-000000000001', group_ID: GRP_MCP_ID, tutorial_ID: TUT_MCP_A.ID, itemOrder: 1 }, + { ID: 'ffffffff-2887-aaaa-0000-000000000002', group_ID: GRP_MCP_ID, tutorial_ID: TUT_MCP_B.ID, itemOrder: 2 }, + // Agents group children + { ID: 'ffffffff-2887-aaaa-0000-000000000003', group_ID: GRP_AGENTS_ID, tutorial_ID: TUT_AGT_A.ID, itemOrder: 1 }, + // Lone group children + { ID: 'ffffffff-2887-aaaa-0000-000000000004', group_ID: LONE_GRP_ID, tutorial_ID: TUT_LONE_A.ID, itemOrder: 1 }, + { ID: 'ffffffff-2887-aaaa-0000-000000000005', group_ID: LONE_GRP_ID, tutorial_ID: TUT_LONE_B.ID, itemOrder: 2 }, + ]); + + // ── Prizes ───────────────────────────────────────────────────────────── + await INSERT.into(Prizes).entries([ + { ID: PRIZE_ID, legacyId: 10008040, name: 'MCP Prize', event_ID: FORK_EVENT_UUID }, + ]); + + // ── Scenario A: fork mission + event ─────────────────────────────────── + await INSERT.into(Missions).entries({ + ID: FORK_MISSION_ID, legacyId: 10008050, title: 'Fork Mission', + slug: 'fork-mission', description: 'A fork mission', published: true, event_ID: null, + }); + await INSERT.into(Events).entries({ + ID: FORK_EVENT_UUID, legacyId: FORK_EVENT_ID, + name: 'Fork Test Event', eventType: 'OTHER', mission_ID: FORK_MISSION_ID, + }); + await INSERT.into(CompletionPaths).entries({ + ID: FORK_PATH_ID, legacyId: 10008060, name: 'Fork Path', + mission_ID: FORK_MISSION_ID, description: 'The fork path', + }); + await INSERT.into(CompletionPathItems).entries([ + // Order 10: single TUTORIAL (linear) + { ID: 'dddddddd-2887-aaaa-0000-000000000001', path_ID: FORK_PATH_ID, itemOrder: 10, + taskType: 'TUTORIAL', taskLegacyId: null, tutorial_ID: TUT_SETUP.ID }, + // Order 20: GROUP MCP (with prize) — fork lane 1 + { ID: 'dddddddd-2887-aaaa-0000-000000000002', path_ID: FORK_PATH_ID, itemOrder: 20, + taskType: 'GROUP', taskLegacyId: null, group_ID: GRP_MCP_ID, prize_ID: PRIZE_ID }, + // Order 20: GROUP Agents (no prize) — fork lane 2 + { ID: 'dddddddd-2887-aaaa-0000-000000000003', path_ID: FORK_PATH_ID, itemOrder: 20, + taskType: 'GROUP', taskLegacyId: null, group_ID: GRP_AGENTS_ID }, + // Order 30: single CHECKPOINT (linear) + { ID: 'dddddddd-2887-aaaa-0000-000000000004', path_ID: FORK_PATH_ID, itemOrder: 30, + taskType: 'CHECKPOINT', taskLegacyId: 10008070, checkpointTitle: 'Final Check' }, + ]); + + // ── Scenario B: lone-group mission + event ───────────────────────────── + await INSERT.into(Missions).entries({ + ID: LONE_MISSION_ID, legacyId: 10008051, title: 'Lone Group Mission', + slug: 'lone-group-mission', description: 'Lone group', published: true, event_ID: null, + }); + await INSERT.into(Events).entries({ + ID: LONE_EVENT_UUID, legacyId: LONE_EVENT_ID, + name: 'Lone Group Test Event', eventType: 'OTHER', mission_ID: LONE_MISSION_ID, + }); + await INSERT.into(CompletionPaths).entries({ + ID: LONE_PATH_ID, legacyId: 10008061, name: 'Lone Group Path', + mission_ID: LONE_MISSION_ID, description: 'Lone group path', + }); + await INSERT.into(CompletionPathItems).entries([ + // Single GROUP at Order 10 (lone-GROUP case → linear, expands inline) + { ID: 'dddddddd-2887-aaaa-0000-000000000005', path_ID: LONE_PATH_ID, itemOrder: 10, + taskType: 'GROUP', taskLegacyId: null, group_ID: LONE_GRP_ID }, + ]); + }); + + afterAll(async () => { + const { + Events, Missions, CompletionPaths, CompletionPathItems, + Groups, GroupPathItems, Tutorials, Prizes + } = cds.entities('com.sap.developers.ims'); + await DELETE.from(CompletionPathItems).where({ + path_ID: { in: [FORK_PATH_ID, LONE_PATH_ID] } + }); + await DELETE.from(CompletionPaths).where({ ID: { in: [FORK_PATH_ID, LONE_PATH_ID] } }); + await DELETE.from(Events).where({ legacyId: { in: [FORK_EVENT_ID, LONE_EVENT_ID] } }); + await DELETE.from(Missions).where({ ID: { in: [FORK_MISSION_ID, LONE_MISSION_ID] } }); + await DELETE.from(GroupPathItems).where({ + group_ID: { in: [GRP_MCP_ID, GRP_AGENTS_ID, LONE_GRP_ID] } + }); + await DELETE.from(Groups).where({ ID: { in: [GRP_MCP_ID, GRP_AGENTS_ID, LONE_GRP_ID] } }); + await DELETE.from(Prizes).where({ ID: PRIZE_ID }); + await DELETE.from(Tutorials).where({ + ID: { in: [TUT_SETUP.ID, TUT_MCP_A.ID, TUT_MCP_B.ID, TUT_AGT_A.ID, TUT_LONE_A.ID, TUT_LONE_B.ID] } + }); + }); + + // ── Scenario A tests ────────────────────────────────────────────────────── + + it('fork path has 3 segments with kinds [linear, fork, linear]', async () => { + const { data } = await project.get(`/api/getAppSpaceProgress(eventLegacyId=${FORK_EVENT_ID})`); + const path = data.paths.find(p => p.id === 10008060); + expect(path).toBeDefined(); + expect(path.segments).toBeDefined(); + expect(path.segments).toHaveLength(3); + expect(path.segments.map(s => s.kind)).toEqual(['linear', 'fork', 'linear']); + }); + + it('fork segment has 2 lanes labeled MCP and Agents (from Groups.title)', async () => { + const { data } = await project.get(`/api/getAppSpaceProgress(eventLegacyId=${FORK_EVENT_ID})`); + const path = data.paths.find(p => p.id === 10008060); + const forkSeg = path.segments.find(s => s.kind === 'fork'); + expect(forkSeg).toBeDefined(); + expect(forkSeg.lanes).toHaveLength(2); + const labels = forkSeg.lanes.map(l => l.label); + expect(labels).toContain('MCP'); + expect(labels).toContain('Agents'); + }); + + it('MCP lane contains its child tutorials', async () => { + const { data } = await project.get(`/api/getAppSpaceProgress(eventLegacyId=${FORK_EVENT_ID})`); + const path = data.paths.find(p => p.id === 10008060); + const forkSeg = path.segments.find(s => s.kind === 'fork'); + const mcpLane = forkSeg.lanes.find(l => l.label === 'MCP'); + expect(mcpLane).toBeDefined(); + const tutTitles = mcpLane.items.filter(i => i.type === 'TUTORIAL').map(i => i.title); + expect(tutTitles).toContain(TUT_MCP_A.title); + expect(tutTitles).toContain(TUT_MCP_B.title); + }); + + it('MCP lane ends with a synthetic CHECKPOINT (from prize_ID)', async () => { + const { data } = await project.get(`/api/getAppSpaceProgress(eventLegacyId=${FORK_EVENT_ID})`); + const path = data.paths.find(p => p.id === 10008060); + const forkSeg = path.segments.find(s => s.kind === 'fork'); + const mcpLane = forkSeg.lanes.find(l => l.label === 'MCP'); + const lastItem = mcpLane.items[mcpLane.items.length - 1]; + expect(lastItem.type).toBe('CHECKPOINT'); + }); + + it('Agents lane has no synthetic CHECKPOINT (no prize)', async () => { + const { data } = await project.get(`/api/getAppSpaceProgress(eventLegacyId=${FORK_EVENT_ID})`); + const path = data.paths.find(p => p.id === 10008060); + const forkSeg = path.segments.find(s => s.kind === 'fork'); + const agentsLane = forkSeg.lanes.find(l => l.label === 'Agents'); + expect(agentsLane).toBeDefined(); + expect(agentsLane.items.every(i => i.type !== 'CHECKPOINT')).toBe(true); + }); + + it('linear segment at Order 10 has the Setup tutorial', async () => { + const { data } = await project.get(`/api/getAppSpaceProgress(eventLegacyId=${FORK_EVENT_ID})`); + const path = data.paths.find(p => p.id === 10008060); + const setupSeg = path.segments.find(s => s.order === 10); + expect(setupSeg.kind).toBe('linear'); + expect(setupSeg.items).toHaveLength(1); + expect(setupSeg.items[0].title).toBe(TUT_SETUP.title); + }); + + it('linear segment at Order 30 has the CHECKPOINT', async () => { + const { data } = await project.get(`/api/getAppSpaceProgress(eventLegacyId=${FORK_EVENT_ID})`); + const path = data.paths.find(p => p.id === 10008060); + const cpSeg = path.segments.find(s => s.order === 30); + expect(cpSeg.kind).toBe('linear'); + expect(cpSeg.items).toHaveLength(1); + expect(cpSeg.items[0].type).toBe('CHECKPOINT'); + expect(cpSeg.items[0].title).toBe('Final Check'); + }); + + it('flat items array is still present (backwards compat)', async () => { + const { data } = await project.get(`/api/getAppSpaceProgress(eventLegacyId=${FORK_EVENT_ID})`); + const path = data.paths.find(p => p.id === 10008060); + expect(Array.isArray(path.items)).toBe(true); + // items should contain the tutorials (flat, existing behavior) + expect(path.items.length).toBeGreaterThan(0); + }); + + // ── Scenario B: lone GROUP → linear (no lane wrapper) ──────────────────── + + it('lone GROUP at an order produces a linear segment with expanded tutorials (no lane wrapper)', async () => { + const { data } = await project.get(`/api/getAppSpaceProgress(eventLegacyId=${LONE_EVENT_ID})`); + const path = data.paths.find(p => p.id === 10008061); + expect(path).toBeDefined(); + expect(path.segments).toBeDefined(); + expect(path.segments).toHaveLength(1); + const seg = path.segments[0]; + expect(seg.kind).toBe('linear'); + // Linear segment must have items array (not lanes) + expect(seg.items).toBeDefined(); + expect(seg.lanes).toBeUndefined(); + // Items must be the group's expanded tutorials + const titles = seg.items.map(i => i.title); + expect(titles).toContain(TUT_LONE_A.title); + expect(titles).toContain(TUT_LONE_B.title); + // No GROUP cards, only TUTORIAL cards + expect(seg.items.every(i => i.type === 'TUTORIAL')).toBe(true); + }); +}); From 17cd1060e9c2db4521a23a358e156782afc96dba Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 22 Oct 2026 12:35:06 -0500 Subject: [PATCH 06/38] feat(#2887): server-side segment-aware unlock flags (composes with #2866/#2864) --- srv/developer-service.js | 7 +- srv/lib/app-space-segments.js | 109 +++++++++++++ test/unit/app-space-unlock.test.js | 243 +++++++++++++++++++++++++++++ 3 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 test/unit/app-space-unlock.test.js diff --git a/srv/developer-service.js b/srv/developer-service.js index a886f0f8b..cb85ac102 100644 --- a/srv/developer-service.js +++ b/srv/developer-service.js @@ -15,6 +15,7 @@ import { stampSubmissionId } from './lib/task-record-submission-id.js'; import { rollUpParentsForCompletion } from './lib/completion-rollup.js'; import { renderMarkdown } from './lib/markdown.js'; import { buildTaskMap, effectiveLegacyId, expandGroupTutorials, buildPathItems, buildPathSegments, fetchGroupTitles, groupChildLegacyIds } from './lib/path-item-task-resolver.js'; +import { annotateUnlocked } from './lib/app-space-segments.js'; import { handleRequestAccountMerge, handleConfirmAccountMerge } from './lib/account-merge-request.js'; // Per-user rate limit for resetTutorialProgress — same window as the @@ -739,11 +740,13 @@ export default class DeveloperService extends cds.ApplicationService { }), // #2887: nested segment structure for the new parallel-paths UI. // Kept alongside flat `items` — Task 4 will migrate the Vue off items. - segments: buildPathSegments({ + // annotateUnlocked sets `unlocked` per item using segment-aware rules + // (composes with #2866 COMPLETED_OUT_OF_WINDOW and #2864 temporarilyUnavailable). + segments: annotateUnlocked(buildPathSegments({ pathItems, taskMap, effLegacyByItem, groupTutorials, groupTitles, recordMap, event, - }), + })), }; }), }; diff --git a/srv/lib/app-space-segments.js b/srv/lib/app-space-segments.js index 958310969..4e4d1523e 100644 --- a/srv/lib/app-space-segments.js +++ b/srv/lib/app-space-segments.js @@ -1,3 +1,112 @@ +/** + * Returns true only when an item counts as "complete" for segment satisfaction. + * + * Rules (#2866/#2864): + * - status must be exactly 'COMPLETED' (not COMPLETED_OUT_OF_WINDOW or anything else) + * - temporarilyUnavailable items are never complete regardless of status + * + * @param {object} item - shapeItem-shaped output item. + * @returns {boolean} + */ +function isComplete(item) { + if (item.temporarilyUnavailable) return false; + return item.status === 'COMPLETED'; +} + +/** + * Returns true when a lane (array of items) is "complete" for fork-join purposes. + * A lane is complete when every item (tutorials + synthetic checkpoint prize) is complete. + * + * @param {Array} items - Lane items. + * @returns {boolean} + */ +function isLaneComplete(items) { + return items.every(item => isComplete(item)); +} + +/** + * Returns true when a lane's tutorials (i.e. non-prize items) are all complete, + * which is the condition for unlocking that lane's own synthetic prize item. + * + * @param {Array} items - Lane items. + * @returns {boolean} + */ +function isLaneTutorialsComplete(items) { + const tutorials = items.filter(item => !item.prizeId); + return tutorials.length > 0 && tutorials.every(item => isComplete(item)); +} + +/** + * Returns true when a segment is "satisfied" — i.e. all required work is done. + * Composes with #2866 (COMPLETED_OUT_OF_WINDOW) and #2864 (temporarilyUnavailable). + * + * @param {object} segment - A LinearSegment, ClusterSegment, or ForkSegment. + * @returns {boolean} + */ +function isSegmentSatisfied(segment) { + if (segment.kind === 'linear') { + return segment.items.every(item => isComplete(item)); + } + if (segment.kind === 'parallel-any') { + return segment.items.every(item => isComplete(item)); + } + if (segment.kind === 'fork') { + // All lanes must be complete for the fork to be satisfied (join gate). + return segment.lanes.every(l => isLaneComplete(l.items)); + } + return false; +} + +/** + * Annotates every output item in the segment array with an `unlocked` boolean. + * + * Rules: + * - First segment is always unlocked. + * - Segment N unlocks when segment N-1 is satisfied. + * - For a fork: all lanes' items unlock together (they share the segment's + * unlock state), EXCEPT for synthetic prize items which unlock independently + * per-lane when that lane's own tutorials are complete. + * + * Mutates items in-place and returns the segments array. + * + * @param {Array} segments - Array of LinearSegment | ClusterSegment | ForkSegment. + * @returns {Array} The same segments array with `unlocked` set on each item. + */ +export function annotateUnlocked(segments) { + let prevSatisfied = true; // Conceptual "segment -1" is always satisfied. + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + // A segment is unlocked when i===0 (first) OR the previous segment was satisfied. + const segmentUnlocked = (i === 0) || prevSatisfied; + + if (segment.kind === 'linear' || segment.kind === 'parallel-any') { + for (const item of segment.items) { + item.unlocked = segmentUnlocked; + } + } else if (segment.kind === 'fork') { + for (const l of segment.lanes) { + const laneTutsDone = isLaneTutorialsComplete(l.items); + for (const item of l.items) { + if (item.prizeId) { + // Synthetic lane prize: unlocks when THIS lane's tutorials are complete + // and the segment itself is unlocked. Independent of other lanes. + item.unlocked = segmentUnlocked && laneTutsDone; + } else { + item.unlocked = segmentUnlocked; + } + } + } + } + + prevSatisfied = isSegmentSatisfied(segment); + } + + return segments; +} + +// ───────────────────────────────────────────────────────────────────────────── + /** * Groups a CompletionPath's items by itemOrder and classifies each group's shape. * Pure JS function with no DB access. diff --git a/test/unit/app-space-unlock.test.js b/test/unit/app-space-unlock.test.js new file mode 100644 index 000000000..881b7a989 --- /dev/null +++ b/test/unit/app-space-unlock.test.js @@ -0,0 +1,243 @@ +// test/unit/app-space-unlock.test.js +// +// Unit tests for annotateUnlocked — server-side segment-aware unlock flags (#2887). +// +// Rules: +// - First segment always unlocked. +// - Segment N unlocks when segment N-1 is SATISFIED. +// - linear satisfied: single item status === 'COMPLETED'. +// - parallel-any satisfied: ALL items status === 'COMPLETED'. +// - fork satisfied: ALL lanes complete (every item in each lane 'COMPLETED', +// including synthetic checkpoint). +// - A lane's own synthetic prize unlocks when THAT lane's tutorials complete, +// independent of other lanes. +// - COMPLETED_OUT_OF_WINDOW and temporarilyUnavailable are never 'complete'. + +import { describe, it, expect } from 'vitest'; +import { annotateUnlocked } from '../../srv/lib/app-space-segments.js'; + +// Helpers for building minimal item/segment fixtures. +const tut = (status, extra = {}) => ({ type: 'TUTORIAL', status, temporarilyUnavailable: false, ...extra }); +const checkpoint = (status = '') => ({ type: 'CHECKPOINT', status, temporarilyUnavailable: false }); +const prize = (prizeId = 'prize-1') => ({ type: 'CHECKPOINT', status: '', temporarilyUnavailable: false, prizeId }); + +const linear = (item) => ({ order: 1, kind: 'linear', items: [item] }); +const cluster = (items) => ({ order: 2, kind: 'parallel-any', items }); +const fork = (lanes) => ({ order: 3, kind: 'fork', lanes }); +const lane = (items, label = '') => ({ label, items }); + +describe('annotateUnlocked — linear gating', () => { + it('first linear segment is always unlocked', () => { + const segs = [ + { order: 1, kind: 'linear', items: [tut('AVAILABLE')] }, + ]; + const result = annotateUnlocked(segs); + expect(result[0].items[0].unlocked).toBe(true); + }); + + it('second segment unlocks when first linear is COMPLETED', () => { + const segs = [ + { order: 1, kind: 'linear', items: [tut('COMPLETED')] }, + { order: 2, kind: 'linear', items: [tut('AVAILABLE')] }, + ]; + const result = annotateUnlocked(segs); + expect(result[0].items[0].unlocked).toBe(true); + expect(result[1].items[0].unlocked).toBe(true); + }); + + it('second segment remains locked when first linear is not COMPLETED', () => { + const segs = [ + { order: 1, kind: 'linear', items: [tut('AVAILABLE')] }, + { order: 2, kind: 'linear', items: [tut('LOCKED')] }, + ]; + const result = annotateUnlocked(segs); + expect(result[0].items[0].unlocked).toBe(true); + expect(result[1].items[0].unlocked).toBe(false); + }); + + it('linear gate carries over: third segment locked when second is not complete', () => { + const segs = [ + { order: 1, kind: 'linear', items: [tut('COMPLETED')] }, + { order: 2, kind: 'linear', items: [tut('AVAILABLE')] }, + { order: 3, kind: 'linear', items: [tut('LOCKED')] }, + ]; + const result = annotateUnlocked(segs); + expect(result[0].items[0].unlocked).toBe(true); + expect(result[1].items[0].unlocked).toBe(true); + expect(result[2].items[0].unlocked).toBe(false); + }); +}); + +describe('annotateUnlocked — parallel-any (cluster) gating', () => { + it('cluster items are all unlocked when the preceding segment is satisfied', () => { + const segs = [ + { order: 1, kind: 'linear', items: [tut('COMPLETED')] }, + { order: 2, kind: 'parallel-any', items: [tut('AVAILABLE'), tut('AVAILABLE')] }, + ]; + const result = annotateUnlocked(segs); + expect(result[1].items[0].unlocked).toBe(true); + expect(result[1].items[1].unlocked).toBe(true); + }); + + it('cluster locks downstream until ALL members are COMPLETED', () => { + const segs = [ + { order: 1, kind: 'parallel-any', items: [tut('COMPLETED'), tut('AVAILABLE')] }, + { order: 2, kind: 'linear', items: [tut('LOCKED')] }, + ]; + const result = annotateUnlocked(segs); + // First cluster segment is always unlocked (first segment rule). + expect(result[0].items[0].unlocked).toBe(true); + expect(result[0].items[1].unlocked).toBe(true); + // Second segment NOT unlocked because cluster is not fully satisfied. + expect(result[1].items[0].unlocked).toBe(false); + }); + + it('cluster satisfied when ALL members COMPLETED unlocks downstream', () => { + const segs = [ + { order: 1, kind: 'parallel-any', items: [tut('COMPLETED'), tut('COMPLETED')] }, + { order: 2, kind: 'linear', items: [tut('AVAILABLE')] }, + ]; + const result = annotateUnlocked(segs); + expect(result[1].items[0].unlocked).toBe(true); + }); +}); + +describe('annotateUnlocked — fork gating', () => { + it('fork join: downstream locks until BOTH lanes are complete', () => { + const segs = [ + { + order: 1, + kind: 'fork', + lanes: [ + lane([tut('COMPLETED'), checkpoint('COMPLETED')]), // lane 1 complete + lane([tut('AVAILABLE'), checkpoint()]), // lane 2 incomplete + ], + }, + { order: 2, kind: 'linear', items: [tut('LOCKED')] }, + ]; + const result = annotateUnlocked(segs); + // Fork segment is first → always unlocked. + expect(result[0].lanes[0].items[0].unlocked).toBe(true); + // Downstream locked because lane 2 not done. + expect(result[1].items[0].unlocked).toBe(false); + }); + + it('fork join: downstream unlocks when ALL lanes complete', () => { + const segs = [ + { + order: 1, + kind: 'fork', + lanes: [ + lane([tut('COMPLETED'), checkpoint('COMPLETED')]), + lane([tut('COMPLETED'), checkpoint('COMPLETED')]), + ], + }, + { order: 2, kind: 'linear', items: [tut('AVAILABLE')] }, + ]; + const result = annotateUnlocked(segs); + expect(result[1].items[0].unlocked).toBe(true); + }); + + it('per-lane synthetic prize unlocks on its own lane completing, independent of other lanes', () => { + const segs = [ + { + order: 1, + kind: 'fork', + lanes: [ + lane([tut('COMPLETED'), prize('prize-a')]), // lane 1 tutorials done → prize unlocked + lane([tut('AVAILABLE'), prize('prize-b')]), // lane 2 tutorials NOT done → prize locked + ], + }, + ]; + const result = annotateUnlocked(segs); + const lane1Items = result[0].lanes[0].items; + const lane2Items = result[0].lanes[1].items; + // Lane 1 tutorial and its prize should both be unlocked. + expect(lane1Items[0].unlocked).toBe(true); // tutorial (first seg → all unlocked) + expect(lane1Items[1].unlocked).toBe(true); // synthetic prize: lane 1 tutorials complete + // Lane 2 tutorial unlocked (first seg), but prize locked (lane not complete). + expect(lane2Items[0].unlocked).toBe(true); // tutorial (first seg → all unlocked) + expect(lane2Items[1].unlocked).toBe(false); // synthetic prize: lane 2 tutorials NOT complete + }); +}); + +describe('annotateUnlocked — #2866 COMPLETED_OUT_OF_WINDOW never counts as complete', () => { + it('COMPLETED_OUT_OF_WINDOW in a linear leaves the next segment locked', () => { + const segs = [ + { order: 1, kind: 'linear', items: [tut('COMPLETED_OUT_OF_WINDOW')] }, + { order: 2, kind: 'linear', items: [tut('AVAILABLE')] }, + ]; + const result = annotateUnlocked(segs); + expect(result[1].items[0].unlocked).toBe(false); + }); + + it('COMPLETED_OUT_OF_WINDOW in a lane leaves that lane incomplete (prize locked)', () => { + const segs = [ + { + order: 1, + kind: 'fork', + lanes: [ + lane([tut('COMPLETED_OUT_OF_WINDOW'), prize('prize-a')]), + ], + }, + ]; + const result = annotateUnlocked(segs); + // Synthetic prize must NOT unlock — out-of-window never satisfies a lane. + expect(result[0].lanes[0].items[1].unlocked).toBe(false); + }); + + it('COMPLETED_OUT_OF_WINDOW in a cluster leaves the cluster unsatisfied', () => { + const segs = [ + { order: 1, kind: 'parallel-any', items: [tut('COMPLETED'), tut('COMPLETED_OUT_OF_WINDOW')] }, + { order: 2, kind: 'linear', items: [tut('LOCKED')] }, + ]; + const result = annotateUnlocked(segs); + expect(result[1].items[0].unlocked).toBe(false); + }); +}); + +describe('annotateUnlocked — #2864 temporarilyUnavailable never counts as complete', () => { + it('temporarilyUnavailable item in a linear leaves the next segment locked', () => { + const segs = [ + { order: 1, kind: 'linear', items: [{ ...tut('COMPLETED'), temporarilyUnavailable: true }] }, + { order: 2, kind: 'linear', items: [tut('AVAILABLE')] }, + ]; + const result = annotateUnlocked(segs); + expect(result[1].items[0].unlocked).toBe(false); + }); + + it('temporarilyUnavailable item in a cluster leaves the cluster unsatisfied', () => { + const segs = [ + { + order: 1, + kind: 'parallel-any', + items: [tut('COMPLETED'), { ...tut('COMPLETED'), temporarilyUnavailable: true }], + }, + { order: 2, kind: 'linear', items: [tut('LOCKED')] }, + ]; + const result = annotateUnlocked(segs); + expect(result[1].items[0].unlocked).toBe(false); + }); + + it('temporarilyUnavailable item in a lane leaves that lane unsatisfied', () => { + const segs = [ + { + order: 1, + kind: 'fork', + lanes: [ + lane([{ ...tut('COMPLETED'), temporarilyUnavailable: true }, prize('prize-a')]), + ], + }, + ]; + const result = annotateUnlocked(segs); + expect(result[0].lanes[0].items[1].unlocked).toBe(false); + }); +}); + +describe('annotateUnlocked — return value', () => { + it('returns the same segments array (mutate-and-return)', () => { + const segs = [{ order: 1, kind: 'linear', items: [tut('AVAILABLE')] }]; + const result = annotateUnlocked(segs); + expect(result).toBe(segs); + }); +}); From 2ca7efb61cffe8c90d59fd14e3853729d360f20f Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 22 Oct 2026 12:38:58 -0500 Subject: [PATCH 07/38] fix(#2887): exclude prize items from isLaneComplete gate; add regression tests --- srv/lib/app-space-segments.js | 7 +++-- test/unit/app-space-unlock.test.js | 46 ++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/srv/lib/app-space-segments.js b/srv/lib/app-space-segments.js index 4e4d1523e..8c3007c95 100644 --- a/srv/lib/app-space-segments.js +++ b/srv/lib/app-space-segments.js @@ -15,13 +15,16 @@ function isComplete(item) { /** * Returns true when a lane (array of items) is "complete" for fork-join purposes. - * A lane is complete when every item (tutorials + synthetic checkpoint prize) is complete. + * Prize items (identified by `prizeId`) are EXCLUDED — they are rewards, not gates. + * A lane is complete when every non-prize item is complete (mirrors isLaneTutorialsComplete). + * An all-prize lane (no tutorials) evaluates to true via [].every() — treated as satisfied + * because there is no required work to gate on. * * @param {Array} items - Lane items. * @returns {boolean} */ function isLaneComplete(items) { - return items.every(item => isComplete(item)); + return items.filter(item => !item.prizeId).every(item => isComplete(item)); } /** diff --git a/test/unit/app-space-unlock.test.js b/test/unit/app-space-unlock.test.js index 881b7a989..c0cd60218 100644 --- a/test/unit/app-space-unlock.test.js +++ b/test/unit/app-space-unlock.test.js @@ -234,6 +234,52 @@ describe('annotateUnlocked — #2864 temporarilyUnavailable never counts as comp }); }); +describe('annotateUnlocked — fork with prizes does NOT block downstream join', () => { + // Regression guard for the isLaneComplete bug: prize items were included in the + // gate check (status: '' → never COMPLETED) which made the fork permanently + // unsatisfied and locked every downstream segment. + + it('(positive) fork lanes with tutorials+checkpoint+prize all done → downstream unlocked', () => { + const segs = [ + { + order: 1, + kind: 'fork', + lanes: [ + lane([tut('COMPLETED'), checkpoint('COMPLETED'), prize('prize-a')]), + lane([tut('COMPLETED'), checkpoint('COMPLETED'), prize('prize-b')]), + ], + }, + { order: 2, kind: 'linear', items: [tut('AVAILABLE')] }, + ]; + const result = annotateUnlocked(segs); + // Both lane prizes should be unlocked (their tutorials complete). + expect(result[0].lanes[0].items[2].unlocked).toBe(true); + expect(result[0].lanes[1].items[2].unlocked).toBe(true); + // Downstream segment must be unlocked — the fork IS satisfied (prizes are not gates). + expect(result[1].items[0].unlocked).toBe(true); + }); + + it('(negative) fork with one lane tutorial incomplete → downstream still locked', () => { + const segs = [ + { + order: 1, + kind: 'fork', + lanes: [ + lane([tut('COMPLETED'), checkpoint('COMPLETED'), prize('prize-a')]), + lane([tut('AVAILABLE'), checkpoint(), prize('prize-b')]), // lane 2 not done + ], + }, + { order: 2, kind: 'linear', items: [tut('LOCKED')] }, + ]; + const result = annotateUnlocked(segs); + // Lane 1 prize unlocked; lane 2 prize NOT unlocked. + expect(result[0].lanes[0].items[2].unlocked).toBe(true); + expect(result[0].lanes[1].items[2].unlocked).toBe(false); + // Downstream remains locked because lane 2 is not satisfied. + expect(result[1].items[0].unlocked).toBe(false); + }); +}); + describe('annotateUnlocked — return value', () => { it('returns the same segments array (mutate-and-return)', () => { const segs = [{ order: 1, kind: 'linear', items: [tut('AVAILABLE')] }]; From 1a23c2e87ec20575d58e8074adce3fa87642970b Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 22 Oct 2026 12:42:26 -0500 Subject: [PATCH 08/38] feat(#2887): app-space-gating reads server unlocked flag, legacy fallback --- hugo-apps/src/app-space/app-space-gating.ts | 14 +++++++- test/unit/app-space-gating.test.js | 36 +++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/hugo-apps/src/app-space/app-space-gating.ts b/hugo-apps/src/app-space/app-space-gating.ts index 648b858b2..aa9aaf5d7 100644 --- a/hugo-apps/src/app-space/app-space-gating.ts +++ b/hugo-apps/src/app-space/app-space-gating.ts @@ -13,7 +13,11 @@ export interface GatingItem { /** * Whether an item is "unlocked" for PROGRESS / PRIZE gating. * - * Strict: a TUTORIAL unlocks only when the previous item is exactly + * #2887 (parallel paths): Server sets `unlocked` boolean on each item (#2887 Task 3); + * this function prefers that flag when present, falling back to index-adjacency logic + * for older payloads (legacy linear tracks). + * + * Strict (legacy fallback): a TUTORIAL unlocks only when the previous item is exactly * 'COMPLETED', and a CHECKPOINT/PRIZE unlocks only when every preceding * tutorial is exactly 'COMPLETED'. #2866 statuses such as * 'COMPLETED_OUT_OF_WINDOW' deliberately do NOT count here — an out-of-window @@ -22,6 +26,14 @@ export interface GatingItem { export function isItemUnlocked(items: GatingItem[], index: number): boolean { const item = items[index] if (!item) return false + + // #2887: Prefer server unlocked flag when present (boolean type check ensures + // undefined / null don't match). + if (typeof item.unlocked === 'boolean') { + return item.unlocked + } + + // Fallback to legacy index-adjacency logic for older payloads. if (item.type === 'TUTORIAL') { if (index === 0) return true const prev = items[index - 1] diff --git a/test/unit/app-space-gating.test.js b/test/unit/app-space-gating.test.js index 456dbe5bf..02dc9ec32 100644 --- a/test/unit/app-space-gating.test.js +++ b/test/unit/app-space-gating.test.js @@ -50,6 +50,42 @@ describe('isItemUnlocked — progress/prize gating (strict COMPLETED)', () => { }); }); +describe('isItemUnlocked — server unlocked flag (#2887)', () => { + it('unlocked:true on an item whose predecessor is incomplete returns true (flag wins)', () => { + // Task 3 set unlocked=true server-side, bypassing adjacency logic. + const items = [tut('AVAILABLE'), { type: 'TUTORIAL', status: 'LOCKED', unlocked: true }]; + expect(isItemUnlocked(items, 1)).toBe(true); + }); + + it('unlocked:false with a COMPLETED predecessor returns false (flag wins)', () => { + // Even though the predecessor is COMPLETED, the flag says don't unlock. + const items = [tut('COMPLETED'), { type: 'TUTORIAL', status: 'AVAILABLE', unlocked: false }]; + expect(isItemUnlocked(items, 1)).toBe(false); + }); + + it('item with NO unlocked property falls back to adjacency behavior', () => { + // Legacy payloads or existing linear tracks without the flag use the old logic. + const items = [tut('COMPLETED'), tut('AVAILABLE')]; + expect(isItemUnlocked(items, 1)).toBe(true); // predecessor COMPLETED → unlock + }); + + it('unlocked flag on a checkpoint works the same way', () => { + const items = [tut('AVAILABLE'), { type: 'CHECKPOINT', status: 'LOCKED', unlocked: true }]; + expect(isItemUnlocked(items, 1)).toBe(true); + + const items2 = [tut('COMPLETED'), { type: 'CHECKPOINT', status: 'AVAILABLE', unlocked: false }]; + expect(isItemUnlocked(items2, 1)).toBe(false); + }); + + it('unlocked flag on a prize works the same way', () => { + const items = [tut('AVAILABLE'), { type: 'PRIZE', status: 'LOCKED', unlocked: true }]; + expect(isItemUnlocked(items, 1)).toBe(true); + + const items2 = [tut('COMPLETED'), { type: 'PRIZE', status: 'AVAILABLE', unlocked: false }]; + expect(isItemUnlocked(items2, 1)).toBe(false); + }); +}); + describe('isTutorialOpenable — keeps the tutorial link live (#2866)', () => { it('an out-of-window completed tutorial stays openable (the bug fix)', () => { // Even though it is NOT unlocked for gating, its URL must stay reachable so From c066db780cd2acb972427a9966ae135de8a97c1c Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Thu, 22 Oct 2026 12:55:27 -0500 Subject: [PATCH 09/38] feat(#2887): render fork lanes + any-order clusters in App Space track detail --- hugo-apps/src/app-space/AppSpace.vue | 595 +++++++++++++++++- .../__tests__/AppSpace.segments.test.ts | 211 +++++++ 2 files changed, 797 insertions(+), 9 deletions(-) create mode 100644 hugo-apps/src/app-space/__tests__/AppSpace.segments.test.ts diff --git a/hugo-apps/src/app-space/AppSpace.vue b/hugo-apps/src/app-space/AppSpace.vue index 061e0b78a..a12fadb26 100644 --- a/hugo-apps/src/app-space/AppSpace.vue +++ b/hugo-apps/src/app-space/AppSpace.vue @@ -4,6 +4,7 @@ import { useRealtimeProgress } from './useRealtimeProgress' import { useConfetti } from '../composables/useConfetti' import { checkpointBarcodeUrl as _checkpointBarcodeUrl, shouldShowCheckpointBarcode as _shouldShowCheckpointBarcode } from './checkpoint-barcode' import { isItemUnlocked as _isItemUnlocked, isTutorialOpenable as _isTutorialOpenable } from './app-space-gating' +import type { GatingItem } from './app-space-gating' // ── Configuration ───────────────────────────────────────────────── const eventId = ref(null) @@ -45,7 +46,7 @@ const logoUrl = computed(() => // ── Interfaces ───────────────────────────────────────────────────── interface AppSpaceItem { - imsId: number + imsId: number | null title: string type: 'TUTORIAL' | 'CHECKPOINT' | 'PRIZE' status: string @@ -55,13 +56,49 @@ interface AppSpaceItem { url: string description: string recordId?: number + // #2887: server-computed per-item unlock flag (Task 3). Preferred by + // app-space-gating's isItemUnlocked over the legacy index-adjacency fallback. + unlocked?: boolean + // #2864: a path item flagged temporarily unavailable upstream. + temporarilyUnavailable?: boolean + // #2887: synthetic lane-end prize checkpoints carry the prize legacyId. The + // item still has type==='CHECKPOINT' so it flows through the normal barcode + // path (shouldShowCheckpointBarcode keys only on type/unlocked/authenticated). + prizeId?: number | null +} + +// #2887: nested segment structure produced by srv/lib/path-item-task-resolver.js +// (buildPathSegments) and annotated with per-item `unlocked` by +// srv/lib/app-space-segments.js (annotateUnlocked). The detail view renders +// from these; the legacy flat `items[]` is kept for back-compat (overview grid +// counts) and older payloads that predate segments. +interface ForkLane { + label: string + items: AppSpaceItem[] +} +interface LinearSegment { + order: number + kind: 'linear' + items: AppSpaceItem[] +} +interface ClusterSegment { + order: number + kind: 'parallel-any' + items: AppSpaceItem[] } +interface ForkSegment { + order: number + kind: 'fork' + lanes: ForkLane[] +} +type AppSpaceSegment = LinearSegment | ClusterSegment | ForkSegment interface AppSpaceTrack { id: number title: string description: string items: AppSpaceItem[] + segments?: AppSpaceSegment[] } interface AppSpaceData { @@ -223,25 +260,149 @@ function tutorialItems(track: AppSpaceTrack): AppSpaceItem[] { return track.items.filter(i => i.type === 'TUTORIAL') } +// Tutorial count that works for both segment and legacy flat tracks. +function trackTutorialCount(track: AppSpaceTrack): number { + return hasSegments(track) ? segmentTutorials(track).length : tutorialItems(track).length +} + +// ── Segment helpers (#2887) ──────────────────────────────────────── +// The detail view renders from `track.segments`. Aggregates (counts, percent, +// complete, barcode eligibility) are computed over segments when present so the +// AUTHORED completion rule is honored — a per-lane prize means that lane +// completing can satisfy the track's reward, while a downstream checkpoint +// after the fork requires all lanes. We do NOT define "complete" as "every flat +// tutorial COMPLETED" — that would contradict a prize already earned on one lane. + +function trackSegments(track: AppSpaceTrack): AppSpaceSegment[] { + return track.segments ?? [] +} + +function hasSegments(track: AppSpaceTrack): boolean { + return Array.isArray(track.segments) && track.segments.length > 0 +} + +// Flatten every item a segment contains (cluster/linear items, or all lane items +// for a fork). Used for progress math and "all tutorials" totals. +function segmentItems(seg: AppSpaceSegment): AppSpaceItem[] { + if (seg.kind === 'fork') return seg.lanes.flatMap(l => l.items) + return seg.items +} + +// All items across a track's segments (flattened). Synthetic lane prizes +// (CHECKPOINT with prizeId) are included — they render and claim like any +// checkpoint. +function allSegmentItems(track: AppSpaceTrack): AppSpaceItem[] { + return trackSegments(track).flatMap(segmentItems) +} + +// TUTORIAL items across all segments — the denominator for progress. +function segmentTutorials(track: AppSpaceTrack): AppSpaceItem[] { + return allSegmentItems(track).filter(i => i.type === 'TUTORIAL') +} + +// An item counts as "done" only on a strict COMPLETED (mirrors the server's +// isComplete in app-space-segments.js; #2866 COMPLETED_OUT_OF_WINDOW and #2864 +// temporarilyUnavailable never count). +function itemDone(item: AppSpaceItem): boolean { + if (item.temporarilyUnavailable) return false + return item.status === 'COMPLETED' +} + +// A lane is complete when all its non-prize items are done (the server uses the +// same rule to unlock the lane's synthetic prize + any downstream join). +function laneTutorialsDone(lane: ForkLane): boolean { + const work = lane.items.filter(i => !i.prizeId) + return work.length > 0 && work.every(itemDone) +} + +// #2887 cross-task note: a GROUP's temporarilyUnavailable is NOT propagated to +// its expanded child tutorials upstream (hardcoded false in buildPathSegments). +// With only per-item flags available, we surface lane-level unavailability when +// ANY item in a lane is temporarilyUnavailable. Documented UI-only treatment: +// the lane column gets a visibly-unavailable state (dimmed + a notice). If a +// future payload adds a lane-level flag, prefer it here. +function laneUnavailable(lane: ForkLane): boolean { + return lane.items.some(i => i.temporarilyUnavailable) +} + function completedCount(track: AppSpaceTrack): number { + if (hasSegments(track)) return segmentTutorials(track).filter(itemDone).length return tutorialItems(track).filter(i => i.status === 'COMPLETED').length } function trackTotalTime(track: AppSpaceTrack): number { + if (hasSegments(track)) return segmentTutorials(track).reduce((sum, i) => sum + i.timeToComplete, 0) return tutorialItems(track).reduce((sum, i) => sum + i.timeToComplete, 0) } +// #2887 authored completion rule: a track is complete when its reward is +// satisfiable. A segment carrying a synthetic lane prize (CHECKPOINT+prizeId) +// completes the track's reward as soon as THAT lane's tutorials are done; a +// downstream CHECKPOINT (no prizeId) requires all preceding work. We model this +// as: the track is complete when every REQUIRED checkpoint/prize in it is +// unlocked, OR (no checkpoints authored) when all tutorials are done. function isTrackComplete(track: AppSpaceTrack): boolean { - const tuts = tutorialItems(track) - return tuts.length > 0 && tuts.every(i => i.status === 'COMPLETED') + if (!hasSegments(track)) { + const tuts = tutorialItems(track) + return tuts.length > 0 && tuts.every(i => i.status === 'COMPLETED') + } + const items = allSegmentItems(track) + const checkpoints = items.filter(i => i.type === 'CHECKPOINT') + if (checkpoints.length > 0) { + // Any earnable checkpoint (lane prize OR downstream) being unlocked means a + // reward is claimable → the track's goal is met. A lane-prize checkpoint + // unlocks per-lane (the authored "this branch alone earns it" case); + // a downstream checkpoint unlocks only when all lanes finish. + return checkpoints.some(c => isSegmentItemUnlocked(c)) + } + const tuts = segmentTutorials(track) + return tuts.length > 0 && tuts.every(itemDone) } function progressPercent(track: AppSpaceTrack): number { - const tuts = tutorialItems(track) + const tuts = hasSegments(track) ? segmentTutorials(track) : tutorialItems(track) if (tuts.length === 0) return 0 return Math.round((completedCount(track) / tuts.length) * 100) } +// Per-item unlock. Prefers the server flag (set on every segment item by +// annotateUnlocked). Segment items always carry `unlocked`; fall back to the +// shared gating helper (single-item array) only for the defensive case where +// the flag is missing. +function isSegmentItemUnlocked(item: AppSpaceItem): boolean { + if (typeof item.unlocked === 'boolean') return item.unlocked + return _isItemUnlocked([item as unknown as GatingItem], 0) +} + +// A TUTORIAL's link stays live if it's a redo (#2866) or unlocked. +function isSegmentTutorialOpenable(item: AppSpaceItem): boolean { + if (item.type !== 'TUTORIAL') return false + if (item.status === 'COMPLETED_OUT_OF_WINDOW') return true + return isSegmentItemUnlocked(item) +} + +function segmentItemStatusClass(item: AppSpaceItem): string { + if (item.status === 'COMPLETED') return 'step--completed' + if (item.status === 'COMPLETED_OUT_OF_WINDOW') return 'step--redo' + if (item.status === 'EARNED') return 'step--earned' + if (item.status === 'IN_PROGRESS') return 'step--in-progress' + if (isSegmentItemUnlocked(item)) return 'step--available' + return 'step--locked' +} + +function handleSegmentItemClick(item: AppSpaceItem) { + if (item.type === 'TUTORIAL' && isSegmentTutorialOpenable(item) && item.url) { + window.open(item.url, '_blank') + } +} + +// Barcode parity (#2887): the synthetic lane-end CHECKPOINT (prizeId) and a +// normal downstream CHECKPOINT flow through the exact same predicate — it keys +// only on type==='CHECKPOINT' && unlocked && authenticated. +function shouldShowSegmentBarcode(item: AppSpaceItem): boolean { + return _shouldShowCheckpointBarcode(item, isSegmentItemUnlocked(item), authenticated.value) +} + function isItemUnlocked(track: AppSpaceTrack, index: number): boolean { return _isItemUnlocked(track.items, index) } @@ -297,7 +458,7 @@ function shouldShowCheckpointBarcode(track: AppSpaceTrack, index: number): boole // ── Computed ─────────────────────────────────────────────────────── const totalTutorials = computed(() => - tracks.value.reduce((sum, t) => sum + tutorialItems(t).length, 0) + tracks.value.reduce((sum, t) => sum + (hasSegments(t) ? segmentTutorials(t).length : tutorialItems(t).length), 0) ) const totalCompleted = computed(() => @@ -455,7 +616,7 @@ const emptyStateMessage = computed(() => {

{{ track.title }}

{{ track.description }}

- {{ tutorialItems(track).length }} tutorials + {{ trackTutorialCount(track) }} tutorials {{ formatTime(trackTotalTime(track)) }}
@@ -476,15 +637,252 @@ const emptyStateMessage = computed(() => {

{{ selectedTrack.title }}

- {{ completedCount(selectedTrack) }} / {{ tutorialItems(selectedTrack).length }} completed + {{ completedCount(selectedTrack) }} / {{ trackTutorialCount(selectedTrack) }} completed

{{ selectedTrack.description }}

-
+ +
+ +
+ + +
isSegmentItemUnlocked(c)) + // Any lane-prize checkpoint being unlocked earns the reward on its own. + const lanePrizeUnlocked = checkpoints.some(c => c.prizeId != null && isSegmentItemUnlocked(c)) + // Otherwise, only the highest-order plain (non-prize) checkpoint counts — + // the terminal join. Lower-order plain checkpoints never complete the track. + const plainCheckpoints = checkpoints.filter(c => c.prizeId == null) + const terminalPlain = plainCheckpoints[plainCheckpoints.length - 1] + const terminalPlainUnlocked = terminalPlain != null && isSegmentItemUnlocked(terminalPlain) + return lanePrizeUnlocked || terminalPlainUnlocked } const tuts = segmentTutorials(track) return tuts.length > 0 && tuts.every(itemDone) diff --git a/hugo-apps/src/app-space/__tests__/AppSpace.segments.test.ts b/hugo-apps/src/app-space/__tests__/AppSpace.segments.test.ts index de6d2bd40..3af87f5d6 100644 --- a/hugo-apps/src/app-space/__tests__/AppSpace.segments.test.ts +++ b/hugo-apps/src/app-space/__tests__/AppSpace.segments.test.ts @@ -114,6 +114,109 @@ const clusterPayload = () => ({ }], }) +// #2889: a track with a NON-TERMINAL plain checkpoint (no prizeId) at a lower +// order than later tutorials — the latent shape the highest-order rule guards. +// `earlyUnlocked` toggles whether the mid-track checkpoint is unlocked while the +// downstream tutorial (and terminal checkpoint) remain incomplete. Only the +// TERMINAL checkpoint (order 40) should be able to complete the track. +const nonTerminalCheckpointPayload = (earlyUnlocked: boolean, terminalUnlocked: boolean) => ({ + eventId: 10000008, + eventName: 'TechEd 2026', + eventType: 'TECHED', + authenticated: true, + type: 'COMPLEX', + paths: [{ + id: 3, + title: 'Two-stage track with a mid checkpoint', + description: 'Checkpoint before more work', + items: [], + segments: [ + { + order: 10, kind: 'linear', items: [ + tutorial({ imsId: 20, title: 'Stage one tutorial', status: 'COMPLETED', unlocked: true }), + ], + }, + { + // Non-terminal plain checkpoint: authored mid-track, no prizeId. + order: 20, kind: 'linear', items: [ + { + imsId: 21, title: 'Mid checkpoint', type: 'CHECKPOINT', status: '', progress: 0, + experience: '', timeToComplete: 0, url: '', description: '', + recordId: 0, temporarilyUnavailable: false, unlocked: earlyUnlocked, + }, + ], + }, + { + order: 30, kind: 'linear', items: [ + tutorial({ imsId: 22, title: 'Stage two tutorial', status: terminalUnlocked ? 'COMPLETED' : '', unlocked: earlyUnlocked }), + ], + }, + { + // Terminal plain checkpoint (highest order): the real completion gate. + order: 40, kind: 'linear', items: [ + { + imsId: 23, title: 'Final checkpoint', type: 'CHECKPOINT', status: '', progress: 0, + experience: '', timeToComplete: 0, url: '', description: '', + recordId: 0, temporarilyUnavailable: false, unlocked: terminalUnlocked, + }, + ], + }, + ], + }], +}) + +// #2889: a fork track where the lane-prize checkpoint (prizeId) is unlocked +// because its lane finished, but the DOWNSTREAM join checkpoint is still locked. +// Discriminates the prize-aware rule: the unlocked lane prize alone completes +// the track even though the terminal (highest-order) plain checkpoint is locked. +const lanePrizeOnlyPayload = () => ({ + eventId: 10000009, + eventName: 'TechEd 2026', + eventType: 'TECHED', + authenticated: true, + type: 'COMPLEX', + paths: [{ + id: 4, + title: 'Lane prize earns the reward', + description: 'One branch suffices', + items: [], + segments: [ + { + order: 10, kind: 'fork', lanes: [ + { + label: 'MCP', + items: [ + tutorial({ imsId: 30, title: 'Finish the MCP lane', status: 'COMPLETED', unlocked: true }), + { + imsId: null, title: 'MCP prize', type: 'CHECKPOINT', status: '', progress: 0, + experience: '', timeToComplete: 0, url: '', description: '', + recordId: 0, temporarilyUnavailable: false, prizeId: 700, + unlocked: true, // this lane is done → lane prize unlocked + }, + ], + }, + { + label: 'Agents', + items: [ + tutorial({ imsId: 31, title: 'Agents lane still going', status: '', unlocked: true }), + ], + }, + ], + }, + { + // Downstream join: needs ALL lanes → still locked while Agents is open. + order: 20, kind: 'linear', items: [ + { + imsId: 32, title: 'Grand finale checkpoint', type: 'CHECKPOINT', status: '', progress: 0, + experience: '', timeToComplete: 0, url: '', description: '', + recordId: 0, temporarilyUnavailable: false, unlocked: false, + }, + ], + }, + ], + }], +}) + async function openTrack(wrapper: ReturnType) { const card = wrapper.find('.track-card') expect(card.exists()).toBe(true) @@ -209,3 +312,62 @@ describe('AppSpace segment rendering', () => { expect(locked.length).toBe(0) }) }) + +// #2889: isTrackComplete / progressPercent over segment payloads. These +// aggregates drive the overview grid (completion badge + progress ring); the +// functions aren't exported, so we assert through the rendered card. +describe('AppSpace track completion aggregate (#2889)', () => { + beforeEach(() => { + vi.restoreAllMocks() + }) + + function mountFor(eventId: number, body: unknown) { + stubFetch((url) => { + if (url.includes('getAppSpaceProgress')) return { ok: true, status: 200, body } + return { ok: false, status: 404, body: {} } + }) + window.history.replaceState({}, '', `/app-space/?eventId=${eventId}`) + return mount(AppSpace) + } + + it('does NOT complete the track when only a non-terminal plain checkpoint is unlocked', async () => { + // Mid checkpoint (order 20) unlocked, but downstream tutorial + terminal + // checkpoint (order 40) are not → track must read incomplete. This is the + // exact latent bug the old `checkpoints.some(unlocked)` rule hit. + const wrapper = mountFor(10000008, nonTerminalCheckpointPayload(true, false)) + await flushPromises() + expect(wrapper.find('.track-card--complete').exists()).toBe(false) + expect(wrapper.find('.track-card__badge').exists()).toBe(false) + // 1 of 2 tutorials done → 50%. + expect(wrapper.find('.progress-ring-text').text()).toBe('50%') + }) + + it('completes the track when the terminal (highest-order) checkpoint unlocks', async () => { + const wrapper = mountFor(10000008, nonTerminalCheckpointPayload(true, true)) + await flushPromises() + expect(wrapper.find('.track-card--complete').exists()).toBe(true) + expect(wrapper.find('.track-card__badge').exists()).toBe(true) + expect(wrapper.find('.progress-ring-text').text()).toBe('100%') + }) + + it('completes the track when a lane-prize checkpoint unlocks, even with a locked downstream checkpoint', async () => { + // Lane prize (prizeId) unlocked because its lane finished; the downstream + // join checkpoint is still LOCKED. Prize-aware rule → complete. Reverting to + // the old `checkpoints.some(unlocked)` would also pass here, but reverting to + // a naive highest-order-only rule would (wrongly) fail — this guards both. + const wrapper = mountFor(10000009, lanePrizeOnlyPayload()) + await flushPromises() + expect(wrapper.find('.track-card--complete').exists()).toBe(true) + expect(wrapper.find('.track-card__badge').exists()).toBe(true) + // MCP lane tutorial done, Agents lane not → 1 of 2 = 50%. + expect(wrapper.find('.progress-ring-text').text()).toBe('50%') + }) + + it('does NOT complete the fork track while every checkpoint is locked', async () => { + const wrapper = mountFor(10000006, forkPayload(true, false)) + await flushPromises() + expect(wrapper.find('.track-card--complete').exists()).toBe(false) + // Setup tutorial done, both lane tutorials incomplete → 1 of 3 = 33%. + expect(wrapper.find('.progress-ring-text').text()).toBe('33%') + }) +}) From c4b511deefd9e76b8c9a0518dc6753fca267f916 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 23 Oct 2026 13:18:16 +0200 Subject: [PATCH 13/38] fix(#2886): refuse deploy when Hugo fingerprint inputs are dirty A PROD deploy on 2026-10-21 shipped the approuter built from an uncommitted working-tree edit to hugo/assets/css/sap-fundamental.css, so Hugo fingerprinted the dirty CSS and the approuter served a hash the content pipeline (built from committed origin/main) never bakes -> 404 on every tutorial on /tutorials/ and /tutorials-qa/ (PROD-down). guardPrimaryTreeBranch (Step 0) missed it: a dirty tree keeps the same HEAD SHA, so both the PROD (branch) and DEV/QA (HEAD===origin SHA) paths pass with a dirty hugo/assets/** file. Add deploy-mta.cjs Step 0.5 (guardCleanBuildInputs, all envs, before build:all): git status --porcelain -- hugo/assets hugo/layouts, die(1) listing any tracked-modified build input. Pure parser dirtyBuildInputs exported + unit-tested; ignores untracked so build:all-generated hugo/data/*.json never false-positives (scope excludes hugo/data). Read-only -> runs under --dry-run and --skip-build. Doc the regression in tutorials-ims-gotchas.md + CLAUDE.md pointer. --- CLAUDE.md | 1 + .../reference/tutorials-ims-gotchas.md | 7 ++ scripts/deploy-mta.cjs | 66 ++++++++++++++++++- test/unit/deploy-dirty-build-inputs.test.ts | 61 +++++++++++++++++ 4 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 test/unit/deploy-dirty-build-inputs.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 168cd58d1..689af04ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,6 +136,7 @@ The load-bearing few. **Full detail for every relocated item → [tutorials-ims- - **Fresh worktree: run `npm run setup` after `npm install`** — global npmrc `ignore-scripts=true` skips native builds; without it `hugo-apps/node_modules` is empty and `better-sqlite3` won't build (tests hang / fail resolving `@mediapipe/tasks-vision`). - **`ignore-scripts=true` silences all `pre*`/`post*` hooks** — local `build:all` does NOT fire `postbuild:apps`; island-manifest + `build:page-fallback` are wired as explicit `build:all` steps. Rely on the hook and merged JS/CSS ships dead (unhashed island paths) → "not deployed" despite a green deploy. → gotchas.md "Build artifacts & lifecycle hooks". +- **Deploy refuses a dirty Hugo working tree (#2886)** — uncommitted edits under `hugo/assets/**` or `hugo/layouts/**` make Hugo fingerprint dirty CSS/JS, so the approuter serves a hash the committed-source content pipeline never bakes → 404 on every tutorial (the 2026-10-21 PROD-down). `deploy-mta.cjs` **Step 0.5** `die(1)`s on any tracked-modified build input (branch/SHA guards miss it — a dirty tree keeps the same HEAD SHA). Fix: commit or `git stash -u`. → gotchas.md "Deploy bakes dirty working-tree CSS". - **`hugo/content/tutorials/` is entirely generated** — never edit; `fetch-tutorials` overwrites. Edit `scripts/parsers/` or source repos. - **Group/Mission completions are rollup-derived (#1934)** — `srv/lib/completion-rollup.js` recomputes parents after any TUTORIAL/PUZZLE/CHECKPOINT/PETOBERFEST completion; upserts on `(user_ID, taskLegacyId, taskType)`; NOT a `content-store.js` dep. → gotchas.md "Completions rollup". - **Never run `publish-content` from a workstation** — use `gh workflow run rebuild-content.yml`; workstation publishes skip CI validation. diff --git a/docs/developers/reference/tutorials-ims-gotchas.md b/docs/developers/reference/tutorials-ims-gotchas.md index cb183e035..44636e9e1 100644 --- a/docs/developers/reference/tutorials-ims-gotchas.md +++ b/docs/developers/reference/tutorials-ims-gotchas.md @@ -114,6 +114,13 @@ These paragraphs used to live inline in `CLAUDE.md`'s "Top Gotchas" section. The - **`ignore-scripts=true` silences `postbuild:apps` — build artifacts wired into it are NOT produced by local `npm run build:all`** — the global npmrc `ignore-scripts=true` means npm **lifecycle hooks never fire**. The `postbuild:apps` hook is where the #1604 island-fingerprint step (`build:island-manifest`, which writes `hugo/data/island_manifest.json`) and 8 static guards live. During a local `build:all`, none of them run. Symptom class: fresh JS/CSS **compiles** (Vite emits `navigator-.js`) but is **never referenced** — `hugo/layouts/partials/island-src.html` falls back to the unhashed `/js/.js`, Hugo bakes the stale path, and the approuter ships old bundles sitting next to the new ones. **Merged fixes look "not deployed" even though the deploy succeeded.** CI dodges this because `deploy.yml`/`unit-tests.yml` run `npm run postbuild:apps` as an **explicit step** (see deploy.yml:217-223 comment). Fix (2026-08-10): `build:all` now calls `npm run build:island-manifest` **explicitly** (not via the hook), and `scripts/deploy-mta.cjs` Step 2.5 fails the deploy if `hugo/public/index.html` bakes only unhashed island paths while a Vite manifest exists. Rule: any build **artifact** (not just a guard) needed for a correct ship must be an explicit step in `build:all`, never left to a `post*`/`pre*` lifecycle hook. - **`build:page-fallback` is an explicit `build:all` step (NOT a lifecycle hook)** — `scripts/build-page-fallback.cjs` copies in-scope page snapshots from `hugo/public` into `srv/page-fallback/.` after `build:hugo` runs. Because `ignore-scripts=true` silences all `pre*`/`post*` hooks, it is wired as an explicit `npm run build:page-fallback` in the `build:all` chain, positioned right after `build:hugo`. If you add a new in-scope page to `IN_SCOPE_PAGES` in `srv/lib/page-key-map.js`, the snapshot is picked up automatically on the next full build. Snapshots are gitignored (`srv/page-fallback/*` except `.gitkeep`); the directory is committed empty and populated at build time. +## Deploy bakes dirty working-tree CSS → approuter/HTML fingerprint skew → 404 (#2886) + +- **A deploy from a tree with uncommitted Hugo-input edits ships a fingerprint the content pipeline never bakes** — the approuter builder (`.deploy/mta.yaml`, `tutorials-approuter`) does `cp -r ../hugo/public/. static/`, shipping **whatever `hugo/public/` contains at `mbt build` time**. `hugo/public/css/sap-fundamental..css` is Hugo's fingerprint of `hugo/assets/css/sap-fundamental.css`. On 2026-10-21 a PROD deploy ran `build:all` in a checkout with **uncommitted edits** to `hugo/assets/css/sap-fundamental.css` + `fundamental-styles-icon.css`, so Hugo fingerprinted the **dirty** CSS (`9dbb07c7…`) while the CI content pipeline (`rebuild-content*.yml`, built from clean `origin/main`) baked HTML referencing the **committed** hash (`7f01bb5…`). The two never matched → every tutorial on `/tutorials/` **and** `/tutorials-qa/` (shared approuter) → **HTTP 404 → unstyled pages → PROD-down**. +- **Why the pre-existing guards missed it** — `guardPrimaryTreeBranch` (Step 0) checks branch + (DEV/QA) `HEAD === origin/` SHA. A dirty working tree has the **same HEAD SHA**, so both the PROD path (branch + not-a-worktree) and the DEV/QA path (SHA match) pass with a dirty `hugo/assets/**` file. +- **The gate (#2886)** — `scripts/deploy-mta.cjs` **Step 0.5** (`guardCleanBuildInputs`, all envs, before `build:all`) runs `git status --porcelain -- hugo/assets hugo/layouts` and `die(1)`s listing any **tracked, modified** entry (the pure parser `dirtyBuildInputs`, exported + unit-tested in `test/unit/deploy-dirty-build-inputs.test.ts`, ignores `??` untracked so `build:all`-generated `hugo/data/*.json` never false-positives — which is also why the scope deliberately excludes `hugo/data`). Fix on trip: commit or `git stash -u`, then redeploy. Read-only, so it runs under `--dry-run` and `--skip-build` too. +- **Belt-and-suspenders** — the served-asset guards stay: Step 4.6 (in-place) already `cf restart`s and then `die(1)`s if the just-built css/island-js refs still 404, and Step 4.5 (blue-green) probes the green `-idle` route **advisory** (the swap is operator-gated, so it never blocks by design). The publish-side blocking gate is #2885's scope; this issue is the deploy-side dirty-tree gate. Both are needed. + ## Admin-UI preload build (#2749) - **Admin component tiles are UI5-`ui5 build`'d (preload + minify), not raw-copied** — `app/admin-shell/scripts/copy-components.js` runs each tile in `PRELOAD_COMPONENTS` (now **all** discovered tiles, derived in `preload-components.cjs`) through `ui5 build`, emitting a minified `Component-preload.js` so the shell loads each tile in ~1 bundle request instead of ~10 per-module round-trips (every one a full XSUAA-authed approuter hit, no CDN). Decision/strip/fallback logic lives in `app/admin-shell/scripts/build-component.cjs` (unit-tested: `test/unit/build-component.test.ts`). diff --git a/scripts/deploy-mta.cjs b/scripts/deploy-mta.cjs index 07a9cd5c4..a217246f1 100644 --- a/scripts/deploy-mta.cjs +++ b/scripts/deploy-mta.cjs @@ -208,6 +208,60 @@ function guardPrimaryTreeBranch(cfg, envName) { ok(`fresh ${expected} checkout @ ${head.slice(0, 8)}${inWorktree ? ' (worktree)' : ''}`); } +// --------------------------------------------------------------------------- +// #2886 dirty-build-inputs gate (pure parser). +// +// Parse `git status --porcelain -- hugo/assets hugo/layouts` and return the +// TRACKED, modified build-input paths. These are Hugo's fingerprint-bearing +// inputs: hugo/assets/** is fingerprinted into /css|/js/ artifacts and +// hugo/layouts/** emits the HTML that references them. A dirty tracked file +// here means the approuter would ship a fingerprint derived from UNCOMMITTED +// source — which the content pipeline (built from clean origin/main) never +// bakes → the 2026-10-21 PROD-down 404. +// +// We IGNORE untracked entries (`??`): build:all generates untracked artifacts +// and scratch files under these dirs are not fingerprint inputs until tracked. +// Tracked changes in either porcelain column (index or worktree: M/A/D/R/C) +// block. Renames/copies report the destination path (after the `-> `) — that +// is the path Hugo fingerprints. +// --------------------------------------------------------------------------- +function dirtyBuildInputs(porcelain) { + const out = []; + for (const raw of (porcelain || '').split('\n')) { + const line = raw.replace(/\r$/, ''); + if (!line) continue; + const xy = line.slice(0, 2); + const rest = line.slice(3); + if (xy === '??') continue; // untracked — not a committed-source fingerprint input + // Rename/copy: "R old -> new" / "C old -> new". Fingerprint follows the dest. + const arrow = rest.indexOf(' -> '); + out.push(arrow === -1 ? rest : rest.slice(arrow + 4)); + } + return out; +} + +// Step 0.5 guard: refuse any deploy when Hugo's fingerprint-bearing inputs have +// uncommitted TRACKED changes, for ALL envs. Runs BEFORE build:all so the +// approuter can only ship fingerprints derived from committed source. Scoped to +// hugo/assets + hugo/layouts (NOT hugo/data — build:all regenerates untracked +// hugo/data/*.json, which would false-positive). Read-only, so it runs under +// --dry-run and --skip-build too (a reused mtar may have been built dirty). +function guardCleanBuildInputs() { + const r = shCapture('git', ['status', '--porcelain', '--', 'hugo/assets', 'hugo/layouts']); + if (r.status !== 0) { + die(1, `\`git status --porcelain\` failed — cannot verify build inputs are committed.\n${r.stderr}`); + } + const dirty = dirtyBuildInputs(r.stdout); + if (dirty.length) { + die(1, `uncommitted changes to Hugo fingerprint inputs — the approuter would ship a CSS/JS\n` + + ` fingerprint the content pipeline (built from committed origin/main) never bakes,\n` + + ` 404-ing every tutorial page (the 2026-10-21 PROD-down incident, #2886):\n` + + dirty.map(f => ` ${f}`).join('\n') + '\n' + + ` Fix: commit the change, or set it aside with \`git stash -u\`, then redeploy.`); + } + ok('Hugo build inputs (hugo/assets, hugo/layouts) are committed — no fingerprint skew'); +} + function guardCfTarget(cfg, envName) { const t = shCapture('cf', ['target']); if (t.status !== 0) { @@ -379,6 +433,16 @@ async function main() { step(0, 'Preconditions'); guardPrimaryTreeBranch(cfg, envName); + // ---- Step 0.5: clean build inputs (#2886) ---------------------------- + // The branch/SHA guard above does NOT catch a dirty working tree (a dirty + // file has the same HEAD SHA). On 2026-10-21 an uncommitted edit to + // hugo/assets/css/sap-fundamental.css made Hugo fingerprint the dirty CSS, so + // the approuter served a hash the committed-source content pipeline never + // baked → 404 on every tutorial (PROD-down). Fail fast on local state before + // touching CF or building. Read-only, so it runs even under --dry-run/--skip-build. + step('0.5', 'Hugo fingerprint inputs are committed (no dirty-tree skew)'); + guardCleanBuildInputs(); + // ---- Step 1: cf target guard ----------------------------------------- step(1, `cf target must be region "${cfg.region}", space "${cfg.space}"`); if (args.dryRun) warn('dry-run: still checking cf target (read-only)'); @@ -748,4 +812,4 @@ if (require.main === module) { main(); } -module.exports = { notifyDeploy }; +module.exports = { notifyDeploy, dirtyBuildInputs }; diff --git a/test/unit/deploy-dirty-build-inputs.test.ts b/test/unit/deploy-dirty-build-inputs.test.ts new file mode 100644 index 000000000..def2e4b4d --- /dev/null +++ b/test/unit/deploy-dirty-build-inputs.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Unit test for the #2886 dirty-build-inputs deploy gate in +// scripts/deploy-mta.cjs. The 2026-10-21 PROD-down incident shipped an +// approuter built from an UNCOMMITTED working-tree edit to +// hugo/assets/css/sap-fundamental.css, so Hugo fingerprinted the dirty CSS and +// the approuter served a hash the committed-source content pipeline never +// bakes → 404 on every tutorial. Step 0's branch/SHA guard missed it (a dirty +// tree has the same HEAD SHA). `dirtyBuildInputs` parses the output of +// git status --porcelain -- hugo/assets hugo/layouts +// and returns the TRACKED, modified build-input paths that must block the +// deploy. We test the parser in isolation — the git scoping (the `--` pathspec) +// is exercised by the integration path, not here. + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const { dirtyBuildInputs } = require(join(__dirname, '..', '..', 'scripts', 'deploy-mta.cjs')); + +describe('dirtyBuildInputs (#2886 dirty-tree deploy gate)', () => { + it('flags a tracked, modified build-input file (the incident case)', () => { + const porcelain = ' M hugo/assets/css/sap-fundamental.css\n'; + expect(dirtyBuildInputs(porcelain)).toEqual(['hugo/assets/css/sap-fundamental.css']); + }); + + it('flags staged (index-side) modifications too', () => { + const porcelain = 'M hugo/assets/css/sap-fundamental.css\n'; + expect(dirtyBuildInputs(porcelain)).toEqual(['hugo/assets/css/sap-fundamental.css']); + }); + + it('flags added, deleted, and renamed tracked inputs', () => { + const porcelain = + 'A hugo/layouts/partials/new.html\n' + + ' D hugo/assets/css/old.css\n' + + 'R hugo/layouts/a.html -> hugo/layouts/b.html\n'; + const out = dirtyBuildInputs(porcelain); + expect(out).toContain('hugo/layouts/partials/new.html'); + expect(out).toContain('hugo/assets/css/old.css'); + // A rename reports the destination path (what Hugo would fingerprint). + expect(out).toContain('hugo/layouts/b.html'); + }); + + it('IGNORES untracked files (build:all-generated artifacts, stray scratch)', () => { + const porcelain = + '?? hugo/assets/css/scratch.css\n' + + '?? hugo/layouts/wip.html\n'; + expect(dirtyBuildInputs(porcelain)).toEqual([]); + }); + + it('returns empty on a clean tree', () => { + expect(dirtyBuildInputs('')).toEqual([]); + expect(dirtyBuildInputs('\n')).toEqual([]); + }); + + it('handles CRLF and trailing whitespace without phantom entries', () => { + const porcelain = ' M hugo/assets/css/sap-fundamental.css\r\n'; + expect(dirtyBuildInputs(porcelain)).toEqual(['hugo/assets/css/sap-fundamental.css']); + }); +}); From c5cfd521230baa8e40e5c8eb6166023541fee12e Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 23 Oct 2026 13:20:25 +0200 Subject: [PATCH 14/38] ci(#2885): gate QA publish on approuter-served CSS/JS assets --- .github/workflows/rebuild-content-qa.yml | 33 ++ .../plans/2026-10-23-publish-asset-gate.md | 304 ++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-23-publish-asset-gate.md diff --git a/.github/workflows/rebuild-content-qa.yml b/.github/workflows/rebuild-content-qa.yml index ecd29f990..ead034b04 100644 --- a/.github/workflows/rebuild-content-qa.yml +++ b/.github/workflows/rebuild-content-qa.yml @@ -397,6 +397,39 @@ jobs: - name: Highlight CDS code blocks (Shiki) run: npm run build:highlight -- --dir hugo/public-qa + # [#2885] Pre-flight asset gate — the same guard rebuild-content.yml + # already runs (its "Guard - approuter serves referenced CSS + islands" + # step), now on the QA path — the surface stranded on 2026-10-22. Probes + # every /css + hashed island /js the rendered QA HTML references against + # the deployed QA approuter and FAILS before the HANA publish on any 404, + # so QA BLOBs are never poisoned with HTML the approuter can't dress. QA + # refreshes ONLY on its own tutorial-qa-updated dispatch (it does not + # self-heal like prod), so this gate is the only thing standing between a + # fingerprint-skewed deploy and unstyled /tutorials-qa/* pages. + - name: Guard - approuter serves referenced CSS + islands (QA) + # Share the publish step's skip condition (line ~405): an author + # source-error (#2384) leaves no rendered page for the slug, and + # check-approuter-assets.cjs resolvePages() die()s (exit 1) on a missing + # page — without this `if:`, a benign author typo would become a red + # infra run and file a false failure issue. + if: ${{ steps.source_errors.outputs.bad_slug == '' }} + env: + APPROUTER_URL: ${{ secrets.APPROUTER_URL_QA }} + # Match the publish step's PUBLISH_SLUG / PUBLISH_SLUGS expressions + # EXACTLY (lines 424/431) so the probe scope tracks the publish scope: + # a force-publish run ships the WHOLE catalog, so it must NOT pin the + # guard to one slug (that would gate 1 and strand ~1400 — the inverse + # of the bug this closes). check-approuter-assets.cjs reads both. + PUBLISH_SLUG: ${{ ((! inputs.force-publish) || inputs.allow-revert) && (github.event.client_payload.slug || inputs.slug) || '' }} + PUBLISH_SLUGS: ${{ (! inputs.force-publish) && github.event.client_payload.slugs || '' }} + run: | + SLUGS_ARG="" + [ -n "$PUBLISH_SLUGS" ] && SLUGS_ARG="--slugs $PUBLISH_SLUGS" + node scripts/check-approuter-assets.cjs \ + --approuter-url "$APPROUTER_URL" \ + --hugo-dir hugo/public-qa \ + --check-islands $SLUGS_ARG + - name: Publish QA content to HANA # Skip when the ONLY targeted slug failed to fetch (author source error, # #2384): nothing rendered for it, so a slug-scoped publish would throw diff --git a/docs/superpowers/plans/2026-10-23-publish-asset-gate.md b/docs/superpowers/plans/2026-10-23-publish-asset-gate.md new file mode 100644 index 000000000..c808250d4 --- /dev/null +++ b/docs/superpowers/plans/2026-10-23-publish-asset-gate.md @@ -0,0 +1,304 @@ +# Publish Asset-Gate Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Block any content publish that references a CSS/JS fingerprint the target approuter doesn't serve — on the QA workflow and the emergency CLI — before any HANA BLOB is written, and make the post-deploy QA publish a mandatory runbook step. + +**Architecture:** Reuse the existing, battle-tested `scripts/check-approuter-assets.cjs` gate verbatim as a subprocess. `rebuild-content.yml` (PROD) already wires it; add the same step to `rebuild-content-qa.yml` and invoke it from `publish-content.ts` `main()` before the publish session. No new asset-probing logic. + +**Tech Stack:** Node.js 22, TypeScript (tsx), GitHub Actions YAML, Vitest. + +**Spec:** `d:\projects\cloud-cap-hana-swapi\docs\superpowers\specs\2026-10-23-publish-asset-gate-design.md` + +## Global Constraints + +- Targets **DEV** — feature PR → DEV; `main` is protected; no main-hotfix path. +- **Reuse `check-approuter-assets.cjs` as a subprocess** — do NOT reimplement extraction/probe/inconclusive logic. One implementation only. +- QA approuter URL = `secrets.APPROUTER_URL_QA` (already used at `rebuild-content.yml:373`). No new secret. +- CLI approuter URL = `process.env.APPROUTER_URL` (established convention: `seed-*-from-deployed.ts`, `retain-asset-bundles.cjs`). +- Static assets are public → no API key to probe. All-404 (no 200s) = inconclusive → pass (never a false block). +- CLI gate must **skip (loud) when `APPROUTER_URL` is unset** — local `cds watch` has no approuter; failing there breaks every local publish. +- QA build renders to `hugo/public-qa`; the CLI already sets `opts.hugoDir` per channel (`main()` ~line 864), so one call shape works for both. +- CLI tests import **pure functions** from `publish-content.js` (see `scripts/__tests__/publish-content-cli.test.ts`) — the gate's testable surface must be a pure arg-builder + a thin runner, not `main()`. + +## Review Focus + +- **force-publish QA run** publishes the whole catalog — the guard must NOT pin to one slug (would gate 1, ship ~1400). Pinned by Task 1 test `force-publish probes whole catalog (no slug pin)`. +- **author source-error QA dispatch** (slug failed to fetch → no rendered page) currently skips publish and goes green — the guard must share the `if: bad_slug == ''` skip or it hard-fails a benign typo. Pinned by Task 1 (YAML `if:` + inspection assertion). +- **multi-slug CLI publish** (`--slugs a,b`) must probe exactly those N — not just the single `--slug` fallback. Pinned by Task 2 test `builds --slugs arg for multi-slug publish`. +- **APPROUTER_URL unset** (local dev) must skip, not fail. Pinned by Task 2 test `skips gate when APPROUTER_URL unset`. +- **non-zero subprocess exit** must abort before any BLOB write and propagate the code. Pinned by Task 3 test `propagates non-zero gate exit`. + +--- + +### Task 1: QA workflow guard + +**Files:** +- Modify: `.github/workflows/rebuild-content-qa.yml` (insert a step after "Highlight CDS code blocks (Shiki)" ~line 398, before "Publish QA content to HANA" ~line 400) + +**Interfaces:** +- Consumes: `scripts/check-approuter-assets.cjs` (unchanged), `secrets.APPROUTER_URL_QA`, `steps.source_errors.outputs.bad_slug`. +- Produces: nothing downstream depends on it (a gate step). + +- [ ] **Step 1: Add the guard step** + +Insert this step immediately before `- name: Publish QA content to HANA`: + +```yaml + # [#2885] Pre-flight asset gate — same guard rebuild-content.yml already + # runs, now on the QA path (the surface stranded on 2026-10-22). Probes + # every /css + hashed island /js the rendered QA HTML references against + # the deployed QA approuter; FAILS before the HANA publish on any 404, so + # QA BLOBs are never poisoned with HTML the approuter can't dress. + - name: Guard - approuter serves referenced CSS + islands (QA) + # Share the publish step's skip: an author source-error leaves no + # rendered page, and check-approuter-assets.cjs die()s on a missing page + # (exit 1) — without this, a benign author typo becomes a red infra run. + if: ${{ steps.source_errors.outputs.bad_slug == '' }} + env: + APPROUTER_URL: ${{ secrets.APPROUTER_URL_QA }} + # Match the publish step's scope EXACTLY (lines 424/431): a + # force-publish run ships the whole catalog, so it must NOT pin the + # guard to one slug. + PUBLISH_SLUG: ${{ ((! inputs.force-publish) || inputs.allow-revert) && (github.event.client_payload.slug || inputs.slug) || '' }} + PUBLISH_SLUGS: ${{ (! inputs.force-publish) && github.event.client_payload.slugs || '' }} + run: | + SLUGS_ARG="" + [ -n "$PUBLISH_SLUGS" ] && SLUGS_ARG="--slugs $PUBLISH_SLUGS" + node scripts/check-approuter-assets.cjs \ + --approuter-url "$APPROUTER_URL" \ + --hugo-dir hugo/public-qa \ + --check-islands $SLUGS_ARG +``` + +- [ ] **Step 2: Verify YAML parses and the step is correctly placed** + +Run: `npx js-yaml .github/workflows/rebuild-content-qa.yml > /dev/null && echo OK` (or `yq '.jobs."rebuild-qa".steps[] | .name' .github/workflows/rebuild-content-qa.yml | grep -n 'Guard - approuter\|Highlight CDS\|Publish QA'`) +Expected: `OK`; the Guard step appears between "Highlight CDS code blocks (Shiki)" and "Publish QA content to HANA". + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/rebuild-content-qa.yml +git commit -m "ci(#2885): gate QA publish on approuter-served CSS/JS assets" +``` + +--- + +### Task 2: CLI gate — pure arg-builder + unset/skip logic + +**Files:** +- Modify: `scripts/publish-content.ts` (add exported `buildApprouterGateArgs`, `approuterGateDecision`) +- Test: `scripts/__tests__/publish-content-cli.test.ts` (extend) + +**Interfaces:** +- Consumes: `PublishOptions` (`{ hugoDir, slug, slugs, ... }`), `process.env.APPROUTER_URL`, `process.env.SKIP_APPROUTER_GATE`. +- Produces: + - `approuterGateDecision(env: { APPROUTER_URL?: string; SKIP_APPROUTER_GATE?: string }): { run: boolean; reason: string }` — `run:false` when `APPROUTER_URL` empty/unset (reason `"no-approuter"`) or `SKIP_APPROUTER_GATE` truthy (reason `"skip-flag"`); else `run:true`. + - `buildApprouterGateArgs(opts: { hugoDir: string; slugs: string }, approuterUrl: string): string[]` — returns the `.cjs` argv array: `['scripts/check-approuter-assets.cjs', '--approuter-url', approuterUrl, '--hugo-dir', opts.hugoDir, '--check-islands']` plus `['--slugs', opts.slugs]` when `opts.slugs` is non-empty. (`--slug` is threaded via the `PUBLISH_SLUG` env, matching the workflow, not as an arg.) + +- [ ] **Step 1: Write the failing tests** + +Add to `scripts/__tests__/publish-content-cli.test.ts`: + +```typescript +import { approuterGateDecision, buildApprouterGateArgs } from '../publish-content.js'; + +describe('approuterGateDecision', () => { + it('skips gate when APPROUTER_URL unset', () => { + expect(approuterGateDecision({}).run).toBe(false); + expect(approuterGateDecision({ APPROUTER_URL: '' }).run).toBe(false); + expect(approuterGateDecision({}).reason).toBe('no-approuter'); + }); + it('skips gate when SKIP_APPROUTER_GATE set', () => { + const d = approuterGateDecision({ APPROUTER_URL: 'https://x', SKIP_APPROUTER_GATE: '1' }); + expect(d.run).toBe(false); + expect(d.reason).toBe('skip-flag'); + }); + it('runs gate when APPROUTER_URL set and no skip', () => { + expect(approuterGateDecision({ APPROUTER_URL: 'https://x' }).run).toBe(true); + }); +}); + +describe('buildApprouterGateArgs', () => { + it('base args: no --slugs when slugs empty', () => { + expect(buildApprouterGateArgs({ hugoDir: 'hugo/public', slugs: '' }, 'https://x')) + .toEqual(['scripts/check-approuter-assets.cjs', '--approuter-url', 'https://x', '--hugo-dir', 'hugo/public', '--check-islands']); + }); + it('builds --slugs arg for multi-slug publish', () => { + expect(buildApprouterGateArgs({ hugoDir: 'hugo/public-qa', slugs: 'a,b' }, 'https://x')) + .toContain('--slugs'); + expect(buildApprouterGateArgs({ hugoDir: 'hugo/public-qa', slugs: 'a,b' }, 'https://x').at(-1)).toBe('a,b'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run scripts/__tests__/publish-content-cli.test.ts -t 'approuterGate|buildApprouterGateArgs'` +Expected: FAIL — `approuterGateDecision`/`buildApprouterGateArgs` not exported. + +- [ ] **Step 3: Implement the two pure functions in `scripts/publish-content.ts`** + +Export both near the other exported helpers (e.g. after `parseArgs`). Signatures exactly as in Interfaces. `approuterGateDecision` treats any non-empty `SKIP_APPROUTER_GATE` as truthy; `buildApprouterGateArgs` appends `--slugs ` only when `opts.slugs.trim()` is non-empty. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run scripts/__tests__/publish-content-cli.test.ts -t 'approuterGate|buildApprouterGateArgs'` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/publish-content.ts scripts/__tests__/publish-content-cli.test.ts +git commit -m "feat(#2885): add approuter-gate decision + arg-builder to publish CLI" +``` + +--- + +### Task 3: CLI gate — wire into main() before publish + +**Files:** +- Modify: `scripts/publish-content.ts` (`main()`, insert gate after the `tutorials.size === 0` check ~line 1309, before `validateProductionBuild` ~line 1313; add `spawnSync` import) +- Test: `scripts/__tests__/publish-content-cli.test.ts` (add `runApprouterGate` test with mocked `spawnSync`) + +**Interfaces:** +- Consumes: `approuterGateDecision`, `buildApprouterGateArgs` (Task 2); `spawnSync` from `node:child_process`. +- Produces: `runApprouterGate(opts: { hugoDir: string; slug: string; slugs: string }, env: NodeJS.ProcessEnv, spawn?: typeof spawnSync): number` — returns `0` when the gate is skipped (decision `run:false`) or passes; returns the subprocess's non-zero `status` otherwise. Passes `PUBLISH_SLUG: opts.slug` in the child env so a single-slug publish scopes the probe. The `spawn` param defaults to the real `spawnSync` (injectable for tests). + +- [ ] **Step 1: Write the failing test** + +```typescript +import { runApprouterGate } from '../publish-content.js'; + +describe('runApprouterGate', () => { + it('skips (returns 0) when APPROUTER_URL unset', () => { + const spy = vi.fn(); + expect(runApprouterGate({ hugoDir: 'hugo/public', slug: '', slugs: '' }, {}, spy as any)).toBe(0); + expect(spy).not.toHaveBeenCalled(); + }); + it('propagates non-zero gate exit', () => { + const spawn = vi.fn().mockReturnValue({ status: 1 }); + expect(runApprouterGate({ hugoDir: 'hugo/public', slug: '', slugs: '' }, { APPROUTER_URL: 'https://x' }, spawn as any)).toBe(1); + }); + it('passes PUBLISH_SLUG in child env for a scoped publish', () => { + const spawn = vi.fn().mockReturnValue({ status: 0 }); + runApprouterGate({ hugoDir: 'hugo/public', slug: 'foo', slugs: '' }, { APPROUTER_URL: 'https://x' }, spawn as any); + const callEnv = spawn.mock.calls[0][2].env; + expect(callEnv.PUBLISH_SLUG).toBe('foo'); + }); +}); +``` + +(Add `import { vi } from 'vitest'` to the test file's imports if absent.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run scripts/__tests__/publish-content-cli.test.ts -t 'runApprouterGate'` +Expected: FAIL — `runApprouterGate` not exported. + +- [ ] **Step 3: Implement `runApprouterGate` + wire into `main()`** + +Implement `runApprouterGate` per the Interfaces signature: call `approuterGateDecision(env)`; if `run:false` log a loud warning naming the reason and return `0`; else `spawn('node', buildApprouterGateArgs(opts, env.APPROUTER_URL), { stdio: 'inherit', env: { ...env, PUBLISH_SLUG: opts.slug } })` and return `result.status ?? 0`. + +In `main()`, after the `tutorials.size === 0` check and before `validateProductionBuild`, call it only on the real publish path (NOT verify-only/dry-run/unpublish/purge — those return earlier or are separate branches): + +```typescript +const gateCode = runApprouterGate( + { hugoDir: opts.hugoDir, slug: opts.slug, slugs: opts.slugs }, + process.env, +); +if (gateCode !== 0) { + console.error('[publish-content] approuter asset gate failed — aborting before any BLOB write.'); + process.exit(gateCode); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run scripts/__tests__/publish-content-cli.test.ts -t 'runApprouterGate'` +Expected: PASS. + +- [ ] **Step 5: Run the full CLI test file (no regressions)** + +Run: `npx vitest run scripts/__tests__/publish-content-cli.test.ts` +Expected: PASS (all). + +- [ ] **Step 6: Commit** + +```bash +git add scripts/publish-content.ts scripts/__tests__/publish-content-cli.test.ts +git commit -m "feat(#2885): run approuter asset gate before CLI publish" +``` + +--- + +### Task 4: Deploy runbook — mandatory post-deploy QA publish + +**Files:** +- Modify: `docs/developers/operations/mta-deployment.md` + +**Interfaces:** docs only. + +- [ ] **Step 1: Add a mandatory post-deploy-publish subsection** + +Add a subsection stating: whenever a deploy changes a CSS/JS fingerprint, the post-deploy content publish is **mandatory on BOTH surfaces** — +1. PROD/main: `gh workflow run rebuild-content.yml -f mode=full` (env=prod), confirm green. +2. QA: `gh workflow run rebuild-content-qa.yml` (or the `Rebuild Content (QA)` UI), confirm green. + +Emphasize: **QA does NOT self-heal** (it refreshes only on its own `tutorial-qa-updated` dispatch), the QA publish is the step most often skipped, and the `check-approuter-assets` gate (now on all three paths) will hard-fail a publish that references an unshipped hash. `Rebuild Content (QA)` is the canonical QA path (QA sources from `-Contribution` repos). + +- [ ] **Step 2: Verify the doc references resolve** + +Run: `grep -n 'rebuild-content-qa\|does NOT self-heal\|check-approuter-assets' docs/developers/operations/mta-deployment.md` +Expected: matches present. + +- [ ] **Step 3: Commit** + +```bash +git add docs/developers/operations/mta-deployment.md +git commit -m "docs(#2885): make post-deploy QA content publish mandatory" +``` + +--- + +### Task 5: Gotchas regression note + +**Files:** +- Modify: `docs/developers/reference/tutorials-ims-gotchas.md` ("Top Gotchas — full detail" section, ~line 108) + +**Interfaces:** docs only. + +- [ ] **Step 1: Add the regression entry** + +Add an entry: a fingerprint-changing deploy strands **both** `/tutorials/*` and `/tutorials-qa/*` until each surface is re-published; an MTA deploy does not re-bake HTML BLOBs; the `check-approuter-assets.cjs` gate now blocks a publish referencing an unshipped CSS/JS hash on all three paths (`rebuild-content.yml`, `rebuild-content-qa.yml`, and the `publish-content.ts` CLI — CLI bypass is `SKIP_APPROUTER_GATE=1`, loud); QA does not self-heal. Cross-reference issue #2885. + +- [ ] **Step 2: Verify** + +Run: `grep -n '2885\|stranded\|self-heal' docs/developers/reference/tutorials-ims-gotchas.md` +Expected: match present. + +- [ ] **Step 3: Commit** + +```bash +git add docs/developers/reference/tutorials-ims-gotchas.md +git commit -m "docs(#2885): add stranded-asset regression note to gotchas" +``` + +--- + +### Task 6: Whole-suite sanity + PR + +**Files:** none (verification + PR). + +- [ ] **Step 1: Run the publish-content + approuter-assets test suites** + +Run: `npx vitest run scripts/__tests__/publish-content-cli.test.ts test/unit/check-approuter-assets.test.ts` +Expected: PASS (all). + +- [ ] **Step 2: Push the branch and open a PR targeting DEV** + +```bash +git push -u origin worktree-2885-publish-asset-gate +gh pr create --base DEV --title "fix(#2885): gate content publish on approuter-served CSS/JS assets (QA + CLI)" --body "" +``` From f40c98588586bb41b49a65a852217c532d1d1425 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 23 Oct 2026 13:22:15 +0200 Subject: [PATCH 15/38] docs(#2886): recommend scoped `git stash push` over `git stash -u` Code-review minor: the gate only blocks TRACKED changes, so plain stash of the file suffices; -u also stashes untracked (broader than needed and at odds with the repo's multi-worktree bare-stash warning). --- CLAUDE.md | 2 +- docs/developers/reference/tutorials-ims-gotchas.md | 2 +- scripts/deploy-mta.cjs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 689af04ea..24f83be8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,7 +136,7 @@ The load-bearing few. **Full detail for every relocated item → [tutorials-ims- - **Fresh worktree: run `npm run setup` after `npm install`** — global npmrc `ignore-scripts=true` skips native builds; without it `hugo-apps/node_modules` is empty and `better-sqlite3` won't build (tests hang / fail resolving `@mediapipe/tasks-vision`). - **`ignore-scripts=true` silences all `pre*`/`post*` hooks** — local `build:all` does NOT fire `postbuild:apps`; island-manifest + `build:page-fallback` are wired as explicit `build:all` steps. Rely on the hook and merged JS/CSS ships dead (unhashed island paths) → "not deployed" despite a green deploy. → gotchas.md "Build artifacts & lifecycle hooks". -- **Deploy refuses a dirty Hugo working tree (#2886)** — uncommitted edits under `hugo/assets/**` or `hugo/layouts/**` make Hugo fingerprint dirty CSS/JS, so the approuter serves a hash the committed-source content pipeline never bakes → 404 on every tutorial (the 2026-10-21 PROD-down). `deploy-mta.cjs` **Step 0.5** `die(1)`s on any tracked-modified build input (branch/SHA guards miss it — a dirty tree keeps the same HEAD SHA). Fix: commit or `git stash -u`. → gotchas.md "Deploy bakes dirty working-tree CSS". +- **Deploy refuses a dirty Hugo working tree (#2886)** — uncommitted edits under `hugo/assets/**` or `hugo/layouts/**` make Hugo fingerprint dirty CSS/JS, so the approuter serves a hash the committed-source content pipeline never bakes → 404 on every tutorial (the 2026-10-21 PROD-down). `deploy-mta.cjs` **Step 0.5** `die(1)`s on any tracked-modified build input (branch/SHA guards miss it — a dirty tree keeps the same HEAD SHA). Fix: commit or `git stash push -- `. → gotchas.md "Deploy bakes dirty working-tree CSS". - **`hugo/content/tutorials/` is entirely generated** — never edit; `fetch-tutorials` overwrites. Edit `scripts/parsers/` or source repos. - **Group/Mission completions are rollup-derived (#1934)** — `srv/lib/completion-rollup.js` recomputes parents after any TUTORIAL/PUZZLE/CHECKPOINT/PETOBERFEST completion; upserts on `(user_ID, taskLegacyId, taskType)`; NOT a `content-store.js` dep. → gotchas.md "Completions rollup". - **Never run `publish-content` from a workstation** — use `gh workflow run rebuild-content.yml`; workstation publishes skip CI validation. diff --git a/docs/developers/reference/tutorials-ims-gotchas.md b/docs/developers/reference/tutorials-ims-gotchas.md index 44636e9e1..5b652111a 100644 --- a/docs/developers/reference/tutorials-ims-gotchas.md +++ b/docs/developers/reference/tutorials-ims-gotchas.md @@ -118,7 +118,7 @@ These paragraphs used to live inline in `CLAUDE.md`'s "Top Gotchas" section. The - **A deploy from a tree with uncommitted Hugo-input edits ships a fingerprint the content pipeline never bakes** — the approuter builder (`.deploy/mta.yaml`, `tutorials-approuter`) does `cp -r ../hugo/public/. static/`, shipping **whatever `hugo/public/` contains at `mbt build` time**. `hugo/public/css/sap-fundamental..css` is Hugo's fingerprint of `hugo/assets/css/sap-fundamental.css`. On 2026-10-21 a PROD deploy ran `build:all` in a checkout with **uncommitted edits** to `hugo/assets/css/sap-fundamental.css` + `fundamental-styles-icon.css`, so Hugo fingerprinted the **dirty** CSS (`9dbb07c7…`) while the CI content pipeline (`rebuild-content*.yml`, built from clean `origin/main`) baked HTML referencing the **committed** hash (`7f01bb5…`). The two never matched → every tutorial on `/tutorials/` **and** `/tutorials-qa/` (shared approuter) → **HTTP 404 → unstyled pages → PROD-down**. - **Why the pre-existing guards missed it** — `guardPrimaryTreeBranch` (Step 0) checks branch + (DEV/QA) `HEAD === origin/` SHA. A dirty working tree has the **same HEAD SHA**, so both the PROD path (branch + not-a-worktree) and the DEV/QA path (SHA match) pass with a dirty `hugo/assets/**` file. -- **The gate (#2886)** — `scripts/deploy-mta.cjs` **Step 0.5** (`guardCleanBuildInputs`, all envs, before `build:all`) runs `git status --porcelain -- hugo/assets hugo/layouts` and `die(1)`s listing any **tracked, modified** entry (the pure parser `dirtyBuildInputs`, exported + unit-tested in `test/unit/deploy-dirty-build-inputs.test.ts`, ignores `??` untracked so `build:all`-generated `hugo/data/*.json` never false-positives — which is also why the scope deliberately excludes `hugo/data`). Fix on trip: commit or `git stash -u`, then redeploy. Read-only, so it runs under `--dry-run` and `--skip-build` too. +- **The gate (#2886)** — `scripts/deploy-mta.cjs` **Step 0.5** (`guardCleanBuildInputs`, all envs, before `build:all`) runs `git status --porcelain -- hugo/assets hugo/layouts` and `die(1)`s listing any **tracked, modified** entry (the pure parser `dirtyBuildInputs`, exported + unit-tested in `test/unit/deploy-dirty-build-inputs.test.ts`, ignores `??` untracked so `build:all`-generated `hugo/data/*.json` never false-positives — which is also why the scope deliberately excludes `hugo/data`). Fix on trip: commit, or set the tracked file aside with `git stash push -- `, then redeploy. Read-only, so it runs under `--dry-run` and `--skip-build` too. - **Belt-and-suspenders** — the served-asset guards stay: Step 4.6 (in-place) already `cf restart`s and then `die(1)`s if the just-built css/island-js refs still 404, and Step 4.5 (blue-green) probes the green `-idle` route **advisory** (the swap is operator-gated, so it never blocks by design). The publish-side blocking gate is #2885's scope; this issue is the deploy-side dirty-tree gate. Both are needed. ## Admin-UI preload build (#2749) diff --git a/scripts/deploy-mta.cjs b/scripts/deploy-mta.cjs index a217246f1..cb17690bf 100644 --- a/scripts/deploy-mta.cjs +++ b/scripts/deploy-mta.cjs @@ -257,7 +257,7 @@ function guardCleanBuildInputs() { ` fingerprint the content pipeline (built from committed origin/main) never bakes,\n` + ` 404-ing every tutorial page (the 2026-10-21 PROD-down incident, #2886):\n` + dirty.map(f => ` ${f}`).join('\n') + '\n' + - ` Fix: commit the change, or set it aside with \`git stash -u\`, then redeploy.`); + ` Fix: commit the change, or set it aside with \`git stash push -- \`, then redeploy.`); } ok('Hugo build inputs (hugo/assets, hugo/layouts) are committed — no fingerprint skew'); } From bc8b84cbfc89d961f1e7eab136c795fec61b59f5 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 23 Oct 2026 13:23:59 +0200 Subject: [PATCH 16/38] feat(#2885): add approuter-gate decision + arg-builder to publish CLI --- scripts/__tests__/publish-content-cli.test.ts | 50 ++++++++++++++++++- scripts/publish-content.ts | 47 +++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/scripts/__tests__/publish-content-cli.test.ts b/scripts/__tests__/publish-content-cli.test.ts index cfa18ee42..e0738e8a1 100644 --- a/scripts/__tests__/publish-content-cli.test.ts +++ b/scripts/__tests__/publish-content-cli.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; -import { applySourceHashShortCircuit, computePublishPlan, validateFlagCombo } from '../publish-content.js'; +import { describe, it, expect, vi } from 'vitest'; +import { applySourceHashShortCircuit, computePublishPlan, validateFlagCombo, approuterGateDecision, buildApprouterGateArgs, runApprouterGate } from '../publish-content.js'; describe('validateFlagCombo', () => { it('rejects --force + --heal', () => { @@ -150,3 +150,49 @@ describe('applySourceHashShortCircuit', () => { expect(out.sort()).toEqual(['a', 'b']); }); }); + +describe('approuterGateDecision', () => { + it('skips gate when APPROUTER_URL unset', () => { + expect(approuterGateDecision({}).run).toBe(false); + expect(approuterGateDecision({ APPROUTER_URL: '' }).run).toBe(false); + expect(approuterGateDecision({}).reason).toBe('no-approuter'); + }); + it('skips gate when SKIP_APPROUTER_GATE set', () => { + const d = approuterGateDecision({ APPROUTER_URL: 'https://x', SKIP_APPROUTER_GATE: '1' }); + expect(d.run).toBe(false); + expect(d.reason).toBe('skip-flag'); + }); + it('runs gate when APPROUTER_URL set and no skip', () => { + expect(approuterGateDecision({ APPROUTER_URL: 'https://x' }).run).toBe(true); + }); +}); + +describe('buildApprouterGateArgs', () => { + it('base args: no --slugs when slugs empty', () => { + expect(buildApprouterGateArgs({ hugoDir: 'hugo/public', slugs: '' }, 'https://x')) + .toEqual(['scripts/check-approuter-assets.cjs', '--approuter-url', 'https://x', '--hugo-dir', 'hugo/public', '--check-islands']); + }); + it('builds --slugs arg for multi-slug publish', () => { + expect(buildApprouterGateArgs({ hugoDir: 'hugo/public-qa', slugs: 'a,b' }, 'https://x')) + .toContain('--slugs'); + expect(buildApprouterGateArgs({ hugoDir: 'hugo/public-qa', slugs: 'a,b' }, 'https://x').at(-1)).toBe('a,b'); + }); +}); + +describe('runApprouterGate', () => { + it('skips (returns 0) when APPROUTER_URL unset', () => { + const spy = vi.fn(); + expect(runApprouterGate({ hugoDir: 'hugo/public', slug: '', slugs: '' }, {}, spy as any)).toBe(0); + expect(spy).not.toHaveBeenCalled(); + }); + it('propagates non-zero gate exit', () => { + const spawn = vi.fn().mockReturnValue({ status: 1 }); + expect(runApprouterGate({ hugoDir: 'hugo/public', slug: '', slugs: '' }, { APPROUTER_URL: 'https://x' }, spawn as any)).toBe(1); + }); + it('passes PUBLISH_SLUG in child env for a scoped publish', () => { + const spawn = vi.fn().mockReturnValue({ status: 0 }); + runApprouterGate({ hugoDir: 'hugo/public', slug: 'foo', slugs: '' }, { APPROUTER_URL: 'https://x' }, spawn as any); + const callEnv = spawn.mock.calls[0][2].env; + expect(callEnv.PUBLISH_SLUG).toBe('foo'); + }); +}); diff --git a/scripts/publish-content.ts b/scripts/publish-content.ts index 40ce5cf01..7e6564449 100644 --- a/scripts/publish-content.ts +++ b/scripts/publish-content.ts @@ -1,4 +1,5 @@ import { readFileSync, readdirSync, statSync, existsSync, appendFileSync } from 'node:fs'; +import { spawnSync as _spawnSync } from 'node:child_process'; import { join } from 'node:path'; import { createHash } from 'node:crypto'; import { gzipSync } from 'node:zlib'; @@ -813,6 +814,52 @@ export function filterToSlugs(tutorials: Map, slugs: string[]): return true; } +// [#2885] Approuter asset gate — mirrors the check-approuter-assets.cjs step +// that rebuild-content.yml already runs. Reuses the .cjs as a subprocess to +// avoid re-implementing probe/inconclusive logic. + +/** Decide whether to run the approuter asset gate based on env vars. */ +export function approuterGateDecision(env: { APPROUTER_URL?: string; SKIP_APPROUTER_GATE?: string }): { run: boolean; reason: string } { + if (!env.APPROUTER_URL) return { run: false, reason: 'no-approuter' }; + if (env.SKIP_APPROUTER_GATE) return { run: false, reason: 'skip-flag' }; + return { run: true, reason: 'ok' }; +} + +/** Build the argv array for the check-approuter-assets.cjs subprocess. */ +export function buildApprouterGateArgs(opts: { hugoDir: string; slugs: string }, approuterUrl: string): string[] { + const args = [ + 'scripts/check-approuter-assets.cjs', + '--approuter-url', approuterUrl, + '--hugo-dir', opts.hugoDir, + '--check-islands', + ]; + if (opts.slugs.trim()) args.push('--slugs', opts.slugs.trim()); + return args; +} + +/** Run the approuter asset gate. Returns 0 when skipped or passed; non-zero on gate failure. */ +export function runApprouterGate( + opts: { hugoDir: string; slug: string; slugs: string }, + env: NodeJS.ProcessEnv, + spawn: typeof _spawnSync = _spawnSync, +): number { + const decision = approuterGateDecision(env); + if (!decision.run) { + if (decision.reason === 'skip-flag') { + console.warn('[publish-content] SKIP_APPROUTER_GATE=1 — skipping approuter asset gate (bypass mode).'); + } else { + console.warn('[publish-content] APPROUTER_URL not set — skipping approuter asset gate (local dev mode).'); + } + return 0; + } + const args = buildApprouterGateArgs(opts, env.APPROUTER_URL!); + const result = spawn('node', args, { + stdio: 'inherit', + env: { ...env, PUBLISH_SLUG: opts.slug }, + }); + return result.status ?? 0; +} + async function collectSidecars(hugoDir: string, payload: Record, log: (s: string) => void, channel: Channel = "prod"): Promise { const keys: string[] = []; const navJsonPath = join(hugoDir, 'tutorials', '_nav.json'); From 5ddcfea1c17e9c10b0e0398a3871301db5ca5962 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 23 Oct 2026 13:24:33 +0200 Subject: [PATCH 17/38] feat(#2885): run approuter asset gate before CLI publish --- scripts/publish-content.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/scripts/publish-content.ts b/scripts/publish-content.ts index 7e6564449..62ea027ae 100644 --- a/scripts/publish-content.ts +++ b/scripts/publish-content.ts @@ -1357,6 +1357,19 @@ async function main() { log(`Found ${tutorials.size} tutorials`); + // [#2885] Pre-flight approuter asset gate — block before any BLOB write if + // the deployed approuter doesn't serve the CSS/JS hashes baked into the HTML. + // Skips loudly when APPROUTER_URL is unset (local dev / cds watch has no + // approuter). Set SKIP_APPROUTER_GATE=1 to bypass in a genuine emergency. + const gateCode = runApprouterGate( + { hugoDir: opts.hugoDir, slug: opts.slug ?? '', slugs: opts.slugs ?? '' }, + process.env, + ); + if (gateCode !== 0) { + console.error('[publish-content] approuter asset gate failed — aborting before any BLOB write.'); + process.exit(gateCode); + } + const violations = validateProductionBuild(tutorials); if (violations.length > 0) { console.error('Error: Hugo output contains dev-build artifacts. Refusing to publish.'); From ca7a9272f63f538785886cbe468810a2e21d7d08 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 23 Oct 2026 13:25:26 +0200 Subject: [PATCH 18/38] docs(#2885): make post-deploy QA content publish mandatory --- docs/developers/operations/mta-deployment.md | 22 +++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/developers/operations/mta-deployment.md b/docs/developers/operations/mta-deployment.md index ce5208e0e..7dbaa040d 100644 --- a/docs/developers/operations/mta-deployment.md +++ b/docs/developers/operations/mta-deployment.md @@ -147,7 +147,27 @@ CAP_BASE_URL="https://" npm run publish-content Use `--dry-run` to preview, `--force` to skip delta detection, `--verbose` for extra logging. -## Step 4: XSUAA Role Collections +### Mandatory post-deploy content publish after a fingerprint-changing deploy (#2885) + +When a deploy changes a CSS/JS fingerprint (any `hugo-apps` island rebuild or SAP Fundamental Styles CSS bump), the HANA BLOBs still hard-reference the **old** hash until a new publish overwrites them. Every tutorial page renders unstyled in the gap. **Both surfaces must be re-published** — PROD does not self-heal QA, and QA does not self-heal on its own. + +After any deploy that touches `hugo/static/js/` or `hugo/static/css/`: + +1. **PROD/main** — trigger a full rebuild and confirm green: + ```bash + gh workflow run rebuild-content.yml --repo sap-tutorials/tutorials-ims --ref main -f mode=full + ``` +2. **QA author-preview** — trigger `Rebuild Content (QA)` and confirm green: + ```bash + gh workflow run rebuild-content-qa.yml --repo sap-tutorials/tutorials-ims --ref main + ``` + > **QA does NOT self-heal.** It only refreshes on its own `tutorial-qa-updated` dispatch or a manual `rebuild-content-qa.yml` run. The post-deploy QA publish is the step most often skipped — do not skip it. + +The `check-approuter-assets.cjs` gate (now on all three publish paths: `rebuild-content.yml`, `rebuild-content-qa.yml`, and the `publish-content.ts` CLI) will hard-fail any publish that references an unshipped hash, so a skipped post-deploy publish surfaces as a gate failure on the *next* publish rather than silently stranding content. If the gate fires, redeploy to ship the correct hashes, then re-publish. + +CLI emergency bypass: `SKIP_APPROUTER_GATE=1 npm run publish-content` (loud, logged; CLI-only — the workflow guards have no bypass input by design). + + Role collections are defined in `xs-security.json` and created automatically when the XSUAA service instance is created via MTA deploy. **Any subsequent change to `xs-security.json` (scope add/remove, role template edit, or authorities-array change) requires a manual `cf update-service` to reconcile the deployed instance.** The MTA `cf deploy` command alone does NOT re-read `xs-security.json`. From 63ac93391775971bc8ddcbf9dbafb0bc331426a9 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 23 Oct 2026 13:26:14 +0200 Subject: [PATCH 19/38] docs(#2885): add stranded-asset regression note to gotchas --- .../reference/tutorials-ims-gotchas.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/developers/reference/tutorials-ims-gotchas.md b/docs/developers/reference/tutorials-ims-gotchas.md index cb183e035..2ead1c091 100644 --- a/docs/developers/reference/tutorials-ims-gotchas.md +++ b/docs/developers/reference/tutorials-ims-gotchas.md @@ -44,7 +44,22 @@ Cross-references: - **`hugo.qa.toml`** — sibling Hugo config for QA. Strips Joule FAB, rating, completion buttons, progress UI when `site.Params.qa = true`. - **QA bootstrap runbook** — [docs/developers/operations/qa-channel-bootstrap.md](../operations/qa-channel-bootstrap.md). -## Rebuild workflow & admin writes +## Stranded CSS/JS after a fingerprint-changing deploy (issue #2885) + +A deploy that changes a CSS/JS fingerprint (any `hugo-apps` island rebuild or SAP Fundamental Styles CSS bump) ships new hashes to the approuter but leaves HANA BLOBs pointing at the **old** hash. Every tutorial page renders unstyled until content is republished with the new hash. Both surfaces are stranded independently: + +- **`/tutorials/*` (PROD/main)** — re-publish via `gh workflow run rebuild-content.yml --ref main -f mode=full` +- **`/tutorials-qa/*` (QA author-preview)** — re-publish via `gh workflow run rebuild-content-qa.yml --ref main`. **QA does NOT self-heal** — it only refreshes on its own `tutorial-qa-updated` dispatch; the post-deploy QA rebuild is the step most often skipped. + +The `check-approuter-assets.cjs` gate now blocks a publish that references an unshipped CSS/JS hash on **all three paths**: + +1. `rebuild-content.yml` (PROD — was already gated) +2. `rebuild-content-qa.yml` (QA — added by #2885) +3. `publish-content.ts` CLI (emergency path — added by #2885) + +If the gate fires, the approuter hasn't shipped the new hashes yet — redeploy first, then re-publish. CLI emergency bypass: `SKIP_APPROUTER_GATE=1 npm run publish-content` (loud, logged; CLI-only — workflow guards have no bypass input by design). See [mta-deployment.md §"Mandatory post-deploy content publish"](../operations/mta-deployment.md#mandatory-post-deploy-content-publish-after-a-fingerprint-changing-deploy-2885). + + - **`rebuild-content.yml` mode auto-infer** — `gh workflow run rebuild-content.yml -f slug=X` auto-infers `mode=slug-targeted` when `inputs.mode` is default `full` AND a slug input is set. Don't pass `-f mode=slug-targeted`. Only `workflow_dispatch` auto-infers; `repository_dispatch` (admin auto-trigger) uses `srv/lib/_classify-rebuild-mode.js`. Wall-clock: `catalog-only` ~5min, `slug-targeted` ~2min, `full` ~10min. Runbook: [rebuild-content-workflow.md](../operations/rebuild-content-workflow.md). - **`GITHUB_DISPATCH_TOKEN` env var** — Read by `srv/lib/rebuild-trigger.js`; admin saves debounce-dispatch `rebuild-content.yml` after 60s. Sourced from `DISPATCH_TOKEN` GitHub Actions secret (**not** `GITHUB_DISPATCH_TOKEN` — GH reserves `GITHUB_` prefix). All four mtaext placeholders resolve at deploy time via `envsubst` writing `deploy/.resolved.mtaext`. Rotation: [github-dispatch-pat-rotation.md](../operations/github-dispatch-pat-rotation.md). From 081e0139e6db3d1a8a78d1271c4ff0eae0cd396d Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Fri, 23 Oct 2026 13:44:24 +0200 Subject: [PATCH 20/38] fix(#2857): surface admin PAT scope in mint UI (gated to admins) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin PAT scope (headless /admin-pat/* + /graphql-pat, #2574) is validated and enforced end-to-end in the backend, but the mint dialog only offered read/write — so a user following the docs could never mint an admin-scoped PAT and GET /admin-pat/* 403'd with 'lacking required roles: [Admin]'. - srv/pat-service.{cds,js}: add unbound PatService.patCapabilities() returning {adminScopeAvailable} = PAT_ADMIN_SCOPE_ENABLED AND req.user.is('Admin'), mirroring the handleMintPAT gate. - MintDialog.fragment.xml + PatActionsController.js: the admin scope item (and its hint) are hidden by default and shown only when patCapabilities reports the caller may mint one; fails closed on error. - docs: clarify the admin-scope gate in api-consumption.md; add a regression note to tutorials-ims-gotchas.md (don't make the admin item statically visible — reintroduces the non-admin 403). - test/pat-admin-grant.test.js: cover patCapabilities (flag+admin -> true, non-admin -> false, flag off -> false). Backend gates, AdminGrants, feature flag, approuter routes and TTL clamp are unchanged. --- .../pats/webapp/ext/MintDialog.fragment.xml | 8 +++ .../pats/webapp/ext/PatActionsController.js | 57 +++++++++++++++---- app/admin/pats/webapp/i18n/i18n.properties | 2 + .../reference/tutorials-ims-gotchas.md | 1 + docs/end-users/api-consumption.md | 5 +- srv/pat-service.cds | 8 +++ srv/pat-service.js | 8 +++ test/pat-admin-grant.test.js | 39 ++++++++++++- 8 files changed, 114 insertions(+), 14 deletions(-) diff --git a/app/admin/pats/webapp/ext/MintDialog.fragment.xml b/app/admin/pats/webapp/ext/MintDialog.fragment.xml index b4a9671b3..2f4019573 100644 --- a/app/admin/pats/webapp/ext/MintDialog.fragment.xml +++ b/app/admin/pats/webapp/ext/MintDialog.fragment.xml @@ -21,7 +21,15 @@ placeholder="{i18n>mint.fieldScopesPlaceholder}"> + + + +