mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 09:58:46 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
normalizeLocalExecutionContextRecipe,
|
||||
normalizeLocalTaskExecutionRevision,
|
||||
type LocalExecutionContextRecipe,
|
||||
type LocalTaskExecutionRevision,
|
||||
} from '@qinglong/runtime-core/local-dispatch';
|
||||
import type { LocalCommandTaskExecutionPlan } from '@qinglong/runtime-core/task-definition-execution-compiler';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
export class LocalSqliteDispatchDefinitionConflictError extends Error {
|
||||
constructor() {
|
||||
super('Local SQLite dispatch definition identity already has other content');
|
||||
this.name = 'LocalSqliteDispatchDefinitionConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
function sqliteConstraint(error: unknown): boolean {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
if (
|
||||
'code' in error &&
|
||||
typeof error.code === 'string' &&
|
||||
error.code.startsWith('SQLITE_CONSTRAINT')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
'errcode' in error &&
|
||||
Number.isSafeInteger(error.errcode) &&
|
||||
((error.errcode as number) & 0xff) === 19
|
||||
);
|
||||
}
|
||||
|
||||
function requiredString(row: Row, key: string): string {
|
||||
const value = row[key];
|
||||
if (typeof value !== 'string') {
|
||||
throw new TypeError(`Stored local dispatch ${key} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(row: Row, key: string): string | undefined {
|
||||
const value = row[key];
|
||||
if (value === null) return undefined;
|
||||
return requiredString(row, key);
|
||||
}
|
||||
|
||||
function requiredInteger(row: Row, key: string): number {
|
||||
const value = row[key];
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new TypeError(`Stored local dispatch ${key} is invalid`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function optionalInteger(row: Row, key: string): number | undefined {
|
||||
const value = row[key];
|
||||
if (value === null) return undefined;
|
||||
return requiredInteger(row, key);
|
||||
}
|
||||
|
||||
function requiredJson(row: Row, key: string): unknown {
|
||||
try {
|
||||
return JSON.parse(requiredString(row, key)) as unknown;
|
||||
} catch {
|
||||
throw new TypeError(`Stored local dispatch ${key} is invalid`);
|
||||
}
|
||||
}
|
||||
|
||||
function sameRecipeContent(
|
||||
left: LocalExecutionContextRecipe,
|
||||
right: LocalExecutionContextRecipe,
|
||||
): boolean {
|
||||
return (
|
||||
left.contextRef === right.contextRef &&
|
||||
left.contentDigest === right.contentDigest &&
|
||||
JSON.stringify(left.environment) === JSON.stringify(right.environment)
|
||||
);
|
||||
}
|
||||
|
||||
function sameRevisionContent(
|
||||
left: LocalTaskExecutionRevision,
|
||||
right: LocalTaskExecutionRevision,
|
||||
): boolean {
|
||||
return (
|
||||
left.projectId === right.projectId &&
|
||||
left.taskId === right.taskId &&
|
||||
left.taskRevision === right.taskRevision &&
|
||||
left.contentDigest === right.contentDigest
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous transaction-scoped storage primitive. Public repositories own
|
||||
* admission/error mapping; this class never opens a transaction or queue.
|
||||
*/
|
||||
export class LocalSqliteDispatchDefinitionStore {
|
||||
constructor(private readonly client: DatabaseSync) {}
|
||||
|
||||
resolveRecipe(contextRef: string): LocalExecutionContextRecipe | null {
|
||||
const row = this.client
|
||||
.prepare(
|
||||
`SELECT "context_ref" AS "contextRef",
|
||||
"environment_json" AS "environmentJson",
|
||||
"content_digest" AS "contentDigest",
|
||||
"created_at_ms" AS "createdAtMs"
|
||||
FROM "QingLong3LocalExecutionContextRecipes"
|
||||
WHERE "context_ref" = ?`,
|
||||
)
|
||||
.get(contextRef) as Row | undefined;
|
||||
if (!row) return null;
|
||||
return normalizeLocalExecutionContextRecipe({
|
||||
contextRef: requiredString(row, 'contextRef'),
|
||||
environment: requiredJson(
|
||||
row,
|
||||
'environmentJson',
|
||||
) as LocalExecutionContextRecipe['environment'],
|
||||
contentDigest: requiredString(row, 'contentDigest'),
|
||||
createdAtMs: requiredInteger(row, 'createdAtMs'),
|
||||
});
|
||||
}
|
||||
|
||||
resolveRevision(identity: {
|
||||
readonly projectId: string;
|
||||
readonly taskId: string;
|
||||
readonly taskRevision: string;
|
||||
}): LocalTaskExecutionRevision | null {
|
||||
const row = this.client
|
||||
.prepare(
|
||||
`SELECT "project_id" AS "projectId", "task_id" AS "taskId",
|
||||
"task_revision" AS "taskRevision",
|
||||
"executor_type" AS "executorType",
|
||||
"command_json" AS "commandJson",
|
||||
"working_directory" AS "workingDirectory",
|
||||
"timeout_ms" AS "timeoutMs", "context_ref" AS "contextRef",
|
||||
"content_digest" AS "contentDigest",
|
||||
"created_at_ms" AS "createdAtMs"
|
||||
FROM "QingLong3LocalTaskExecutionRevisions"
|
||||
WHERE "project_id" = ? AND "task_id" = ? AND "task_revision" = ?`,
|
||||
)
|
||||
.get(identity.projectId, identity.taskId, identity.taskRevision) as
|
||||
| Row
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
const executorType = requiredString(row, 'executorType');
|
||||
if (executorType !== 'local_process') {
|
||||
throw new TypeError('Stored local dispatch executorType is invalid');
|
||||
}
|
||||
const workingDirectory = optionalString(row, 'workingDirectory');
|
||||
const timeoutMs = optionalInteger(row, 'timeoutMs');
|
||||
return normalizeLocalTaskExecutionRevision({
|
||||
projectId: requiredString(row, 'projectId'),
|
||||
taskId: requiredString(row, 'taskId'),
|
||||
taskRevision: requiredString(row, 'taskRevision'),
|
||||
executorType,
|
||||
command: requiredJson(
|
||||
row,
|
||||
'commandJson',
|
||||
) as LocalTaskExecutionRevision['command'],
|
||||
...(workingDirectory === undefined ? {} : { workingDirectory }),
|
||||
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
||||
contextRef: requiredString(row, 'contextRef'),
|
||||
contentDigest: requiredString(row, 'contentDigest'),
|
||||
createdAtMs: requiredInteger(row, 'createdAtMs'),
|
||||
});
|
||||
}
|
||||
|
||||
appendRecipe(
|
||||
value: LocalExecutionContextRecipe,
|
||||
): 'inserted' | 'existing' {
|
||||
const recipe = normalizeLocalExecutionContextRecipe(value);
|
||||
try {
|
||||
this.client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalExecutionContextRecipes" (
|
||||
"context_ref", "environment_json", "content_digest", "created_at_ms"
|
||||
) VALUES (?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
recipe.contextRef,
|
||||
JSON.stringify(recipe.environment),
|
||||
recipe.contentDigest,
|
||||
recipe.createdAtMs,
|
||||
);
|
||||
return 'inserted';
|
||||
} catch (error) {
|
||||
if (!sqliteConstraint(error)) throw error;
|
||||
const existing = this.resolveRecipe(recipe.contextRef);
|
||||
if (existing && sameRecipeContent(existing, recipe)) return 'existing';
|
||||
throw new LocalSqliteDispatchDefinitionConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
appendRevision(
|
||||
value: LocalTaskExecutionRevision,
|
||||
): 'inserted' | 'existing' {
|
||||
const revision = normalizeLocalTaskExecutionRevision(value);
|
||||
try {
|
||||
this.client
|
||||
.prepare(
|
||||
`INSERT INTO "QingLong3LocalTaskExecutionRevisions" (
|
||||
"project_id", "task_id", "task_revision", "executor_type",
|
||||
"command_json", "working_directory", "timeout_ms",
|
||||
"context_ref", "content_digest", "created_at_ms"
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
revision.projectId,
|
||||
revision.taskId,
|
||||
revision.taskRevision,
|
||||
revision.executorType,
|
||||
JSON.stringify(revision.command),
|
||||
revision.workingDirectory ?? null,
|
||||
revision.timeoutMs ?? null,
|
||||
revision.contextRef,
|
||||
revision.contentDigest,
|
||||
revision.createdAtMs,
|
||||
);
|
||||
return 'inserted';
|
||||
} catch (error) {
|
||||
if (!sqliteConstraint(error)) throw error;
|
||||
const existing = this.resolveRevision(revision);
|
||||
if (existing && sameRevisionContent(existing, revision)) {
|
||||
return 'existing';
|
||||
}
|
||||
throw new LocalSqliteDispatchDefinitionConflictError();
|
||||
}
|
||||
}
|
||||
|
||||
appendPlan(plan: LocalCommandTaskExecutionPlan): Readonly<{
|
||||
recipe: 'inserted' | 'existing';
|
||||
revision: 'inserted' | 'existing';
|
||||
}> {
|
||||
const recipe = normalizeLocalExecutionContextRecipe(plan.contextRecipe);
|
||||
const revision = normalizeLocalTaskExecutionRevision(
|
||||
plan.executionRevision,
|
||||
);
|
||||
if (
|
||||
revision.projectId !== plan.source.projectId ||
|
||||
revision.taskId !== plan.source.taskId ||
|
||||
revision.taskRevision !== plan.source.taskRevision ||
|
||||
revision.contextRef !== recipe.contextRef
|
||||
) {
|
||||
throw new TypeError('Local command execution plan identities do not match');
|
||||
}
|
||||
return Object.freeze({
|
||||
recipe: this.appendRecipe(recipe),
|
||||
revision: this.appendRevision(revision),
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user