Skip to content

feat: Allow actions to recue the next part#1797

Draft
PeterC89 wants to merge 3 commits into
Sofie-Automation:mainfrom
bbc:feat/recue-next-part
Draft

feat: Allow actions to recue the next part#1797
PeterC89 wants to merge 3 commits into
Sofie-Automation:mainfrom
bbc:feat/recue-next-part

Conversation

@PeterC89

Copy link
Copy Markdown
Contributor

About the Contributor

This pull request is posted on behalf of the BBC."

Type of Contribution

This is a: Feature

Current Behavior

You can't recue a part via an action to clean any state changes easily.

New Behavior

Actions can request that the next part be recued.

Testing

  • I have added one or more unit tests for this PR
  • I have updated the relevant unit tests
  • No unit test changes are needed for this PR

Affected areas

This PR affects blueprints actions

Time Frame

Not urgent, but we would like to get this merged into the in-development release.

Other Information

Status

  • PR is ready to be reviewed.
  • The functionality has been tested by the author.
  • Relevant unit tests has been added / updated.
  • Relevant documentation (code comments, system documentation) has been added / updated.

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The change adds snapshot-based recue support for the next playout part, exposes it through action contexts, restores the snapshot during execution, and reselects the part afterward so next-part callbacks run again.

Changes

Recue Next Part

Layer / File(s) Summary
Next-part snapshot capture and restoration
packages/job-worker/src/playout/model/..., packages/job-worker/src/playout/setNext.ts
The playout model stores and restores per-instance next-part snapshots, while next-part selection captures the initial snapshot.
Action recue context contract
packages/blueprints-integration/src/context/playoutActionContext.ts, packages/job-worker/src/blueprints/context/adlibActions.ts, packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts
Action contexts expose recueNextPart(), reset next-part state, and mark execution for post-action reselection.
Post-action next-part reselection
packages/job-worker/src/playout/adlibAction.ts, packages/job-worker/src/playout/__tests__/playout-executeAction.test.ts
Execution side effects consume the recue flag and reselect the restored next part through setNextPartFromPart.
Estimated code review effort: 3 (Moderate) ~20 minutes

Suggested labels: ✨ enhancement, Contribution from BBC

Suggested reviewers: julusian, nytamin

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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
Title check ✅ Passed The title clearly matches the main change: adding action support to recue the next part.
Description check ✅ Passed The description is directly related to the feature and test updates in this changeset.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@codecov

codecov Bot commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.15789% with 9 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...del/implementation/PlayoutPartInstanceModelImpl.ts 90.00% 5 Missing ⚠️
.../job-worker/src/blueprints/context/adlibActions.ts 83.33% 2 Missing ⚠️
packages/job-worker/src/playout/adlibAction.ts 81.81% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@PeterC89

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@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: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/job-worker/src/blueprints/context/adlibActions.ts`:
- Around line 239-249: Update recueNextPart in
packages/job-worker/src/blueprints/context/adlibActions.ts at lines 239-249 to
set forceRegenerateTimeline to true after restoring the next part state. In
packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts at
lines 146-158, add an assertion that context.forceRegenerateTimeline is true.

In `@packages/job-worker/src/playout/adlibAction.ts`:
- Around line 321-330: Replace setNextPartFromPart with setNextPart in the
recueAfterExecute branch of
packages/job-worker/src/playout/adlibAction.ts#L321-L330, passing
nextPartInstance directly, and update the import at
packages/job-worker/src/playout/adlibAction.ts#L36-L36. In
packages/job-worker/src/playout/__tests__/playout-executeAction.test.ts#L206-L227,
assert setNextPartMock is called once; at `#L13-L17` mock setNextPart from
SetNextApi, and at `#L49` clear setNextPartMock in beforeEach.

In
`@packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts`:
- Around line 237-252: Update snapshotRestore to preserve tracking for current
piece instances absent from the snapshot: before restoring snapshot entries,
mark those existing IDs in pieceInstancesImpl as null instead of clearing the
map, then apply the snapshot entries as currently done. Also set
this.#partInstanceHasChanges = true so the reverted state is persisted.
- Around line 42-44: The rescue snapshot map in PlayoutPartInstanceModelImpl
must not remain process-local without lifecycle management. Persist each rescue
snapshot with its associated part instance, or add guaranteed cleanup whenever
the next-part state changes so stale entries are evicted; ensure rescue handling
can restore state after worker handoff or restart.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5446f1e8-7a5d-4524-ab77-8604411a626c

📥 Commits

Reviewing files that changed from the base of the PR and between 10db30e and ee0da42.

