Skip to content

Commit b9d6bb8

Browse files
authored
Merge pull request #2244 from atomantic/claim/issue-2241
refactor: wrap comic page-render + refine-render as creative tools (route-level persistence) (#2241)
2 parents bfca89c + babd165 commit b9d6bb8

7 files changed

Lines changed: 301 additions & 88 deletions

File tree

.changelog/NEXT.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@
118118

119119
- **[issue-2220] Creative render tools with route-level persistence (CDO Phase 2 slice).** The Creative Director orchestrator can now render comic-issue and volume (season) covers — `pipeline_renderComicCover`, `pipeline_renderComicBackCover`, `pipeline_renderVolumeCover`, `pipeline_renderVolumeBackCover` are registered creative tools. These render jobs need more than a bare enqueue: the media-job filename hook attaches the finished image only if the target cover slot already carries the returned `jobId`, and that slot write used to live in the route factory (`routes/pipeline/covers.js#makeCoverRenderHandler`), so wrapping the bare `enqueueComicCover`-style service in a tool would silently drop orchestrated covers. The enqueue+persist flow (build the in-flight render slot, script-gate, write it onto the record) now lives in shared service entry points — `renderComicCover` / `renderComicBackCover` (→ `stages.comicPages.{cover|backCover}` via the serialized `updateStageWithLatest` series write tail) and `renderVolumeCover` / `renderVolumeBackCover` (→ `series.seasons[].{cover|backCover}` via `updateSeasonOnSeries`) in `visualStages.js` — so the four cover-render routes and the four orchestrator tools share ONE code path. The persist keeps the script-gate semantics (absent `coverScript` preserves the persisted concept, empty string clears it) and serializes against a concurrent blur-save on the same record. Tools charge as `render` against the daily action budget. Cover render is the primary deliverable; wrapping the remaining route-persisted render actions (comic page render / refine-render) as tools stays tracked on #2220.
120120

121+
- **[issue-2241] Creative comic-page render + refine-render tools with route-level persistence (CDO Phase 2 slice).** Completes the #2220 audit clause the covers slice (#2234) deferred: the Creative Director orchestrator can now render and refine full comic pages — `pipeline_renderComicPage` and `pipeline_refineComicPageRender` are registered creative tools. Like the covers, these need more than a bare enqueue — the media-job filename hook attaches the finished image only if `stages.comicPages.pages[pageIndex]`'s active variant slot already carries the returned `jobId`, and that slot write used to live in the route handler (`routes/pipeline/issues.js`), so wrapping the bare `enqueueVisualComicPage` / `refineComicPageRender` in a tool would silently drop orchestrated pages. The enqueue+persist flow now lives in shared service entry points — `renderComicPage` and `refineComicPageRender` (both persist onto `pages[pageIndex].{proofImage|finalImage}` through a shared `persistComicPageSlot` helper over the serialized `updateStageWithLatest` issue write tail) in `visualStages.js` — so the page render + refine-render routes and the two orchestrator tools share ONE code path, mirroring the `renderComicCover` / `makeCoverRenderHandler` structure exactly. The persist runs inside the write tail's compute step so a concurrent page edit or sibling render can't be reverted by a stale snapshot. Tools charge as `render` against the daily action budget. (`server/services/pipeline/visualStages.js`, `server/routes/pipeline/issues.js`, `server/services/creative/tools/pipeline.js`)
122+
121123
- **`getSettings()` now serves from an in-memory cache instead of re-reading settings.json on every call.** `getSettings()` is invoked 100+ times across the codebase (several per request), each previously doing a fresh filesystem read + JSON.parse of app-wide, rarely-changing data. It now memoizes the parsed settings, populated lazily on first read and refreshed off the `settings:updated` event that every `save()`/`updateSettings()`/`updateSettingsWith()` already emits — so app-driven writes keep the cache coherent while write paths still read fresh off disk inside their write queue for the read-merge-write, preserving write-ordering guarantees. Mirrors the existing `settings:updated`-invalidated cache in `sharing/annotationIdentity.js`. Alongside it, a few independent per-file read loops are now parallelized (`digital-twin-analysis.validateCompleteness`, editorial series-health's series+settings load) and hot page derivations memoized (`Review` grouping/sorting, `Tribe` care-queue), cutting redundant work on socket-driven re-renders.
122124

123125
- **Branch & PR reconciliation now runs on any managed app as a Chief-of-Staff scheduled task — not just PortOS.** The branch reconciler (finish this machine's unfinished local git work: delete fully-merged orphaned branches + their worktrees, open PRs for pushed-but-unopened work, resolve merge conflicts, drive the review loop, and auto-merge when a PR is green) used to be a PortOS-only feature with its own daily cron, `/api/branch-reconcile` route, `settings.branchReconcile` toggle, and a dedicated Settings tab — hardwired to the PortOS checkout. It is now a first-class per-app CoS task type, **`branch-reconcile`**, configured like every other improvement task (per-app enable, provider/model, cadence, cooldown, budget) under Chief of Staff → task scheduling. It runs `perpetual` (drain-until-done): each dispatch runs the deterministic, peer-safe cleanup + classification against the *app's* repo, dispatches the coordinator agent only while actionable in-flight branches remain, then parks on a daily recheck — so no agent is burned on a no-op run. A progress-signature convergence guard ensures the drain only re-dispatches when the actionable set actually *changed* since the last run (a branch advanced NEEDS_PR→IN_REVIEW→merged, or a new/removed branch): a cycle that leaves the same branches in the same states (a branch judged "not ready", a PR blocked on human review or red CI) parks instead of re-spawning an identical coordinator back-to-back. The peer-safety guarantee is unchanged (only local `refs/heads/` are ever touched; a federated peer's branch exists here only as a remote-tracking ref and is structurally invisible), and the per-behavior toggles (`cleanupMerged` / `openPr` / `resolveConflicts` / `autoMerge`) are now per-app task-metadata overrides. Off by default; enabling it on an app is the user's explicit consent to let it drive PRs on a schedule. The old dedicated scheduler, route, settings key, and Settings tab are removed; a migration (`scripts/migrations/170-branch-reconcile-to-cos-task.js`) carries a previously-enabled `branchReconcile` opt-in into the new task (cron→recheckCron, actions→per-app metadata) scoped to the PortOS app, and drops the dead settings key — so an install that had it on keeps reconciling instead of silently stopping. (`server/services/branchReconcile.js`, `server/services/cosTaskGenerator.js`, `server/services/taskSchedule.js`, `server/services/taskPromptDefaults/prompts.js`)

server/routes/pipeline.test.js

Lines changed: 53 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,48 @@ vi.mock('../services/pipeline/visualStages.js', async () => {
107107
};
108108
};
109109

110+
// Full-page render+persist. Enqueues then lands the in-flight slot on
111+
// stages.comicPages.pages[pageIndex] through the real issue write tail (#2241)
112+
// so the route tests exercise the persist end-to-end (mirrors renderComicPage).
113+
const persistComicPage = async (issueId, options) => {
114+
const pageIndex = options.pageIndex;
115+
const variant = options?.target === 'final' ? 'final' : 'proof';
116+
const jobId = `page-job-${++uuidCounter}`;
117+
const prompt = `comic-page prompt for page ${pageIndex + 1}`;
118+
const fromProof = variant === 'final' && options?.useProofAsBase === true;
119+
const slotKey = slotKeyForVariant(variant);
120+
const slotRecord = buildSlot({ slotKey, jobId, prompt, width: options?.width, height: options?.height, fromProof });
121+
const { issue, stage } = await issuesSvc.updateStageWithLatest(issueId, 'comicPages', (current) => {
122+
const pages = Array.isArray(current?.pages) ? current.pages : [];
123+
const next = [...pages];
124+
next[pageIndex] = { ...pages[pageIndex], [slotKey]: slotRecord };
125+
return { status: 'edited', pages: next };
126+
});
127+
return { jobId, mode: 'local', prompt, pageIndex, variant, fromProof, issue, stage };
128+
};
129+
130+
// Comic-page AI refine + i2i re-render (issue #1534) + persist (#2241). Lands
131+
// the refined render on the matching variant slot via the real write tail.
132+
const persistComicPageRefine = async (issueId, options) => {
133+
const pageIndex = options.pageIndex;
134+
const variant = options?.target === 'final' ? 'final' : 'proof';
135+
const jobId = `page-refine-job-${++uuidCounter}`;
136+
const prompt = `refined page prompt for page ${pageIndex + 1}`;
137+
const slotKey = slotKeyForVariant(variant);
138+
const slotRecord = buildSlot({ slotKey, jobId, prompt, width: options?.width, height: options?.height });
139+
const { issue, stage } = await issuesSvc.updateStageWithLatest(issueId, 'comicPages', (current) => {
140+
const pages = Array.isArray(current?.pages) ? current.pages : [];
141+
const next = [...pages];
142+
next[pageIndex] = { ...pages[pageIndex], [slotKey]: slotRecord };
143+
return { status: 'edited', pages: next };
144+
});
145+
return {
146+
jobId, mode: 'local', prompt, pageIndex, variant,
147+
changes: ['warmed the lighting'], runId: 'run-mock-page-refine', providerId: 'mock-provider',
148+
issue, stage,
149+
};
150+
};
151+
110152
const persistVolumeCover = async (seriesId, seasonId, target, scriptField, body) => {
111153
const variant = body?.target === 'final' ? 'final' : 'proof';
112154
const jobId = `vol-${target === 'cover' ? 'cover' : 'backcover'}-job-${++uuidCounter}`;
@@ -134,17 +176,10 @@ vi.mock('../services/pipeline/visualStages.js', async () => {
134176
mode: 'local',
135177
prompt: `style, ${opts.description}`,
136178
})),
137-
enqueueVisualComicPage: vi.fn(async (_issueId, opts) => ({
138-
jobId: `page-job-${++uuidCounter}`,
139-
mode: 'local',
140-
// Match the real service contract: pageNumber is 1-based (pageIndex + 1).
141-
prompt: `comic-page prompt for page ${opts.pageIndex + 1}`,
142-
pageIndex: opts.pageIndex,
143-
// Forward proof/final variant + i2i flag so the route's slotKey + slot
144-
// record reflect the schema-validated body (target defaults to 'proof').
145-
variant: opts?.target === 'final' ? 'final' : 'proof',
146-
fromProof: opts?.target === 'final' && opts?.useProofAsBase === true,
147-
})),
179+
// Full-page render+persist entry point (#2241) — enqueues AND lands the
180+
// in-flight slot on stages.comicPages.pages[pageIndex] through the real issue
181+
// write tail, driven through persistComicPage above.
182+
renderComicPage: vi.fn((issueId, opts) => persistComicPage(issueId, opts)),
148183
// Front cover render+persist. Lands the in-flight slot on
149184
// stages.comicPages.cover through the real issue write tail (#2220).
150185
renderComicCover: vi.fn((issueId, body) => persistComicCover(issueId, 'cover', 'coverScript', body || {})),
@@ -201,19 +236,10 @@ vi.mock('../services/pipeline/visualStages.js', async () => {
201236
changes: ['mock change'],
202237
providerId: 'mock-provider',
203238
})),
204-
// Comic-page AI refine + i2i re-render (issue #1534). Returns the shape the
205-
// route lands on the matching variant slot via slotKeyForVariant +
206-
// buildRenderSlot — variant defaults to 'proof' unless the body forces one.
207-
refineComicPageRender: vi.fn(async (_issueId, opts) => ({
208-
jobId: `page-refine-job-${++uuidCounter}`,
209-
mode: 'local',
210-
prompt: `refined page prompt for page ${opts.pageIndex + 1}`,
211-
pageIndex: opts.pageIndex,
212-
variant: opts?.target === 'final' ? 'final' : 'proof',
213-
changes: ['warmed the lighting'],
214-
runId: 'run-mock-page-refine',
215-
providerId: 'mock-provider',
216-
})),
239+
// Comic-page AI refine + i2i re-render (issue #1534) + persist (#2241).
240+
// Enqueues AND lands the refined render on the matching variant slot through
241+
// the real write tail — variant defaults to 'proof' unless the body forces one.
242+
refineComicPageRender: vi.fn((issueId, opts) => persistComicPageRefine(issueId, opts)),
217243
};
218244
});
219245

