mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 02:27:44 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
// Run owns bounded convergence of durable cancellation intent to terminal state.
|
||||
import type {
|
||||
ClusterRunCancellationConvergenceCoordinator,
|
||||
ClusterRunCancellationConvergenceCycleResult,
|
||||
} from '@qinglong/runtime-core/cluster-run-cancellation-convergence';
|
||||
|
||||
export interface ClusterRunCancellationConvergenceLifecycleOptions {
|
||||
readonly intervalMs: number;
|
||||
readonly stopTimeoutMs: number;
|
||||
readonly onDiagnostic?: (
|
||||
error: unknown,
|
||||
summary?: Readonly<ClusterRunCancellationConvergenceCycleResult>,
|
||||
) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface ClusterRunCancellationConvergenceLifecycleStopSummary {
|
||||
readonly status: 'stopped' | 'timed_out';
|
||||
}
|
||||
|
||||
/** One constant-cost cadence for all pending non-executing Run cancellations. */
|
||||
export class ClusterRunCancellationConvergenceLifecycle {
|
||||
private timer: NodeJS.Timeout | undefined;
|
||||
private inFlight:
|
||||
| Promise<Readonly<ClusterRunCancellationConvergenceCycleResult>>
|
||||
| undefined;
|
||||
private stopPromise:
|
||||
| Promise<ClusterRunCancellationConvergenceLifecycleStopSummary>
|
||||
| undefined;
|
||||
private running = false;
|
||||
private stopping = false;
|
||||
|
||||
constructor(
|
||||
private readonly coordinator: Pick<
|
||||
ClusterRunCancellationConvergenceCoordinator,
|
||||
'reconcile'
|
||||
>,
|
||||
private readonly options: ClusterRunCancellationConvergenceLifecycleOptions,
|
||||
) {
|
||||
if (
|
||||
typeof coordinator?.reconcile !== 'function' ||
|
||||
!options ||
|
||||
typeof options !== 'object' ||
|
||||
Array.isArray(options) ||
|
||||
!Number.isSafeInteger(options.intervalMs) ||
|
||||
options.intervalMs < 250 ||
|
||||
options.intervalMs > 60 * 60_000 ||
|
||||
!Number.isSafeInteger(options.stopTimeoutMs) ||
|
||||
options.stopTimeoutMs < 100 ||
|
||||
options.stopTimeoutMs > 30_000 ||
|
||||
(options.onDiagnostic !== undefined &&
|
||||
typeof options.onDiagnostic !== 'function')
|
||||
) {
|
||||
throw new TypeError('Cluster Run cancellation lifecycle options are invalid');
|
||||
}
|
||||
}
|
||||
|
||||
start(): 'started' {
|
||||
if (!this.running && !this.stopping) {
|
||||
this.running = true;
|
||||
this.schedule();
|
||||
}
|
||||
return 'started';
|
||||
}
|
||||
|
||||
runOnce(): Promise<Readonly<ClusterRunCancellationConvergenceCycleResult>> {
|
||||
if (this.stopping) {
|
||||
return Promise.reject(
|
||||
new Error('Cluster Run cancellation lifecycle is stopping'),
|
||||
);
|
||||
}
|
||||
if (this.inFlight) return this.inFlight;
|
||||
const work = this.coordinator.reconcile().finally(() => {
|
||||
if (this.inFlight === work) this.inFlight = undefined;
|
||||
});
|
||||
this.inFlight = work;
|
||||
return work;
|
||||
}
|
||||
|
||||
stopAndDrain(): Promise<ClusterRunCancellationConvergenceLifecycleStopSummary> {
|
||||
if (this.stopPromise) return this.stopPromise;
|
||||
this.stopping = true;
|
||||
this.running = false;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
this.stopPromise = (async () => {
|
||||
const work = this.inFlight;
|
||||
if (!work) return Object.freeze({ status: 'stopped' as const });
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
work.then(
|
||||
() => Object.freeze({ status: 'stopped' as const }),
|
||||
() => Object.freeze({ status: 'stopped' as const }),
|
||||
),
|
||||
new Promise<ClusterRunCancellationConvergenceLifecycleStopSummary>(
|
||||
(resolve) => {
|
||||
timeout = setTimeout(
|
||||
() => resolve(Object.freeze({ status: 'timed_out' as const })),
|
||||
this.options.stopTimeoutMs,
|
||||
);
|
||||
timeout.unref?.();
|
||||
},
|
||||
),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
})();
|
||||
return this.stopPromise;
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (!this.running || this.timer) return;
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = undefined;
|
||||
if (!this.running) return;
|
||||
void this.runOnce()
|
||||
.then((summary) => this.diagnostic(undefined, summary))
|
||||
.catch((error) => this.diagnostic(error))
|
||||
.finally(() => this.schedule());
|
||||
}, this.options.intervalMs);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
private async diagnostic(
|
||||
error: unknown,
|
||||
summary?: Readonly<ClusterRunCancellationConvergenceCycleResult>,
|
||||
): Promise<void> {
|
||||
if (this.stopping) return;
|
||||
try {
|
||||
await this.options.onDiagnostic?.(error, summary);
|
||||
} catch {
|
||||
// Diagnostics cannot own or stop convergence.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Run owns its Policy-fenced durable cancellation mutation route.
|
||||
import {
|
||||
CLUSTER_RUN_CANCELLATION_SCHEMA,
|
||||
ClusterRunCancellationFenceRejectedError,
|
||||
ClusterRunCancellationNotFoundError,
|
||||
ClusterRunCancellationUnavailableError,
|
||||
InvalidClusterRunCancellationError,
|
||||
createClusterRunCancellationResponseBody,
|
||||
parseClusterRunCancellationRequestBody,
|
||||
type ClusterRunCancellationRepository,
|
||||
} from '@qinglong/runtime-core/cluster-run-cancellation';
|
||||
import type { ClusterControlAdmissionResponse } from '../transport/httpSurface';
|
||||
import type {
|
||||
ClusterControlAuthorizedOperationRequest,
|
||||
ClusterControlRouteDefinition,
|
||||
ClusterControlRouteParameters,
|
||||
} from '../transport/routeRegistry';
|
||||
|
||||
export const CLUSTER_CONTROL_RUN_CANCELLATION_ROUTE = Object.freeze({
|
||||
method: 'POST' as const,
|
||||
path: '/api/v3/projects/{projectId}/runs/{runId}/cancellation',
|
||||
operationId: 'run.cancel',
|
||||
permission: 'run.stop',
|
||||
projectParameter: 'projectId',
|
||||
});
|
||||
|
||||
export type ClusterRunCancellationEventIdFactory = () => string;
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): ClusterControlAdmissionResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a durable cancellation command. Authentication, authorization and
|
||||
* the first audit complete in admission; the repository revalidates the exact
|
||||
* policy fence in the same transaction that writes the Run intent and Event.
|
||||
*/
|
||||
export function createClusterControlRunCancellationRoute(
|
||||
repository: ClusterRunCancellationRepository,
|
||||
createEventId: ClusterRunCancellationEventIdFactory,
|
||||
): Readonly<ClusterControlRouteDefinition> {
|
||||
if (
|
||||
!repository ||
|
||||
typeof repository.requestUserCancellation !== 'function' ||
|
||||
typeof createEventId !== 'function'
|
||||
) {
|
||||
throw new TypeError('Cluster-control Run cancellation route is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
...CLUSTER_CONTROL_RUN_CANCELLATION_ROUTE,
|
||||
async handle(
|
||||
authorized: ClusterControlAuthorizedOperationRequest,
|
||||
parameters: ClusterControlRouteParameters,
|
||||
) {
|
||||
let body;
|
||||
try {
|
||||
body = parseClusterRunCancellationRequestBody(
|
||||
authorized.request.body,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidClusterRunCancellationError) {
|
||||
return response(400, {
|
||||
code: 'invalid_run_cancellation_request',
|
||||
schema: CLUSTER_RUN_CANCELLATION_SCHEMA,
|
||||
});
|
||||
}
|
||||
return response(503, { code: 'run_cancellation_unavailable' });
|
||||
}
|
||||
const projectId = authorized.projectId;
|
||||
const runId = parameters.runId;
|
||||
if (
|
||||
projectId === null ||
|
||||
typeof runId !== 'string' ||
|
||||
runId.length < 1 ||
|
||||
!authorized.policyFence ||
|
||||
authorized.policyFence.bindingVersion === null
|
||||
) {
|
||||
return response(503, { code: 'run_cancellation_unavailable' });
|
||||
}
|
||||
try {
|
||||
const result = await repository.requestUserCancellation({
|
||||
projectId,
|
||||
runId,
|
||||
mutationId: body.mutationId,
|
||||
eventId: createEventId(),
|
||||
subject: authorized.principal.subject,
|
||||
policyFence: authorized.policyFence,
|
||||
});
|
||||
return response(
|
||||
result.status === 'accepted' ? 202 : 200,
|
||||
createClusterRunCancellationResponseBody(result),
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ClusterRunCancellationNotFoundError) {
|
||||
return response(404, { code: 'run_not_found' });
|
||||
}
|
||||
if (error instanceof ClusterRunCancellationFenceRejectedError) {
|
||||
return response(409, {
|
||||
code: 'run_cancellation_fence_rejected',
|
||||
reason: error.reason,
|
||||
});
|
||||
}
|
||||
if (
|
||||
error instanceof InvalidClusterRunCancellationError ||
|
||||
error instanceof ClusterRunCancellationUnavailableError
|
||||
) {
|
||||
return response(503, { code: 'run_cancellation_unavailable' });
|
||||
}
|
||||
return response(503, { code: 'run_cancellation_unavailable' });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
BoundedRunEventListProjectionUnavailableError,
|
||||
InvalidBoundedRunEventListProjectionError,
|
||||
executeBoundedRunEventListProjection,
|
||||
} from '@qinglong/runtime-core/bounded-run-event-list-projection';
|
||||
import type { RunRepositoryReader } from '@qinglong/runtime-core/run-repository';
|
||||
|
||||
import type { ClusterControlAdmissionResponse } from '../transport/httpSurface';
|
||||
import type {
|
||||
ClusterControlAuthorizedOperationRequest,
|
||||
ClusterControlRouteDefinition,
|
||||
ClusterControlRouteParameters,
|
||||
} from '../transport/routeRegistry';
|
||||
|
||||
export const CLUSTER_CONTROL_RUN_EVENT_LIST_ROUTE = Object.freeze({
|
||||
method: 'GET' as const,
|
||||
path: '/api/v3/projects/{projectId}/runs/{runId}/events',
|
||||
operationId: 'run.events.list',
|
||||
permission: 'run.read',
|
||||
projectParameter: 'projectId',
|
||||
allowedQuery: Object.freeze(['after_sequence', 'limit']),
|
||||
});
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): ClusterControlAdmissionResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
function parseQuery(
|
||||
query: Readonly<Record<string, readonly string[]>>,
|
||||
): Readonly<{ afterSequence?: number; limit?: number }> {
|
||||
const afterValues = query.after_sequence;
|
||||
const limitValues = query.limit;
|
||||
if (
|
||||
(afterValues !== undefined && afterValues.length !== 1) ||
|
||||
(limitValues !== undefined && limitValues.length !== 1)
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const rawAfter = afterValues?.[0];
|
||||
const afterSequence = rawAfter === undefined ? undefined : Number(rawAfter);
|
||||
const rawLimit = limitValues?.[0];
|
||||
const limit = rawLimit === undefined ? undefined : Number(rawLimit);
|
||||
if (
|
||||
(rawAfter !== undefined &&
|
||||
(!Number.isSafeInteger(afterSequence) ||
|
||||
Number(afterSequence) < 0 ||
|
||||
Number(afterSequence) > 2_147_483_647 ||
|
||||
String(afterSequence) !== rawAfter)) ||
|
||||
(rawLimit !== undefined &&
|
||||
(!Number.isSafeInteger(limit) ||
|
||||
Number(limit) < 1 ||
|
||||
Number(limit) > 64 ||
|
||||
String(limit) !== rawLimit))
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
return Object.freeze({
|
||||
...(afterSequence === undefined ? {} : { afterSequence }),
|
||||
...(limit === undefined ? {} : { limit }),
|
||||
});
|
||||
}
|
||||
|
||||
function validateRunEventListQuery(
|
||||
query: Readonly<Record<string, readonly string[]>>,
|
||||
): void {
|
||||
parseQuery(query);
|
||||
}
|
||||
|
||||
export function createClusterControlRunEventListRoute(
|
||||
runs: Pick<RunRepositoryReader, 'findRunById' | 'listEvents'>,
|
||||
): Readonly<ClusterControlRouteDefinition> {
|
||||
if (
|
||||
!runs ||
|
||||
typeof runs.findRunById !== 'function' ||
|
||||
typeof runs.listEvents !== 'function'
|
||||
) {
|
||||
throw new TypeError('Cluster-control Run event list repository is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
...CLUSTER_CONTROL_RUN_EVENT_LIST_ROUTE,
|
||||
validateQuery: validateRunEventListQuery,
|
||||
async handle(
|
||||
authorized: ClusterControlAuthorizedOperationRequest,
|
||||
parameters: ClusterControlRouteParameters,
|
||||
) {
|
||||
if (authorized.request.body !== null) {
|
||||
return response(400, { code: 'invalid_request_body' });
|
||||
}
|
||||
if (authorized.projectId === null) {
|
||||
return response(503, { code: 'run_event_list_unavailable' });
|
||||
}
|
||||
let input;
|
||||
try {
|
||||
input = parseQuery(authorized.request.query);
|
||||
} catch {
|
||||
return response(400, { code: 'invalid_run_event_list_query' });
|
||||
}
|
||||
try {
|
||||
const result = await executeBoundedRunEventListProjection(
|
||||
runs,
|
||||
authorized.projectId,
|
||||
parameters.runId!,
|
||||
input,
|
||||
);
|
||||
if (!result.found) {
|
||||
return response(404, { code: 'run_not_found' });
|
||||
}
|
||||
const { found: _found, ...timeline } = result;
|
||||
return response(200, { ...timeline });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof InvalidBoundedRunEventListProjectionError ||
|
||||
error instanceof BoundedRunEventListProjectionUnavailableError
|
||||
) {
|
||||
return response(503, { code: 'run_event_list_unavailable' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
BoundedRunListProjectionUnavailableError,
|
||||
InvalidBoundedRunListProjectionError,
|
||||
executeBoundedRunListProjection,
|
||||
} from '@qinglong/runtime-core/bounded-run-list-projection';
|
||||
import type { ProjectRunListReader } from '@qinglong/runtime-core/project-run-list';
|
||||
|
||||
import type { ClusterControlAdmissionResponse } from '../transport/httpSurface';
|
||||
import type {
|
||||
ClusterControlAuthorizedOperationRequest,
|
||||
ClusterControlRouteDefinition,
|
||||
} from '../transport/routeRegistry';
|
||||
|
||||
export const CLUSTER_CONTROL_RUN_LIST_ROUTE = Object.freeze({
|
||||
method: 'GET' as const,
|
||||
path: '/api/v3/projects/{projectId}/runs',
|
||||
operationId: 'run.list',
|
||||
permission: 'run.read',
|
||||
projectParameter: 'projectId',
|
||||
allowedQuery: Object.freeze([
|
||||
'after_created_at_ms',
|
||||
'after_run_id',
|
||||
'limit',
|
||||
]),
|
||||
});
|
||||
|
||||
const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): ClusterControlAdmissionResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
function parseQuery(
|
||||
query: Readonly<Record<string, readonly string[]>>,
|
||||
): Readonly<{
|
||||
limit?: number;
|
||||
after?: Readonly<{ createdAtMs: number; runId: string }>;
|
||||
}> {
|
||||
const limitValues = query.limit;
|
||||
const createdAtValues = query.after_created_at_ms;
|
||||
const runIdValues = query.after_run_id;
|
||||
if (
|
||||
(limitValues !== undefined && limitValues.length !== 1) ||
|
||||
(createdAtValues !== undefined && createdAtValues.length !== 1) ||
|
||||
(runIdValues !== undefined && runIdValues.length !== 1) ||
|
||||
(createdAtValues === undefined) !== (runIdValues === undefined)
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const rawLimit = limitValues?.[0];
|
||||
const limit = rawLimit === undefined ? undefined : Number(rawLimit);
|
||||
if (
|
||||
rawLimit !== undefined &&
|
||||
(!Number.isSafeInteger(limit) ||
|
||||
Number(limit) < 1 ||
|
||||
Number(limit) > 64 ||
|
||||
String(limit) !== rawLimit)
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const rawCreatedAtMs = createdAtValues?.[0];
|
||||
const runId = runIdValues?.[0];
|
||||
if (rawCreatedAtMs === undefined || runId === undefined) {
|
||||
return Object.freeze({ ...(limit === undefined ? {} : { limit }) });
|
||||
}
|
||||
const createdAtMs = Number(rawCreatedAtMs);
|
||||
if (
|
||||
!Number.isSafeInteger(createdAtMs) ||
|
||||
createdAtMs < 0 ||
|
||||
String(createdAtMs) !== rawCreatedAtMs ||
|
||||
!RUN_ID_PATTERN.test(runId)
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
return Object.freeze({
|
||||
...(limit === undefined ? {} : { limit }),
|
||||
after: Object.freeze({ createdAtMs, runId }),
|
||||
});
|
||||
}
|
||||
|
||||
function validateRunListQuery(
|
||||
query: Readonly<Record<string, readonly string[]>>,
|
||||
): void {
|
||||
parseQuery(query);
|
||||
}
|
||||
|
||||
export function createClusterControlRunListRoute(
|
||||
runs: ProjectRunListReader,
|
||||
): Readonly<ClusterControlRouteDefinition> {
|
||||
if (!runs || typeof runs.listRunsByProject !== 'function') {
|
||||
throw new TypeError('Cluster-control Run list repository is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
...CLUSTER_CONTROL_RUN_LIST_ROUTE,
|
||||
validateQuery: validateRunListQuery,
|
||||
async handle(authorized: ClusterControlAuthorizedOperationRequest) {
|
||||
if (authorized.request.body !== null) {
|
||||
return response(400, { code: 'invalid_request_body' });
|
||||
}
|
||||
if (authorized.projectId === null) {
|
||||
return response(503, { code: 'run_list_unavailable' });
|
||||
}
|
||||
let input;
|
||||
try {
|
||||
input = parseQuery(authorized.request.query);
|
||||
} catch {
|
||||
return response(400, { code: 'invalid_run_list_query' });
|
||||
}
|
||||
try {
|
||||
const result = await executeBoundedRunListProjection(
|
||||
runs,
|
||||
authorized.projectId,
|
||||
input,
|
||||
);
|
||||
return response(200, { ...result });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof InvalidBoundedRunListProjectionError ||
|
||||
error instanceof BoundedRunListProjectionUnavailableError
|
||||
) {
|
||||
return response(503, { code: 'run_list_unavailable' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Run owns its bounded read projection and masks cross-Project storage facts.
|
||||
import {
|
||||
EXECUTION_ORIGINS,
|
||||
RUN_STATUSES,
|
||||
type ExecutionOrigin,
|
||||
type ExecutionOwner,
|
||||
type RunRecord,
|
||||
type RunRepositoryReader,
|
||||
type RunStatus,
|
||||
} from '@qinglong/runtime-core';
|
||||
import type { ClusterControlAdmissionResponse } from '../transport/httpSurface';
|
||||
import type {
|
||||
ClusterControlAuthorizedOperationRequest,
|
||||
ClusterControlRouteDefinition,
|
||||
ClusterControlRouteParameters,
|
||||
} from '../transport/routeRegistry';
|
||||
|
||||
export interface ClusterControlRunReadRepository
|
||||
extends Pick<RunRepositoryReader, 'findRunById'> {}
|
||||
|
||||
export interface ClusterControlRunView {
|
||||
readonly id: string;
|
||||
readonly projectId: string;
|
||||
readonly taskId: string;
|
||||
readonly taskRevision: string;
|
||||
readonly status: RunStatus;
|
||||
readonly version: number;
|
||||
readonly eventSequence: number;
|
||||
readonly priority: number;
|
||||
readonly executionOrigin: ExecutionOrigin;
|
||||
readonly executionOwner: ExecutionOwner;
|
||||
readonly createdAtMs: number;
|
||||
readonly queuedAtMs: number | null;
|
||||
readonly startedAtMs: number | null;
|
||||
readonly finishedAtMs: number | null;
|
||||
}
|
||||
|
||||
export const CLUSTER_CONTROL_RUN_READ_ROUTE = Object.freeze({
|
||||
method: 'GET' as const,
|
||||
path: '/api/v3/projects/{projectId}/runs/{runId}',
|
||||
operationId: 'run.get',
|
||||
permission: 'run.read',
|
||||
projectParameter: 'projectId',
|
||||
});
|
||||
|
||||
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
|
||||
function boundedText(value: unknown, maximum: number): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
value.length <= maximum &&
|
||||
!CONTROL_CHARACTER_PATTERN.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && Number(value) >= 0;
|
||||
}
|
||||
|
||||
function optionalTimestamp(value: unknown): value is number | undefined {
|
||||
return value === undefined || nonNegativeInteger(value);
|
||||
}
|
||||
|
||||
function projectRunView(
|
||||
run: RunRecord,
|
||||
runId: string,
|
||||
): Readonly<ClusterControlRunView> | null {
|
||||
if (
|
||||
!run ||
|
||||
typeof run !== 'object' ||
|
||||
Array.isArray(run) ||
|
||||
run.id !== runId ||
|
||||
!boundedText(run.id, 128) ||
|
||||
!boundedText(run.projectId, 128) ||
|
||||
!boundedText(run.taskId, 255) ||
|
||||
!boundedText(run.taskRevision, 255) ||
|
||||
!RUN_STATUSES.includes(run.status) ||
|
||||
!EXECUTION_ORIGINS.includes(run.executionOrigin) ||
|
||||
(run.executionOwner !== 'legacy' && run.executionOwner !== 'runtime') ||
|
||||
!Number.isSafeInteger(run.version) ||
|
||||
run.version < 0 ||
|
||||
!nonNegativeInteger(run.eventSequence) ||
|
||||
!Number.isSafeInteger(run.priority) ||
|
||||
!nonNegativeInteger(run.createdAtMs) ||
|
||||
!optionalTimestamp(run.queuedAtMs) ||
|
||||
!optionalTimestamp(run.startedAtMs) ||
|
||||
!optionalTimestamp(run.finishedAtMs)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Object.freeze({
|
||||
id: run.id,
|
||||
projectId: run.projectId,
|
||||
taskId: run.taskId,
|
||||
taskRevision: run.taskRevision,
|
||||
status: run.status,
|
||||
version: run.version,
|
||||
eventSequence: run.eventSequence,
|
||||
priority: run.priority,
|
||||
executionOrigin: run.executionOrigin,
|
||||
executionOwner: run.executionOwner,
|
||||
createdAtMs: run.createdAtMs,
|
||||
queuedAtMs: run.queuedAtMs ?? null,
|
||||
startedAtMs: run.startedAtMs ?? null,
|
||||
finishedAtMs: run.finishedAtMs ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): ClusterControlAdmissionResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the first reviewed cluster-control business route. The response is a
|
||||
* deliberately low-sensitive projection: refs, trigger identity, request IDs,
|
||||
* executor handles, error summaries and output locations never cross the wire.
|
||||
*/
|
||||
export function createClusterControlRunReadRoute(
|
||||
repository: ClusterControlRunReadRepository,
|
||||
): Readonly<ClusterControlRouteDefinition> {
|
||||
if (!repository || typeof repository.findRunById !== 'function') {
|
||||
throw new TypeError('Cluster-control Run read repository is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
...CLUSTER_CONTROL_RUN_READ_ROUTE,
|
||||
async handle(
|
||||
authorized: ClusterControlAuthorizedOperationRequest,
|
||||
parameters: ClusterControlRouteParameters,
|
||||
) {
|
||||
if (authorized.request.body !== null) {
|
||||
return response(400, { code: 'invalid_request_body' });
|
||||
}
|
||||
const runId = parameters.runId;
|
||||
if (!boundedText(runId, 128)) {
|
||||
return response(503, { code: 'run_query_unavailable' });
|
||||
}
|
||||
let run: RunRecord | null;
|
||||
try {
|
||||
run = await repository.findRunById(runId);
|
||||
} catch {
|
||||
return response(503, { code: 'run_query_unavailable' });
|
||||
}
|
||||
if (!run) {
|
||||
return response(404, { code: 'run_not_found' });
|
||||
}
|
||||
const view = projectRunView(run, runId);
|
||||
if (!view) {
|
||||
return response(503, { code: 'run_query_unavailable' });
|
||||
}
|
||||
if (view.projectId !== authorized.projectId) {
|
||||
return response(404, { code: 'run_not_found' });
|
||||
}
|
||||
return response(200, { run: view });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export * from './runCancellationRoute';
|
||||
export * from './runListRoute';
|
||||
export * from './runEventListRoute';
|
||||
export * from './runStepListRoute';
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
BoundedRunStepListProjectionUnavailableError,
|
||||
InvalidBoundedRunStepListProjectionError,
|
||||
executeBoundedRunStepListProjection,
|
||||
} from '@qinglong/runtime-core/bounded-run-step-list-projection';
|
||||
import type { RunRepositoryReader } from '@qinglong/runtime-core/run-repository';
|
||||
import type { StepRunRepository } from '@qinglong/runtime-core/step-run';
|
||||
|
||||
import type { ClusterControlAdmissionResponse } from '../transport/httpSurface';
|
||||
import type {
|
||||
ClusterControlAuthorizedOperationRequest,
|
||||
ClusterControlRouteDefinition,
|
||||
ClusterControlRouteParameters,
|
||||
} from '../transport/routeRegistry';
|
||||
|
||||
export const CLUSTER_CONTROL_RUN_STEP_LIST_ROUTE = Object.freeze({
|
||||
method: 'GET' as const,
|
||||
path: '/api/v3/projects/{projectId}/runs/{runId}/steps',
|
||||
operationId: 'run.steps.list',
|
||||
permission: 'run.read',
|
||||
projectParameter: 'projectId',
|
||||
allowedQuery: Object.freeze(['after_step_key', 'after_step_run_id', 'limit']),
|
||||
});
|
||||
|
||||
const IDENTITY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
|
||||
function response(
|
||||
statusCode: number,
|
||||
body: Readonly<Record<string, unknown>>,
|
||||
): ClusterControlAdmissionResponse {
|
||||
return Object.freeze({ statusCode, body: Object.freeze(body) });
|
||||
}
|
||||
|
||||
function parseQuery(query: Readonly<Record<string, readonly string[]>>) {
|
||||
const stepKeyValues = query.after_step_key;
|
||||
const stepRunIdValues = query.after_step_run_id;
|
||||
const limitValues = query.limit;
|
||||
if (
|
||||
(stepKeyValues !== undefined && stepKeyValues.length !== 1) ||
|
||||
(stepRunIdValues !== undefined && stepRunIdValues.length !== 1) ||
|
||||
(limitValues !== undefined && limitValues.length !== 1) ||
|
||||
(stepKeyValues === undefined) !== (stepRunIdValues === undefined)
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
const stepKey = stepKeyValues?.[0];
|
||||
const stepRunId = stepRunIdValues?.[0];
|
||||
const rawLimit = limitValues?.[0];
|
||||
const limit = rawLimit === undefined ? undefined : Number(rawLimit);
|
||||
if (
|
||||
(stepKey !== undefined && !IDENTITY_PATTERN.test(stepKey)) ||
|
||||
(stepRunId !== undefined && !IDENTITY_PATTERN.test(stepRunId)) ||
|
||||
(rawLimit !== undefined &&
|
||||
(!Number.isSafeInteger(limit) ||
|
||||
Number(limit) < 1 ||
|
||||
Number(limit) > 64 ||
|
||||
String(limit) !== rawLimit))
|
||||
) {
|
||||
throw new TypeError();
|
||||
}
|
||||
return Object.freeze({
|
||||
...(limit === undefined ? {} : { limit }),
|
||||
...(stepKey === undefined || stepRunId === undefined
|
||||
? {}
|
||||
: { after: Object.freeze({ stepKey, stepRunId }) }),
|
||||
});
|
||||
}
|
||||
|
||||
function validateRunStepListQuery(
|
||||
query: Readonly<Record<string, readonly string[]>>,
|
||||
): void {
|
||||
parseQuery(query);
|
||||
}
|
||||
|
||||
export function createClusterControlRunStepListRoute(
|
||||
runs: Pick<RunRepositoryReader, 'findRunById'>,
|
||||
stepRuns: Pick<StepRunRepository, 'listByRun'>,
|
||||
): Readonly<ClusterControlRouteDefinition> {
|
||||
if (
|
||||
!runs ||
|
||||
typeof runs.findRunById !== 'function' ||
|
||||
!stepRuns ||
|
||||
typeof stepRuns.listByRun !== 'function'
|
||||
) {
|
||||
throw new TypeError('Cluster-control Run Step list repository is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
...CLUSTER_CONTROL_RUN_STEP_LIST_ROUTE,
|
||||
validateQuery: validateRunStepListQuery,
|
||||
async handle(
|
||||
authorized: ClusterControlAuthorizedOperationRequest,
|
||||
parameters: ClusterControlRouteParameters,
|
||||
) {
|
||||
if (authorized.request.body !== null) {
|
||||
return response(400, { code: 'invalid_request_body' });
|
||||
}
|
||||
if (authorized.projectId === null) {
|
||||
return response(503, { code: 'run_step_list_unavailable' });
|
||||
}
|
||||
let input;
|
||||
try {
|
||||
input = parseQuery(authorized.request.query);
|
||||
} catch {
|
||||
return response(400, { code: 'invalid_run_step_list_query' });
|
||||
}
|
||||
try {
|
||||
const result = await executeBoundedRunStepListProjection(
|
||||
runs,
|
||||
stepRuns,
|
||||
authorized.projectId,
|
||||
parameters.runId!,
|
||||
input,
|
||||
);
|
||||
if (!result.found) {
|
||||
return response(404, { code: 'run_not_found' });
|
||||
}
|
||||
const { found: _found, ...page } = result;
|
||||
return response(200, { ...page });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof InvalidBoundedRunStepListProjectionError ||
|
||||
error instanceof BoundedRunStepListProjectionUnavailableError
|
||||
) {
|
||||
return response(503, { code: 'run_step_list_unavailable' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user