📒 Files selected for processing (9)
  • packages/blueprints-integration/src/context/playoutActionContext.ts
  • packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts
  • packages/job-worker/src/blueprints/context/adlibActions.ts
  • packages/job-worker/src/playout/__tests__/playout-executeAction.test.ts
  • packages/job-worker/src/playout/adlibAction.ts
  • packages/job-worker/src/playout/model/PlayoutPartInstanceModel.ts
  • packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts
  • packages/job-worker/src/playout/model/implementation/__tests__/PlayoutPartInstanceModelImpl.spec.ts
  • packages/job-worker/src/playout/setNext.ts

Comment on lines +239 to +249
async recueNextPart(): Promise<void> {
const nextPartInstance = this._playoutModel.nextPartInstance
if (!nextPartInstance) {
throw new Error('Cannot recue next part when no next part instance is set')
}

nextPartInstance.recueNextPart()
this.partAndPieceInstanceService.nextPartState = ActionPartChange.NONE
this.recueAfterExecute = true
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Force timeline regeneration to apply the recued state.

Since recueNextPart sets nextPartState to NONE, applyAnyExecutionSideEffects will skip generating the timeline unless explicitly forced. The timeline must be regenerated so that the playout gateway receives the restored piece states.

  • packages/job-worker/src/blueprints/context/adlibActions.ts#L239-L249: Set this.forceRegenerateTimeline = true at the end of the method.
  • packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts#L146-L158: Add expect(context.forceRegenerateTimeline).toBe(true) to the test assertions.
📍 Affects 2 files
  • packages/job-worker/src/blueprints/context/adlibActions.ts#L239-L249 (this comment)
  • packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts#L146-L158
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/job-worker/src/blueprints/context/adlibActions.ts` around lines 239
- 249, Update recueNextPart in
packages/job-worker/src/blueprints/context/adlibActions.ts at lines 239-249 to
set forceRegenerateTimeline to true after restoring the next part state. In
packages/job-worker/src/blueprints/__tests__/context-adlibActions.test.ts at
lines 146-158, add an assertion that context.forceRegenerateTimeline is true.

Comment on lines +321 to +330
if (actionContext.recueAfterExecute) {
actionContext.recueAfterExecute = false
const nextPartInstance = playoutModel.nextPartInstance
if (!nextPartInstance) {
throw new Error('Cannot recue next part when no next part instance is set')
}

await setNextPartFromPart(context, playoutModel, nextPartInstance.partInstance, true)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

Pass nextPartInstance directly to setNextPart to preserve in-memory changes.

setNextPartFromPart handles DBPartInstance by querying PieceInstances directly from the database. Because the playoutModel is not yet saved during action execution, this fetches stale pre-action data, entirely discarding the snapshot restoration performed by recueNextPart(). Passing the PlayoutPartInstanceModel directly to setNextPart preserves the in-memory changes.

  • packages/job-worker/src/playout/adlibAction.ts#L321-L330: Use await setNextPart(context, playoutModel, nextPartInstance, true) instead of setNextPartFromPart.
  • packages/job-worker/src/playout/adlibAction.ts#L36-L36: Import setNextPart instead of setNextPartFromPart.
  • packages/job-worker/src/playout/__tests__/playout-executeAction.test.ts#L206-L227: Update the test to assert expect(setNextPartMock).toHaveBeenCalledTimes(1).
  • packages/job-worker/src/playout/__tests__/playout-executeAction.test.ts#L13-L17: Change the spy to mock setNextPart from SetNextApi instead of setNextPartFromPart.
  • packages/job-worker/src/playout/__tests__/playout-executeAction.test.ts#L49-L49: Clear setNextPartMock instead of setNextPartFromPartMock in the beforeEach block.
📍 Affects 2 files
  • packages/job-worker/src/playout/adlibAction.ts#L321-L330 (this comment)
  • packages/job-worker/src/playout/adlibAction.ts#L36-L36
  • packages/job-worker/src/playout/__tests__/playout-executeAction.test.ts#L206-L227
  • packages/job-worker/src/playout/__tests__/playout-executeAction.test.ts#L13-L17
  • packages/job-worker/src/playout/__tests__/playout-executeAction.test.ts#L49-L49
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/job-worker/src/playout/adlibAction.ts` around lines 321 - 330,
Replace setNextPartFromPart with setNextPart in the recueAfterExecute branch of
packages/job-worker/src/playout/adlibAction.ts#L321-L330, passing
nextPartInstance directly, and update the import at
packages/job-worker/src/playout/adlibAction.ts#L36-L36. In
packages/job-worker/src/playout/__tests__/playout-executeAction.test.ts#L206-L227,
assert setNextPartMock is called once; at `#L13-L17` mock setNextPart from
SetNextApi, and at `#L49` clear setNextPartMock in beforeEach.

Comment on lines +42 to +44

const recueNextPartSnapshots = new Map<PartInstanceId, PlayoutPartInstanceModelSnapshot>()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check if recueNextPartSnapshot is ever explicitly cleared (set to undefined).

# Test: Search for assignments clearing the snapshot. Expect: Usage where it's cleared when a part is no longer next.
rg -A 2 'recueNextPartSnapshot\s*=\s*(undefined|null)' packages/job-worker/src/

Repository: Sofie-Automation/sofie-core

Length of output: 165


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the implementation around the map and its usages.
git ls-files packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts
wc -l packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts
ast-grep outline packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts --view expanded || true
printf '\n--- file excerpt ---\n'
sed -n '1,220p' packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts

printf '\n--- repo-wide search for snapshot identifiers ---\n'
rg -n "recueNextPartSnapshots|recueNextPartSnapshot|rescueNextPartSnapshot|nextPartSnapshots" packages/job-worker/src packages -S || true

Repository: Sofie-Automation/sofie-core

Length of output: 16420


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Look for lifecycle hooks that add/remove the snapshot from the map.
rg -n "Map<PartInstanceId, PlayoutPartInstanceModelSnapshot>|delete\\(|clear\\(|set\\(" packages/job-worker/src/playout -S || true

printf '\n--- related classes/functions around rescue/next-part logic ---\n'
rg -n "rescue|next part|nexted|nextPart" packages/job-worker/src/playout -S || true

Repository: Sofie-Automation/sofie-core

Length of output: 50383


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the target file and find the exact snapshot map definition and all references.
sed -n '1,220p' packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts

printf '\n--- references ---\n'
rg -n "recueNextPartSnapshots|recueNextPartSnapshot|snapshot" packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts packages/job-worker/src/playout -S

Repository: Sofie-Automation/sofie-core

Length of output: 28964


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Narrow search to the exact file and nearby code paths, plus references from the job-worker package.
file=packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts

printf '%s\n' '--- file outline ---'
ast-grep outline "$file" --view expanded || true

printf '\n%s\n' '--- excerpt ---'
sed -n '1,220p' "$file"

printf '\n%s\n' '--- references in package ---'
rg -n "recueNextPartSnapshots|recueNextPartSnapshot|rescueNextPartSnapshot|clear\\(|delete\\(" packages/job-worker/src -S || true

Repository: Sofie-Automation/sofie-core

Length of output: 18646


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Find where recueNextPart() is actually invoked and how the next-part snapshot is managed.
rg -n "\.recueNextPart\(" packages/job-worker/src -S || true
printf '\n--- setNext excerpt around snapshot assignment ---\n'
sed -n '150,230p' packages/job-worker/src/playout/setNext.ts
printf '\n--- PlayoutPartInstanceModel interface excerpt ---\n'
sed -n '1,120p' packages/job-worker/src/playout/model/PlayoutPartInstanceModel.ts

Repository: Sofie-Automation/sofie-core

Length of output: 8405


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the next-part lifecycle and any cleanup paths that could clear rescue snapshots.
sed -n '520,700p' packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts
printf '\n---\n'
sed -n '700,820p' packages/job-worker/src/playout/model/implementation/PlayoutModelImpl.ts
printf '\n--- search for explicit snapshot clearing ---\n'
rg -n "recueNextPartSnapshot\s*=\s*undefined|recueNextPartSnapshots\.delete|recueNextPartSnapshots\.clear|setNextPartSnapshot|setPartInstanceAsNext" packages/job-worker/src -S || true

Repository: Sofie-Automation/sofie-core

Length of output: 11856


Persist or evict the rescue snapshot
recueNextPartSnapshots is process-local and only shrinks on an explicit undefined assignment. If a part stops being nexted without clearing it, stale snapshots accumulate, and a rescue handled by another worker or after a restart can’t restore state. Persist this with the part instance or add a guaranteed cleanup path when next changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts`
around lines 42 - 44, The rescue snapshot map in PlayoutPartInstanceModelImpl
must not remain process-local without lifecycle management. Persist each rescue
snapshot with its associated part instance, or add guaranteed cleanup whenever
the next-part state changes so stale entries are evicted; ensure rescue handling
can restore state after worker handoff or restart.

Comment on lines 237 to 252
this.pieceInstancesImpl.clear()
for (const [pieceInstanceId, pieceInstance] of snapshot.pieceInstances) {
const pieceInstancesEntries =
snapshotImpl.pieceInstances instanceof Map
? snapshotImpl.pieceInstances.entries()
: Object.entries(snapshotImpl.pieceInstances)

for (const [pieceInstanceId, pieceInstance] of pieceInstancesEntries) {
if (pieceInstance) {
this.pieceInstancesImpl.set(
pieceInstanceId,
pieceInstanceId as PieceInstanceId,
new PlayoutPieceInstanceModelImpl(pieceInstance.PieceInstance, pieceInstance.HasChanges)
)
} else {
this.pieceInstancesImpl.set(pieceInstanceId, null)
this.pieceInstancesImpl.set(pieceInstanceId as PieceInstanceId, null)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

snapshotRestore will orphan newly inserted piece instances in the database.

Clearing the pieceInstancesImpl map completely drops tracking for any PieceInstance that was inserted after the snapshot was taken. Because the deleted piece is not marked with null in the map, changedPieceInstanceIds() will not report it, and the database sync will fail to delete it from the database. This will cause "rescued" parts to retain pieces that were supposed to be reverted.

Instead of .clear(), you must identify pieces that exist currently but are absent in the snapshot, and explicitly set them to null to ensure the database layer deletes them. Also, restoring the snapshot should ideally force this.#partInstanceHasChanges = true to guarantee the reverted state is persisted.

🐛 Proposed fix for snapshot restoration
-		this.#partInstanceHasChanges = snapshotImpl.partInstanceHasChanges
-		this.pieceInstancesImpl.clear()
+		this.#partInstanceHasChanges = true
+
+		// Mark all current pieces as null (deleted) first, so that pieces not in the snapshot are deleted from the DB
+		for (const id of this.pieceInstancesImpl.keys()) {
+			this.pieceInstancesImpl.set(id, null)
+		}
+
 		const pieceInstancesEntries =
 			snapshotImpl.pieceInstances instanceof Map
 				? snapshotImpl.pieceInstances.entries()
 				: Object.entries(snapshotImpl.pieceInstances)

 		for (const [pieceInstanceId, pieceInstance] of pieceInstancesEntries) {
 			if (pieceInstance) {
 				this.pieceInstancesImpl.set(
 					pieceInstanceId as PieceInstanceId,
-					new PlayoutPieceInstanceModelImpl(pieceInstance.PieceInstance, pieceInstance.HasChanges)
+					// Force HasChanges to true so the restored piece is saved to the DB
+					new PlayoutPieceInstanceModelImpl(pieceInstance.PieceInstance, true)
 				)
 			} else {
 				this.pieceInstancesImpl.set(pieceInstanceId as PieceInstanceId, null)
 			}
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
this.pieceInstancesImpl.clear()
for (const [pieceInstanceId, pieceInstance] of snapshot.pieceInstances) {
const pieceInstancesEntries =
snapshotImpl.pieceInstances instanceof Map
? snapshotImpl.pieceInstances.entries()
: Object.entries(snapshotImpl.pieceInstances)
for (const [pieceInstanceId, pieceInstance] of pieceInstancesEntries) {
if (pieceInstance) {
this.pieceInstancesImpl.set(
pieceInstanceId,
pieceInstanceId as PieceInstanceId,
new PlayoutPieceInstanceModelImpl(pieceInstance.PieceInstance, pieceInstance.HasChanges)
)
} else {
this.pieceInstancesImpl.set(pieceInstanceId, null)
this.pieceInstancesImpl.set(pieceInstanceId as PieceInstanceId, null)
}
}
this.#partInstanceHasChanges = true
// Mark all current pieces as null (deleted) first, so that pieces not in the snapshot are deleted from the DB
for (const id of this.pieceInstancesImpl.keys()) {
this.pieceInstancesImpl.set(id, null)
}
const pieceInstancesEntries =
snapshotImpl.pieceInstances instanceof Map
? snapshotImpl.pieceInstances.entries()
: Object.entries(snapshotImpl.pieceInstances)
for (const [pieceInstanceId, pieceInstance] of pieceInstancesEntries) {
if (pieceInstance) {
this.pieceInstancesImpl.set(
pieceInstanceId as PieceInstanceId,
// Force HasChanges to true so the restored piece is saved to the DB
new PlayoutPieceInstanceModelImpl(pieceInstance.PieceInstance, true)
)
} else {
this.pieceInstancesImpl.set(pieceInstanceId as PieceInstanceId, null)
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/job-worker/src/playout/model/implementation/PlayoutPartInstanceModelImpl.ts`
around lines 237 - 252, Update snapshotRestore to preserve tracking for current
piece instances absent from the snapshot: before restoring snapshot entries,
mark those existing IDs in pieceInstancesImpl as null instead of clearing the
map, then apply the snapshot entries as currently done. Also set
this.#partInstanceHasChanges = true so the reverted state is persisted.

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