feat(ql3): establish 3.0 incubation baseline

This commit is contained in:
whyour
2026-08-12 00:25:26 +08:00
parent 4bf92dcfeb
commit c699c32461
2817 changed files with 779642 additions and 653 deletions
@@ -0,0 +1,283 @@
import { createHash } from 'node:crypto';
import {
createLocalExecutionContextRecipe,
normalizeLocalDispatchCommand,
type LocalDispatchCommand,
type LocalExecutionEnvironmentBinding,
} from '../local-runtime/localDispatch';
import { parseSecretRef } from '../secret/secretReference';
import {
compileCommandTaskDefinition,
createTaskDefinitionRevisionRef,
type CommandTaskExecutionPlan,
} from './taskDefinitionExecutionCompiler';
import type { TaskDefinitionRecord } from './taskDefinition';
import type { TaskSpecSemanticRegistry } from './taskSpecSemantic';
import {
effectiveRemoteWorkerPlacement,
type RemoteWorkerPlacementSpec,
} from '../remote-execution/remoteWorkerPlacement';
export const CLUSTER_EXECUTOR_TYPE = 'remote_worker';
export const CLUSTER_EXECUTION_PLAN_SCHEMA = 'qinglong/command-execution@v1';
export const MAX_CLUSTER_EXECUTION_PLAN_BYTES = 96 * 1024;
export interface ClusterTaskExecutionRevisionContent {
readonly projectId: string;
readonly taskId: string;
readonly taskRevision: string;
readonly sourceRevision: number;
readonly sourceContentDigest: string;
readonly executorType: typeof CLUSTER_EXECUTOR_TYPE;
readonly planSchema: typeof CLUSTER_EXECUTION_PLAN_SCHEMA;
readonly command: LocalDispatchCommand;
readonly environment: readonly LocalExecutionEnvironmentBinding[];
readonly workingDirectory?: string;
readonly timeoutMs?: number;
readonly placement?: RemoteWorkerPlacementSpec;
readonly createdAtMs: number;
}
export interface ClusterTaskExecutionRevision
extends ClusterTaskExecutionRevisionContent {
readonly contentDigest: string;
}
export interface ClusterTaskExecutionRevisionSource {
resolveClusterTaskExecutionRevision(identity: {
readonly projectId: string;
readonly taskId: string;
readonly sourceRevision: number;
}): Promise<ClusterTaskExecutionRevision | null>;
}
export class InvalidClusterExecutionRevisionError extends TypeError {
readonly code = 'CLUSTER_EXECUTION_REVISION_INVALID';
constructor(message: string) {
super(`Cluster execution revision is invalid: ${message}`);
this.name = 'InvalidClusterExecutionRevisionError';
}
}
function identifier(value: unknown, label: string): string {
if (
typeof value !== 'string' ||
value.length === 0 ||
value.includes('\0') ||
/[\u0001-\u001f\u007f]/.test(value) ||
Buffer.byteLength(value, 'utf8') > 128
) {
throw new InvalidClusterExecutionRevisionError(`${label} is invalid`);
}
return value;
}
function revision(value: unknown): number {
if (
!Number.isSafeInteger(value) ||
(value as number) < 1 ||
(value as number) > 2_147_483_647
) {
throw new InvalidClusterExecutionRevisionError(
'sourceRevision is invalid',
);
}
return value as number;
}
function timestamp(value: unknown): number {
if (!Number.isSafeInteger(value) || (value as number) < 0) {
throw new InvalidClusterExecutionRevisionError('createdAtMs is invalid');
}
return value as number;
}
function normalizeContent(
value: ClusterTaskExecutionRevisionContent,
): ClusterTaskExecutionRevisionContent {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidClusterExecutionRevisionError('shape is invalid');
}
const allowed = new Set([
'command',
'createdAtMs',
'environment',
'executorType',
'planSchema',
'placement',
'projectId',
'sourceContentDigest',
'sourceRevision',
'taskId',
'taskRevision',
'timeoutMs',
'workingDirectory',
]);
const keys = Object.keys(value);
if (
keys.some((key) => !allowed.has(key)) ||
[
'command',
'createdAtMs',
'environment',
'executorType',
'planSchema',
'projectId',
'sourceContentDigest',
'sourceRevision',
'taskId',
'taskRevision',
].some((key) => !keys.includes(key))
) {
throw new InvalidClusterExecutionRevisionError('shape is invalid');
}
const projectId = identifier(value.projectId, 'projectId');
const taskId = identifier(value.taskId, 'taskId');
const sourceRevision = revision(value.sourceRevision);
if (!/^[0-9a-f]{64}$/.test(value.sourceContentDigest)) {
throw new InvalidClusterExecutionRevisionError(
'sourceContentDigest is invalid',
);
}
const taskRevision = createTaskDefinitionRevisionRef({
revision: sourceRevision,
contentDigest: value.sourceContentDigest,
});
if (
value.taskRevision !== taskRevision ||
value.executorType !== CLUSTER_EXECUTOR_TYPE ||
value.planSchema !== CLUSTER_EXECUTION_PLAN_SCHEMA
) {
throw new InvalidClusterExecutionRevisionError(
'source or executor fence is invalid',
);
}
let command: LocalDispatchCommand;
let environment: readonly LocalExecutionEnvironmentBinding[];
try {
command = normalizeLocalDispatchCommand(value.command);
environment = createLocalExecutionContextRecipe({
environment: value.environment,
createdAtMs: value.createdAtMs,
}).environment;
for (const binding of environment) {
if (
binding.kind === 'secret' &&
parseSecretRef(binding.secretRef).projectId !== projectId
) {
throw new Error('cross-project Secret reference');
}
}
} catch {
throw new InvalidClusterExecutionRevisionError(
'command or environment is invalid',
);
}
let workingDirectory: string | undefined;
if (value.workingDirectory !== undefined) {
if (
typeof value.workingDirectory !== 'string' ||
!value.workingDirectory.startsWith('/') ||
value.workingDirectory.includes('\0') ||
Buffer.byteLength(value.workingDirectory, 'utf8') > 4096
) {
throw new InvalidClusterExecutionRevisionError(
'workingDirectory is invalid',
);
}
workingDirectory = value.workingDirectory;
}
let timeoutMs: number | undefined;
if (value.timeoutMs !== undefined) {
if (
!Number.isSafeInteger(value.timeoutMs) ||
value.timeoutMs < 1 ||
value.timeoutMs > 365 * 24 * 60 * 60_000
) {
throw new InvalidClusterExecutionRevisionError('timeoutMs is invalid');
}
timeoutMs = value.timeoutMs;
}
const createdAtMs = timestamp(value.createdAtMs);
const placement = value.placement === undefined
? undefined
: effectiveRemoteWorkerPlacement(value.placement);
const normalized = Object.freeze({
projectId,
taskId,
taskRevision,
sourceRevision,
sourceContentDigest: value.sourceContentDigest,
executorType: CLUSTER_EXECUTOR_TYPE,
planSchema: CLUSTER_EXECUTION_PLAN_SCHEMA,
command,
environment,
...(workingDirectory === undefined ? {} : { workingDirectory }),
...(timeoutMs === undefined ? {} : { timeoutMs }),
...(placement === undefined ? {} : { placement }),
createdAtMs,
});
if (Buffer.byteLength(JSON.stringify(normalized), 'utf8') > MAX_CLUSTER_EXECUTION_PLAN_BYTES) {
throw new InvalidClusterExecutionRevisionError('plan byte budget exceeded');
}
return normalized;
}
function digest(
content: ClusterTaskExecutionRevisionContent,
): string {
const { createdAtMs: _createdAtMs, ...immutable } = content;
return createHash('sha256')
.update('qinglong.cluster-task-execution-revision.v1\0', 'utf8')
.update(JSON.stringify(immutable), 'utf8')
.digest('hex');
}
export function createClusterTaskExecutionRevision(
value: ClusterTaskExecutionRevisionContent,
): ClusterTaskExecutionRevision {
const content = normalizeContent(value);
return Object.freeze({ ...content, contentDigest: digest(content) });
}
export function normalizeClusterTaskExecutionRevision(
value: ClusterTaskExecutionRevision,
): ClusterTaskExecutionRevision {
const { contentDigest, ...candidate } = value;
const content = normalizeContent(candidate);
const expected = digest(content);
if (contentDigest !== expected) {
throw new InvalidClusterExecutionRevisionError(
'contentDigest does not match content',
);
}
return Object.freeze({ ...content, contentDigest: expected });
}
export function compileClusterCommandTaskDefinition(
definition: TaskDefinitionRecord,
semanticRegistry: TaskSpecSemanticRegistry,
): ClusterTaskExecutionRevision {
const plan: CommandTaskExecutionPlan = compileCommandTaskDefinition(
definition,
semanticRegistry,
);
return createClusterTaskExecutionRevision({
projectId: plan.projectId,
taskId: plan.taskId,
taskRevision: plan.taskRevision,
sourceRevision: plan.sourceRevision,
sourceContentDigest: plan.sourceContentDigest,
executorType: CLUSTER_EXECUTOR_TYPE,
planSchema: CLUSTER_EXECUTION_PLAN_SCHEMA,
command: plan.command,
environment: plan.environment,
...(plan.workingDirectory === undefined
? {}
: { workingDirectory: plan.workingDirectory }),
...(plan.timeoutMs === undefined ? {} : { timeoutMs: plan.timeoutMs }),
placement: effectiveRemoteWorkerPlacement(plan.placement ?? {}),
createdAtMs: plan.createdAtMs,
});
}
@@ -0,0 +1,191 @@
import {
TASK_DEFINITION_KINDS,
normalizeTaskDefinitionCursor,
type TaskDefinitionCursor,
type TaskDefinitionRecord,
type TaskDefinitionSource,
} from '../taskDefinition';
export const DEFAULT_BOUNDED_TASK_LIST_LIMIT = 32;
export const MAX_BOUNDED_TASK_LIST_LIMIT = 64;
const MAX_REVISION = 2_147_483_647;
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
export interface BoundedTaskListInput {
readonly limit?: number;
readonly after?: Readonly<TaskDefinitionCursor>;
}
export interface BoundedTaskListItem {
readonly taskId: string;
readonly revision: number;
readonly name: string;
readonly kind: TaskDefinitionRecord['kind'];
readonly specSchema: string;
readonly enabled: boolean;
readonly updatedAtMs: number;
}
export interface BoundedTaskListProjection {
readonly tasks: readonly Readonly<BoundedTaskListItem>[];
readonly hasMore: boolean;
readonly next?: Readonly<TaskDefinitionCursor>;
}
export class InvalidBoundedTaskListProjectionError extends TypeError {
readonly code = 'BOUNDED_TASK_LIST_PROJECTION_INVALID';
constructor() {
super('Bounded Task list projection input is invalid');
this.name = 'InvalidBoundedTaskListProjectionError';
}
}
export class BoundedTaskListProjectionUnavailableError extends Error {
readonly code = 'BOUNDED_TASK_LIST_PROJECTION_UNAVAILABLE';
constructor() {
super('Bounded Task list projection is unavailable');
this.name = 'BoundedTaskListProjectionUnavailableError';
}
}
function boundedText(value: unknown, maximumBytes: number): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
Buffer.byteLength(value, 'utf8') <= maximumBytes &&
!CONTROL_PATTERN.test(value)
);
}
function integer(
value: unknown,
minimum: number,
maximum: number,
): value is number {
return (
Number.isSafeInteger(value) &&
Number(value) >= minimum &&
Number(value) <= maximum
);
}
function normalizeInput(
value: Readonly<BoundedTaskListInput>,
): Readonly<{ limit: number; after?: Readonly<TaskDefinitionCursor> }> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidBoundedTaskListProjectionError();
}
const keys = Reflect.ownKeys(value);
if (
keys.length > 2 ||
keys.some((key) => key !== 'limit' && key !== 'after') ||
(value.limit !== undefined &&
!integer(value.limit, 1, MAX_BOUNDED_TASK_LIST_LIMIT))
) {
throw new InvalidBoundedTaskListProjectionError();
}
let after: Readonly<TaskDefinitionCursor> | undefined;
try {
after =
value.after === undefined
? undefined
: normalizeTaskDefinitionCursor(value.after);
} catch {
throw new InvalidBoundedTaskListProjectionError();
}
return Object.freeze({
limit: value.limit ?? DEFAULT_BOUNDED_TASK_LIST_LIMIT,
...(after === undefined ? {} : { after }),
});
}
function projectTask(
value: TaskDefinitionRecord,
projectId: string,
after?: string,
): Readonly<BoundedTaskListItem> | null {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
value.projectId !== projectId ||
(after !== undefined && value.taskId <= after) ||
!boundedText(value.taskId, 128) ||
!integer(value.revision, 1, MAX_REVISION) ||
!boundedText(value.name, 255) ||
!TASK_DEFINITION_KINDS.includes(value.kind) ||
!boundedText(value.spec?.schema, 137) ||
typeof value.enabled !== 'boolean' ||
!integer(value.updatedAtMs, 0, Number.MAX_SAFE_INTEGER)
) {
return null;
}
return Object.freeze({
taskId: value.taskId,
revision: value.revision,
name: value.name,
kind: value.kind,
specSchema: value.spec.schema,
enabled: value.enabled,
updatedAtMs: value.updatedAtMs,
});
}
export async function executeBoundedTaskListProjection(
source: Pick<TaskDefinitionSource, 'listTaskDefinitions'>,
projectId: string,
input: Readonly<BoundedTaskListInput>,
): Promise<Readonly<BoundedTaskListProjection>> {
if (
!source ||
typeof source.listTaskDefinitions !== 'function' ||
!boundedText(projectId, 128)
) {
throw new InvalidBoundedTaskListProjectionError();
}
const normalized = normalizeInput(input);
let page;
try {
page = await source.listTaskDefinitions({
projectId,
limit: normalized.limit,
...(normalized.after === undefined ? {} : { after: normalized.after }),
});
} catch {
throw new BoundedTaskListProjectionUnavailableError();
}
if (
!page ||
!Array.isArray(page.definitions) ||
page.definitions.length > normalized.limit ||
typeof page.truncated !== 'boolean' ||
page.truncated !== Boolean(page.next)
) {
throw new BoundedTaskListProjectionUnavailableError();
}
const tasks: Readonly<BoundedTaskListItem>[] = [];
let boundary = normalized.after?.taskId;
for (const definition of page.definitions) {
const projected = projectTask(definition, projectId, boundary);
if (!projected) throw new BoundedTaskListProjectionUnavailableError();
tasks.push(projected);
boundary = definition.taskId;
}
if (
page.truncated &&
(!page.next || page.next.taskId !== boundary || tasks.length === 0)
) {
throw new BoundedTaskListProjectionUnavailableError();
}
return Object.freeze({
tasks: Object.freeze(tasks),
hasMore: page.truncated,
...(page.truncated
? { next: Object.freeze({ taskId: boundary! }) }
: {}),
});
}
@@ -0,0 +1,95 @@
import {
normalizeTaskDefinitionRecord,
type TaskDefinitionRecord,
type TaskDefinitionSource,
} from '../taskDefinition';
const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
export type BoundedTaskReadProjection =
| Readonly<{ found: false }>
| Readonly<{
found: true;
taskId: string;
revision: number;
name: string;
kind: TaskDefinitionRecord['kind'];
specSchema: string;
enabled: boolean;
contentDigest: string;
createdAtMs: number;
updatedAtMs: number;
}>;
export class InvalidBoundedTaskReadProjectionError extends TypeError {
readonly code = 'BOUNDED_TASK_READ_PROJECTION_INVALID';
constructor() {
super('Bounded Task read projection input is invalid');
this.name = 'InvalidBoundedTaskReadProjectionError';
}
}
export class BoundedTaskReadProjectionUnavailableError extends Error {
readonly code = 'BOUNDED_TASK_READ_PROJECTION_UNAVAILABLE';
constructor() {
super('Bounded Task read projection is unavailable');
this.name = 'BoundedTaskReadProjectionUnavailableError';
}
}
function identifier(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
Buffer.byteLength(value, 'utf8') <= 128 &&
!CONTROL_PATTERN.test(value)
);
}
export async function executeBoundedTaskReadProjection(
source: Pick<TaskDefinitionSource, 'findCurrentTaskDefinition'>,
projectId: string,
taskId: string,
): Promise<Readonly<BoundedTaskReadProjection>> {
if (
!source ||
typeof source.findCurrentTaskDefinition !== 'function' ||
!identifier(projectId) ||
!identifier(taskId)
) {
throw new InvalidBoundedTaskReadProjectionError();
}
let definition: TaskDefinitionRecord | null;
try {
definition = await source.findCurrentTaskDefinition(projectId, taskId);
} catch {
throw new BoundedTaskReadProjectionUnavailableError();
}
if (!definition) return Object.freeze({ found: false });
let current: TaskDefinitionRecord;
try {
current = normalizeTaskDefinitionRecord(definition);
} catch {
throw new BoundedTaskReadProjectionUnavailableError();
}
if (current.projectId !== projectId || current.taskId !== taskId) {
return Object.freeze({ found: false });
}
return Object.freeze({
found: true,
taskId: current.taskId,
revision: current.revision,
name: current.name,
kind: current.kind,
specSchema: current.spec.schema,
enabled: current.enabled,
contentDigest: current.contentDigest,
createdAtMs: current.createdAtMs,
updatedAtMs: current.updatedAtMs,
});
}
@@ -0,0 +1,533 @@
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') });
}
@@ -0,0 +1,307 @@
import {
assertTaskDefinitionIdentifier,
assertTaskDefinitionPageSize,
normalizeAppendTaskDefinitionRevisionCommand,
normalizeTaskDefinitionCursor,
type AppendTaskDefinitionRevisionCommand,
type TaskDefinitionCursor,
type TaskDefinitionPage,
type TaskDefinitionRecord,
} from './taskDefinition';
import { normalizeProjectPolicySubject } from '../security/project-policy/projectPolicy';
import type { SecurityPolicyFence, SecuritySubject } from '../security/security';
import {
normalizeSecurityAuditRecord,
type SecurityAuditRecord,
} from '../security/audit/securityAudit';
export interface AuthorizedTaskDefinitionRevisionMutation {
readonly command: AppendTaskDefinitionRevisionCommand;
readonly actor: SecuritySubject;
readonly fence: SecurityPolicyFence;
readonly audit: SecurityAuditRecord;
}
export interface TaskDefinitionAdministrationRepository {
appendAuthorizedTaskDefinitionRevision(
mutation: AuthorizedTaskDefinitionRevisionMutation,
): Promise<
Readonly<{
status: 'created' | 'updated' | 'existing';
definition: TaskDefinitionRecord;
}>
>;
}
export interface AuthorizedTaskDefinitionInspection {
readonly projectId: string;
readonly taskId: string;
readonly actor: SecuritySubject;
readonly fence: SecurityPolicyFence;
readonly audit: SecurityAuditRecord;
}
export interface AuthorizedTaskDefinitionList {
readonly projectId: string;
readonly limit: number;
readonly after?: TaskDefinitionCursor;
readonly actor: SecuritySubject;
readonly fence: SecurityPolicyFence;
readonly audit: SecurityAuditRecord;
}
export interface TaskDefinitionAdministrationSource {
findAuthorizedCurrentTaskDefinition(
inspection: AuthorizedTaskDefinitionInspection,
): Promise<TaskDefinitionRecord | null>;
listAuthorizedTaskDefinitions(
query: AuthorizedTaskDefinitionList,
): Promise<TaskDefinitionPage>;
}
export class InvalidTaskDefinitionAdministrationMutationError extends TypeError {
readonly code = 'TASK_DEFINITION_ADMINISTRATION_MUTATION_INVALID';
constructor(message: string) {
super(`TaskDefinition administration mutation is invalid: ${message}`);
this.name = 'InvalidTaskDefinitionAdministrationMutationError';
}
}
export class TaskDefinitionAdministrationAuthorizationFenceConflictError extends Error {
readonly code = 'TASK_DEFINITION_ADMINISTRATION_AUTHORIZATION_FENCE_CONFLICT';
constructor() {
super('TaskDefinition administration authorization fence changed');
this.name =
'TaskDefinitionAdministrationAuthorizationFenceConflictError';
}
}
export class TaskDefinitionAdministrationMutationConflictError extends Error {
readonly code = 'TASK_DEFINITION_ADMINISTRATION_MUTATION_CONFLICT';
constructor() {
super('TaskDefinition administration mutation conflicts with durable state');
this.name = 'TaskDefinitionAdministrationMutationConflictError';
}
}
export class InvalidTaskDefinitionAdministrationReadError extends TypeError {
readonly code = 'TASK_DEFINITION_ADMINISTRATION_READ_INVALID';
constructor(message: string) {
super(`TaskDefinition administration read is invalid: ${message}`);
this.name = 'InvalidTaskDefinitionAdministrationReadError';
}
}
export class TaskDefinitionAdministrationReadConflictError extends Error {
readonly code = 'TASK_DEFINITION_ADMINISTRATION_READ_CONFLICT';
constructor() {
super('TaskDefinition administration read conflicts with durable state');
this.name = 'TaskDefinitionAdministrationReadConflictError';
}
}
function exactKeys(value: object, expected: readonly string[]): boolean {
const actual = Object.keys(value).sort();
const canonical = [...expected].sort();
return (
actual.length === canonical.length &&
actual.every((key, index) => key === canonical[index])
);
}
function sameSubject(
left: Readonly<SecuritySubject>,
right: Readonly<SecuritySubject>,
): boolean {
return left.type === right.type && left.id === right.id;
}
function normalizeFence(value: SecurityPolicyFence): SecurityPolicyFence {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, ['bindingVersion', 'projectVersion']) ||
!Number.isSafeInteger(value.projectVersion) ||
value.projectVersion < 1 ||
!Number.isSafeInteger(value.bindingVersion) ||
(value.bindingVersion as number) < 1
) {
throw new InvalidTaskDefinitionAdministrationMutationError(
'authorization fence is invalid',
);
}
return Object.freeze({
projectVersion: value.projectVersion,
bindingVersion: value.bindingVersion,
});
}
export function normalizeAuthorizedTaskDefinitionRevisionMutation(
value: AuthorizedTaskDefinitionRevisionMutation,
): Readonly<AuthorizedTaskDefinitionRevisionMutation> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, ['actor', 'audit', 'command', 'fence'])
) {
throw new InvalidTaskDefinitionAdministrationMutationError(
'mutation shape is invalid',
);
}
try {
const command = normalizeAppendTaskDefinitionRevisionCommand(
value.command,
);
const actor = normalizeProjectPolicySubject(value.actor);
const fence = normalizeFence(value.fence);
const audit = normalizeSecurityAuditRecord(value.audit);
const operationId =
command.expectedRevision === null ? 'task.create' : 'task.update';
if (
audit.eventId !== command.mutationId ||
audit.operationId !== operationId ||
audit.projectId !== command.projectId ||
audit.outcome !== 'allowed' ||
!audit.subject ||
!sameSubject(audit.subject, actor) ||
audit.authenticationId === null ||
!audit.fence ||
audit.fence.projectVersion !== fence.projectVersion ||
audit.fence.bindingVersion !== fence.bindingVersion
) {
throw new InvalidTaskDefinitionAdministrationMutationError(
'audit binding is invalid',
);
}
return Object.freeze({ command, actor, fence, audit });
} catch (error) {
if (error instanceof InvalidTaskDefinitionAdministrationMutationError) {
throw error;
}
throw new InvalidTaskDefinitionAdministrationMutationError(
'mutation value is invalid',
);
}
}
function normalizeTaskDefinitionReadAuthority(
value: Readonly<{
projectId: string;
actor: SecuritySubject;
fence: SecurityPolicyFence;
audit: SecurityAuditRecord;
}>,
): Readonly<{
projectId: string;
actor: SecuritySubject;
fence: SecurityPolicyFence;
audit: SecurityAuditRecord;
}> {
const actor = normalizeProjectPolicySubject(value.actor);
const fence = normalizeFence(value.fence);
const audit = normalizeSecurityAuditRecord(value.audit);
if (
audit.operationId !== 'task.read' ||
audit.projectId !== value.projectId ||
audit.outcome !== 'allowed' ||
!audit.subject ||
!sameSubject(audit.subject, actor) ||
audit.authenticationId === null ||
!audit.fence ||
audit.fence.projectVersion !== fence.projectVersion ||
audit.fence.bindingVersion !== fence.bindingVersion
) {
throw new InvalidTaskDefinitionAdministrationReadError(
'audit binding is invalid',
);
}
return Object.freeze({
projectId: value.projectId,
actor,
fence,
audit,
});
}
export function normalizeAuthorizedTaskDefinitionInspection(
value: AuthorizedTaskDefinitionInspection,
): Readonly<AuthorizedTaskDefinitionInspection> {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
!exactKeys(value, ['actor', 'audit', 'fence', 'projectId', 'taskId'])
) {
throw new InvalidTaskDefinitionAdministrationReadError(
'inspection shape is invalid',
);
}
try {
assertTaskDefinitionIdentifier(value.projectId, 'projectId');
assertTaskDefinitionIdentifier(value.taskId, 'taskId');
return Object.freeze({
...normalizeTaskDefinitionReadAuthority(value),
taskId: value.taskId,
});
} catch (error) {
if (error instanceof InvalidTaskDefinitionAdministrationReadError) {
throw error;
}
throw new InvalidTaskDefinitionAdministrationReadError(
'inspection value is invalid',
);
}
}
export function normalizeAuthorizedTaskDefinitionList(
value: AuthorizedTaskDefinitionList,
): Readonly<AuthorizedTaskDefinitionList> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidTaskDefinitionAdministrationReadError(
'list shape is invalid',
);
}
const keys = Object.keys(value);
if (
!['actor', 'audit', 'fence', 'limit', 'projectId'].every((key) =>
keys.includes(key),
) ||
keys.some(
(key) =>
!['actor', 'after', 'audit', 'fence', 'limit', 'projectId'].includes(
key,
),
)
) {
throw new InvalidTaskDefinitionAdministrationReadError(
'list shape is invalid',
);
}
try {
assertTaskDefinitionIdentifier(value.projectId, 'projectId');
assertTaskDefinitionPageSize(value.limit);
const authority = normalizeTaskDefinitionReadAuthority(value);
const after = Object.hasOwn(value, 'after')
? normalizeTaskDefinitionCursor(value.after as TaskDefinitionCursor)
: undefined;
return Object.freeze({
...authority,
limit: value.limit,
...(after ? { after } : {}),
});
} catch (error) {
if (error instanceof InvalidTaskDefinitionAdministrationReadError) {
throw error;
}
throw new InvalidTaskDefinitionAdministrationReadError(
'list value is invalid',
);
}
}
@@ -0,0 +1,223 @@
import {
createLocalExecutionContextRecipe,
createLocalTaskExecutionRevision,
type LocalDispatchCommand,
type LocalExecutionContextRecipe,
type LocalExecutionEnvironmentBinding,
type LocalTaskExecutionRevision,
} from '../local-runtime/localDispatch';
import {
normalizeTaskDefinitionRecord,
type TaskDefinitionRecord,
} from './taskDefinition';
import {
BUILT_IN_COMMAND_TASK_SPEC_SCHEMA,
InvalidTaskSpecSemanticError,
TaskSpecSemanticRegistry,
UnsupportedTaskSpecError,
} from './taskSpecSemantic';
import type { RemoteWorkerPlacementSpec } from '../remote-execution/remoteWorkerPlacement';
export const TASK_DEFINITION_REVISION_REF_PREFIX = 'qltd:v1:';
const TASK_DEFINITION_REVISION_REF_PATTERN =
/^qltd:v1:([1-9][0-9]{0,9}):([a-f0-9]{64})$/;
export interface TaskDefinitionRevisionRef {
readonly revision: number;
readonly contentDigest: string;
}
export interface CommandTaskExecutionPlan {
readonly projectId: string;
readonly taskId: string;
readonly taskRevision: string;
readonly sourceRevision: number;
readonly sourceContentDigest: string;
readonly command: LocalDispatchCommand;
readonly environment: readonly LocalExecutionEnvironmentBinding[];
readonly workingDirectory?: string;
readonly timeoutMs?: number;
readonly placement?: RemoteWorkerPlacementSpec;
readonly createdAtMs: number;
}
export interface LocalCommandTaskExecutionPlan {
readonly source: CommandTaskExecutionPlan;
readonly contextRecipe: LocalExecutionContextRecipe;
readonly executionRevision: LocalTaskExecutionRevision;
}
export class UnsupportedTaskDefinitionCompilationError extends Error {
readonly code = 'TASK_DEFINITION_COMPILATION_UNSUPPORTED';
constructor() {
super('TaskDefinition cannot be compiled by this execution compiler');
this.name = 'UnsupportedTaskDefinitionCompilationError';
}
}
export class InvalidTaskDefinitionCompilationError extends TypeError {
readonly code = 'TASK_DEFINITION_COMPILATION_INVALID';
constructor(message: string) {
super(`TaskDefinition compilation is invalid: ${message}`);
this.name = 'InvalidTaskDefinitionCompilationError';
}
}
export function createTaskDefinitionRevisionRef(
value: TaskDefinitionRevisionRef,
): string {
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
Object.keys(value).sort().join(',') !== 'contentDigest,revision' ||
!Number.isSafeInteger(value.revision) ||
value.revision < 1 ||
value.revision > 2_147_483_647 ||
typeof value.contentDigest !== 'string' ||
!/^[a-f0-9]{64}$/.test(value.contentDigest)
) {
throw new InvalidTaskDefinitionCompilationError(
'source revision identity is invalid',
);
}
return `${TASK_DEFINITION_REVISION_REF_PREFIX}${value.revision}:${value.contentDigest}`;
}
export function parseTaskDefinitionRevisionRef(
value: unknown,
): TaskDefinitionRevisionRef {
if (typeof value !== 'string') {
throw new InvalidTaskDefinitionCompilationError(
'source revision reference is invalid',
);
}
const match = TASK_DEFINITION_REVISION_REF_PATTERN.exec(value);
const revision = match ? Number(match[1]) : Number.NaN;
const contentDigest = match?.[2];
if (
!Number.isSafeInteger(revision) ||
revision < 1 ||
revision > 2_147_483_647 ||
!contentDigest ||
createTaskDefinitionRevisionRef({ revision, contentDigest }) !== value
) {
throw new InvalidTaskDefinitionCompilationError(
'source revision reference is invalid',
);
}
return Object.freeze({ revision, contentDigest });
}
function canonicalRecord(definition: TaskDefinitionRecord): TaskDefinitionRecord {
try {
return normalizeTaskDefinitionRecord(definition);
} catch {
throw new InvalidTaskDefinitionCompilationError(
'source record is invalid',
);
}
}
export function compileCommandTaskDefinition(
definition: TaskDefinitionRecord,
semanticRegistry: TaskSpecSemanticRegistry,
): CommandTaskExecutionPlan {
if (!(semanticRegistry instanceof TaskSpecSemanticRegistry)) {
throw new InvalidTaskDefinitionCompilationError(
'semantic registry is invalid',
);
}
const source = canonicalRecord(definition);
if (
source.kind !== 'command' ||
source.spec.schema !== BUILT_IN_COMMAND_TASK_SPEC_SCHEMA
) {
throw new UnsupportedTaskDefinitionCompilationError();
}
if (!source.enabled) {
throw new InvalidTaskDefinitionCompilationError('source is disabled');
}
let semanticSpec;
try {
semanticSpec = semanticRegistry.normalize({
projectId: source.projectId,
taskId: source.taskId,
kind: source.kind,
spec: source.spec,
});
} catch (error) {
if (error instanceof UnsupportedTaskSpecError) {
throw new UnsupportedTaskDefinitionCompilationError();
}
if (error instanceof InvalidTaskSpecSemanticError) {
throw new InvalidTaskDefinitionCompilationError(
'source spec semantics are invalid',
);
}
throw new InvalidTaskDefinitionCompilationError(
'semantic validation failed',
);
}
if (JSON.stringify(semanticSpec) !== JSON.stringify(source.spec)) {
throw new InvalidTaskDefinitionCompilationError(
'source spec is not semantically canonical',
);
}
const config = semanticSpec.config as unknown as Readonly<{
command: LocalDispatchCommand;
environment: readonly LocalExecutionEnvironmentBinding[];
workingDirectory?: string;
timeoutMs?: number;
placement?: RemoteWorkerPlacementSpec;
}>;
const taskRevision = createTaskDefinitionRevisionRef({
revision: source.revision,
contentDigest: source.contentDigest,
});
return Object.freeze({
projectId: source.projectId,
taskId: source.taskId,
taskRevision,
sourceRevision: source.revision,
sourceContentDigest: source.contentDigest,
command: config.command,
environment: config.environment,
...(config.workingDirectory === undefined
? {}
: { workingDirectory: config.workingDirectory }),
...(config.timeoutMs === undefined ? {} : { timeoutMs: config.timeoutMs }),
...(config.placement === undefined ? {} : { placement: config.placement }),
createdAtMs: source.updatedAtMs,
});
}
export function compileLocalCommandTaskDefinition(
definition: TaskDefinitionRecord,
semanticRegistry: TaskSpecSemanticRegistry,
): LocalCommandTaskExecutionPlan {
const source = compileCommandTaskDefinition(definition, semanticRegistry);
const contextRecipe = createLocalExecutionContextRecipe({
environment: source.environment,
createdAtMs: source.createdAtMs,
});
const executionRevision = createLocalTaskExecutionRevision({
projectId: source.projectId,
taskId: source.taskId,
taskRevision: source.taskRevision,
executorType: 'local_process',
command: source.command,
...(source.workingDirectory === undefined
? {}
: { workingDirectory: source.workingDirectory }),
...(source.timeoutMs === undefined ? {} : { timeoutMs: source.timeoutMs }),
contextRef: contextRecipe.contextRef,
createdAtMs: source.createdAtMs,
});
return Object.freeze({ source, contextRecipe, executionRevision });
}
@@ -0,0 +1,400 @@
import {
TASK_DEFINITION_KINDS,
assertTaskDefinitionIdentifier,
normalizeTaskDefinitionSpec,
type TaskDefinitionJson,
type TaskDefinitionKind,
type TaskDefinitionSpec,
} from './taskDefinition';
import { parseSecretRef } from '../secret/secretReference';
import { normalizeRemoteWorkerPlacement } from '../remote-execution/remoteWorkerPlacement';
export const MAX_TASK_SPEC_SEMANTIC_SCHEMAS = 32;
export const BUILT_IN_COMMAND_TASK_SPEC_SCHEMA = 'qinglong/command@v1';
export const MAX_COMMAND_TASK_ENVIRONMENT_ENTRIES = 256;
export const MAX_COMMAND_TASK_ENVIRONMENT_BYTES = 64 * 1024;
export const MAX_COMMAND_TASK_TIMEOUT_MS = 365 * 24 * 60 * 60_000;
const ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
export interface TaskSpecSemanticContext {
readonly projectId: string;
readonly taskId: string;
readonly kind: TaskDefinitionKind;
readonly spec: TaskDefinitionSpec;
}
export interface TaskSpecSemanticDescriptor {
readonly schema: string;
readonly kind: TaskDefinitionKind;
normalizeConfig(
config: Readonly<Record<string, TaskDefinitionJson>>,
context: Readonly<Omit<TaskSpecSemanticContext, 'spec'>>,
): Readonly<Record<string, TaskDefinitionJson>>;
}
export interface TaskSpecSemanticMetadata {
readonly schema: string;
readonly kind: TaskDefinitionKind;
}
export class UnsupportedTaskSpecError extends Error {
readonly code = 'TASK_SPEC_UNSUPPORTED';
constructor() {
super('TaskDefinition spec schema is unsupported');
this.name = 'UnsupportedTaskSpecError';
}
}
export class InvalidTaskSpecSemanticError extends TypeError {
readonly code = 'TASK_SPEC_SEMANTIC_INVALID';
constructor(message: string) {
super(`TaskDefinition spec semantics are invalid: ${message}`);
this.name = 'InvalidTaskSpecSemanticError';
}
}
function exactKeys<T>(
value: T,
required: readonly string[],
optional: readonly string[],
label: string,
): asserts value is T & Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new InvalidTaskSpecSemanticError(`${label} must be an object`);
}
const keys = Object.keys(value);
const allowed = new Set([...required, ...optional]);
if (
required.some((key) => !keys.includes(key)) ||
keys.some((key) => !allowed.has(key))
) {
throw new InvalidTaskSpecSemanticError(`${label} shape is invalid`);
}
}
function boundedText(
value: unknown,
label: string,
maximumBytes: number,
allowEmpty = false,
): string {
if (
typeof value !== 'string' ||
(!allowEmpty && value.length === 0) ||
value.includes('\0') ||
Buffer.byteLength(value, 'utf8') > maximumBytes
) {
throw new InvalidTaskSpecSemanticError(`${label} is invalid`);
}
return value;
}
function normalizeCommand(value: unknown): TaskDefinitionJson {
exactKeys(value, ['kind'], ['args', 'command', 'file', 'shell'], 'command');
if (value.kind === 'argv') {
exactKeys(value, ['args', 'file', 'kind'], [], 'argv command');
const file = boundedText(value.file, 'command file', 4096);
if (!file.startsWith('/')) {
throw new InvalidTaskSpecSemanticError(
'command file must be an absolute path',
);
}
if (!Array.isArray(value.args) || value.args.length > 256) {
throw new InvalidTaskSpecSemanticError('command arguments are invalid');
}
let bytes = Buffer.byteLength(file, 'utf8');
const args = value.args.map((argument) => {
const normalized = boundedText(
argument,
'command argument',
16 * 1024,
true,
);
bytes += Buffer.byteLength(normalized, 'utf8');
return normalized;
});
if (bytes > 64 * 1024) {
throw new InvalidTaskSpecSemanticError('command byte budget exceeded');
}
return Object.freeze({
kind: 'argv',
file,
args: Object.freeze(args),
});
}
if (value.kind === 'shell') {
exactKeys(value, ['command', 'kind'], ['shell'], 'shell command');
const command = boundedText(value.command, 'shell command', 64 * 1024);
const shell = value.shell ?? '/bin/sh';
if (shell !== '/bin/sh' && shell !== '/bin/bash') {
throw new InvalidTaskSpecSemanticError('shell is not allowlisted');
}
return Object.freeze({ kind: 'shell', command, shell });
}
throw new InvalidTaskSpecSemanticError('command kind is invalid');
}
function normalizeEnvironment(
value: unknown,
projectId: string,
): readonly TaskDefinitionJson[] {
if (
!Array.isArray(value) ||
value.length > MAX_COMMAND_TASK_ENVIRONMENT_ENTRIES
) {
throw new InvalidTaskSpecSemanticError('environment is invalid');
}
const names = new Set<string>();
let bytes = 0;
const environment = value.map((entry) => {
exactKeys(entry, ['kind', 'name'], ['secretRef', 'value'], 'environment');
const name = boundedText(entry.name, 'environment name', 128);
if (
!ENVIRONMENT_NAME_PATTERN.test(name) ||
name.startsWith('QL3_') ||
names.has(name)
) {
throw new InvalidTaskSpecSemanticError(
'environment name is invalid or duplicated',
);
}
names.add(name);
bytes += Buffer.byteLength(name, 'utf8');
if (entry.kind === 'public') {
exactKeys(entry, ['kind', 'name', 'value'], [], 'public environment');
const publicValue = boundedText(
entry.value,
'environment value',
16 * 1024,
true,
);
bytes += Buffer.byteLength(publicValue, 'utf8');
return Object.freeze({ name, kind: 'public', value: publicValue });
}
if (entry.kind === 'secret') {
exactKeys(entry, ['kind', 'name', 'secretRef'], [], 'secret environment');
const secretRef = boundedText(entry.secretRef, 'secretRef', 512);
let reference;
try {
reference = parseSecretRef(secretRef);
} catch {
throw new InvalidTaskSpecSemanticError('secretRef is invalid');
}
if (reference.projectId !== projectId) {
throw new InvalidTaskSpecSemanticError(
'secretRef belongs to another Project',
);
}
bytes += Buffer.byteLength(secretRef, 'utf8');
return Object.freeze({ name, kind: 'secret', secretRef });
}
throw new InvalidTaskSpecSemanticError(
'environment binding kind is invalid',
);
});
if (bytes > MAX_COMMAND_TASK_ENVIRONMENT_BYTES) {
throw new InvalidTaskSpecSemanticError(
'environment byte budget exceeded',
);
}
environment.sort((left, right) =>
(left as { name: string }).name.localeCompare(
(right as { name: string }).name,
),
);
return Object.freeze(environment);
}
function normalizeCommandConfig(
config: Readonly<Record<string, TaskDefinitionJson>>,
context: Readonly<Omit<TaskSpecSemanticContext, 'spec'>>,
): Readonly<Record<string, TaskDefinitionJson>> {
exactKeys(
config,
['command'],
['environment', 'placement', 'timeoutMs', 'workingDirectory'],
'command config',
);
const command = normalizeCommand(config.command);
const environment = normalizeEnvironment(config.environment ?? [], context.projectId);
const placement = config.placement === undefined
? undefined
: normalizeRemoteWorkerPlacement(config.placement);
let workingDirectory: string | undefined;
if (config.workingDirectory !== undefined) {
workingDirectory = boundedText(
config.workingDirectory,
'workingDirectory',
4096,
);
if (!workingDirectory.startsWith('/')) {
throw new InvalidTaskSpecSemanticError(
'workingDirectory must be an absolute path',
);
}
}
let timeoutMs: number | undefined;
if (config.timeoutMs !== undefined) {
if (
!Number.isSafeInteger(config.timeoutMs) ||
(config.timeoutMs as number) < 1 ||
(config.timeoutMs as number) > MAX_COMMAND_TASK_TIMEOUT_MS
) {
throw new InvalidTaskSpecSemanticError('timeoutMs is invalid');
}
timeoutMs = config.timeoutMs as number;
}
return Object.freeze({
command,
environment,
...(placement === undefined
? {}
: { placement: placement as unknown as TaskDefinitionJson }),
...(workingDirectory === undefined ? {} : { workingDirectory }),
...(timeoutMs === undefined ? {} : { timeoutMs }),
});
}
const BUILT_IN_DESCRIPTORS: readonly TaskSpecSemanticDescriptor[] =
Object.freeze([
Object.freeze({
schema: BUILT_IN_COMMAND_TASK_SPEC_SCHEMA,
kind: 'command' as const,
normalizeConfig: normalizeCommandConfig,
}),
]);
export class TaskSpecSemanticRegistry {
readonly #descriptors: ReadonlyMap<
string,
TaskSpecSemanticDescriptor
>;
readonly #metadata: readonly TaskSpecSemanticMetadata[];
constructor(descriptors: readonly TaskSpecSemanticDescriptor[]) {
if (
!Array.isArray(descriptors) ||
descriptors.length < 1 ||
descriptors.length > MAX_TASK_SPEC_SEMANTIC_SCHEMAS
) {
throw new InvalidTaskSpecSemanticError(
'registry descriptor count is invalid',
);
}
const bySchema = new Map<string, TaskSpecSemanticDescriptor>();
for (const descriptor of descriptors) {
exactKeys(
descriptor,
['kind', 'normalizeConfig', 'schema'],
[],
'registry descriptor',
);
const schema = normalizeTaskDefinitionSpec({
schema: descriptor.schema,
config: {},
}).schema;
if (
!TASK_DEFINITION_KINDS.includes(descriptor.kind) ||
typeof descriptor.normalizeConfig !== 'function' ||
bySchema.has(schema)
) {
throw new InvalidTaskSpecSemanticError(
'registry descriptor is invalid or duplicated',
);
}
bySchema.set(
schema,
Object.freeze({
schema,
kind: descriptor.kind,
normalizeConfig: descriptor.normalizeConfig,
}),
);
}
this.#descriptors = bySchema;
this.#metadata = Object.freeze(
[...bySchema.values()]
.map(({ schema, kind }) => Object.freeze({ schema, kind }))
.sort((left, right) => left.schema.localeCompare(right.schema)),
);
Object.freeze(this);
}
list(): readonly TaskSpecSemanticMetadata[] {
return this.#metadata;
}
supports(kind: TaskDefinitionKind, schema: string): boolean {
return this.#descriptors.get(schema)?.kind === kind;
}
normalize(context: TaskSpecSemanticContext): TaskDefinitionSpec {
if (!context || typeof context !== 'object' || Array.isArray(context)) {
throw new InvalidTaskSpecSemanticError('context is invalid');
}
exactKeys(
context,
['kind', 'projectId', 'spec', 'taskId'],
[],
'semantic context',
);
assertTaskDefinitionIdentifier(context.projectId, 'projectId');
assertTaskDefinitionIdentifier(context.taskId, 'taskId');
if (!TASK_DEFINITION_KINDS.includes(context.kind)) {
throw new InvalidTaskSpecSemanticError('TaskDefinition kind is invalid');
}
const spec = normalizeTaskDefinitionSpec(context.spec);
const descriptor = this.#descriptors.get(spec.schema);
if (!descriptor) throw new UnsupportedTaskSpecError();
if (descriptor.kind !== context.kind) {
throw new InvalidTaskSpecSemanticError(
'schema does not match TaskDefinition kind',
);
}
let config: Readonly<Record<string, TaskDefinitionJson>>;
try {
config = descriptor.normalizeConfig(
spec.config,
Object.freeze({
projectId: context.projectId,
taskId: context.taskId,
kind: context.kind,
}),
);
return normalizeTaskDefinitionSpec({ schema: spec.schema, config });
} catch (error) {
if (error instanceof InvalidTaskSpecSemanticError) throw error;
throw new InvalidTaskSpecSemanticError('validator rejected the config');
}
}
}
export function createBuiltInTaskSpecSemanticRegistry(): TaskSpecSemanticRegistry {
return new TaskSpecSemanticRegistry(BUILT_IN_DESCRIPTORS);
}
export function createTaskSpecSemanticRegistry(
extensions: readonly TaskSpecSemanticDescriptor[] = [],
): TaskSpecSemanticRegistry {
if (
!Array.isArray(extensions) ||
extensions.some(
(descriptor) =>
!descriptor ||
typeof descriptor !== 'object' ||
typeof descriptor.schema !== 'string' ||
descriptor.schema.startsWith('qinglong/'),
)
) {
throw new InvalidTaskSpecSemanticError(
'extension descriptor uses the reserved qinglong namespace',
);
}
return new TaskSpecSemanticRegistry([
...BUILT_IN_DESCRIPTORS,
...extensions,
]);
}