Skip to content

feat: upgrade lesson planner to question-driven walkthroughs with asy… - #14

Merged
dfed25 merged 11 commits into
mainfrom
feature/lesson-walkthrough
Apr 25, 2026
Merged

dfed25 merged 11 commits into
mainfrom
feature/lesson-walkthrough

Conversation

@dfed25

@dfed25 dfed25 commented Apr 25, 2026

Copy link
Copy Markdown
Owner

…nc video export

Make lesson generation grounded in multi-source retrieval, add richer lesson schema/citations, and expand dashboard with question input, presenter narration, and MP4 export polling. Add async lesson render APIs with ffmpeg-backed pipeline plus local render job store and docs updates.

Made-with: Cursor

Summary by CodeRabbit

Release Notes

  • New Features

    • Added OpenAI API as fallback when Gemini experiences outages or quota issues.
    • Added text-to-speech narration for lessons (per-slide and full walkthrough audio).
    • Added lesson video export capability with MP4 rendering.
    • Enhanced task creation with structured fields: objective, execution steps, verification criteria, and escalation guidance.
    • Improved task display with expandable cards showing detailed task information.
  • Documentation

    • Updated README with MP4 rendering workflow and system requirements.

…nc video export

Make lesson generation grounded in multi-source retrieval, add richer lesson schema/citations, and expand dashboard with question input, presenter narration, and MP4 export polling. Add async lesson render APIs with ffmpeg-backed pipeline plus local render job store and docs updates.

Made-with: Cursor
@vercel

vercel Bot commented Apr 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
run-book Ready Ready Preview, Comment Apr 25, 2026 10:10pm
runbook Ready Ready Preview, Comment Apr 25, 2026 10:10pm

@coderabbitai

coderabbitai Bot commented Apr 25, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@dfed25 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 1 minutes and 34 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 1 minutes and 34 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 48a58358-7bf2-4e62-9902-b177ec34f7ac

📥 Commits

Reviewing files that changed from the base of the PR and between 13bf699 and 517b18c.

📒 Files selected for processing (15)
  • README.md
  • src/app/api/lesson/render/[jobId]/route.ts
  • src/app/api/lesson/render/[jobId]/video/route.ts
  • src/app/api/lesson/render/route.ts
  • src/app/api/lesson/route.ts
  • src/app/api/tasks/route.ts
  • src/app/api/tts/route.ts
  • src/app/dashboard/page.tsx
  • src/app/manager/tasks/page.tsx
  • src/components/ui/ChatMessageBody.tsx
  • src/lib/ai.ts
  • src/lib/dataStore.ts
  • src/lib/lessonRenderStore.ts
  • src/lib/lessonVideoRenderer.ts
  • src/lib/prompts.ts
📝 Walkthrough

Walkthrough

This PR introduces lesson video rendering with FFmpeg and ElevenLabs text-to-speech, adds task structure normalization with preset playbooks, implements OpenAI fallback for Gemini API, extends lesson schema with metadata (citations, confidence, speaker notes), integrates task-aware chat context, and updates UI for task creation, lesson generation with narration, and async video export with progress tracking.

Changes

Cohort / File(s) Summary
Environment & Configuration
.env.example, src/app/layout.tsx
Added OpenAI and ElevenLabs API credentials; suppressed hydration warnings in root layout.
Documentation
README.md
Updated checklist wording for task-based lesson generation; documented MP4 rendering workflow, ffmpeg requirement, and render job API endpoints.
AI & Resilience
src/lib/ai.ts, src/lib/prompts.ts
Rewired text generation to prioritize OpenAI with Gemini fallback; added multi-model retry logic. Rewrote lesson generation prompt to enforce fact-based content, confidence levels, and structured slide metadata with visual hints and citations.
Task Management & Structure
src/app/api/tasks/route.ts, src/app/manager/tasks/page.tsx, src/lib/demoTasks.ts, src/lib/dataStore.ts
Added task normalization layer to transform objective/steps/verification/escalation fields into structured descriptions with preset playbooks for common patterns (HR, Slack, GitHub, dev setup, security); expanded task form UI with four new fields.
Chat Enhancement
src/app/api/chat/route.ts, src/components/ui/ChatMessageBody.tsx
Integrated assigned task retrieval and context for hire-scoped task-aware responses; normalized ChatMessageBody text input handling to support multiple types.
Lesson Rendering Pipeline
src/app/api/lesson/render/route.ts, src/app/api/lesson/render/[jobId]/route.ts, src/app/api/lesson/render/[jobId]/video/route.ts, src/lib/lessonRenderStore.ts, src/lib/lessonVideoRenderer.ts
Added async lesson-to-video API with job queuing (202 response), polling endpoint, and video download endpoint. Implemented filesystem-backed render job store and complete FFmpeg-based video pipeline with per-slide duration, color backgrounds, text overlays, and optional narration muxing.
Text-to-Speech Integration
src/app/api/tts/route.ts
Added POST endpoint for ElevenLabs text-to-speech conversion with configurable voice/model and fallback handling when API key is absent.
Type Extensions
src/lib/types.ts
Extended Lesson and LessonSlide types with metadata (citations, speaker notes, duration, visual hints, warnings, confidence levels); introduced LessonRenderStatus and LessonRenderJob types for render tracking.
Dashboard Rewrite
src/app/dashboard/page.tsx
Replaced fixed lesson document picker with question-based lesson generation; added task detail parsing and expandable task cards; integrated slide narration with TTS and speech synthesis fallback; implemented async MP4 export with job polling, progress estimation, and error handling with ffmpeg-specific guidance.

Sequence Diagrams

