Fix Story Intelligence global progress reconnect

This commit is contained in:
Nick Beckley 2026-08-29 20:00:05 +00:00
parent b639b12163
commit 80d13c6a7f
3 changed files with 104 additions and 17 deletions

View File

@ -264,6 +264,7 @@ var tests = new (string Name, Action Test)[]
("Story Intelligence global banner remains visible through finalising gaps", StoryIntelligenceGlobalBannerRemainsVisibleThroughFinalisingGaps), ("Story Intelligence global banner remains visible through finalising gaps", StoryIntelligenceGlobalBannerRemainsVisibleThroughFinalisingGaps),
("Story Intelligence global banner rejects stale live snapshots", StoryIntelligenceGlobalBannerRejectsStaleLiveSnapshots), ("Story Intelligence global banner rejects stale live snapshots", StoryIntelligenceGlobalBannerRejectsStaleLiveSnapshots),
("Story Intelligence live global progress publishes authoritative scene aggregates", StoryIntelligenceLiveGlobalProgressPublishesAuthoritativeSceneAggregates), ("Story Intelligence live global progress publishes authoritative scene aggregates", StoryIntelligenceLiveGlobalProgressPublishesAuthoritativeSceneAggregates),
("Story Intelligence global progress survives initial SignalR start races", StoryIntelligenceGlobalProgressSurvivesInitialSignalRStartRaces),
("Story Intelligence scene analysis completion barrier releases review", StoryIntelligenceSceneAnalysisCompletionBarrierReleasesReview), ("Story Intelligence scene analysis completion barrier releases review", StoryIntelligenceSceneAnalysisCompletionBarrierReleasesReview),
("Story Intelligence active progress only appears in global strip", StoryIntelligenceActiveProgressOnlyAppearsInGlobalStrip), ("Story Intelligence active progress only appears in global strip", StoryIntelligenceActiveProgressOnlyAppearsInGlobalStrip),
("Story Intelligence book actions do not open review centre while running", StoryIntelligenceBookActionsDoNotOpenReviewCentreWhileRunning) ("Story Intelligence book actions do not open review centre while running", StoryIntelligenceBookActionsDoNotOpenReviewCentreWhileRunning)
@ -471,13 +472,34 @@ static void StoryIntelligenceLiveGlobalProgressPublishesAuthoritativeSceneAggreg
Assert(globalService.Contains("Task<StoryIntelligenceGlobalProgressViewModel> GetForUserAsync(int userId)", StringComparison.Ordinal), "Background publishing should be able to retrieve the same user-scoped aggregate used by page load."); Assert(globalService.Contains("Task<StoryIntelligenceGlobalProgressViewModel> GetForUserAsync(int userId)", StringComparison.Ordinal), "Background publishing should be able to retrieve the same user-scoped aggregate used by page load.");
Assert(notifier.Contains("GetForUserAsync(userId)", StringComparison.Ordinal), "Live progress publish should fetch the authoritative aggregate for the run owner."); Assert(notifier.Contains("GetForUserAsync(userId)", StringComparison.Ordinal), "Live progress publish should fetch the authoritative aggregate for the run owner.");
Assert(notifier.Contains("StoryIntelligenceGlobalProgressChanged", StringComparison.Ordinal), "Live progress publish should send a compact aggregate SignalR event."); Assert(notifier.Contains("StoryIntelligenceGlobalProgressChanged", StringComparison.Ordinal), "Live progress publish should send a compact aggregate SignalR event.");
Assert(script.Contains("connection.on(\"StoryIntelligenceGlobalProgressChanged\", render)", StringComparison.Ordinal), "The global banner should render authoritative aggregate payloads directly."); Assert(script.Contains("connection.on(\"StoryIntelligenceGlobalProgressChanged\"", StringComparison.Ordinal)
Assert(script.Contains("connection.onreconnected(refresh)", StringComparison.Ordinal), "Reconnect should rejoin the user group and immediately catch up from persisted aggregate state."); && script.Contains("render(snapshot);", StringComparison.Ordinal), "The global banner should render authoritative aggregate payloads directly.");
Assert(script.Contains("connection.onreconnected", StringComparison.Ordinal)
&& script.Contains("refresh();", StringComparison.Ordinal), "Reconnect should rejoin the user group and immediately catch up from persisted aggregate state.");
Assert(script.Contains("updatedTime === lastSnapshotTime && snapshotScore < lastSnapshotScore", StringComparison.Ordinal), "Equal-timestamp out-of-order snapshots should not regress completed/failed scene totals."); Assert(script.Contains("updatedTime === lastSnapshotTime && snapshotScore < lastSnapshotScore", StringComparison.Ordinal), "Equal-timestamp out-of-order snapshots should not regress completed/failed scene totals.");
Assert(layout.Contains("data-completed-scenes", StringComparison.Ordinal), "Initial server render should seed completed count for monotonic live comparisons."); Assert(layout.Contains("data-completed-scenes", StringComparison.Ordinal), "Initial server render should seed completed count for monotonic live comparisons.");
Assert(layout.Contains("data-failed-scenes", StringComparison.Ordinal), "Initial server render should seed terminal failed count for monotonic live comparisons."); Assert(layout.Contains("data-failed-scenes", StringComparison.Ordinal), "Initial server render should seed terminal failed count for monotonic live comparisons.");
} }
static void StoryIntelligenceGlobalProgressSurvivesInitialSignalRStartRaces()
{
var root = PlotLineRoot();
var layout = File.ReadAllText(Path.Combine(root, "Views/Shared/_Layout.cshtml"));
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/story-intelligence-global-progress.js"));
Assert(layout.Contains("User.Identity?.IsAuthenticated == true", StringComparison.Ordinal), "Authenticated pages should always render the global progress host so a later import can appear without a full refresh.");
Assert(layout.Contains("data-story-intelligence-has-active-work", StringComparison.Ordinal), "Initial render should expose whether the hidden strip currently has active work.");
Assert(layout.Contains("hidden=\"@(!storyIntelligenceProgress.HasActiveWork)\"", StringComparison.Ordinal), "The global strip should be hidden, not absent, when no active work is visible at page render.");
Assert(script.Contains("const startWithRetry = () =>", StringComparison.Ordinal), "Initial SignalR start failures should retry instead of leaving the banner permanently stale.");
Assert(script.Contains("startRetryTimer = window.setTimeout(startWithRetry, 2000)", StringComparison.Ordinal), "Initial start retry should be bounded and low-noise.");
Assert(script.Contains("connection.onclose(() =>", StringComparison.Ordinal), "The client should recover after automatic reconnect is exhausted.");
Assert(script.Contains("connection.onreconnected(() =>", StringComparison.Ordinal), "Reconnect should immediately refresh authoritative persisted progress.");
Assert(script.Contains(".then(() =>") && script.Contains("refresh();"), "Successful connection start should close the render/subscription race by fetching the current aggregate.");
Assert(script.Contains("snapshotKey !== lastSnapshotKey", StringComparison.Ordinal), "Progress monotonicity should reset only when the active book scope changes.");
Assert(script.Contains("snapshotScore < lastSnapshotScore", StringComparison.Ordinal), "A newer but backwards snapshot for the same book must not regress the banner.");
Assert(script.Contains("StoryIntelligenceGlobalProgress]"), "Development builds should expose low-noise client diagnostics.");
}
static void StoryIntelligenceSceneAnalysisCompletionBarrierReleasesReview() static void StoryIntelligenceSceneAnalysisCompletionBarrierReleasesReview()
{ {
var root = PlotLineRoot(); var root = PlotLineRoot();
@ -581,7 +603,8 @@ static void StoryIntelligenceRecoveredJsonRepairStaysOperational()
Assert(notificationSql.Contains("Status IN (N'Failed', N'Cancelled')", StringComparison.Ordinal), "Failure notification should be based on final failed/cancelled runs."); Assert(notificationSql.Contains("Status IN (N'Failed', N'Cancelled')", StringComparison.Ordinal), "Failure notification should be based on final failed/cancelled runs.");
Assert(notificationSql.Contains("SET @NotificationKind = CASE WHEN @HasFailedRuns = 1 THEN N'Failure' ELSE N'Success' END", StringComparison.Ordinal), "Recovered runs should not generate failure notifications."); Assert(notificationSql.Contains("SET @NotificationKind = CASE WHEN @HasFailedRuns = 1 THEN N'Failure' ELSE N'Success' END", StringComparison.Ordinal), "Recovered runs should not generate failure notifications.");
Assert(progressScript.Contains("current.failedScenes += failed", StringComparison.Ordinal), "Onboarding Needs review should use persisted terminal FailedScenes only."); Assert(progressScript.Contains("current.failedScenes += failed", StringComparison.Ordinal), "Onboarding Needs review should use persisted terminal FailedScenes only.");
Assert(globalScript.Contains("const failed = readNumber(progress.failedScenes ?? progress.FailedScenes) || 0", StringComparison.Ordinal), "Global banner should use terminal failed-scene count only."); Assert(globalScript.Contains("const failed = readNumber(progress.failedScenes ?? progress.FailedScenes)", StringComparison.Ordinal)
&& globalScript.Contains("readNumber(primary.failedScenes ?? primary.FailedScenes)", StringComparison.Ordinal), "Global banner should use terminal failed-scene count only.");
} }
static void StoryIntelligenceTerminalFailuresRemainUserVisible() static void StoryIntelligenceTerminalFailuresRemainUserVisible()

View File

@ -228,7 +228,7 @@
</div> </div>
</div> </div>
</nav> </nav>
@if (storyIntelligenceProgress.HasActiveWork) @if (User.Identity?.IsAuthenticated == true)
{ {
var primaryBook = storyIntelligenceProgress.Books.FirstOrDefault(book => book.HasActiveRuns); var primaryBook = storyIntelligenceProgress.Books.FirstOrDefault(book => book.HasActiveRuns);
var progressUpdatedUtc = storyIntelligenceProgress.UpdatedUtc?.ToUniversalTime().ToString("O", System.Globalization.CultureInfo.InvariantCulture); var progressUpdatedUtc = storyIntelligenceProgress.UpdatedUtc?.ToUniversalTime().ToString("O", System.Globalization.CultureInfo.InvariantCulture);
@ -242,8 +242,10 @@
data-story-intelligence-updated-utc="@progressUpdatedUtc" data-story-intelligence-updated-utc="@progressUpdatedUtc"
data-book-id="@primaryBook?.BookID" data-book-id="@primaryBook?.BookID"
data-project-id="@primaryBook?.ProjectID" data-project-id="@primaryBook?.ProjectID"
data-story-intelligence-has-active-work="@storyIntelligenceProgress.HasActiveWork.ToString().ToLowerInvariant()"
role="status" role="status"
aria-live="polite"> aria-live="polite"
hidden="@(!storyIntelligenceProgress.HasActiveWork)">
<div class="story-intelligence-global-strip__inner"> <div class="story-intelligence-global-strip__inner">
<div class="story-intelligence-global-strip__copy"> <div class="story-intelligence-global-strip__copy">
<span class="story-intelligence-global-strip__dot" aria-hidden="true"></span> <span class="story-intelligence-global-strip__dot" aria-hidden="true"></span>

View File

@ -8,6 +8,16 @@
const detail = strip.querySelector("[data-story-intelligence-global-detail]"); const detail = strip.querySelector("[data-story-intelligence-global-detail]");
const bar = strip.querySelector("[data-story-intelligence-global-bar]"); const bar = strip.querySelector("[data-story-intelligence-global-bar]");
let refreshTimer = null; let refreshTimer = null;
let startRetryTimer = null;
const diagnosticsEnabled =
location.hostname === "localhost"
|| location.hostname === "127.0.0.1"
|| location.hostname.startsWith("dev.");
const log = (message, data) => {
if (diagnosticsEnabled && window.console?.debug) {
window.console.debug(`[StoryIntelligenceGlobalProgress] ${message}`, data || "");
}
};
let lastSnapshotTime = Date.parse(strip.dataset.storyIntelligenceUpdatedUtc || "") || 0; let lastSnapshotTime = Date.parse(strip.dataset.storyIntelligenceUpdatedUtc || "") || 0;
const readNumber = (value) => { const readNumber = (value) => {
const parsed = Number.parseInt(value || "", 10); const parsed = Number.parseInt(value || "", 10);
@ -16,6 +26,7 @@
let lastSnapshotScore = let lastSnapshotScore =
(readNumber(strip.dataset.completedScenes) || 0) (readNumber(strip.dataset.completedScenes) || 0)
+ (readNumber(strip.dataset.failedScenes) || 0); + (readNumber(strip.dataset.failedScenes) || 0);
let lastSnapshotKey = `${strip.dataset.projectId || ""}:${strip.dataset.bookId || ""}`;
const formatCount = (value) => new Intl.NumberFormat().format(Math.max(0, value || 0)); const formatCount = (value) => new Intl.NumberFormat().format(Math.max(0, value || 0));
const estimateRemaining = (elapsedMs, completed, failed, total, status) => { const estimateRemaining = (elapsedMs, completed, failed, total, status) => {
@ -52,15 +63,36 @@
const primary = books.find((book) => book.hasActiveRuns || book.HasActiveRuns) || books[0]; const primary = books.find((book) => book.hasActiveRuns || book.HasActiveRuns) || books[0];
const activeBookCount = readNumber(progress.activeBookCount ?? progress.ActiveBookCount) || books.length; const activeBookCount = readNumber(progress.activeBookCount ?? progress.ActiveBookCount) || books.length;
const status = progress.status || progress.Status || primary.status || primary.Status || ""; const status = progress.status || progress.Status || primary.status || primary.Status || "";
const total = readNumber(progress.totalExpectedScenes ?? progress.TotalExpectedScenes) || 0; const total = readNumber(progress.totalExpectedScenes ?? progress.TotalExpectedScenes)
const completed = readNumber(progress.completedScenes ?? progress.CompletedScenes) || 0; ?? readNumber(primary.totalExpectedScenes ?? primary.TotalExpectedScenes)
const failed = readNumber(progress.failedScenes ?? progress.FailedScenes) || 0; ?? 0;
const active = readNumber(progress.activeScenes ?? progress.ActiveScenes) || 0; const completed = readNumber(progress.completedScenes ?? progress.CompletedScenes)
?? readNumber(primary.completedScenes ?? primary.CompletedScenes)
?? 0;
const failed = readNumber(progress.failedScenes ?? progress.FailedScenes)
?? readNumber(primary.failedScenes ?? primary.FailedScenes)
?? 0;
const active = readNumber(progress.activeScenes ?? progress.ActiveScenes)
?? readNumber(primary.activeScenes ?? primary.ActiveScenes)
?? 0;
const updatedRaw = progress.updatedUtc || progress.UpdatedUtc || primary.updatedUtc || primary.UpdatedUtc || ""; const updatedRaw = progress.updatedUtc || progress.UpdatedUtc || primary.updatedUtc || primary.UpdatedUtc || "";
const updatedTime = Date.parse(updatedRaw); const updatedTime = Date.parse(updatedRaw);
const snapshotKey = `${primary.projectID ?? primary.projectId ?? ""}:${primary.bookID ?? primary.bookId ?? ""}`;
const snapshotScore = completed + failed; const snapshotScore = completed + failed;
if (snapshotKey !== lastSnapshotKey) {
lastSnapshotKey = snapshotKey;
lastSnapshotScore = 0;
lastSnapshotTime = 0;
}
if (snapshotScore < lastSnapshotScore) {
log("ignored backwards snapshot", { completed, failed, updatedUtc: updatedRaw, snapshotScore, lastSnapshotScore });
return;
}
if (Number.isFinite(updatedTime)) { if (Number.isFinite(updatedTime)) {
if (updatedTime < lastSnapshotTime || (updatedTime === lastSnapshotTime && snapshotScore < lastSnapshotScore)) { if (updatedTime < lastSnapshotTime || (updatedTime === lastSnapshotTime && snapshotScore < lastSnapshotScore)) {
log("ignored stale snapshot", { completed, failed, updatedUtc: updatedRaw, snapshotScore, lastSnapshotScore });
return; return;
} }
@ -73,6 +105,8 @@
lastSnapshotScore = Math.max(lastSnapshotScore, snapshotScore); lastSnapshotScore = Math.max(lastSnapshotScore, snapshotScore);
strip.dataset.completedScenes = String(completed); strip.dataset.completedScenes = String(completed);
strip.dataset.failedScenes = String(failed); strip.dataset.failedScenes = String(failed);
strip.dataset.projectId = String(primary.projectID ?? primary.projectId ?? "");
strip.dataset.bookId = String(primary.bookID ?? primary.bookId ?? "");
strip.hidden = false; strip.hidden = false;
strip.dataset.connection = "live"; strip.dataset.connection = "live";
@ -105,13 +139,18 @@
title.textContent = primary.summaryText || primary.SummaryText || title.textContent; title.textContent = primary.summaryText || primary.SummaryText || title.textContent;
} }
} }
log("applied snapshot", { completed, failed, total, active, status, updatedUtc: updatedRaw });
}; };
const refresh = () => { const refresh = () => {
connection.invoke("WatchGlobalStoryIntelligenceProgress") connection.invoke("WatchGlobalStoryIntelligenceProgress")
.then(render) .then((snapshot) => {
log("authoritative refresh received", snapshot);
render(snapshot);
})
.catch(() => { .catch(() => {
strip.dataset.connection = "offline"; strip.dataset.connection = "offline";
log("authoritative refresh failed");
}); });
}; };
@ -126,11 +165,34 @@
.build(); .build();
connection.on("StoryIntelligenceRunProgressChanged", scheduleRefresh); connection.on("StoryIntelligenceRunProgressChanged", scheduleRefresh);
connection.on("StoryIntelligenceGlobalProgressChanged", render); connection.on("StoryIntelligenceGlobalProgressChanged", (snapshot) => {
connection.onreconnected(refresh); log("live snapshot received", snapshot);
connection.start() render(snapshot);
.then(refresh) });
.catch(() => { connection.onreconnected(() => {
strip.dataset.connection = "offline"; log("SignalR reconnected");
}); refresh();
});
connection.onclose(() => {
strip.dataset.connection = "offline";
log("SignalR closed; retrying start");
startWithRetry();
});
const startWithRetry = () => {
window.clearTimeout(startRetryTimer);
connection.start()
.then(() => {
strip.dataset.connection = "live";
log("SignalR connected");
refresh();
})
.catch(() => {
strip.dataset.connection = "offline";
log("SignalR initial start failed; retrying");
startRetryTimer = window.setTimeout(startWithRetry, 2000);
});
};
startWithRetry();
})(); })();