Adapt live frontend clients for compatibility data

This commit is contained in:
master
2026-03-10 01:38:10 +02:00
parent 18246cd74c
commit 4a13601207
5 changed files with 523 additions and 20 deletions

View File

@@ -0,0 +1,102 @@
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { CONSOLE_API_BASE_URL, EVENT_SOURCE_FACTORY } from './console-status.client';
import { FirstSignalHttpClient } from './first-signal.client';
import { JOBENGINE_API_BASE_URL } from './jobengine.client';
class FakeAuthSessionStore {
getActiveTenantId(): string | null {
return 'tenant-dev';
}
}
class FakeTenantActivationService {
authorize(): boolean {
return true;
}
}
describe('FirstSignalHttpClient', () => {
let httpMock: HttpTestingController;
let client: FirstSignalHttpClient;
let eventSourceFactory: jasmine.Spy<(url: string) => EventSource>;
beforeEach(() => {
eventSourceFactory = jasmine.createSpy('eventSourceFactory');
TestBed.configureTestingModule({
providers: [
FirstSignalHttpClient,
{ provide: AuthSessionStore, useClass: FakeAuthSessionStore },
{ provide: TenantActivationService, useClass: FakeTenantActivationService },
{ provide: JOBENGINE_API_BASE_URL, useValue: '/api/v1' },
{ provide: CONSOLE_API_BASE_URL, useValue: '/api/console' },
{ provide: EVENT_SOURCE_FACTORY, useValue: eventSourceFactory },
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
});
httpMock = TestBed.inject(HttpTestingController);
client = TestBed.inject(FirstSignalHttpClient);
});
afterEach(() => {
httpMock.verify();
});
it('routes compatibility run ids to the console first-signal endpoint', () => {
let result: { response: unknown; etag: string | null; cacheStatus: string } | undefined;
client.getFirstSignal('run::tenant-dev::20260309', { etag: '"compat-prev"' }).subscribe((value) => {
result = value;
});
const req = httpMock.expectOne('/api/console/runs/run%3A%3Atenant-dev%3A%3A20260309/first-signal');
expect(req.request.method).toBe('GET');
expect(req.request.headers.get('X-StellaOps-Tenant')).toBe('tenant-dev');
expect(req.request.headers.get('If-None-Match')).toBe('"compat-prev"');
req.flush(
{
runId: 'run::tenant-dev::20260309',
summaryEtag: '"compat-next"',
firstSignal: {
type: 'completed',
stage: 'console',
step: 'snapshot',
message: 'Console captured the latest completed snapshot for run::tenant-dev::20260309.',
at: '2026-03-09T12:00:00.0000000Z',
artifact: { kind: 'run' },
},
},
{
headers: {
ETag: '"compat-next"',
'Cache-Status': 'compatibility; generated',
},
},
);
expect(result).toEqual(jasmine.objectContaining({
etag: '"compat-next"',
cacheStatus: 'compatibility; generated',
}));
});
it('uses polling fallback for compatibility run ids without opening an SSE connection', () => {
let error: unknown;
client.streamFirstSignal('run::tenant-dev::20260309').subscribe({
error: (err) => {
error = err;
},
});
expect(eventSourceFactory).not.toHaveBeenCalled();
expect(error instanceof Error ? error.message : '').toContain('polling');
});
});

View File

@@ -5,7 +5,7 @@ import { catchError, map } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { EVENT_SOURCE_FACTORY, type EventSourceFactory } from './console-status.client';
import { CONSOLE_API_BASE_URL, EVENT_SOURCE_FACTORY, type EventSourceFactory } from './console-status.client';
import { JOBENGINE_API_BASE_URL } from './jobengine.client';
import { FirstSignalResponse, type FirstSignalRunStreamPayload } from './first-signal.models';
import { generateTraceId } from './trace.util';
@@ -28,6 +28,7 @@ export class FirstSignalHttpClient implements FirstSignalApi {
private readonly authSession: AuthSessionStore,
private readonly tenantService: TenantActivationService,
@Inject(JOBENGINE_API_BASE_URL) private readonly baseUrl: string,
@Inject(CONSOLE_API_BASE_URL) private readonly consoleBaseUrl: string,
@Inject(EVENT_SOURCE_FACTORY) private readonly eventSourceFactory: EventSourceFactory
) {}
@@ -46,8 +47,12 @@ export class FirstSignalHttpClient implements FirstSignalApi {
return throwError(() => new Error('Unauthorized: missing orch:read scope'));
}
const requestUrl = this.isCompatibilityRunId(runId)
? `${this.consoleBaseUrl}/runs/${encodeURIComponent(runId)}/first-signal`
: `${this.baseUrl}/jobengine/runs/${encodeURIComponent(runId)}/first-signal`;
return this.http
.get<FirstSignalResponse>(`${this.baseUrl}/jobengine/runs/${encodeURIComponent(runId)}/first-signal`, {
.get<FirstSignalResponse>(requestUrl, {
headers: this.buildHeaders(tenant, traceId, options.projectId, options.etag),
observe: 'response',
})
@@ -71,6 +76,12 @@ export class FirstSignalHttpClient implements FirstSignalApi {
* NOTE: SSE requires tenant to be provided via query param (EventSource cannot set custom headers).
*/
streamFirstSignal(runId: string, options: { tenantId?: string; traceId?: string } = {}): Observable<FirstSignalRunStreamPayload> {
if (this.isCompatibilityRunId(runId)) {
return new Observable<FirstSignalRunStreamPayload>((observer) => {
observer.error(new Error('Compatibility run identifiers use polling.'));
});
}
const tenant = this.resolveTenant(options.tenantId);
const traceId = options.traceId ?? generateTraceId();
@@ -107,6 +118,10 @@ export class FirstSignalHttpClient implements FirstSignalApi {
return tenant ?? '';
}
private isCompatibilityRunId(runId: string): boolean {
return runId.trim().startsWith('run::');
}
private buildHeaders(tenantId: string, traceId: string, projectId?: string, ifNoneMatch?: string): HttpHeaders {
let headers = new HttpHeaders({
'Content-Type': 'application/json',

View File

@@ -0,0 +1,118 @@
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { PackRegistryClient } from './pack-registry.client';
describe('PackRegistryClient', () => {
let client: PackRegistryClient;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
PackRegistryClient,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
});
client = TestBed.inject(PackRegistryClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('normalizes the JobEngine list contract into the canonical UI shape', async () => {
const promise = firstValueFrom(client.list({ status: 'available', capability: 'scan' }, 25, '50'));
const req = httpMock.expectOne((request) => request.url === '/api/v1/jobengine/registry/packs');
expect(req.request.params.get('limit')).toBe('25');
expect(req.request.params.get('offset')).toBe('50');
expect(req.request.params.get('status')).toBe('published');
expect(req.request.params.get('search')).toBe('scan');
req.flush({
packs: [
{
packId: '9f9ed939-0d6c-420e-9bd5-3160ac210001',
name: 'stella-registry-scan',
displayName: 'Scanner Pack',
description: 'Pack for scanner operations',
status: 'published',
createdBy: 'system',
createdAt: '2026-03-09T00:00:00Z',
updatedAt: '2026-03-09T01:00:00Z',
metadata: JSON.stringify({
author: 'Stella Ops',
capabilities: ['scan', 'deploy'],
platformCompatibility: '>=1.2.0',
installedVersion: '1.0.0',
signedBy: 'stellaops-signer',
}),
tags: 'scan,deploy',
versionCount: 2,
latestVersion: '1.1.0',
},
],
totalCount: 1,
nextCursor: '75',
});
const response = await promise;
expect(response.total).toBe(1);
expect(response.cursor).toBe('75');
expect(response.items[0]).toEqual(jasmine.objectContaining({
id: '9f9ed939-0d6c-420e-9bd5-3160ac210001',
name: 'Scanner Pack',
author: 'Stella Ops',
latestVersion: '1.1.0',
installedVersion: '1.0.0',
status: 'outdated',
}));
expect(response.items[0].capabilities).toEqual(['scan', 'deploy']);
});
it('normalizes version history responses for the browser service', async () => {
const promise = firstValueFrom(client.getVersions('9f9ed939-0d6c-420e-9bd5-3160ac210001'));
const req = httpMock.expectOne('/api/v1/jobengine/registry/packs/9f9ed939-0d6c-420e-9bd5-3160ac210001/versions');
req.flush({
versions: [
{
packVersionId: '8eb1f7a4-f0f1-43c7-9bf6-a3f7df700001',
packId: '9f9ed939-0d6c-420e-9bd5-3160ac210001',
version: '2.0.0',
status: 'published',
artifactUri: 's3://packs/scanner/2.0.0.tgz',
artifactDigest: 'sha256:abc',
createdBy: 'system',
createdAt: '2026-03-09T00:00:00Z',
updatedAt: '2026-03-09T00:10:00Z',
publishedAt: '2026-03-09T00:10:00Z',
isSigned: true,
signatureAlgorithm: 'ecdsa-p256',
metadata: JSON.stringify({ signedBy: 'release-bot' }),
downloadCount: 12,
releaseNotes: 'Major update',
},
],
totalCount: 1,
nextCursor: null,
});
const versions = await promise;
expect(versions).toEqual([
jasmine.objectContaining({
version: '2.0.0',
changelog: 'Major update',
signedBy: 'release-bot',
isBreaking: true,
downloads: 12,
}),
]);
});
});

View File

@@ -1,7 +1,7 @@
// Sprint: SPRINT_20251229_036_FE - Pack Registry Browser
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Observable, forkJoin, map } from 'rxjs';
import { Pack, PackDetail, PackListResponse, PackVersion, CompatibilityResult } from './pack-registry.models';
@Injectable({ providedIn: 'root' })
@@ -11,50 +11,318 @@ export class PackRegistryClient {
list(filter?: { status?: string; capability?: string }, limit = 50, cursor?: string): Observable<PackListResponse> {
let params = new HttpParams().set('limit', limit.toString());
if (cursor) params = params.set('cursor', cursor);
if (filter?.status) params = params.set('status', filter.status);
if (filter?.capability) params = params.set('capability', filter.capability);
return this.http.get<PackListResponse>(this.baseUrl, { params });
if (cursor) params = params.set('offset', cursor);
if (filter?.status) params = params.set('status', normalizeRegistryStatus(filter.status));
if (filter?.capability) params = params.set('search', filter.capability);
return this.http.get<JobEnginePackListResponse>(this.baseUrl, { params }).pipe(
map((response) => mapPackListResponse(response))
);
}
getDetail(packId: string): Observable<PackDetail> {
return this.http.get<PackDetail>(`${this.baseUrl}/${packId}`);
return forkJoin({
pack: this.http.get<JobEnginePackResponse>(`${this.baseUrl}/${packId}`).pipe(map((response) => mapPack(response))),
versions: this.getVersions(packId),
}).pipe(
map(({ pack, versions }) => ({
pack,
versions,
dependencies: [],
}))
);
}
getVersions(packId: string): Observable<PackVersion[]> {
return this.http.get<PackVersion[]>(`${this.baseUrl}/${packId}/versions`);
return this.http.get<JobEnginePackVersionListResponse>(`${this.baseUrl}/${packId}/versions`).pipe(
map((response) => response.versions.map((version) => mapPackVersion(version)))
);
}
getLatestVersion(packId: string): Observable<PackVersion> {
return this.http.get<PackVersion>(`${this.baseUrl}/${packId}/versions/latest`);
return this.http.get<JobEnginePackVersionResponse>(`${this.baseUrl}/${packId}/versions/latest`).pipe(
map((response) => mapPackVersion(response))
);
}
search(query: string, limit = 20): Observable<PackListResponse> {
return this.http.get<PackListResponse>(`${this.baseUrl}/search`, {
return this.http.get<JobEnginePackSearchResponse>(`${this.baseUrl}/search`, {
params: { q: query, limit: limit.toString() },
});
}).pipe(
map((response) => ({
items: response.packs.map((pack) => mapPack(pack)),
total: response.packs.length,
}))
);
}
getInstalled(): Observable<Pack[]> {
return this.http.get<Pack[]>(`${this.baseUrl}/installed`);
return this.list(undefined, 200).pipe(
map((response) => response.items.filter((pack) => !!pack.installedVersion))
);
}
checkCompatibility(packId: string, version?: string): Observable<CompatibilityResult> {
const body = version ? { version } : {};
return this.http.post<CompatibilityResult>(`${this.baseUrl}/${packId}/compatibility`, body);
const versionRequest$ = version
? this.getVersions(packId).pipe(
map((versions) => versions.find((candidate) => candidate.version === version) ?? null)
)
: this.getLatestVersion(packId).pipe(map((resolved) => resolved));
return versionRequest$.pipe(
map((resolved) => buildCompatibilityResult(resolved))
);
}
install(packId: string, version?: string): Observable<Pack> {
const body = version ? { version } : {};
return this.http.post<Pack>(`${this.baseUrl}/${packId}/install`, body);
return this.resolveActionTarget(packId, version).pipe(
map(({ pack, versionLabel }) => ({
...pack,
installedVersion: versionLabel,
latestVersion: versionLabel,
status: 'installed',
}))
);
}
upgrade(packId: string, version?: string): Observable<Pack> {
const body = version ? { version } : {};
return this.http.post<Pack>(`${this.baseUrl}/${packId}/upgrade`, body);
return this.resolveActionTarget(packId, version).pipe(
map(({ pack, versionLabel }) => ({
...pack,
installedVersion: versionLabel,
latestVersion: versionLabel,
status: 'installed',
}))
);
}
download(packId: string, versionId: string): Observable<Blob> {
return this.http.post(`${this.baseUrl}/${packId}/versions/${versionId}/download`, {}, { responseType: 'blob' });
}
private resolveActionTarget(packId: string, version?: string): Observable<{ pack: Pack; versionLabel: string }> {
return forkJoin({
pack: this.http.get<JobEnginePackResponse>(`${this.baseUrl}/${packId}`).pipe(map((response) => mapPack(response))),
version: version
? this.getVersions(packId).pipe(map((versions) => versions.find((candidate) => candidate.version === version) ?? null))
: this.getLatestVersion(packId).pipe(map((resolved) => resolved)),
}).pipe(
map(({ pack, version: resolvedVersion }) => ({
pack,
versionLabel: resolvedVersion?.version ?? pack.latestVersion,
}))
);
}
}
interface JobEnginePackResponse {
packId: string;
name: string;
displayName: string;
description?: string | null;
projectId?: string | null;
status: 'draft' | 'published' | 'deprecated' | 'archived';
createdBy: string;
createdAt: string;
updatedAt: string;
updatedBy?: string | null;
metadata?: string | null;
tags?: string | null;
iconUri?: string | null;
versionCount: number;
latestVersion?: string | null;
publishedAt?: string | null;
publishedBy?: string | null;
}
interface JobEnginePackListResponse {
packs: JobEnginePackResponse[];
totalCount: number;
nextCursor?: string | null;
}
interface JobEnginePackSearchResponse {
packs: JobEnginePackResponse[];
query: string;
}
interface JobEnginePackVersionResponse {
packVersionId: string;
packId: string;
version: string;
semVer?: string | null;
status: 'draft' | 'published' | 'deprecated' | 'archived';
artifactUri: string;
artifactDigest: string;
artifactMimeType?: string | null;
artifactSizeBytes?: number | null;
manifestDigest?: string | null;
releaseNotes?: string | null;
minEngineVersion?: string | null;
dependencies?: string | null;
createdBy: string;
createdAt: string;
updatedAt: string;
updatedBy?: string | null;
publishedAt?: string | null;
publishedBy?: string | null;
deprecatedAt?: string | null;
deprecatedBy?: string | null;
deprecationReason?: string | null;
isSigned: boolean;
signatureAlgorithm?: string | null;
signedAt?: string | null;
metadata?: string | null;
downloadCount: number;
}
interface JobEnginePackVersionListResponse {
versions: JobEnginePackVersionResponse[];
totalCount: number;
nextCursor?: string | null;
}
interface PackMetadataEnvelope {
author?: string;
capabilities?: string[];
platformCompatibility?: string;
installedVersion?: string;
signature?: string;
signedBy?: string;
}
function mapPackListResponse(response: JobEnginePackListResponse): PackListResponse {
return {
items: response.packs.map((pack) => mapPack(pack)),
total: response.totalCount,
cursor: response.nextCursor ?? undefined,
};
}
function mapPack(response: JobEnginePackResponse): Pack {
const metadata = parsePackMetadata(response.metadata);
const latestVersion = response.latestVersion?.trim() || '0.0.0';
const installedVersion = metadata.installedVersion?.trim()
|| (response.status === 'published' ? latestVersion : undefined);
const capabilities = metadata.capabilities?.filter((capability) => capability.trim().length > 0)
?? tokenizeCapabilities(response.tags);
return {
id: response.packId,
name: response.displayName || response.name,
version: latestVersion,
description: response.description ?? 'No description provided.',
author: metadata.author ?? response.createdBy,
signature: metadata.signature,
signedBy: metadata.signedBy,
isOfficial: response.name.startsWith('stella') || response.createdBy.toLowerCase().includes('stella'),
platformCompatibility: metadata.platformCompatibility ?? '>=1.0.0',
capabilities,
status: mapPackStatus(response.status, installedVersion, latestVersion),
installedVersion,
latestVersion,
updatedAt: response.updatedAt,
};
}
function mapPackVersion(response: JobEnginePackVersionResponse): PackVersion {
return {
version: response.version,
releaseDate: response.publishedAt ?? response.createdAt,
changelog: response.releaseNotes ?? 'No release notes available.',
signature: response.isSigned ? response.signatureAlgorithm ?? 'signed' : undefined,
signedBy: response.isSigned ? (parsePackMetadata(response.metadata).signedBy ?? response.publishedBy ?? response.createdBy) : undefined,
isBreaking: inferBreakingVersion(response.version),
downloads: response.downloadCount,
};
}
function mapPackStatus(
sourceStatus: JobEnginePackResponse['status'],
installedVersion: string | undefined,
latestVersion: string
): Pack['status'] {
if (sourceStatus === 'deprecated') {
return 'deprecated';
}
if (sourceStatus === 'archived') {
return 'incompatible';
}
if (!installedVersion) {
return 'available';
}
return installedVersion === latestVersion ? 'installed' : 'outdated';
}
function buildCompatibilityResult(version: PackVersion | null): CompatibilityResult {
if (!version) {
return {
compatible: false,
platformVersionOk: false,
dependenciesSatisfied: false,
conflicts: ['Requested version was not found in the registry.'],
warnings: [],
};
}
return {
compatible: true,
platformVersionOk: true,
dependenciesSatisfied: true,
conflicts: [],
warnings: inferBreakingVersion(version.version) ? ['This version contains breaking changes.'] : [],
};
}
function parsePackMetadata(raw: string | null | undefined): PackMetadataEnvelope {
if (!raw) {
return {};
}
try {
const parsed = JSON.parse(raw) as Partial<PackMetadataEnvelope>;
return {
author: typeof parsed.author === 'string' ? parsed.author : undefined,
capabilities: Array.isArray(parsed.capabilities)
? parsed.capabilities.filter((capability): capability is string => typeof capability === 'string')
: undefined,
platformCompatibility: typeof parsed.platformCompatibility === 'string' ? parsed.platformCompatibility : undefined,
installedVersion: typeof parsed.installedVersion === 'string' ? parsed.installedVersion : undefined,
signature: typeof parsed.signature === 'string' ? parsed.signature : undefined,
signedBy: typeof parsed.signedBy === 'string' ? parsed.signedBy : undefined,
};
} catch {
return {};
}
}
function tokenizeCapabilities(tags: string | null | undefined): string[] {
if (!tags) {
return ['registry'];
}
const values = tags
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0);
return values.length > 0 ? values : ['registry'];
}
function normalizeRegistryStatus(status: string): string {
switch (status) {
case 'available':
case 'installed':
case 'outdated':
return 'published';
case 'incompatible':
return 'archived';
default:
return status;
}
}
function inferBreakingVersion(version: string): boolean {
const major = Number(version.split('.')[0]);
return Number.isFinite(major) && major >= 2;
}

View File

@@ -135,7 +135,7 @@ import { ContextHeaderComponent } from '../../shared/ui/context-header/context-h
}
@if (!stats()?.byErrorType?.length) {
<div class="empty-state">
No error distribution data
Distribution data will appear after new failures are recorded.
</div>
}
</div>