sequenceDiagram
    participant Client as Client
    participant API as POST /api/lesson/render
    participant Store as Render Store
    participant BG as Background Job
    participant FFmpeg as FFmpeg Pipeline
    participant TTS as ElevenLabs/TTS
    participant Disk as Disk Storage

    Client->>API: POST {lesson}
    API->>Store: createLessonRenderJob()
    Store->>Disk: Save job (queued)
    API-->>Client: Return job + 202

    API->>BG: Start async render
    BG->>Store: setStatus(running)
    BG->>FFmpeg: renderLessonVideo(jobId, lesson)
    FFmpeg->>TTS: Synthesize slide text
    TTS-->>FFmpeg: Audio stream
    FFmpeg->>Disk: Write slide clips + narration
    FFmpeg->>Disk: Concatenate to final video
    BG->>Store: setStatus(completed, outputUrl)
    
    Client->>API: Poll GET /api/lesson/render/[jobId]
    API->>Store: getLessonRenderJob(jobId)
    Store-->>API: Job status + progress
    API-->>Client: Return status

    Client->>API: GET /api/lesson/render/[jobId]/video
    API->>Disk: Read video file
    API-->>Client: Stream MP4 audio/mpeg
Loading
sequenceDiagram
    participant Client as Client (Dashboard)
    participant ChatAPI as POST /api/chat
    participant TaskDB as Task Store
    participant AIProvider as AI (Gemini/OpenAI)
    participant Retrieval as Lesson Retrieval

    Client->>ChatAPI: POST {hireId, question}
    ChatAPI->>TaskDB: Retrieve assigned tasks
    TaskDB-->>ChatAPI: Tasks (normalized description)
    ChatAPI->>Retrieval: Fetch context (if not task-only)
    Retrieval-->>ChatAPI: Documents + company context
    ChatAPI->>ChatAPI: Format taskContext + contextDocs
    ChatAPI->>AIProvider: Generate with task-first prompt
    AIProvider-->>ChatAPI: Lesson {slides, sources, confidence}
    ChatAPI-->>Client: Return lesson + sourcesUsed
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • feat: add manager onboarding control plane with hire-scoped AI context #10: Modifies hire-scoped task context and task-aware chat in src/app/api/chat/route.ts, task/manager pages, and dataStore.ts; this PR builds on task integration with rendering and TTS.
  • Backend ai #6: Modifies AI backend (src/lib/prompts.ts, src/lib/types.ts) for lesson generation; this PR extends that with confidence levels and metadata.
  • Person1 #9: Modifies task data layer (src/lib/demoTasks.ts, src/lib/dataStore.ts, src/lib/types.ts); this PR enhances task normalization and structure.

Poem

🎬 A lesson now takes wing with FFmpeg's might,
Task playbooks shine through ElevenLabs' voice,
When Gemini stumbles, OpenAI's our choice,
Videos render, narration flows just right! 🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.23% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title 'feat: upgrade lesson planner to question-driven walkthroughs with asy…' directly summarizes the main change: converting lesson generation to question-driven mode and adding asynchronous video export functionality.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/lesson-walkthrough

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

dfed25 added 7 commits April 25, 2026 12:01
Increase lesson context depth and slide detail requirements, remove confusing lesson source dropdown, and improve slide readability with speaker notes. Fix video export reliability by running render requests synchronously and correcting ffmpeg drawtext path handling.

Made-with: Cursor
…fully

Show clear inline UI guidance when video export cannot run due to missing ffmpeg, avoid hard throw behavior in dashboard render flow, and render slide body/notes/summary with rich formatting. Also strengthen lesson depth prompt with per-step action and verification requirements.

Made-with: Cursor
Inject assigned hire tasks into chat context so "how do I do my task" requests produce concrete step-by-step plans grounded in both tasks and docs. Also tighten lesson prompts/context handling to keep outputs question-specific instead of repeating generic slide templates.

Made-with: Cursor
…estion

Fix ffmpeg filter syntax for slide rendering so MP4 export runs on local ffmpeg 8.x builds. Improve lesson generation by injecting relevant assigned tasks, scoring tasks by question intent, and hardening model JSON parsing to reduce generic fallback behavior.

Made-with: Cursor
Detect drawtext support at runtime and gracefully fall back to plain slide clips when the local ffmpeg build lacks libfreetype/drawtext. This keeps lesson MP4 export working instead of hard-failing on filter-not-found errors.

Made-with: Cursor
…ext video output

Replace the generic fallback lesson with a question-grounded flow built from assigned tasks and retrieved docs so different questions produce different walkthroughs even when Gemini is unavailable. Improve ffmpeg no-drawtext fallback rendering to use animated patterned visuals instead of flat color-only clips.

Made-with: Cursor
Make task guidance consistently actionable across seeded and manager-created tasks, add clickable task deep-dives on the new hire dashboard, and extend lesson narration to ElevenLabs for both exported videos and slide reading.

Made-with: Cursor

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 19

🧹 Nitpick comments (12)
README.md (1)

48-59: LGTM — docs accurately mirror the new endpoints and runtime requirements.

The endpoint paths match the new routes (POST /api/lesson/render, GET /api/lesson/render/[jobId], GET /api/lesson/render/[jobId]/video) and the .runbook-data/lesson-renders/ storage location is consistent with renderOutputPath in src/lib/lessonRenderStore.ts. One optional follow-up: you may want to mention non-macOS install paths (e.g., apt install ffmpeg, choco install ffmpeg) so Linux/Windows contributors aren't left guessing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 48 - 59, Update README.md to mention ffmpeg install
commands for Linux and Windows in addition to macOS: add brief instructions or
examples such as apt-based install for Debian/Ubuntu and Chocolatey for Windows
so non-macOS contributors know how to install ffmpeg; ensure the text still
references the MP4 export requirement and the existing endpoints (POST
/api/lesson/render, GET /api/lesson/render/:jobId, GET
/api/lesson/render/:jobId/video) and the storage location
.runbook-data/lesson-renders/ (which corresponds to renderOutputPath in
src/lib/lessonRenderStore.ts).
src/components/ui/ChatMessageBody.tsx (1)

