mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
feat(intelligence): expose durable refresh progress
This commit is contained in:
7 files changed
+154
-14
No files matched your search
@@ -146,6 +146,7 @@ public sealed class IntelligenceControllerTests : IAsyncLifetime
|
||||
ScopeKey = $"{_tenant:N}:{_user:N}",
|
||||
TenantId = _tenant,
|
||||
OwnerUserId = _user,
|
||||
LibraryScopeId = "music",
|
||||
Type = "recommendation.generate",
|
||||
PayloadJson = "{}",
|
||||
PolicySnapshotJson = "{}",
|
||||
@@ -158,6 +159,18 @@ public sealed class IntelligenceControllerTests : IAsyncLifetime
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
db.AuditEvents.Add(new()
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TenantId = _tenant,
|
||||
ActorUserId = _user,
|
||||
Category = "job-progress",
|
||||
Action = "recommendation.rank",
|
||||
Outcome = "running",
|
||||
CorrelationId = "test",
|
||||
DetailsJson = """{"stage":"recommendation.rank","message":"Ranking tracks.","completed":1,"total":2}""",
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
db.RecommendationRuns.Add(new()
|
||||
{
|
||||
Id = run,
|
||||
@@ -228,6 +241,7 @@ public sealed class IntelligenceControllerTests : IAsyncLifetime
|
||||
Assert.Contains("lastfm:fixture", json, StringComparison.Ordinal);
|
||||
Assert.Contains("Recommended song", json, StringComparison.Ordinal);
|
||||
Assert.Contains("\"latestRunState\":\"succeeded\"", json, StringComparison.Ordinal);
|
||||
Assert.Contains("Ranking tracks.", json, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("TenantId", json, StringComparison.Ordinal);
|
||||
var feedback = Assert.IsType<OkObjectResult>(await Controller().SetFeedback(
|
||||
Guid.Parse("33333333-3333-3333-3333-333333333333"), new()
|
||||
|
||||
@@ -63,8 +63,14 @@ public sealed class IntelligenceCoreTests : IAsyncLifetime
|
||||
var claim = await _jobs.ClaimNextAsync("intelligence-restart", ["recommendation.generate"]); Assert.NotNull(claim);
|
||||
var handler = new RecommendationRunJobHandler(_factory, [new FixtureProvider()],
|
||||
new ListeningProfileService(_factory, _clock), _clock);
|
||||
var completion = await handler.ExecuteAsync(new(claim!, EmptyServices.Instance), default);
|
||||
var progress = new List<DurableJobProgressUpdate>();
|
||||
var completion = await handler.ExecuteAsync(new(claim!, EmptyServices.Instance)
|
||||
{
|
||||
ReportProgressAsync = (update, _) => { progress.Add(update); return Task.FromResult(true); }
|
||||
}, default);
|
||||
await _jobs.CompleteAsync(claim!, completion); Assert.Equal(DurableJobCompletionKind.Succeeded, completion.Kind);
|
||||
Assert.Equal(["recommendation.profile", "recommendation.provider", "recommendation.provider",
|
||||
"recommendation.rank", "recommendation.complete"], progress.Select(item => item.Stage));
|
||||
await using var db = await _factory.CreateDbContextAsync(); var candidate = Assert.Single(await db.RecommendationCandidates.ToListAsync());
|
||||
Assert.Equal("track-1", candidate.TrackKey); Assert.Contains("shared-genre", candidate.SignalsJson, StringComparison.Ordinal);
|
||||
Assert.Equal(Guid.Parse("11111111-1111-1111-1111-111111111111"), candidate.CanonicalRecordingId);
|
||||
@@ -269,6 +275,33 @@ public sealed class IntelligenceCoreTests : IAsyncLifetime
|
||||
Assert.NotNull(run.CompletedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TransientProviderFailureUsesDurableRetryAndThenCompletes()
|
||||
{
|
||||
await _policies.SetAsync(_scope, new(true, 30, ["play"], ["flaky"]));
|
||||
await new RecommendationRunService(_factory, _jobs, _clock).EnqueueAsync(
|
||||
_scope, [], 10, "retry-run");
|
||||
var provider = new FailOnceProvider();
|
||||
var handler = new RecommendationRunJobHandler(_factory, [provider],
|
||||
new ListeningProfileService(_factory, _clock), _clock);
|
||||
var firstClaim = await _jobs.ClaimNextAsync("retry-worker", ["recommendation.generate"]);
|
||||
var first = await handler.ExecuteAsync(new(firstClaim!, EmptyServices.Instance), default);
|
||||
await _jobs.CompleteAsync(firstClaim!, first);
|
||||
Assert.Equal(DurableJobCompletionKind.Retry, first.Kind);
|
||||
await using (var failed = await _factory.CreateDbContextAsync())
|
||||
Assert.Equal(RecommendationRunState.Failed,
|
||||
(await failed.RecommendationRuns.SingleAsync()).State);
|
||||
|
||||
_clock.UtcNow = _clock.UtcNow.AddMinutes(1);
|
||||
var retryClaim = await _jobs.ClaimNextAsync("retry-worker", ["recommendation.generate"]);
|
||||
var retry = await handler.ExecuteAsync(new(retryClaim!, EmptyServices.Instance), default);
|
||||
await _jobs.CompleteAsync(retryClaim!, retry);
|
||||
Assert.Equal(DurableJobCompletionKind.Succeeded, retry.Kind);
|
||||
await using var completed = await _factory.CreateDbContextAsync();
|
||||
Assert.Equal(RecommendationRunState.Succeeded,
|
||||
(await completed.RecommendationRuns.SingleAsync()).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmptySeedRunUsesWeightedExactScopeListeningHabits()
|
||||
{
|
||||
@@ -315,6 +348,17 @@ public sealed class IntelligenceCoreTests : IAsyncLifetime
|
||||
public Task<RecommendationProviderResult> RecommendAsync(RecommendationRequest request)
|
||||
{ Seeds = request.SeedTrackKeys; return Task.FromResult(new RecommendationProviderResult(RecommendationProviderState.Succeeded, [])); }
|
||||
}
|
||||
private sealed class FailOnceProvider : IRecommendationProvider
|
||||
{
|
||||
private bool _failed;
|
||||
public string Id => "flaky";
|
||||
public Task<RecommendationProviderResult> RecommendAsync(RecommendationRequest request)
|
||||
{
|
||||
if (!_failed) { _failed = true; throw new HttpRequestException(); }
|
||||
return Task.FromResult(new RecommendationProviderResult(
|
||||
RecommendationProviderState.Succeeded, []));
|
||||
}
|
||||
}
|
||||
private sealed class Clock(DateTimeOffset now) : IPlatformClock { public DateTimeOffset UtcNow { get; set; } = now; }
|
||||
private sealed class Factory(DbContextOptions<AllstarrDbContext> options) : IDbContextFactory<AllstarrDbContext>
|
||||
{ public AllstarrDbContext CreateDbContext() => new(options); public Task<AllstarrDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) => Task.FromResult(CreateDbContext()); }
|
||||
|
||||
@@ -40,6 +40,15 @@ public sealed class IntelligenceController(
|
||||
item.OwnerUserId == scope.OwnerUserId && item.Protocol == scope.Protocol &&
|
||||
item.BackendInstanceId == scope.BackendInstanceId && item.LibraryScopeId == scope.LibraryScopeId)
|
||||
.OrderByDescending(item => item.CreatedAt).FirstOrDefaultAsync(cancellationToken);
|
||||
var latestJob = latestRun == null ? null : await db.Jobs.AsNoTracking().SingleOrDefaultAsync(item =>
|
||||
item.Id == latestRun.JobId && item.TenantId == scope.TenantId &&
|
||||
item.OwnerUserId == scope.OwnerUserId && item.LibraryScopeId == scope.LibraryScopeId,
|
||||
cancellationToken);
|
||||
var latestProgress = latestJob == null ? null : await db.AuditEvents.AsNoTracking()
|
||||
.Where(item => item.Category == "job-progress" &&
|
||||
item.CorrelationId == latestJob.CorrelationId)
|
||||
.OrderByDescending(item => item.CreatedAt).Select(item => item.DetailsJson)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
var candidates = latestRun == null ? [] : await db.RecommendationCandidates.AsNoTracking()
|
||||
.Where(item => item.RunId == latestRun.Id && item.TenantId == scope.TenantId && item.OwnerUserId == scope.OwnerUserId)
|
||||
.OrderBy(item => item.Position).Take(100).ToListAsync(cancellationToken);
|
||||
@@ -116,7 +125,16 @@ public sealed class IntelligenceController(
|
||||
canRun = policy?.Enabled == true && enabledProviders.Any(id => readinessById.TryGetValue(id, out var item) && item.State == RecommendationProviderReadinessState.Ready),
|
||||
canGenerate = latestRun?.State == RecommendationRunState.Succeeded,
|
||||
latestRunId = latestRun?.Id,
|
||||
latestRunState = latestRun?.State.ToString().ToLowerInvariant()
|
||||
latestRunState = latestJob?.State.ToString().ToLowerInvariant() ??
|
||||
latestRun?.State.ToString().ToLowerInvariant(),
|
||||
latestJobId = latestJob?.Id,
|
||||
latestJob?.AttemptCount,
|
||||
latestJob?.FailureCount,
|
||||
latestJob?.MaxAttempts,
|
||||
canCancel = latestJob?.State is DurableJobState.Pending or DurableJobState.RetryScheduled
|
||||
or DurableJobState.Running,
|
||||
progress = latestProgress == null ? (JsonElement?)null :
|
||||
JsonSerializer.Deserialize<JsonElement>(latestProgress)
|
||||
},
|
||||
candidates = candidates.Select(item =>
|
||||
{
|
||||
|
||||
@@ -171,12 +171,22 @@ public sealed class RecommendationRunJobHandler(IDbContextFactory<AllstarrDbCont
|
||||
if (run.State == RecommendationRunState.Succeeded) return await EnsureScheduledSetAsync(db, run, snapshot.Automation, cancellationToken);
|
||||
var scope = new IntelligenceScope(run.TenantId, run.OwnerUserId, run.Protocol, run.BackendInstanceId, run.LibraryScopeId);
|
||||
var policy = await IntelligencePolicyService.Query(db, scope).AsNoTracking().SingleOrDefaultAsync(cancellationToken); if (policy?.Enabled != true) { run.State = RecommendationRunState.Cancelled; run.CompletedAt = clock.UtcNow; await db.SaveChangesAsync(cancellationToken); return DurableJobCompletion.Cancelled(); }
|
||||
var enabled = snapshot.EnabledProviders.ToHashSet(StringComparer.Ordinal); run.State = RecommendationRunState.Running; run.UpdatedAt = clock.UtcNow; run.Revision++; await db.SaveChangesAsync(cancellationToken);
|
||||
var enabled = snapshot.EnabledProviders.ToHashSet(StringComparer.Ordinal);
|
||||
var selectedProviders = providers.Where(item => enabled.Contains(item.Id))
|
||||
.OrderBy(item => item.Id, StringComparer.Ordinal).ToArray();
|
||||
run.State = RecommendationRunState.Running; run.CompletedAt = null; run.ErrorCode = null;
|
||||
run.UpdatedAt = clock.UtcNow; run.Revision++; await db.SaveChangesAsync(cancellationToken);
|
||||
await execution.ReportProgressAsync(new("recommendation.profile",
|
||||
"Building the retained listening profile.", 0, selectedProviders.Length), cancellationToken);
|
||||
var profile = await profiles.BuildAsync(scope, cancellationToken); var seeds = JsonSerializer.Deserialize<string[]>(run.SeedTrackKeysJson) ?? [];
|
||||
if (seeds.Length == 0) seeds = profile.TopTrackKeys.ToArray();
|
||||
var results = new List<RecommendationCandidate>();
|
||||
foreach (var provider in providers.Where(x => enabled.Contains(x.Id)))
|
||||
for (var providerIndex = 0; providerIndex < selectedProviders.Length; providerIndex++)
|
||||
{
|
||||
var provider = selectedProviders[providerIndex];
|
||||
await execution.ReportProgressAsync(new("recommendation.provider",
|
||||
$"Searching {provider.Id}.", providerIndex, selectedProviders.Length,
|
||||
Provider: provider.Id), cancellationToken);
|
||||
RecommendationProviderResult outcome; try { outcome = await provider.RecommendAsync(new(scope, run.Id, profile, seeds, run.Limit, run.IdempotencyKey, true, cancellationToken)); }
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -185,8 +195,18 @@ public sealed class RecommendationRunJobHandler(IDbContextFactory<AllstarrDbCont
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return DurableJobCompletion.Cancelled();
|
||||
}
|
||||
catch { return DurableJobCompletion.Retry("recommendation_provider_temporary_failure", "A recommendation provider temporarily failed."); }
|
||||
if (outcome.State == RecommendationProviderState.Succeeded) results.AddRange(outcome.Candidates);
|
||||
catch
|
||||
{
|
||||
run.State = RecommendationRunState.Failed; run.ErrorCode = "recommendation_provider_temporary_failure";
|
||||
run.CompletedAt = clock.UtcNow; run.UpdatedAt = clock.UtcNow; run.Revision++;
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
return DurableJobCompletion.Retry("recommendation_provider_temporary_failure", "A recommendation provider temporarily failed.");
|
||||
}
|
||||
if (outcome.State == RecommendationProviderState.Succeeded)
|
||||
results.AddRange(outcome.Candidates.Take(run.Limit * 4));
|
||||
await execution.ReportProgressAsync(new("recommendation.provider",
|
||||
$"Finished {provider.Id}.", providerIndex + 1, selectedProviders.Length,
|
||||
Provider: provider.Id), cancellationToken);
|
||||
}
|
||||
var excludedTrackKeys = await db.RecommendationFeedback.AsNoTracking().Where(item =>
|
||||
item.TenantId == run.TenantId && item.OwnerUserId == run.OwnerUserId &&
|
||||
@@ -201,11 +221,15 @@ public sealed class RecommendationRunJobHandler(IDbContextFactory<AllstarrDbCont
|
||||
var ordered = deduplicated.Where(item => !excluded.Contains(item.TrackKey)).Take(run.Limit)
|
||||
.Concat(deduplicated.Where(item => excluded.Contains(item.TrackKey)).Take(Math.Min(100, run.Limit))
|
||||
.Select(item => item with { Exclusions = ["user-feedback"] })).ToArray();
|
||||
await execution.ReportProgressAsync(new("recommendation.rank",
|
||||
$"Ranking {ordered.Length} candidate tracks.", 0, ordered.Length), cancellationToken);
|
||||
ordered = await ResolveProvenanceAsync(db, run, ordered, cancellationToken);
|
||||
db.RecommendationCandidates.RemoveRange(db.RecommendationCandidates.Where(x => x.RunId == run.Id));
|
||||
for (var i = 0; i < ordered.Length; i++) db.RecommendationCandidates.Add(new() { Id = Guid.CreateVersion7(), RunId = run.Id, TenantId = run.TenantId, OwnerUserId = run.OwnerUserId, Position = i, TrackKey = ordered[i].TrackKey, Score = ordered[i].Score, Source = ordered[i].Source, SignalsJson = JsonSerializer.Serialize(ordered[i].Signals), IdentityJson = JsonSerializer.Serialize(ordered[i].Identity), CanonicalRecordingId = ordered[i].CanonicalRecordingId, ProviderAccountId = ordered[i].ProviderAccountId, SourceRevision = ordered[i].SourceRevision ?? $"run:{run.Id:N}", ExclusionsJson = JsonSerializer.Serialize(ordered[i].Exclusions), CreatedAt = clock.UtcNow, Revision = 1 });
|
||||
run.State = RecommendationRunState.Succeeded; run.CompletedAt = clock.UtcNow; run.UpdatedAt = clock.UtcNow; run.Revision++;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
await execution.ReportProgressAsync(new("recommendation.complete",
|
||||
$"Saved {ordered.Length} recommendation tracks.", ordered.Length, ordered.Length), cancellationToken);
|
||||
return await EnsureScheduledSetAsync(db, run, snapshot.Automation, cancellationToken);
|
||||
}
|
||||
private async Task<DurableJobCompletion> EnsureScheduledSetAsync(AllstarrDbContext db,
|
||||
@@ -340,6 +364,8 @@ public sealed class GeneratedSetMaterializationJobHandler(IDbContextFactory<Alls
|
||||
var candidates = entries.Select(x => new RecommendationCandidate(x.TrackKey, x.Score, x.Source,
|
||||
JsonSerializer.Deserialize<RecommendationSignal[]>(x.ExplanationJson) ?? [],
|
||||
JsonSerializer.Deserialize<RecommendationTrackIdentity>(x.IdentityJson))).ToArray();
|
||||
await execution.ReportProgressAsync(new("playlist.materialize",
|
||||
$"Creating {set.Name}.", 0, candidates.Length, Playlist: set.Name), cancellationToken);
|
||||
GeneratedSetMaterializationResult result;
|
||||
try
|
||||
{
|
||||
@@ -352,7 +378,7 @@ public sealed class GeneratedSetMaterializationJobHandler(IDbContextFactory<Alls
|
||||
set.UpdatedAt = Now; set.Revision++; await db.SaveChangesAsync(CancellationToken.None);
|
||||
return DurableJobCompletion.Cancelled();
|
||||
}
|
||||
if (result.Succeeded) { set.MaterializationState = GeneratedSetMaterializationState.Succeeded; set.BackendPlaylistId = result.BackendPlaylistId; set.TargetRevision = result.TargetRevision; set.MaterializedAt = Now; set.UpdatedAt = Now; set.Revision++; await db.SaveChangesAsync(cancellationToken); return DurableJobCompletion.Success(); }
|
||||
if (result.Succeeded) { set.MaterializationState = GeneratedSetMaterializationState.Succeeded; set.BackendPlaylistId = result.BackendPlaylistId; set.TargetRevision = result.TargetRevision; set.MaterializedAt = Now; set.UpdatedAt = Now; set.Revision++; await db.SaveChangesAsync(cancellationToken); await execution.ReportProgressAsync(new("playlist.materialize", $"Created {set.Name}.", candidates.Length, candidates.Length, Playlist: set.Name), cancellationToken); return DurableJobCompletion.Success(); }
|
||||
set.MaterializationState = GeneratedSetMaterializationState.Failed; set.LastErrorCode = result.SafeErrorCode ?? "generated_set_failed"; set.UpdatedAt = Now; set.Revision++; await db.SaveChangesAsync(cancellationToken);
|
||||
return result.Retryable ? DurableJobCompletion.Retry(result.SafeErrorCode ?? "generated_set_retry", "Generated playlist materialization will retry.")
|
||||
: DurableJobCompletion.Failure(result.SafeErrorCode ?? "generated_set_failed", "Generated playlist materialization failed.");
|
||||
|
||||
@@ -564,6 +564,20 @@ export type IntelligenceState = {
|
||||
canGenerate: boolean;
|
||||
latestRunId?: string | null;
|
||||
latestRunState?: string | null;
|
||||
latestJobId?: string | null;
|
||||
attemptCount?: number | null;
|
||||
failureCount?: number | null;
|
||||
maxAttempts?: number | null;
|
||||
canCancel?: boolean;
|
||||
progress?: {
|
||||
stage: string;
|
||||
message: string;
|
||||
completed?: number | null;
|
||||
total?: number | null;
|
||||
provider?: string | null;
|
||||
playlist?: string | null;
|
||||
track?: string | null;
|
||||
} | null;
|
||||
};
|
||||
candidates: Array<{
|
||||
id: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog } from "bits-ui";
|
||||
import { intelligence, type IntelligenceScope, type IntelligenceState } from "$lib/api";
|
||||
import { home, intelligence, type IntelligenceScope, type IntelligenceState } from "$lib/api";
|
||||
|
||||
let protocol = $state("jellyfin");
|
||||
let backendInstanceId = $state("");
|
||||
@@ -18,6 +18,13 @@
|
||||
|
||||
const scope = $derived<IntelligenceScope>({ protocol, backendInstanceId, libraryScopeId });
|
||||
const visibleCandidates = $derived(data?.candidates.filter((item) => !item.exclusions.length) ?? []);
|
||||
const runState = $derived(data?.actions.latestRunState?.replace("retryscheduled", "retry scheduled"));
|
||||
|
||||
$effect(() => {
|
||||
if (!["pending", "running", "retry scheduled"].includes(runState ?? "")) return;
|
||||
const timer = setTimeout(() => void load(), 1500);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
function adopt(next: IntelligenceState) {
|
||||
data = next;
|
||||
@@ -69,7 +76,7 @@
|
||||
</div>
|
||||
{#if data?.actions.canRun}
|
||||
<div class="heading-actions">
|
||||
{#if data.actions.latestRunState}<span class={`status-pill ${data.actions.latestRunState === "succeeded" ? "healthy" : "suggested"}`}>{data.actions.latestRunState}</span>{/if}
|
||||
{#if runState}<span class={`status-pill ${runState === "succeeded" ? "healthy" : "suggested"}`}>{runState}</span>{/if}
|
||||
<button class="button-primary" type="button" disabled={Boolean(action)}
|
||||
onclick={() => void perform("run", () => intelligence.run(scope))}>
|
||||
{action === "run" ? "Starting…" : "Refresh recommendations"}
|
||||
@@ -107,6 +114,16 @@
|
||||
{#if data.state === "degraded"}
|
||||
<div class="degraded-banner" role="status"><span aria-hidden="true">!</span><p><strong>Some discovery sources need attention.</strong> Existing results remain available.</p></div>
|
||||
{/if}
|
||||
{#if data.actions.progress}
|
||||
<section class="panel run-progress" role="status">
|
||||
<div><p class="eyebrow">{data.actions.progress.stage}</p><strong>{data.actions.progress.message}</strong>
|
||||
{#if data.actions.progress.provider || data.actions.progress.playlist || data.actions.progress.track}<small>{[data.actions.progress.provider, data.actions.progress.playlist, data.actions.progress.track].filter(Boolean).join(" · ")}</small>{/if}
|
||||
{#if data.actions.attemptCount && data.actions.maxAttempts}<small>Attempt {data.actions.attemptCount} of {data.actions.maxAttempts}{data.actions.failureCount ? ` · ${data.actions.failureCount} failed` : ""}</small>{/if}
|
||||
</div>
|
||||
{#if data.actions.progress.total}<progress max={data.actions.progress.total} value={data.actions.progress.completed ?? 0}>{data.actions.progress.completed ?? 0} / {data.actions.progress.total}</progress>{/if}
|
||||
{#if data.actions.canCancel && data.actions.latestJobId}<button class="button-secondary" type="button" disabled={Boolean(action)} onclick={() => void perform("cancel", () => home.cancelJob(data!.actions.latestJobId!))}>{action === "cancel" ? "Cancelling…" : "Cancel refresh"}</button>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<div class="intelligence-grid">
|
||||
<section class="panel recommendations">
|
||||
@@ -190,7 +207,7 @@
|
||||
</AlertDialog.Root>
|
||||
|
||||
<style>
|
||||
.intelligence-view{display:grid;gap:1.25rem}.route-heading{display:flex;align-items:end;justify-content:space-between;gap:1rem}.route-heading h2{margin:.25rem 0;font-family:var(--font-display);font-size:clamp(1.5rem,3vw,2.2rem)}.route-heading p:last-child,.muted{color:var(--color-ink-muted)}.heading-actions{display:flex;align-items:center;gap:.75rem}.scope-card{display:grid;grid-template-columns:repeat(3,minmax(0,1fr)) auto;align-items:end;gap:1rem;padding:1rem}.field select{min-height:3rem;border:1px solid var(--color-edge);border-radius:.8rem;background:var(--color-panel-raised);color:var(--color-ink);padding:0 .9rem}.intelligence-grid{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(18rem,.8fr);gap:1rem}.recommendations,.profile-card,.generated-card,.privacy-card{padding:1.15rem}.recommendations>header,.privacy-card>header{display:flex;align-items:center;justify-content:space-between}.recommendations h3,.profile-card h3,.generated-card h3,.privacy-card h3{margin:.2rem 0 1rem}.recommendation-list{display:grid;margin:0;padding:0;list-style:none}.recommendation-list>li{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.85rem;align-items:center;border-top:1px solid var(--color-edge);padding:.9rem 0}.track-art{display:grid;width:3rem;height:3rem;place-items:center;overflow:hidden;border-radius:.7rem;background:var(--color-panel-raised);color:var(--color-signal)}.track-art img{width:100%;height:100%;object-fit:cover}.track-copy{min-width:0}.track-copy>strong,.track-copy>small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.track-copy small,summary{color:var(--color-ink-muted);font-size:.75rem}.track-copy details{margin-top:.35rem}.track-copy ul{margin:.4rem 0 0;padding-left:1.1rem;color:var(--color-ink-muted);font-size:.78rem}.track-actions{display:flex;align-items:center;gap:.35rem}.track-actions button{border:1px solid var(--color-edge);border-radius:.55rem;background:transparent;color:var(--color-ink-muted);padding:.35rem .5rem;cursor:pointer}.score{color:var(--color-signal);font-size:.75rem;font-weight:800}.side-stack{display:grid;align-content:start;gap:1rem}.profile-card label,.generated-row{display:flex;align-items:center;justify-content:space-between;gap:1rem;border-top:1px solid var(--color-edge);padding:.7rem 0}.profile-card meter{width:55%;accent-color:var(--color-signal)}.generated-row span:first-child strong,.generated-row span:first-child small{display:block}.generated-row small{color:var(--color-ink-muted)}.generate-form{display:grid;gap:.75rem;margin-top:1rem}.privacy-card{grid-column:1/-1}.privacy-card form{display:grid;grid-template-columns:minmax(14rem,.6fr) 1fr 1fr;gap:1rem}.toggle-line{display:flex;gap:.75rem}.toggle-line span>*{display:block}.toggle-line small,fieldset small{color:var(--color-ink-muted)}fieldset{display:grid;align-content:start;gap:.55rem;border:0;margin:0;padding:0}fieldset legend{margin-bottom:.55rem;font-weight:750}fieldset label{display:flex;gap:.5rem}.unavailable{opacity:.5}.privacy-card footer{grid-column:1/-1;display:flex;justify-content:space-between;gap:.75rem;border-top:1px solid var(--color-edge);padding-top:1rem}
|
||||
@media(max-width:900px){.scope-card{grid-template-columns:1fr 1fr}.intelligence-grid{grid-template-columns:1fr}.privacy-card{grid-column:auto}.privacy-card form{grid-template-columns:1fr 1fr}}
|
||||
@media(max-width:620px){.route-heading{align-items:stretch;flex-direction:column}.scope-card,.privacy-card form{grid-template-columns:1fr}.recommendation-list>li{grid-template-columns:auto minmax(0,1fr)}.track-actions{grid-column:2;flex-wrap:wrap}.privacy-card footer{grid-column:auto;flex-direction:column-reverse}.privacy-card footer>*{width:100%}}
|
||||
.intelligence-view{display:grid;gap:1.25rem}.route-heading{display:flex;align-items:end;justify-content:space-between;gap:1rem}.route-heading h2{margin:.25rem 0;font-family:var(--font-display);font-size:clamp(1.5rem,3vw,2.2rem)}.route-heading p:last-child,.muted{color:var(--color-ink-muted)}.heading-actions{display:flex;align-items:center;gap:.75rem}.scope-card{display:grid;grid-template-columns:repeat(3,minmax(0,1fr)) auto;align-items:end;gap:1rem;padding:1rem}.field select{min-height:3rem;border:1px solid var(--color-edge);border-radius:.8rem;background:var(--color-panel-raised);color:var(--color-ink);padding:0 .9rem}.run-progress{display:grid;grid-template-columns:minmax(0,1fr) minmax(10rem,.5fr) auto;align-items:center;gap:1rem;padding:1rem}.run-progress p{margin:0}.run-progress small{display:block;color:var(--color-ink-muted)}.run-progress progress{width:100%;accent-color:var(--color-signal)}.intelligence-grid{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(18rem,.8fr);gap:1rem}.recommendations,.profile-card,.generated-card,.privacy-card{padding:1.15rem}.recommendations>header,.privacy-card>header{display:flex;align-items:center;justify-content:space-between}.recommendations h3,.profile-card h3,.generated-card h3,.privacy-card h3{margin:.2rem 0 1rem}.recommendation-list{display:grid;margin:0;padding:0;list-style:none}.recommendation-list>li{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.85rem;align-items:center;border-top:1px solid var(--color-edge);padding:.9rem 0}.track-art{display:grid;width:3rem;height:3rem;place-items:center;overflow:hidden;border-radius:.7rem;background:var(--color-panel-raised);color:var(--color-signal)}.track-art img{width:100%;height:100%;object-fit:cover}.track-copy{min-width:0}.track-copy>strong,.track-copy>small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.track-copy small,summary{color:var(--color-ink-muted);font-size:.75rem}.track-copy details{margin-top:.35rem}.track-copy ul{margin:.4rem 0 0;padding-left:1.1rem;color:var(--color-ink-muted);font-size:.78rem}.track-actions{display:flex;align-items:center;gap:.35rem}.track-actions button{border:1px solid var(--color-edge);border-radius:.55rem;background:transparent;color:var(--color-ink-muted);padding:.35rem .5rem;cursor:pointer}.score{color:var(--color-signal);font-size:.75rem;font-weight:800}.side-stack{display:grid;align-content:start;gap:1rem}.profile-card label,.generated-row{display:flex;align-items:center;justify-content:space-between;gap:1rem;border-top:1px solid var(--color-edge);padding:.7rem 0}.profile-card meter{width:55%;accent-color:var(--color-signal)}.generated-row span:first-child strong,.generated-row span:first-child small{display:block}.generated-row small{color:var(--color-ink-muted)}.generate-form{display:grid;gap:.75rem;margin-top:1rem}.privacy-card{grid-column:1/-1}.privacy-card form{display:grid;grid-template-columns:minmax(14rem,.6fr) 1fr 1fr;gap:1rem}.toggle-line{display:flex;gap:.75rem}.toggle-line span>*{display:block}.toggle-line small,fieldset small{color:var(--color-ink-muted)}fieldset{display:grid;align-content:start;gap:.55rem;border:0;margin:0;padding:0}fieldset legend{margin-bottom:.55rem;font-weight:750}fieldset label{display:flex;gap:.5rem}.unavailable{opacity:.5}.privacy-card footer{grid-column:1/-1;display:flex;justify-content:space-between;gap:.75rem;border-top:1px solid var(--color-edge);padding-top:1rem}
|
||||
@media(max-width:900px){.scope-card{grid-template-columns:1fr 1fr}.run-progress{grid-template-columns:1fr}.intelligence-grid{grid-template-columns:1fr}.privacy-card{grid-column:auto}.privacy-card form{grid-template-columns:1fr 1fr}}
|
||||
@media(max-width:620px){.route-heading{align-items:stretch;flex-direction:column}.scope-card,.privacy-card form,.run-progress{grid-template-columns:1fr}.recommendation-list>li{grid-template-columns:auto minmax(0,1fr)}.track-actions{grid-column:2;flex-wrap:wrap}.privacy-card footer{grid-column:auto;flex-direction:column-reverse}.privacy-card footer>*{width:100%}}
|
||||
</style>
|
||||
@@ -173,7 +173,12 @@ const responses: Record<string, unknown> = {
|
||||
enabled: true, available: true, state: "ready",
|
||||
}],
|
||||
actions: {
|
||||
canRun: true, canGenerate: true, latestRunId: "run-1", latestRunState: "succeeded",
|
||||
canRun: true, canGenerate: true, latestRunId: "run-1", latestRunState: "running",
|
||||
latestJobId: "job-1", attemptCount: 1, failureCount: 0, maxAttempts: 5,
|
||||
canCancel: true, progress: {
|
||||
stage: "recommendation.provider", message: "Searching Lumen Audio.",
|
||||
completed: 1, total: 2, provider: "lumen-audio", track: "Future Song",
|
||||
},
|
||||
},
|
||||
candidates: [{
|
||||
id: "candidate-1", trackKey: "track-1", title: "Future Song", artist: "Artist",
|
||||
@@ -381,9 +386,11 @@ for (const viewport of viewports) {
|
||||
await page.getByLabel("Library scope").fill("music");
|
||||
await page.getByRole("button", { name: "Open library" }).click();
|
||||
await expect(page.getByLabel("Loading Intelligence")).toBeVisible();
|
||||
await expect(page.getByText("Future Song")).toBeVisible();
|
||||
await expect(page.getByText("Future Song", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Morning discovery")).toBeVisible();
|
||||
await expect(page.getByText("Private similarity source. · ready")).toBeVisible();
|
||||
await expect(page.getByText("Searching Lumen Audio.")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Cancel refresh" })).toBeInViewport();
|
||||
await expect(page.getByRole("button", { name: "Refresh recommendations" })).toBeInViewport();
|
||||
await expect.poll(() => page.evaluate(() =>
|
||||
document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||||
|
||||
Reference in new issue
Block a user