@@ -1696,8 +1722,9 @@ describe('pipeline routes', () => {
16961722
.send({});
16971723
expect(r.status).toBe(404);
16981724
expect(r.body.code || r.body.error).toMatch(/PIPELINE_COMIC_PAGE_NOT_FOUND|out of range/i);
1699-
// Enqueue is never reached when the page doesn't exist.
1700-
expect(visualStages.enqueueVisualComicPage).not.toHaveBeenCalled();
1725+
// The render+persist service is never reached when the page doesn't exist
1726+
// (the route pre-validates the page index before dispatching).
1727+
expect(visualStages.renderComicPage).not.toHaveBeenCalled();
17011728
});
17021729

17031730
// ---- comicPages/cover/render ----

server/routes/pipeline/issues.js

Lines changed: 17 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,14 @@ import { generateStage } from '../../services/pipeline/textStages.js';
2121
import * as autoRunner from '../../services/pipeline/autoRunner.js';
2222
import {
2323
enqueueVisualImage,
24-
enqueueVisualComicPage,
24+
renderComicPage,
2525
enqueueStoryboardSceneVideo,
2626
enqueueStoryboardShotStartFrame,
2727
refineComicPanelPrompt,
2828
refineComicPageRender,
2929
refineStoryboardScenePrompt,
3030
generateComicPanelImagePrompts,
3131
generateStoryboardSceneImagePrompts,
32-
buildRenderSlot,
3332
} from '../../services/pipeline/visualStages.js';
3433
import { IMAGE_PROMPT_CANDIDATE_MAX } from '../../services/pipeline/refineHelpers.js';
3534
import {
@@ -38,7 +37,7 @@ import {
3837
} from '../../services/universeCanon.js';
3938
import { getSeriesCanon } from '../../services/pipeline/seriesCanon.js';
4039
import { startEpisodeVideoForIssue } from '../../services/pipeline/episodeVideo.js';
41-
import { COMIC_PAGE_VARIANTS, slotKeyForVariant } from '../../services/pipeline/owners.js';
40+
import { COMIC_PAGE_VARIANTS } from '../../services/pipeline/owners.js';
4241
import { ASPECT_RATIOS, QUALITIES } from '../../lib/creativeDirectorPresets.js';
4342
import { IMAGE_GEN_MODE } from '../../services/imageGen/modes.js';
4443
import { extractScenes, scenesFromBeatSheet, SOURCE_KIND } from '../../lib/sceneExtractor.js';
@@ -878,38 +877,16 @@ router.post('/issues/:id/stages/comicPages/pages/:pageIndex/render', asyncHandle
878877
);
879878
}
880879