30-38: LGTM — defensive normalization handles the new shape variability.

Coercing unknownstring (with array-join and String() fallbacks) is a sensible response to upstream chat/lesson payloads that may now contain non-string content. Two small notes you can take or leave:

  • Widening the prop from string to unknown weakens compile-time safety for all callers; if only one or two callsites legitimately pass non-strings, consider keeping the public type as string | string[] | null | undefined instead of unknown.
  • For arrays of objects, .join("\n") will yield "[object Object]" lines. Fine as a guardrail, but worth knowing if such inputs ever reach this component.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/ui/ChatMessageBody.tsx` around lines 30 - 38, The prop type
for ChatMessageBody is currently too broad (text: unknown) and the normalization
with normalizedText can produce "[object Object]" for arrays of objects; change
the public prop signature on ChatMessageBody to a narrower union (e.g. string |
string[] | null | undefined) and update the normalization logic to handle array
entries safely by mapping items to strings (using JSON.stringify for objects,
otherwise String(item)) before joining, leaving role and blocks logic unchanged.
src/app/api/tasks/route.ts (1)

32-40: Minor: numbering preserves user-supplied numbers but doesn't normalize them.

If a user pastes steps like 1. foo\n3. bar\n2. baz, the regex ^\d+\. keeps the original numbers (1, 3, 2) instead of renumbering. Likely fine since the chat prompt at src/app/api/chat/route.ts lines 96-101 just pastes task.description verbatim, but worth noting if you ever want strictly canonical step ordering.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/api/tasks/route.ts` around lines 32 - 40, The current normalization
preserves user-supplied numeric prefixes (using /^\d+\./) which can leave steps
out-of-order; update the normalizedSteps logic so every line is renumbered
sequentially: in the chain that builds normalizedSteps (operating on steps),
strip any existing leading numeric prefix from each line (e.g., remove
/^\d+\.\s*/), then always prepend `${idx + 1}. ` to the cleaned line before
joining; reference the variables normalizedSteps, steps and parts to locate and
replace the existing map() that conditionally keeps /^\d+\./.
src/app/dashboard/page.tsx (2)

361-381: Type cast assumes the error response shape matches LessonRenderJob.

When !res.ok, the response body is most likely { error: "..." } (per the route at src/app/api/lesson/render/route.ts lines 18, 40), not a LessonRenderJob. The cast (await res.json()) as LessonRenderJob is unsafe and reading data.error here happens to work only because TS doesn't enforce runtime shape. Tightening:

