mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 09:58:46 +08:00
534 lines
15 KiB
TypeScript
534 lines
15 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
|
|
export const TASK_DEFINITION_KINDS = Object.freeze([
|
|
'script',
|
|
'command',
|
|
'workflow',
|
|
'agent',
|
|
'tool',
|
|
] as const);
|
|
export const MAX_TASK_DEFINITION_PAGE_SIZE = 256;
|
|
export const MAX_TASK_DEFINITION_SPEC_BYTES = 64 * 1024;
|
|
export const MAX_TASK_DEFINITION_LABELS = 32;
|
|
|
|
const TASK_SPEC_SCHEMA_PATTERN =
|
|
/^[a-z][a-z0-9.-]{0,63}\/[a-z][a-z0-9.-]{0,63}@v[1-9][0-9]{0,5}$/;
|
|
const LABEL_KEY_PATTERN =
|
|
/^[a-z0-9](?:[a-z0-9._-]{0,61}[a-z0-9])?(?:\/[a-z0-9](?:[a-z0-9._-]{0,61}[a-z0-9])?)?$/;
|
|
const MUTATION_ID_PATTERN =
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
|
|
export type TaskDefinitionKind = (typeof TASK_DEFINITION_KINDS)[number];
|
|
export type TaskDefinitionJson =
|
|
| null
|
|
| boolean
|
|
| number
|
|
| string
|
|
| readonly TaskDefinitionJson[]
|
|
| Readonly<{ [key: string]: TaskDefinitionJson }>;
|
|
|
|
export interface TaskDefinitionSpec {
|
|
readonly schema: string;
|
|
readonly config: Readonly<{ [key: string]: TaskDefinitionJson }>;
|
|
}
|
|
|
|
export interface TaskDefinitionRecord {
|
|
readonly projectId: string;
|
|
readonly taskId: string;
|
|
readonly revision: number;
|
|
readonly mutationId: string;
|
|
readonly name: string;
|
|
readonly description?: string;
|
|
readonly kind: TaskDefinitionKind;
|
|
readonly spec: TaskDefinitionSpec;
|
|
readonly labels: Readonly<Record<string, string>>;
|
|
readonly enabled: boolean;
|
|
readonly contentDigest: string;
|
|
readonly createdAtMs: number;
|
|
readonly updatedAtMs: number;
|
|
}
|
|
|
|
export interface AppendTaskDefinitionRevisionCommand {
|
|
readonly projectId: string;
|
|
readonly taskId: string;
|
|
readonly expectedRevision: number | null;
|
|
readonly mutationId: string;
|
|
readonly name: string;
|
|
readonly description?: string;
|
|
readonly kind: TaskDefinitionKind;
|
|
readonly spec: TaskDefinitionSpec;
|
|
readonly labels: Readonly<Record<string, string>>;
|
|
readonly enabled: boolean;
|
|
readonly occurredAtMs: number;
|
|
}
|
|
|
|
export interface TaskDefinitionCursor {
|
|
readonly taskId: string;
|
|
}
|
|
|
|
export interface TaskDefinitionPage {
|
|
readonly definitions: readonly TaskDefinitionRecord[];
|
|
readonly truncated: boolean;
|
|
readonly next?: TaskDefinitionCursor;
|
|
}
|
|
|
|
export interface TaskDefinitionSource {
|
|
findCurrentTaskDefinition(
|
|
projectId: string,
|
|
taskId: string,
|
|
): Promise<TaskDefinitionRecord | null>;
|
|
findTaskDefinitionRevision(
|
|
projectId: string,
|
|
taskId: string,
|
|
revision: number,
|
|
): Promise<TaskDefinitionRecord | null>;
|
|
listTaskDefinitions(options: {
|
|
readonly projectId: string;
|
|
readonly limit: number;
|
|
readonly after?: TaskDefinitionCursor;
|
|
}): Promise<TaskDefinitionPage>;
|
|
}
|
|
|
|
export interface TaskDefinitionRepository extends TaskDefinitionSource {
|
|
appendTaskDefinitionRevision(
|
|
command: AppendTaskDefinitionRevisionCommand,
|
|
): Promise<
|
|
Readonly<{
|
|
status: 'created' | 'updated' | 'existing';
|
|
definition: TaskDefinitionRecord;
|
|
}>
|
|
>;
|
|
}
|
|
|
|
export class InvalidTaskDefinitionError extends TypeError {
|
|
readonly code = 'TASK_DEFINITION_INVALID';
|
|
|
|
constructor(message: string) {
|
|
super(`TaskDefinition is invalid: ${message}`);
|
|
this.name = 'InvalidTaskDefinitionError';
|
|
}
|
|
}
|
|
|
|
export class TaskDefinitionConflictError extends Error {
|
|
readonly code = 'TASK_DEFINITION_CONFLICT';
|
|
|
|
constructor() {
|
|
super('TaskDefinition mutation conflicts with durable state');
|
|
this.name = 'TaskDefinitionConflictError';
|
|
}
|
|
}
|
|
|
|
export class TaskDefinitionUnavailableError extends Error {
|
|
readonly code = 'TASK_DEFINITION_UNAVAILABLE';
|
|
|
|
constructor() {
|
|
super('TaskDefinition storage is unavailable');
|
|
this.name = 'TaskDefinitionUnavailableError';
|
|
}
|
|
}
|
|
|
|
function exactKeys(
|
|
value: object,
|
|
required: readonly string[],
|
|
optional: readonly string[],
|
|
label: string,
|
|
): void {
|
|
const keys = Object.keys(value).sort();
|
|
const allowed = new Set([...required, ...optional]);
|
|
if (
|
|
required.some((key) => !keys.includes(key)) ||
|
|
keys.some((key) => !allowed.has(key))
|
|
) {
|
|
throw new InvalidTaskDefinitionError(`${label} has an invalid shape`);
|
|
}
|
|
}
|
|
|
|
function boundedText(
|
|
value: unknown,
|
|
label: string,
|
|
maximumBytes: number,
|
|
): string {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
value.length < 1 ||
|
|
value.includes('\0') ||
|
|
/[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value) ||
|
|
Buffer.byteLength(value, 'utf8') > maximumBytes
|
|
) {
|
|
throw new InvalidTaskDefinitionError(`${label} is invalid`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function identifier(value: unknown, label: string): string {
|
|
return boundedText(value, label, 128);
|
|
}
|
|
|
|
export function assertTaskDefinitionIdentifier(
|
|
value: unknown,
|
|
label = 'identifier',
|
|
): asserts value is string {
|
|
identifier(value, label);
|
|
}
|
|
|
|
function timestamp(value: unknown, label: string): number {
|
|
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
|
throw new InvalidTaskDefinitionError(`${label} is invalid`);
|
|
}
|
|
return value as number;
|
|
}
|
|
|
|
function revision(value: unknown, label: string): number {
|
|
if (
|
|
!Number.isSafeInteger(value) ||
|
|
(value as number) < 1 ||
|
|
(value as number) > 2_147_483_647
|
|
) {
|
|
throw new InvalidTaskDefinitionError(`${label} is invalid`);
|
|
}
|
|
return value as number;
|
|
}
|
|
|
|
export function assertTaskDefinitionRevision(
|
|
value: unknown,
|
|
label = 'revision',
|
|
): asserts value is number {
|
|
revision(value, label);
|
|
}
|
|
|
|
function normalizeJson(
|
|
value: unknown,
|
|
budget: { nodes: number },
|
|
depth: number,
|
|
): TaskDefinitionJson {
|
|
budget.nodes += 1;
|
|
if (budget.nodes > 1024 || depth > 12) {
|
|
throw new InvalidTaskDefinitionError('spec exceeds its structure budget');
|
|
}
|
|
if (value === null || typeof value === 'boolean') return value;
|
|
if (typeof value === 'number') {
|
|
if (!Number.isFinite(value)) {
|
|
throw new InvalidTaskDefinitionError('spec contains an invalid number');
|
|
}
|
|
return Object.is(value, -0) ? 0 : value;
|
|
}
|
|
if (typeof value === 'string') {
|
|
if (value.includes('\0') || Buffer.byteLength(value, 'utf8') > 16 * 1024) {
|
|
throw new InvalidTaskDefinitionError('spec contains invalid text');
|
|
}
|
|
return value;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
if (value.length > 256) {
|
|
throw new InvalidTaskDefinitionError('spec array is too large');
|
|
}
|
|
return Object.freeze(
|
|
value.map((entry) => normalizeJson(entry, budget, depth + 1)),
|
|
);
|
|
}
|
|
if (!value || typeof value !== 'object') {
|
|
throw new InvalidTaskDefinitionError('spec contains a non-JSON value');
|
|
}
|
|
const prototype = Object.getPrototypeOf(value);
|
|
if (prototype !== Object.prototype && prototype !== null) {
|
|
throw new InvalidTaskDefinitionError('spec object prototype is invalid');
|
|
}
|
|
const keys = Object.keys(value).sort();
|
|
if (keys.length > 256) {
|
|
throw new InvalidTaskDefinitionError('spec object is too large');
|
|
}
|
|
const normalized = Object.create(null) as Record<string, TaskDefinitionJson>;
|
|
for (const key of keys) {
|
|
boundedText(key, 'spec key', 128);
|
|
normalized[key] = normalizeJson(
|
|
(value as Record<string, unknown>)[key],
|
|
budget,
|
|
depth + 1,
|
|
);
|
|
}
|
|
return Object.freeze(normalized);
|
|
}
|
|
|
|
export function normalizeTaskDefinitionSpec(
|
|
value: TaskDefinitionSpec,
|
|
): TaskDefinitionSpec {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new InvalidTaskDefinitionError('spec must be an object');
|
|
}
|
|
exactKeys(value, ['config', 'schema'], [], 'spec');
|
|
if (
|
|
typeof value.schema !== 'string' ||
|
|
!TASK_SPEC_SCHEMA_PATTERN.test(value.schema)
|
|
) {
|
|
throw new InvalidTaskDefinitionError('spec schema is invalid');
|
|
}
|
|
const config = normalizeJson(value.config, { nodes: 0 }, 0);
|
|
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
throw new InvalidTaskDefinitionError('spec config must be an object');
|
|
}
|
|
const normalized: TaskDefinitionSpec = Object.freeze({
|
|
schema: value.schema,
|
|
config: config as Readonly<Record<string, TaskDefinitionJson>>,
|
|
});
|
|
if (
|
|
Buffer.byteLength(JSON.stringify(normalized), 'utf8') >
|
|
MAX_TASK_DEFINITION_SPEC_BYTES
|
|
) {
|
|
throw new InvalidTaskDefinitionError('spec exceeds its byte budget');
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
export function normalizeTaskDefinitionLabels(
|
|
value: Readonly<Record<string, string>>,
|
|
): Readonly<Record<string, string>> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new InvalidTaskDefinitionError('labels must be an object');
|
|
}
|
|
const prototype = Object.getPrototypeOf(value);
|
|
if (prototype !== Object.prototype && prototype !== null) {
|
|
throw new InvalidTaskDefinitionError('labels prototype is invalid');
|
|
}
|
|
const keys = Object.keys(value).sort();
|
|
if (keys.length > MAX_TASK_DEFINITION_LABELS) {
|
|
throw new InvalidTaskDefinitionError('labels exceed their count budget');
|
|
}
|
|
const normalized = Object.create(null) as Record<string, string>;
|
|
for (const key of keys) {
|
|
if (!LABEL_KEY_PATTERN.test(key)) {
|
|
throw new InvalidTaskDefinitionError('label key is invalid');
|
|
}
|
|
normalized[key] = boundedText(value[key], 'label value', 256);
|
|
}
|
|
return Object.freeze(normalized);
|
|
}
|
|
|
|
function semanticDefinition(value: {
|
|
readonly projectId: string;
|
|
readonly taskId: string;
|
|
readonly revision: number;
|
|
readonly name: string;
|
|
readonly description?: string;
|
|
readonly kind: TaskDefinitionKind;
|
|
readonly spec: TaskDefinitionSpec;
|
|
readonly labels: Readonly<Record<string, string>>;
|
|
readonly enabled: boolean;
|
|
}): object {
|
|
return {
|
|
projectId: value.projectId,
|
|
taskId: value.taskId,
|
|
revision: value.revision,
|
|
name: value.name,
|
|
...(value.description === undefined
|
|
? {}
|
|
: { description: value.description }),
|
|
kind: value.kind,
|
|
spec: value.spec,
|
|
labels: value.labels,
|
|
enabled: value.enabled,
|
|
};
|
|
}
|
|
|
|
export function taskDefinitionContentDigest(
|
|
value: Parameters<typeof semanticDefinition>[0],
|
|
): string {
|
|
return createHash('sha256')
|
|
.update(JSON.stringify(semanticDefinition(value)))
|
|
.digest('hex');
|
|
}
|
|
|
|
function normalizeDefinitionFields(value: {
|
|
readonly projectId: unknown;
|
|
readonly taskId: unknown;
|
|
readonly revision: unknown;
|
|
readonly name: unknown;
|
|
readonly description?: unknown;
|
|
readonly kind: unknown;
|
|
readonly spec: TaskDefinitionSpec;
|
|
readonly labels: Readonly<Record<string, string>>;
|
|
readonly enabled: unknown;
|
|
}): Omit<
|
|
TaskDefinitionRecord,
|
|
'mutationId' | 'contentDigest' | 'createdAtMs' | 'updatedAtMs'
|
|
> {
|
|
const projectId = identifier(value.projectId, 'projectId');
|
|
const taskId = identifier(value.taskId, 'taskId');
|
|
const normalizedRevision = revision(value.revision, 'revision');
|
|
const name = boundedText(value.name, 'name', 255);
|
|
const description =
|
|
value.description === undefined
|
|
? undefined
|
|
: boundedText(value.description, 'description', 4096);
|
|
if (!TASK_DEFINITION_KINDS.includes(value.kind as TaskDefinitionKind)) {
|
|
throw new InvalidTaskDefinitionError('kind is invalid');
|
|
}
|
|
if (typeof value.enabled !== 'boolean') {
|
|
throw new InvalidTaskDefinitionError('enabled is invalid');
|
|
}
|
|
return Object.freeze({
|
|
projectId,
|
|
taskId,
|
|
revision: normalizedRevision,
|
|
name,
|
|
...(description === undefined ? {} : { description }),
|
|
kind: value.kind as TaskDefinitionKind,
|
|
spec: normalizeTaskDefinitionSpec(value.spec),
|
|
labels: normalizeTaskDefinitionLabels(value.labels),
|
|
enabled: value.enabled,
|
|
});
|
|
}
|
|
|
|
export function normalizeAppendTaskDefinitionRevisionCommand(
|
|
value: AppendTaskDefinitionRevisionCommand,
|
|
): Readonly<AppendTaskDefinitionRevisionCommand> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new InvalidTaskDefinitionError('command must be an object');
|
|
}
|
|
exactKeys(
|
|
value,
|
|
[
|
|
'enabled',
|
|
'expectedRevision',
|
|
'kind',
|
|
'labels',
|
|
'mutationId',
|
|
'name',
|
|
'occurredAtMs',
|
|
'projectId',
|
|
'spec',
|
|
'taskId',
|
|
],
|
|
['description'],
|
|
'command',
|
|
);
|
|
const expectedRevision =
|
|
value.expectedRevision === null
|
|
? null
|
|
: revision(value.expectedRevision, 'expectedRevision');
|
|
if (
|
|
typeof value.mutationId !== 'string' ||
|
|
!MUTATION_ID_PATTERN.test(value.mutationId)
|
|
) {
|
|
throw new InvalidTaskDefinitionError('mutationId is invalid');
|
|
}
|
|
const fields = normalizeDefinitionFields({
|
|
...value,
|
|
revision: expectedRevision === null ? 1 : expectedRevision + 1,
|
|
});
|
|
return Object.freeze({
|
|
projectId: fields.projectId,
|
|
taskId: fields.taskId,
|
|
expectedRevision,
|
|
mutationId: value.mutationId,
|
|
name: fields.name,
|
|
...('description' in fields
|
|
? { description: fields.description as string }
|
|
: {}),
|
|
kind: fields.kind,
|
|
spec: fields.spec,
|
|
labels: fields.labels,
|
|
enabled: fields.enabled,
|
|
occurredAtMs: timestamp(value.occurredAtMs, 'occurredAtMs'),
|
|
});
|
|
}
|
|
|
|
export function normalizeTaskDefinitionRecord(
|
|
value: TaskDefinitionRecord,
|
|
): TaskDefinitionRecord {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new InvalidTaskDefinitionError('record must be an object');
|
|
}
|
|
exactKeys(
|
|
value,
|
|
[
|
|
'contentDigest',
|
|
'createdAtMs',
|
|
'enabled',
|
|
'kind',
|
|
'labels',
|
|
'mutationId',
|
|
'name',
|
|
'projectId',
|
|
'revision',
|
|
'spec',
|
|
'taskId',
|
|
'updatedAtMs',
|
|
],
|
|
['description'],
|
|
'record',
|
|
);
|
|
const fields = normalizeDefinitionFields(value);
|
|
if (
|
|
typeof value.mutationId !== 'string' ||
|
|
!MUTATION_ID_PATTERN.test(value.mutationId) ||
|
|
typeof value.contentDigest !== 'string' ||
|
|
!/^[0-9a-f]{64}$/.test(value.contentDigest)
|
|
) {
|
|
throw new InvalidTaskDefinitionError('record identity is invalid');
|
|
}
|
|
const createdAtMs = timestamp(value.createdAtMs, 'createdAtMs');
|
|
const updatedAtMs = timestamp(value.updatedAtMs, 'updatedAtMs');
|
|
if (updatedAtMs < createdAtMs) {
|
|
throw new InvalidTaskDefinitionError('record time order is invalid');
|
|
}
|
|
const expectedDigest = taskDefinitionContentDigest(fields);
|
|
if (value.contentDigest !== expectedDigest) {
|
|
throw new InvalidTaskDefinitionError('content digest did not match');
|
|
}
|
|
return Object.freeze({
|
|
...fields,
|
|
mutationId: value.mutationId,
|
|
contentDigest: value.contentDigest,
|
|
createdAtMs,
|
|
updatedAtMs,
|
|
});
|
|
}
|
|
|
|
export function createTaskDefinitionRecord(
|
|
command: AppendTaskDefinitionRevisionCommand,
|
|
createdAtMs: number,
|
|
): TaskDefinitionRecord {
|
|
const normalized = normalizeAppendTaskDefinitionRevisionCommand(command);
|
|
const fields = normalizeDefinitionFields({
|
|
...normalized,
|
|
revision:
|
|
normalized.expectedRevision === null
|
|
? 1
|
|
: normalized.expectedRevision + 1,
|
|
});
|
|
const record = {
|
|
...fields,
|
|
mutationId: normalized.mutationId,
|
|
contentDigest: taskDefinitionContentDigest(fields),
|
|
createdAtMs: timestamp(createdAtMs, 'createdAtMs'),
|
|
updatedAtMs: normalized.occurredAtMs,
|
|
};
|
|
return normalizeTaskDefinitionRecord(record);
|
|
}
|
|
|
|
export function assertTaskDefinitionPageSize(limit: number): void {
|
|
if (
|
|
!Number.isSafeInteger(limit) ||
|
|
limit < 1 ||
|
|
limit > MAX_TASK_DEFINITION_PAGE_SIZE
|
|
) {
|
|
throw new RangeError(
|
|
`TaskDefinition page size must be between 1 and ${MAX_TASK_DEFINITION_PAGE_SIZE}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export function normalizeTaskDefinitionCursor(
|
|
cursor: TaskDefinitionCursor,
|
|
): TaskDefinitionCursor {
|
|
if (
|
|
!cursor ||
|
|
typeof cursor !== 'object' ||
|
|
Array.isArray(cursor) ||
|
|
Object.keys(cursor).length !== 1
|
|
) {
|
|
throw new InvalidTaskDefinitionError('cursor is invalid');
|
|
}
|
|
return Object.freeze({ taskId: identifier(cursor.taskId, 'cursor.taskId') });
|
|
}
|