881-
const result = await enqueueVisualComicPage(req.params.id, { pageIndex, ...body })
880+
// renderComicPage enqueues AND persists the in-flight slot onto
881+
// pages[pageIndex].{proofImage|finalImage} through the serialized issue write
882+
// tail (#2241) — the same shared entry point the CDO orchestrator tool calls,
883+
// so an orchestrated page completes exactly like a user-driven one. The
884+
// persist runs inside updateStageWithLatest's computeFn so the slot lands on
885+
// the freshest persisted pages array (a concurrent page edit or sibling
886+
// render can't be reverted by a stale snapshot).
887+
const result = await renderComicPage(req.params.id, { pageIndex, ...body })
882888
.catch((err) => { throw mapServiceError(err); });
883-
884-
// The splice happens inside updateStageWithLatest's computeFn so the
885-
// slot lands on the freshest persisted pages array — a concurrent page
886-
// edit or sibling render that wrote between our enqueue and persist
887-
// would otherwise be reverted by a stale snapshot.
888-
const slotKey = slotKeyForVariant(result.variant);
889-
const slotRecord = buildRenderSlot({
890-
slotKey, jobId: result.jobId, prompt: result.prompt,
891-
width: body.width, height: body.height, fromProof: result.fromProof,
892-
});
893-
const { issue: updatedIssue, stage } = await issuesSvc.updateStageWithLatest(
894-
req.params.id,
895-
'comicPages',
896-
(currentStage) => {
897-
const currentPages = Array.isArray(currentStage?.pages) ? currentStage.pages : [];
898-
if (!currentPages[pageIndex]) {
899-
throw new ServerError(
900-
`pageIndex ${pageIndex} out of range — comicPages has ${currentPages.length} page${currentPages.length === 1 ? '' : 's'}`,
901-
{ status: 404, code: 'PIPELINE_COMIC_PAGE_NOT_FOUND' },
902-
);
903-
}
904-
const nextPages = [...currentPages];
905-
nextPages[pageIndex] = {
906-
...currentPages[pageIndex],
907-
[slotKey]: slotRecord,
908-
};
909-
return { status: 'edited', pages: nextPages };
910-
},
911-
).catch((err) => { throw mapServiceError(err); });
912-
res.json({ ...result, issue: updatedIssue, stage });
889+
res.json(result);
913890
}));
914891

915892
// AI prompt-refine + image-to-image re-render for a small correction to an
@@ -939,34 +916,14 @@ router.post('/issues/:id/stages/comicPages/pages/:pageIndex/refine-render', asyn
939916
);
940917
}
941918

919+
// refineComicPageRender adjusts the page's stored render prompt (LLM),
920+
// re-renders i2i from the page's existing image, AND persists the new slot on
921+
// the matching variant through the serialized issue write tail (#2241) — same
922+
// shared code path the CDO orchestrator tool uses. A concurrent edit or
923+
// sibling render can't be reverted by a stale snapshot.
942924
const result = await refineComicPageRender(req.params.id, { pageIndex, ...body })
943925
.catch((err) => { throw mapServiceError(err); });
944-
945-
const slotKey = slotKeyForVariant(result.variant);
946-
const slotRecord = buildRenderSlot({
947-
slotKey, jobId: result.jobId, prompt: result.prompt,
948-
width: body.width, height: body.height,
949-
});
950-
const { issue: updatedIssue, stage } = await issuesSvc.updateStageWithLatest(
951-
req.params.id,
952-
'comicPages',
953-
(currentStage) => {
954-
const currentPages = Array.isArray(currentStage?.pages) ? currentStage.pages : [];
955-
if (!currentPages[pageIndex]) {
956-
throw new ServerError(
957-
`pageIndex ${pageIndex} out of range — comicPages has ${currentPages.length} page${currentPages.length === 1 ? '' : 's'}`,
958-
{ status: 404, code: 'PIPELINE_COMIC_PAGE_NOT_FOUND' },
959-
);
960-
}
961-
const nextPages = [...currentPages];
962-
nextPages[pageIndex] = {
963-
...currentPages[pageIndex],
964-
[slotKey]: slotRecord,
965-
};
966-
return { status: 'edited', pages: nextPages };
967-
},
968-
).catch((err) => { throw mapServiceError(err); });
969-
res.json({ ...result, issue: updatedIssue, stage });
926+
res.json(result);
970927
}));
971928

972929
// AI-driven prompt refinement for a single comic panel. Uses

0 commit comments

Comments
 (0)