-      const data = (await res.json()) as LessonRenderJob;
-      if (!res.ok) {
-        const message = data.error || "Failed to render lesson video";
+      const data = (await res.json().catch(() => ({}))) as Partial<LessonRenderJob> & { error?: string };
+      if (!res.ok) {
+        const message = data.error || "Failed to render lesson video";

Then narrow further before setRenderJob(data as LessonRenderJob) in the success branch.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/dashboard/page.tsx` around lines 361 - 381, When handling the fetch
response, don't cast the error branch response to LessonRenderJob; instead parse
the JSON into a narrower error shape (e.g. { error?: string }) when !res.ok, use
that error message to populate setRenderJob and setLessonUiError, and only in
the success branch validate/narrow the parsed body before calling
setRenderJob(data as LessonRenderJob). Update the code around res.json() usage
so the error path uses a separate variable/type for the {error} payload and the
success path performs a runtime shape check (or optional property checks) on the
parsed body before treating it as a LessonRenderJob in setRenderJob.

433-449: Cleanup effect doesn't null out audio refs and runs only on unmount.

Two small issues with this unmount cleanup:

  • narrationAudioRef.current and narrationObjectUrlRef.current are paused/revoked but never set to null, so any later code that checks if (narrationAudioRef.current) would still see a stale reference (in practice cleanup runs at unmount so this is benign, but is brittle if the effect is ever reused).
  • The empty dependency array means this only fires at unmount — narration started right before navigation away is fine, but the elapsed timer at lines 451-457 is the one doing real work per render. No change required there; just flagging the consistency.
       if (narrationAudioRef.current) {
         narrationAudioRef.current.pause();
         narrationAudioRef.current.currentTime = 0;
+        narrationAudioRef.current = null;
       }
       if (narrationObjectUrlRef.current) {
         URL.revokeObjectURL(narrationObjectUrlRef.current);
+        narrationObjectUrlRef.current = null;
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/dashboard/page.tsx` around lines 433 - 449, In the useEffect cleanup
(the useEffect shown) make the cleanup null-safe by, after clearing the interval
(pollTimerRef.current) and cancelling speechSynthesis, pausing and resetting
narrationAudioRef.current and then setting narrationAudioRef.current = null, and
after URL.revokeObjectURL(narrationObjectUrlRef.current) set
narrationObjectUrlRef.current = null; keep the empty dependency array as-is but
ensure you reference the existing refs (pollTimerRef, narrationAudioRef,
narrationObjectUrlRef) so later code won’t encounter stale non-null references.
src/lib/dataStore.ts (1)

110-220: Keyword matching is too broad and stomps on unrelated tasks.

t.includes("github"), t.includes("security"), t.includes("environment"), etc. match well beyond the intended task type. A task titled "Document our security policy" or "Audit GitHub repositories for stale branches" will silently inherit the generic "complete security training" / "get GitHub access" playbook on first read of getTasks(), overwriting any author-provided description (see also the related issue in ensureStructuredTaskDescription).

Consider tightening to whole-word + intent matching, e.g. require both an action verb and the keyword (/\b(set\s*up|enable|complete|onboard).*\b(security|2fa)\b/), or only apply presets when sourceTitle matches the canonical seed sources. As per coding guidelines, this is a recommended refactor for correctness of normalization, not a stylistic nit.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/dataStore.ts` around lines 110 - 220, presetTaskPlaybook uses broad
substring checks (t.includes("github"), etc.) that match unrelated titles;
replace each t.includes(...) branch with stricter intent+keyword regex checks or
gate by canonical sourceTitle seeds: update the function presetTaskPlaybook to
use patterns like /\b(setup|set
up|enable|install|complete|onboard|join|get|configure).*\b(github)\b/i (and
analogous patterns for security, slack, hr, environment) or require sourceTitle
to equal known seeds before returning a preset, and keep the same return
payloads for the matching branches (preserve the existing blocks for "hr
profile", "slack", "github", "local dev"/"environment", "security").
src/lib/prompts.ts (1)

30-46: Prompt contract marks confidence, limitedSources, sourcesUsed as required, but the TypeScript type marks them optional.

The JSON schema described to the model lists confidence, limitedSources, and sourcesUsed as non-optional fields, yet Lesson in src/lib/types.ts (lines 80-83) declares them as optional with ?:. The dashboard at src/app/dashboard/page.tsx renders defensively (lesson?.confidence ?), so this is non-blocking, but the schema/contract drift will haunt later consumers. Consider either:

  • Marking them required in Lesson (and validating after parsing in /api/lesson/route.ts), or
  • Annotating them as optional in this prompt schema ("confidence"?: ...) so the model knows they may be omitted.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/prompts.ts` around lines 30 - 46, The prompt schema in
src/lib/prompts.ts says confidence, limitedSources, and sourcesUsed are required
but the TypeScript Lesson type (Lesson in src/lib/types.ts) marks them optional;
pick one resolution: either make those fields required on the Lesson type
(remove the ? for confidence, limitedSources, sourcesUsed) and add runtime
validation in /api/lesson/route.ts to assert presence after parsing, or update
the prompt schema in src/lib/prompts.ts to mark "confidence"?,
"limitedSources"?, and "sourcesUsed"? as optional so the model may omit them;
update any consumers such as src/app/dashboard/page.tsx to match the chosen
contract (remove defensive checks if making required or keep them if optional).
Ensure the chosen change is consistent across Lesson, the prompt schema, and the
API validation.
src/lib/lessonRenderStore.ts (2)

24-26: Prefer crypto.randomUUID() for job IDs.

Date.now() + 6 base36 chars from Math.random() is collision-prone under burst load and is not cryptographically random. Since the id is also user-visible (URL path param), a UUID is a cleaner default.

-import { promises as fs } from "fs";
+import { promises as fs } from "fs";
+import { randomUUID } from "crypto";
@@
-function makeJobId() {
-  return `lesson-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
-}
+function makeJobId() {
+  return `lesson-${randomUUID()}`;
+}

(If you adopt this, also update the JOB_ID_RE regex from the path-traversal comment.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/lessonRenderStore.ts` around lines 24 - 26, Replace the
collision-prone makeJobId implementation with a UUID-based id by using
crypto.randomUUID() inside makeJobId (e.g., return crypto.randomUUID()); also
update the JOB_ID_RE used for validating path params to accept UUID v4 format
(adjust the regex to match typical UUID hex groups with hyphens) so routes and
path-traversal checks remain correct when job IDs are UUIDs.

60-73: Read-modify-write is not atomic.

updateLessonRenderJob reads, mutates, and writes without any locking. If two concurrent updates ever land on the same job (e.g. a future progress callback alongside a status transition), one update will silently overwrite the other. For now the renderer is single-writer per job, so this is mostly future-proofing — consider an atomic "write to temp + fs.rename" pattern at minimum so a crash mid-write doesn't leave a truncated JSON file that getLessonRenderJob then permanently treats as missing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/lessonRenderStore.ts` around lines 60 - 73, The updateLessonRenderJob
function performs a non-atomic read-modify-write which can be corrupted by
concurrent updates or crashes; change updateLessonRenderJob to write to a
temporary file (e.g., jobPath(jobId) + ".tmp" or use a unique temp name),
fs.writeFile the JSON there, fsync the temp file if available, then atomically
replace the original with fs.rename (or fs.promises.rename) and finally return
the updated object; keep getLessonRenderJob and jobPath names as-is and ensure
error handling cleans up the temp file on failure to avoid leaving
partial/truncated JSON files.
src/lib/lessonVideoRenderer.ts (3)

79-86: which is not portable; use command -v or just probe say directly.

which is not guaranteed on every Linux distro and is absent on Windows. Since say is the only consumer here and it's macOS-only anyway, you could just try { execFileAsync("say", ["-v", "?"]) } or skip the helper entirely. Low priority since the ElevenLabs path is the primary one.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/lessonVideoRenderer.ts` around lines 79 - 86, The commandExists
helper currently uses the non-portable "which"; update commandExists (or remove
it and inline its use) to probe the actual consumer by attempting to run "say"
directly: replace await execFileAsync("which", [command]) with a direct probe
like await execFileAsync("say", ["-v", "?"]) (or change callers to try
execFileAsync("say", ["-v", "?"]) and handle failure), ensuring you import/keep
execFileAsync usage and update the function name/usage accordingly so detection
works on macOS without relying on "which".

311-343: LGTM on the orchestration, with two notes.

Slide loop, fallbacks, and the clips.length === 0 guard look right. Two small things to consider:

  • Slides are rendered serially; for a 10-slide lesson with ElevenLabs that's noticeable latency. Parallelizing per-slide work (with a small concurrency cap) would dramatically shorten wall time without changing correctness, since each slide writes to distinct files.
  • If concatSlideClips throws, all the per-slide artifacts under assetDir remain. Wrapping the body in try/finally to rm the asset dir on success (keeping it on failure for debugging) would keep .runbook-data/ from growing unboundedly.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/lessonVideoRenderer.ts` around lines 311 - 343, renderLessonVideo
currently renders slides serially and never cleans up per-slide artifacts on
success; update it to run per-slide work in parallel with a bounded concurrency
(e.g., use a p-limit or a worker pool) so the per-slide operations
(writeSlideTextFile, renderSlideClip, synthesizeSlideSpeech, muxClipWithAudio)
for each index i can run concurrently but still write to distinct files in
assetDir, then collect resulting clip paths into clips; also wrap the
orchestration (the per-slide processing + concatSlideClips call) in a
try/finally so that after concatSlideClips succeeds you remove the assetDir
(renderAssetDir/job-specific folder) but on error you leave the directory intact
for debugging. Ensure you keep existing behavior when audio is missing (push
baseClipPath) and preserve the existing clips.length guard in renderLessonVideo.

213-276: Intermediate audio files (mp3, aiff) are never cleaned up.

speech-N.mp3 and speech-N.aiff accumulate under each job's asset directory along with clip-*.mp4 / clip-narrated-*.mp4 and the concat list. Over many renders this fills the disk on the host. Consider fs.unlink after the WAV is produced, and a job-completion sweep of assetDir once outputPath exists.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/lessonVideoRenderer.ts` around lines 213 - 276, synthesizeSlideSpeech
leaves intermediate files (speech-N.mp3 / .aiff) behind; after successfully
writing the WAV, delete the intermediate file(s) (use fs.unlink or fs.rm wrapped
in try/catch) in synthesizeSlideSpeech (references: synthesizeSlideSpeech,
fs.writeFile, execFileAsync, commandExists) so mp3/aiff are removed whether
ElevenLabs or local "say" path was used; additionally add a short job-completion
cleanup routine that runs once the final outputPath exists to sweep any
remaining speech-*.mp3/.aiff in assetDir (handle and log unlink errors, avoid
throwing) to prevent disk accumulation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/app/api/lesson/render/`[jobId]/video/route.ts:
- Around line 18-20: The catch block in the video route currently returns a 404
for every error; change it to log the caught error (using the same logger
approach as the sibling [jobId]/route.ts) and only map ENOENT to
NextResponse.json({ error: "Rendered video not found" }, { status: 404 });
otherwise return a 500 response (e.g., NextResponse.json({ error: "Internal
server error" }, { status: 500 })); identify the catch by the catch block in
src/app/api/lesson/render/[jobId]/video/route.ts and update error handling to
inspect err.code === "ENOENT" and log the full error before returning the
response.
- Around line 9-11: The handler uses jobId from context.params directly in
renderOutputPath which allows path traversal; validate jobId (e.g., allow only a
strict pattern like /^[A-Za-z0-9_-]+$/ or your project’s canonical ID format)
and return 400/404 on mismatch, then build the file path with renderOutputPath
and immediately resolve it (path.resolve) and assert the resolved path starts
with path.resolve(OUTPUT_DIR) + path.sep (or equivalent) before calling
fs.readFile; reference the jobId param, the renderOutputPath call, and
OUTPUT_DIR for where to add these checks and the post-join verification.
- Around line 11-17: The current handler reads the entire file with fs.readFile
and returns a full-body NextResponse which can OOM and prevents seeking; replace
that with range-aware streaming: read the incoming "range" header, parse
start/end against fs.stat(outputPath).size, validate ranges and on valid partial
requests return status 206 with headers Accept-Ranges: bytes, Content-Range,
Content-Length and Content-Type, and stream the requested byte slice using
fs.createReadStream(outputPath, { start, end }) (on full requests return 200
with a stream and Accept-Ranges: bytes); keep the existing content-disposition
using jobId and ensure invalid ranges return 416 with appropriate Content-Range.
Use the existing symbols outputPath, jobId, fs.readFile (remove),
fs.createReadStream, NextResponse, and the request.headers.get('range') to
locate where to implement this change.

In `@src/app/api/lesson/render/route.ts`:
- Around line 14-21: The POST handler currently accepts any payload and
immediately calls createLessonRenderJob, which allows unauthenticated/unchecked
abuse; before spawning work call requireHireAccess(body.hireId) (same gate used
by /api/chat) and return 403/401 on failure, and add validation on the lesson
payload (e.g., max slides count and total serialized length) to reject oversized
lessons with 400; additionally throttle concurrent renders by checking/using a
job concurrency limiter or queue (wrap or gate createLessonRenderJob with a
semaphore/queue and reject with 429 when limit reached) so heavy
ffmpeg/ElevenLabs work is capped. Ensure you reference POST,
createLessonRenderJob, and requireHireAccess when implementing these checks and
return appropriate NextResponse statuses for auth/size/concurrency failures.
- Around line 22-35: The fire-and-forget IIFE is unreliable on serverless;
replace it with Next.js's after() background response hook (or integrate an
external queue) so work runs reliably after sending the response: move the logic
that calls setLessonRenderStatus, renderLessonVideo, and the final
setLessonRenderStatus into an after(() => { ... }) callback (or enqueue job and
return immediately), and ensure any errors from setLessonRenderStatus or
renderLessonVideo are caught and logged and the job status is reconciled to
"failed" with the error message (use error instanceof Error ? error.message :
String(error)) so no unhandled rejections leave jobs stuck.

In `@src/app/api/lesson/route.ts`:
- Around line 60-71: The fallback narration currently uses raw normalizedSlides,
so any cleaning/framing done by the slides mapping (asWalkthroughBody,
cleanLessonText, default titles, slice to 14) is not applied; fix by computing
the mapped slide array once (e.g. const slidesNormalized =
normalizedSlides.slice(0,14).map(... ) using the same mapping that calls
asWalkthroughBody and cleanLessonText) and then set slides: slidesNormalized and
narrationScript: lesson.narrationScript || slidesNormalized.map(s =>
`${s.title}. ${s.body}`).join("\n"); this ensures narrationScript uses the
cleaned/framed slide text.
- Around line 281-291: The current logic only calls requireHireAccess(hireId)
when hireId is present, allowing unauthenticated requests that omit hireId to
proceed to buildLessonContext(...) and generateFromGemini(), which likely burns
API quota; move the authentication check to apply unconditionally or explicitly
gate the no-hireId path. Concretely, call requireHireAccess(hireId) (or a new
check that enforces RUNBOOK_ALLOW_UNAUTH_HIRE_ACCESS semantics from apiAuth.ts)
before the if (hireId) branch or add an explicit unauthenticated-path guard that
returns a 401/403 like the existing auth error handling, keeping the same
response shape and using the same auth.ok / auth.status logic so
buildLessonContext(...) and generateFromGemini(...) are only reached when
authorized.
- Around line 36-46: The numbered-steps logic uses remaining.replace(/\n/g,
"\n2. ") which hardcodes the "2." label; replace that with proper enumeration by
splitting remaining on '\n' and mapping each line to a numbered prefix that
increments (e.g. remaining.split('\n').map((line,i) => `${i+2}.
${line}`).join('\n')) and use that string in place of the current template;
update the code that builds the "Steps:" block (the return block referencing
remaining and the replace call) to use this mapped/joined value so subsequent
steps are numbered 2., 3., 4., ... correctly.

In `@src/app/api/tasks/route.ts`:
- Around line 20-23: The current early-return in the helper that checks if
(/objective:|steps:|verification:|if blocked:/i.test(description)) can falsely
trigger on inline mentions and causes silently discarding explicit structured
fields; update the condition in the function that processes the incoming
description (the block using the description variable in
src/app/api/tasks/route.ts) to only short-circuit when no structured form fields
were provided (e.g., objective, steps, verification, escalation) OR make the
regex anchor to line starts using a multiline-aware pattern (e.g., match
/^\s*(objective:|steps:|verification:|if blocked:)/im) so the helper keeps
returning raw description only when it truly contains section headers and no
explicit structured fields were sent.

In `@src/app/api/tts/route.ts`:
- Around line 13-40: The route handler lacks auth and hardening: add the same
access check used in src/app/api/chat/route.ts (call requireHireAccess at the
start of the POST handler) to prevent unauthenticated usage; add an upstream
timeout to the fetch by using AbortSignal.timeout(...) (matching the per-attempt
timeouts in src/lib/ai.ts) and pass the signal to fetch; validate the incoming
text length before calling text.slice(0, 4500) and return a 400 JSON via
NextResponse.json when the client-supplied text exceeds your allowed limit
instead of silently truncating; and when handling non-OK responses from
ElevenLabs, avoid returning the raw upstream body — log the err server-side and
return a generic error message (keep the existing NextResponse.json({ error: ...
}, { status: 502 }) pattern but with a sanitized message).

In `@src/app/dashboard/page.tsx`:
- Around line 324-347: pollRenderJob currently always schedules
window.setInterval after awaiting tick(), causing extra polls when the job was
already terminal; modify pollRenderJob so that after await tick() you only set
pollTimerRef.current = window.setInterval(...) if the tick determined the job is
still non-terminal (e.g., check the returned/updated data.status or a boolean
from tick), and update tick to return the latest LessonRenderJob (or a terminal
flag) so you can gate scheduling; additionally add a currentJobIdRef (or
similar) that you set at the start of pollRenderJob and have tick ignore or
avoid calling setRenderJob when its jobId doesn't match currentJobIdRef to
prevent stale ticks from clobbering newer renders, and ensure pollTimerRef is
cleared whenever a terminal status is observed.

In `@src/app/manager/tasks/page.tsx`:
- Around line 342-374: The client textarea bound to form.objective is marked
required (the element with value={form.objective} and onChange updating
form.objective via setForm), but the API only enforces title && (description ||
objective); to resolve the contract drift either remove the required attribute
from the objective textarea so the client matches the backend, or change the
server-side validation to require objective as well (make the API check require
title && objective instead of title && (description || objective)); update
whichever side you choose (client: remove required on the objective textarea;
server: tighten validation in the route that performs title && (description ||
objective)) so both client and API behavior are consistent.

In `@src/lib/ai.ts`:
- Around line 55-101: The inner catch currently throws on per-attempt failures
which aborts the outer models loop; instead, capture the error into
lastGeminiError and skip to the next model so fallback models are tried. In the
catch inside the for (let attempt...) block, replace the throw with logic that
sets lastGeminiError (e.g., stringify error or await response?.text()), breaks
out of the attempt loop, and then continue the outer for (const model of models)
loop (or use a flag/label) so generateFromGemini will try the next model from
models; keep clearing the timeoutId and preserving the response checks that
follow.

In `@src/lib/dataStore.ts`:
- Around line 222-252: ensureStructuredTaskDescription currently returns a
preset playbook unconditionally when presetTaskPlaybook(title, "") finds a
match, which overwrites users' already-structured descriptions; change the logic
in ensureStructuredTaskDescription so it first checks if the provided
description is already structured (use the same
/Objective:|Steps:|Verification:|If blocked:/i test) and returns the raw
description unchanged when structured, and only apply presetTaskPlaybook(title,
"") when the description is empty/unstructured; update the function (referencing
ensureStructuredTaskDescription and presetTaskPlaybook) so normalizeTask callers
get preserved user content instead of being silently replaced by the preset.

In `@src/lib/lessonRenderStore.ts`:
- Around line 51-58: In getLessonRenderJob, don't swallow all errors—only treat
ENOENT as "missing"; change the catch to inspect the thrown error (e.g.,
err.code === "ENOENT") and return null in that case, but rethrow or log and
rethrow other errors (JSON.parse failures, permission errors, etc.) so callers
can return 500s and on-disk corruption isn't hidden; reference
getLessonRenderJob, jobPath and LessonRenderJob when locating the read/parse
block to implement this conditional error handling.
- Around line 20-34: jobId is interpolated directly into paths in jobPath,
renderOutputPath, and renderAssetDir which allows path traversal from URL
params; fix by validating or normalizing jobId before building paths: enforce
the expected pattern (e.g. ^lesson-\d+-[a-z0-9]+$ matching makeJobId) in a
shared helper (e.g. validateJobId) used by
jobPath/renderOutputPath/renderAssetDir (and the callers in route handlers like
src/app/api/lesson/render/[jobId]/route.ts and .../video/route.ts), or resolve
the final path and assert it is inside the intended root (reject/throw if
path.resolve(ROOT, ...) !== pathNormalized or !pathNormalized.startsWith(ROOT));
ensure any invalid jobId results in an error response rather than reading
arbitrary files.

In `@src/lib/lessonVideoRenderer.ts`:
- Around line 224-244: The ElevenLabs fetch in lessonVideoRenderer.ts currently
has no timeout and can hang; fix it by using an AbortController: create an
AbortController before calling fetch, set a timer (e.g., 30s) to call
controller.abort(), pass controller.signal into the fetch options where voiceId,
elevenLabsKey, modelId and body are used, and clear the timer after the fetch
completes; also catch an abort (AbortError) from the fetch/response handling and
throw a clear error so the render job transitions to failed instead of hanging.
- Around line 132-145: The drawtext filter built in lessonVideoRenderer.ts
(inside the useDrawtext branch where relativeTextPath is derived from textPath
and the drawtext array is constructed) must include expansion=none to prevent
ffmpeg from interpreting %{} sequences and backslash escapes in the textfile;
update the drawtext array construction (the variable named drawtext) to add
"expansion=none" as one of the colon-separated filter options so the text is
rendered verbatim.
- Around line 194-211: concatSlideClips fails because inputs mix video-only
files from renderSlideClip with audio-containing files from muxClipWithAudio;
modify the pipeline so every clip has identical streams before calling concat:
in the code path where synthesizeSlideSpeech returns null (or when a clip is
video-only), invoke muxClipWithAudio (or a new helper) to attach a short silent
AAC track (e.g., generate via ffmpeg anullsrc or a precreated silent.aac) to the
video clip so its stream layout matches narrated clips, then pass those
uniformly audio+video files into concatSlideClips; reference functions:
concatSlideClips, renderSlideClip, muxClipWithAudio, synthesizeSlideSpeech.

---

Nitpick comments:
In `@README.md`:
- Around line 48-59: Update README.md to mention ffmpeg install commands for
Linux and Windows in addition to macOS: add brief instructions or examples such
as apt-based install for Debian/Ubuntu and Chocolatey for Windows so non-macOS
contributors know how to install ffmpeg; ensure the text still references the
MP4 export requirement and the existing endpoints (POST /api/lesson/render, GET
/api/lesson/render/:jobId, GET /api/lesson/render/:jobId/video) and the storage
location .runbook-data/lesson-renders/ (which corresponds to renderOutputPath in
src/lib/lessonRenderStore.ts).

In `@src/app/api/tasks/route.ts`:
- Around line 32-40: The current normalization preserves user-supplied numeric
prefixes (using /^\d+\./) which can leave steps out-of-order; update the
normalizedSteps logic so every line is renumbered sequentially: in the chain
that builds normalizedSteps (operating on steps), strip any existing leading
numeric prefix from each line (e.g., remove /^\d+\.\s*/), then always prepend
`${idx + 1}. ` to the cleaned line before joining; reference the variables
normalizedSteps, steps and parts to locate and replace the existing map() that
conditionally keeps /^\d+\./.

In `@src/app/dashboard/page.tsx`:
- Around line 361-381: When handling the fetch response, don't cast the error
branch response to LessonRenderJob; instead parse the JSON into a narrower error
shape (e.g. { error?: string }) when !res.ok, use that error message to populate
setRenderJob and setLessonUiError, and only in the success branch
validate/narrow the parsed body before calling setRenderJob(data as
LessonRenderJob). Update the code around res.json() usage so the error path uses
a separate variable/type for the {error} payload and the success path performs a
runtime shape check (or optional property checks) on the parsed body before
treating it as a LessonRenderJob in setRenderJob.
- Around line 433-449: In the useEffect cleanup (the useEffect shown) make the
cleanup null-safe by, after clearing the interval (pollTimerRef.current) and
cancelling speechSynthesis, pausing and resetting narrationAudioRef.current and
then setting narrationAudioRef.current = null, and after
URL.revokeObjectURL(narrationObjectUrlRef.current) set
narrationObjectUrlRef.current = null; keep the empty dependency array as-is but
ensure you reference the existing refs (pollTimerRef, narrationAudioRef,
narrationObjectUrlRef) so later code won’t encounter stale non-null references.

In `@src/components/ui/ChatMessageBody.tsx`:
- Around line 30-38: The prop type for ChatMessageBody is currently too broad
(text: unknown) and the normalization with normalizedText can produce "[object
Object]" for arrays of objects; change the public prop signature on
ChatMessageBody to a narrower union (e.g. string | string[] | null | undefined)
and update the normalization logic to handle array entries safely by mapping
items to strings (using JSON.stringify for objects, otherwise String(item))
before joining, leaving role and blocks logic unchanged.

In `@src/lib/dataStore.ts`:
- Around line 110-220: presetTaskPlaybook uses broad substring checks
(t.includes("github"), etc.) that match unrelated titles; replace each
t.includes(...) branch with stricter intent+keyword regex checks or gate by
canonical sourceTitle seeds: update the function presetTaskPlaybook to use
patterns like /\b(setup|set
up|enable|install|complete|onboard|join|get|configure).*\b(github)\b/i (and
analogous patterns for security, slack, hr, environment) or require sourceTitle
to equal known seeds before returning a preset, and keep the same return
payloads for the matching branches (preserve the existing blocks for "hr
profile", "slack", "github", "local dev"/"environment", "security").

In `@src/lib/lessonRenderStore.ts`:
- Around line 24-26: Replace the collision-prone makeJobId implementation with a
UUID-based id by using crypto.randomUUID() inside makeJobId (e.g., return
crypto.randomUUID()); also update the JOB_ID_RE used for validating path params
to accept UUID v4 format (adjust the regex to match typical UUID hex groups with
hyphens) so routes and path-traversal checks remain correct when job IDs are
UUIDs.
- Around line 60-73: The updateLessonRenderJob function performs a non-atomic
read-modify-write which can be corrupted by concurrent updates or crashes;
change updateLessonRenderJob to write to a temporary file (e.g., jobPath(jobId)
+ ".tmp" or use a unique temp name), fs.writeFile the JSON there, fsync the temp
file if available, then atomically replace the original with fs.rename (or
fs.promises.rename) and finally return the updated object; keep
getLessonRenderJob and jobPath names as-is and ensure error handling cleans up
the temp file on failure to avoid leaving partial/truncated JSON files.

In `@src/lib/lessonVideoRenderer.ts`:
- Around line 79-86: The commandExists helper currently uses the non-portable
"which"; update commandExists (or remove it and inline its use) to probe the
actual consumer by attempting to run "say" directly: replace await
execFileAsync("which", [command]) with a direct probe like await
execFileAsync("say", ["-v", "?"]) (or change callers to try execFileAsync("say",
["-v", "?"]) and handle failure), ensuring you import/keep execFileAsync usage
and update the function name/usage accordingly so detection works on macOS
without relying on "which".
- Around line 311-343: renderLessonVideo currently renders slides serially and
never cleans up per-slide artifacts on success; update it to run per-slide work
in parallel with a bounded concurrency (e.g., use a p-limit or a worker pool) so
the per-slide operations (writeSlideTextFile, renderSlideClip,
synthesizeSlideSpeech, muxClipWithAudio) for each index i can run concurrently
but still write to distinct files in assetDir, then collect resulting clip paths
into clips; also wrap the orchestration (the per-slide processing +
concatSlideClips call) in a try/finally so that after concatSlideClips succeeds
you remove the assetDir (renderAssetDir/job-specific folder) but on error you
leave the directory intact for debugging. Ensure you keep existing behavior when
audio is missing (push baseClipPath) and preserve the existing clips.length
guard in renderLessonVideo.
- Around line 213-276: synthesizeSlideSpeech leaves intermediate files
(speech-N.mp3 / .aiff) behind; after successfully writing the WAV, delete the
intermediate file(s) (use fs.unlink or fs.rm wrapped in try/catch) in
synthesizeSlideSpeech (references: synthesizeSlideSpeech, fs.writeFile,
execFileAsync, commandExists) so mp3/aiff are removed whether ElevenLabs or
local "say" path was used; additionally add a short job-completion cleanup
routine that runs once the final outputPath exists to sweep any remaining
speech-*.mp3/.aiff in assetDir (handle and log unlink errors, avoid throwing) to
prevent disk accumulation.

In `@src/lib/prompts.ts`:
- Around line 30-46: The prompt schema in src/lib/prompts.ts says confidence,
limitedSources, and sourcesUsed are required but the TypeScript Lesson type
(Lesson in src/lib/types.ts) marks them optional; pick one resolution: either
make those fields required on the Lesson type (remove the ? for confidence,
limitedSources, sourcesUsed) and add runtime validation in /api/lesson/route.ts
to assert presence after parsing, or update the prompt schema in
src/lib/prompts.ts to mark "confidence"?, "limitedSources"?, and "sourcesUsed"?
as optional so the model may omit them; update any consumers such as
src/app/dashboard/page.tsx to match the chosen contract (remove defensive checks
if making required or keep them if optional). Ensure the chosen change is
consistent across Lesson, the prompt schema, and the API validation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f0e735c7-c524-4f8d-abc2-b5bea1cc0c12

📥 Commits

Reviewing files that changed from the base of the PR and between e36eb35 and 13bf699.

📒 Files selected for processing (20)
  • .env.example
  • README.md
  • src/app/api/chat/route.ts
  • src/app/api/lesson/render/[jobId]/route.ts
  • src/app/api/lesson/render/[jobId]/video/route.ts
  • src/app/api/lesson/render/route.ts
  • src/app/api/lesson/route.ts
  • src/app/api/tasks/route.ts
  • src/app/api/tts/route.ts
  • src/app/dashboard/page.tsx
  • src/app/layout.tsx
  • src/app/manager/tasks/page.tsx
  • src/components/ui/ChatMessageBody.tsx
  • src/lib/ai.ts
  • src/lib/dataStore.ts
  • src/lib/demoTasks.ts
  • src/lib/lessonRenderStore.ts
  • src/lib/lessonVideoRenderer.ts
  • src/lib/prompts.ts
  • src/lib/types.ts

Comment thread src/app/api/lesson/render/[jobId]/video/route.ts Outdated
Comment thread src/app/api/lesson/render/[jobId]/video/route.ts Outdated
Comment thread src/app/api/lesson/render/[jobId]/video/route.ts Outdated
Comment thread src/app/api/lesson/render/route.ts
Comment thread src/app/api/lesson/render/route.ts Outdated
Comment thread src/lib/lessonRenderStore.ts
Comment thread src/lib/lessonRenderStore.ts
Comment thread src/lib/lessonVideoRenderer.ts
Comment thread src/lib/lessonVideoRenderer.ts
Comment thread src/lib/lessonVideoRenderer.ts Outdated
Address CodeRabbit findings by securing render/tts endpoints, validating and atomically storing render jobs, adding range-based video streaming, and tightening dashboard/task normalization behavior.

Made-with: Cursor
PR #14 referenced buildLessonContext, normalizeLesson, and related helpers after a merge dropped their definitions. Restore the complete lesson API implementation so TypeScript and CI builds succeed.

Made-with: Cursor
@dfed25
dfed25 merged commit 4b686f4 into main Apr 25, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant