Fix Story Intelligence global progress reconnect
This commit is contained in:
parent
b639b12163
commit
80d13c6a7f
@ -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 rejects stale live snapshots", StoryIntelligenceGlobalBannerRejectsStaleLiveSnapshots),
|
||||
("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 active progress only appears in global strip", StoryIntelligenceActiveProgressOnlyAppearsInGlobalStrip),
|
||||
("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(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(script.Contains("connection.on(\"StoryIntelligenceGlobalProgressChanged\", render)", StringComparison.Ordinal), "The global banner should render authoritative aggregate payloads directly.");
|
||||
Assert(script.Contains("connection.onreconnected(refresh)", StringComparison.Ordinal), "Reconnect should rejoin the user group and immediately catch up from persisted aggregate state.");
|
||||
Assert(script.Contains("connection.on(\"StoryIntelligenceGlobalProgressChanged\"", StringComparison.Ordinal)
|
||||
&& 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(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.");
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
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("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(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()
|
||||
|
||||
@ -228,7 +228,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@if (storyIntelligenceProgress.HasActiveWork)
|
||||
@if (User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var primaryBook = storyIntelligenceProgress.Books.FirstOrDefault(book => book.HasActiveRuns);
|
||||
var progressUpdatedUtc = storyIntelligenceProgress.UpdatedUtc?.ToUniversalTime().ToString("O", System.Globalization.CultureInfo.InvariantCulture);
|
||||
@ -242,8 +242,10 @@
|
||||
data-story-intelligence-updated-utc="@progressUpdatedUtc"
|
||||
data-book-id="@primaryBook?.BookID"
|
||||
data-project-id="@primaryBook?.ProjectID"
|
||||
data-story-intelligence-has-active-work="@storyIntelligenceProgress.HasActiveWork.ToString().ToLowerInvariant()"
|
||||
role="status"
|
||||
aria-live="polite">
|
||||
aria-live="polite"
|
||||
hidden="@(!storyIntelligenceProgress.HasActiveWork)">
|
||||
<div class="story-intelligence-global-strip__inner">
|
||||
<div class="story-intelligence-global-strip__copy">
|
||||
<span class="story-intelligence-global-strip__dot" aria-hidden="true"></span>
|
||||
|
||||
@ -8,6 +8,16 @@
|
||||
const detail = strip.querySelector("[data-story-intelligence-global-detail]");
|
||||
const bar = strip.querySelector("[data-story-intelligence-global-bar]");
|
||||
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;
|
||||
const readNumber = (value) => {
|
||||
const parsed = Number.parseInt(value || "", 10);
|
||||
@ -16,6 +26,7 @@
|
||||
let lastSnapshotScore =
|
||||
(readNumber(strip.dataset.completedScenes) || 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 estimateRemaining = (elapsedMs, completed, failed, total, status) => {
|
||||
@ -52,15 +63,36 @@
|
||||
const primary = books.find((book) => book.hasActiveRuns || book.HasActiveRuns) || books[0];
|
||||
const activeBookCount = readNumber(progress.activeBookCount ?? progress.ActiveBookCount) || books.length;
|
||||
const status = progress.status || progress.Status || primary.status || primary.Status || "";
|
||||
const total = readNumber(progress.totalExpectedScenes ?? progress.TotalExpectedScenes) || 0;
|
||||
const completed = readNumber(progress.completedScenes ?? progress.CompletedScenes) || 0;
|
||||
const failed = readNumber(progress.failedScenes ?? progress.FailedScenes) || 0;
|
||||
const active = readNumber(progress.activeScenes ?? progress.ActiveScenes) || 0;
|
||||
const total = readNumber(progress.totalExpectedScenes ?? progress.TotalExpectedScenes)
|
||||
?? readNumber(primary.totalExpectedScenes ?? primary.TotalExpectedScenes)
|
||||
?? 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 updatedTime = Date.parse(updatedRaw);
|
||||
const snapshotKey = `${primary.projectID ?? primary.projectId ?? ""}:${primary.bookID ?? primary.bookId ?? ""}`;
|
||||
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 (updatedTime < lastSnapshotTime || (updatedTime === lastSnapshotTime && snapshotScore < lastSnapshotScore)) {
|
||||
log("ignored stale snapshot", { completed, failed, updatedUtc: updatedRaw, snapshotScore, lastSnapshotScore });
|
||||
return;
|
||||
}
|
||||
|
||||
@ -73,6 +105,8 @@
|
||||
lastSnapshotScore = Math.max(lastSnapshotScore, snapshotScore);
|
||||
strip.dataset.completedScenes = String(completed);
|
||||
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.dataset.connection = "live";
|
||||
@ -105,13 +139,18 @@
|
||||
title.textContent = primary.summaryText || primary.SummaryText || title.textContent;
|
||||
}
|
||||
}
|
||||
log("applied snapshot", { completed, failed, total, active, status, updatedUtc: updatedRaw });
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
connection.invoke("WatchGlobalStoryIntelligenceProgress")
|
||||
.then(render)
|
||||
.then((snapshot) => {
|
||||
log("authoritative refresh received", snapshot);
|
||||
render(snapshot);
|
||||
})
|
||||
.catch(() => {
|
||||
strip.dataset.connection = "offline";
|
||||
log("authoritative refresh failed");
|
||||
});
|
||||
};
|
||||
|
||||
@ -126,11 +165,34 @@
|
||||
.build();
|
||||
|
||||
connection.on("StoryIntelligenceRunProgressChanged", scheduleRefresh);
|
||||
connection.on("StoryIntelligenceGlobalProgressChanged", render);
|
||||
connection.onreconnected(refresh);
|
||||
connection.start()
|
||||
.then(refresh)
|
||||
.catch(() => {
|
||||
strip.dataset.connection = "offline";
|
||||
});
|
||||
connection.on("StoryIntelligenceGlobalProgressChanged", (snapshot) => {
|
||||
log("live snapshot received", snapshot);
|
||||
render(snapshot);
|
||||
});
|
||||
connection.onreconnected(() => {
|
||||
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();
|
||||
})();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user